Personal save-for-later and Miniflux e-reader proxy for Xteink X4 (wip)
1
fork

Configure Feed

Select the types of activity you want to include in your feed.

feat: atproto-ize

+1393 -586
+5 -1
.env.example
··· 1 1 MINIFLUX_URL=http://miniflux.local:8080 2 2 MINIFLUX_TOKEN=paste-your-miniflux-api-key-here 3 + 4 + # Unset for loopback OAuth (dev/LAN). Set for production: 5 + # NIGHTSHADE_PUBLIC_URL=https://nightshade.solanaceae.net 6 + 3 7 NIGHTSHADE_PORT=8787 4 - NIGHTSHADE_DB=./nightshade.sqlite3 8 + NIGHTSHADE_DATA_DIR=./data
+1
.gitignore
··· 5 5 .env 6 6 .env.local 7 7 result 8 + data/
+52 -54
README.md
··· 1 1 # nightshade 2 2 3 - Personal save-for-later and e-reader proxy. Runs next to a 4 - [Miniflux](https://miniflux.app/) instance and adds: 3 + Personal save-for-later and e-reader proxy, backed by [atproto](https://atproto.com/) 4 + for data ownership and [Miniflux](https://miniflux.app/) for RSS polling. 5 5 6 - - save-arbitrary-URL: fetches the page, runs readability, stores the text in SQLite 7 - - a plain-text HTTP API that paginates unread items for an e-ink device 8 - - a Preact UI for subscribing to feeds, saving URLs, and managing both 6 + - feed subscriptions and saved URLs live on your atproto PDS as records 7 + (`net.solanaceae.nightshade.feed`, `net.solanaceae.nightshade.save`) 8 + - Nightshade syncs feeds two-way between atproto and Miniflux 9 + - the e-reader endpoint fetches saved-URL bodies on demand (no local storage), 10 + with a short in-memory TTL cache 11 + - Preact UI for atproto OAuth sign-in, feed management, and saving URLs 12 + 13 + ## Data shape 9 14 10 - Miniflux holds the RSS subscriptions and entries. Nightshade stores only the 11 - one-off saves. 15 + atproto holds feed subscriptions and saved URLs (metadata only, no bodies). 16 + Miniflux mirrors the feed list and does the actual polling, entry storage, and 17 + entry read-state. Nightshade keeps nothing of its own apart from OAuth session 18 + files and a small sync snapshot. Kill the process and your data is fine. 12 19 13 20 ## Development 14 21 ··· 18 25 pnpm dev # server on 8787, Vite on 5173 19 26 ``` 20 27 21 - Vite proxies `/api` and `/device` through to the Node server. 28 + Leaving `NIGHTSHADE_PUBLIC_URL` unset runs atproto OAuth in loopback mode 29 + (`http://localhost` client_id) so you can sign in without a public URL. 22 30 23 31 ## Environment 24 32 25 - | var | default | purpose | 26 - | --------------------- | ---------------------- | ------------------------------------------------------ | 27 - | `MINIFLUX_URL` | n/a | required, base URL of your Miniflux instance | 28 - | `MINIFLUX_TOKEN` | n/a | required (or `MINIFLUX_TOKEN_FILE`), Miniflux API key | 29 - | `MINIFLUX_TOKEN_FILE` | n/a | path to file containing the token (systemd credential) | 30 - | `NIGHTSHADE_PORT` | `8787` | HTTP port | 31 - | `NIGHTSHADE_DB` | `./nightshade.sqlite3` | SQLite file path | 33 + | var | default | purpose | 34 + | ----------------------- | -------- | ------------------------------------------------------------------ | 35 + | `MINIFLUX_URL` | n/a | required, base URL of your Miniflux instance | 36 + | `MINIFLUX_TOKEN` | n/a | required (or `MINIFLUX_TOKEN_FILE`), Miniflux API key | 37 + | `MINIFLUX_TOKEN_FILE` | n/a | path to file containing the token (systemd credential) | 38 + | `NIGHTSHADE_PUBLIC_URL` | n/a | unset for loopback OAuth; set to `https://host.tld` for production | 39 + | `NIGHTSHADE_PORT` | `8787` | HTTP port | 40 + | `NIGHTSHADE_DATA_DIR` | `./data` | directory for OAuth state/session files and sync snapshot | 32 41 33 42 ## HTTP API 34 43 35 - ### Browser / management (JSON) 44 + ### Auth 36 45 37 - - `GET /api/saves[?all=1]` — list saved items 38 - - `POST /api/saves` — save a URL (body `{url}`) 39 - - `DELETE /api/saves/:id` — remove a saved item 40 - - `POST /api/saves/:id/read` — mark read 41 - - `POST /api/saves/:id/unread` — mark unread 42 - - `GET /api/feeds` — list Miniflux subscriptions 43 - - `POST /api/feeds` — subscribe (body `{url, category_id?}`) 44 - - `DELETE /api/feeds/:id` — unsubscribe 45 - - `POST /api/feeds/refresh` — refresh all feeds 46 + - `GET /auth/status` — `{ authenticated, did? }` 47 + - `POST /auth/login` — body `{ handle }`, returns `{ url }` to redirect to 48 + - `GET /auth/callback` — OAuth redirect target 49 + - `POST /auth/logout` — revoke all local sessions 46 50 47 - ### E-reader device (plain text) 51 + ### Management (JSON, requires session) 48 52 49 - LAN only. No auth, no TLS. 53 + - `GET /api/saves[?all=1]` — list save records 54 + - `POST /api/saves` — body `{ url }`; creates atproto record, fetches title 55 + - `DELETE /api/saves/:rkey` 56 + - `POST /api/saves/:rkey/read` / `/unread` 57 + - `GET /api/feeds` — list feed records 58 + - `POST /api/feeds` — body `{ url, title? }`; creates atproto record, reconciles to Miniflux 59 + - `DELETE /api/feeds/:rkey` — deletes atproto record, reconciles to Miniflux 60 + - `POST /api/feeds/refresh` — refresh all Miniflux feeds now 61 + - `POST /api/sync` — trigger a reconciliation pass immediately 50 62 51 - - `GET /device/list[?all=1&limit=N]` — merged list of unread items 52 - - `GET /device/item/:id[?page=N]` — one item, paginated (60-col wrap, 24 lines/page) 53 - - `POST /device/item/:id/read` — mark read 63 + ### E-reader device (plain text, LAN-only, no auth/TLS) 54 64 55 - IDs: Miniflux entries use the numeric entry id; saved items use `s<id>`. 65 + Save IDs are `s<rkey>`. RSS entry IDs are the numeric Miniflux entry id. 56 66 57 - List format: 67 + - `GET /device/list[?all=1&limit=N]` 68 + - `GET /device/item/:id[?page=N]` 69 + - `POST /device/item/:id/read` 58 70 59 - ``` 60 - COUNT:<n> 61 - === 62 - ID:<id> 63 - S:<rss|save> 64 - R:<0|1> 65 - D:<unix> 66 - T:<title> 67 - === 68 - ... 69 - ``` 71 + ## Sync behavior 70 72 71 - Item format: 73 + Two-way sync between atproto and Miniflux, driven by a last-seen snapshot in 74 + `${NIGHTSHADE_DATA_DIR}/sync-state.json`. Runs on startup, after every 75 + feed-write through `/api/feeds`, and on a 5-minute timer. 72 76 73 - ``` 74 - ID:<id> 75 - S:<rss|save> 76 - T:<title> 77 - U:<url> 78 - D:<unix> 79 - P:<cur>/<total> 80 - --- 81 - <wrapped body for this page> 82 - ``` 77 + - Feed added on atproto → subscribed in Miniflux 78 + - Feed added via Miniflux UI or OPML import → record created on atproto 79 + - Feed removed from either side → removed from the other 80 + - Saves don't sync (Miniflux has no saves concept)
+31
lexicons/net/solanaceae/nightshade/feed.json
··· 1 + { 2 + "lexicon": 1, 3 + "id": "net.solanaceae.nightshade.feed", 4 + "description": "An RSS or Atom feed subscription.", 5 + "defs": { 6 + "main": { 7 + "type": "record", 8 + "key": "tid", 9 + "record": { 10 + "type": "object", 11 + "required": ["url", "createdAt"], 12 + "properties": { 13 + "url": { 14 + "type": "string", 15 + "format": "uri", 16 + "description": "Absolute URL of the feed (RSS/Atom XML endpoint)." 17 + }, 18 + "title": { 19 + "type": "string", 20 + "maxLength": 500, 21 + "description": "Human-readable title. May be set by the feed, the user, or the app." 22 + }, 23 + "createdAt": { 24 + "type": "string", 25 + "format": "datetime" 26 + } 27 + } 28 + } 29 + } 30 + } 31 + }
+36
lexicons/net/solanaceae/nightshade/save.json
··· 1 + { 2 + "lexicon": 1, 3 + "id": "net.solanaceae.nightshade.save", 4 + "description": "A saved web page URL for later reading.", 5 + "defs": { 6 + "main": { 7 + "type": "record", 8 + "key": "tid", 9 + "record": { 10 + "type": "object", 11 + "required": ["url", "createdAt"], 12 + "properties": { 13 + "url": { 14 + "type": "string", 15 + "format": "uri", 16 + "description": "Absolute URL of the saved page." 17 + }, 18 + "title": { 19 + "type": "string", 20 + "maxLength": 500, 21 + "description": "Page title, usually extracted on first fetch." 22 + }, 23 + "createdAt": { 24 + "type": "string", 25 + "format": "datetime" 26 + }, 27 + "readAt": { 28 + "type": "string", 29 + "format": "datetime", 30 + "description": "When the item was last marked read. Absence means unread." 31 + } 32 + } 33 + } 34 + } 35 + } 36 + }
+20 -5
module.nix
··· 53 53 Nix store or the unit environment. 54 54 ''; 55 55 }; 56 + 57 + publicUrl = lib.mkOption { 58 + type = lib.types.nullOr lib.types.str; 59 + default = null; 60 + example = "https://nightshade.solanaceae.net"; 61 + description = '' 62 + Publicly reachable HTTPS URL of this Nightshade instance, used as the 63 + base for atproto OAuth client metadata. Leave null for loopback/dev 64 + OAuth mode (no public URL, only works for browsers on the same host). 65 + ''; 66 + }; 56 67 }; 57 68 58 69 config = lib.mkIf cfg.enable { ··· 70 81 description = "Nightshade e-reader proxy"; 71 82 wantedBy = [ "multi-user.target" ]; 72 83 after = [ "network.target" ]; 73 - environment = { 74 - NIGHTSHADE_PORT = toString cfg.port; 75 - NIGHTSHADE_DB = "${cfg.dataDir}/nightshade.sqlite3"; 76 - MINIFLUX_URL = cfg.minifluxUrl; 77 - }; 84 + environment = 85 + { 86 + NIGHTSHADE_PORT = toString cfg.port; 87 + NIGHTSHADE_DATA_DIR = cfg.dataDir; 88 + MINIFLUX_URL = cfg.minifluxUrl; 89 + } 90 + // lib.optionalAttrs (cfg.publicUrl != null) { 91 + NIGHTSHADE_PUBLIC_URL = cfg.publicUrl; 92 + }; 78 93 serviceConfig = { 79 94 ExecStart = lib.getExe cfg.package; 80 95 User = cfg.user;
+2 -3
package.json
··· 13 13 "typecheck": "tsc --noEmit" 14 14 }, 15 15 "dependencies": { 16 + "@atproto/api": "^0.19.9", 17 + "@atproto/oauth-client-node": "^0.3.17", 16 18 "@hono/node-server": "^1.13.7", 17 19 "@mozilla/readability": "^0.5.0", 18 - "better-sqlite3": "^11.7.0", 19 20 "dotenv": "^17.4.2", 20 21 "hono": "^4.6.13", 21 22 "linkedom": "^0.18.5", ··· 23 24 }, 24 25 "devDependencies": { 25 26 "@preact/preset-vite": "^2.9.1", 26 - "@types/better-sqlite3": "^7.6.12", 27 27 "@types/node": "^22.10.1", 28 28 "concurrently": "^9.1.0", 29 29 "tsx": "^4.19.2", ··· 32 32 }, 33 33 "pnpm": { 34 34 "onlyBuiltDependencies": [ 35 - "better-sqlite3", 36 35 "esbuild" 37 36 ] 38 37 }
+3 -12
package.nix
··· 6 6 pnpmConfigHook, 7 7 fetchPnpmDeps, 8 8 makeWrapper, 9 - python3, 10 - pkg-config, 11 9 }: 12 10 13 11 stdenv.mkDerivation (finalAttrs: { ··· 22 20 ./tsconfig.json 23 21 ./vite.config.ts 24 22 ./src 23 + ./lexicons 25 24 ]; 26 25 }; 27 26 28 27 pnpmDeps = fetchPnpmDeps { 29 28 inherit (finalAttrs) pname version src; 30 29 fetcherVersion = 1; 31 - hash = "sha256-WJ59aHdbHLD0wUhhnt65hw1L7O1gErfVtUXUhgqkHvU="; 30 + hash = "sha256-f1tDsiw0P4AXGYP4J+k7wMUB2AY0xAmVc4RgoEtve7E="; 32 31 }; 33 32 34 33 nativeBuildInputs = [ ··· 36 35 pnpm 37 36 pnpmConfigHook 38 37 makeWrapper 39 - python3 40 - pkg-config 41 38 ]; 42 39 43 - env = { 44 - npm_config_build_from_source = "true"; 45 - npm_config_nodedir = nodejs_22; 46 - }; 47 - 48 40 buildPhase = '' 49 41 runHook preBuild 50 - pnpm rebuild better-sqlite3 51 42 pnpm run build 52 43 runHook postBuild 53 44 ''; ··· 73 64 ''; 74 65 75 66 meta = { 76 - description = "Personal save-for-later + e-reader proxy for Miniflux"; 67 + description = "Personal save-for-later + e-reader proxy for Miniflux (atproto-backed)"; 77 68 mainProgram = "nightshade"; 78 69 platforms = lib.platforms.unix; 79 70 };
+288 -279
pnpm-lock.yaml
··· 8 8 9 9 .: 10 10 dependencies: 11 + '@atproto/api': 12 + specifier: ^0.19.9 13 + version: 0.19.9 14 + '@atproto/oauth-client-node': 15 + specifier: ^0.3.17 16 + version: 0.3.17 11 17 '@hono/node-server': 12 18 specifier: ^1.13.7 13 19 version: 1.19.14(hono@4.12.14) 14 20 '@mozilla/readability': 15 21 specifier: ^0.5.0 16 22 version: 0.5.0 17 - better-sqlite3: 18 - specifier: ^11.7.0 19 - version: 11.10.0 20 23 dotenv: 21 24 specifier: ^17.4.2 22 25 version: 17.4.2 ··· 33 36 '@preact/preset-vite': 34 37 specifier: ^2.9.1 35 38 version: 2.10.5(@babel/core@7.29.0)(preact@10.29.1)(rollup@4.60.2)(vite@6.4.2(@types/node@22.19.17)(tsx@4.21.0)) 36 - '@types/better-sqlite3': 37 - specifier: ^7.6.12 38 - version: 7.6.13 39 39 '@types/node': 40 40 specifier: ^22.10.1 41 41 version: 22.19.17 ··· 53 53 version: 6.4.2(@types/node@22.19.17)(tsx@4.21.0) 54 54 55 55 packages: 56 + 57 + '@atproto-labs/did-resolver@0.2.6': 58 + resolution: {integrity: sha512-2K1bC04nI2fmgNcvof+yA28IhGlpWn2JKYlPa7To9JTKI45FINCGkQSGiL2nyXlyzDJJ34fZ1aq6/IRFIOIiqg==} 59 + 60 + '@atproto-labs/fetch-node@0.2.0': 61 + resolution: {integrity: sha512-Krq09nH/aeoiU2s9xdHA0FjTEFWG9B5FFenipv1iRixCcPc7V3DhTNDawxG9gI8Ny0k4dBVS9WTRN/IDzBx86Q==} 62 + engines: {node: '>=18.7.0'} 63 + 64 + '@atproto-labs/fetch@0.2.3': 65 + resolution: {integrity: sha512-NZtbJOCbxKUFRFKMpamT38PUQMY0hX0p7TG5AEYOPhZKZEP7dHZ1K2s1aB8MdVH0qxmqX7nQleNrrvLf09Zfdw==} 66 + 67 + '@atproto-labs/handle-resolver-node@0.1.25': 68 + resolution: {integrity: sha512-NY9WYM2VLd3IuMGRkkmvGBg8xqVEaK/fitv1vD8SMXqFTekdpjOLCCyv7EFtqVHouzmDcL83VOvWRfHVa8V9Yw==} 69 + engines: {node: '>=18.7.0'} 70 + 71 + '@atproto-labs/handle-resolver@0.3.6': 72 + resolution: {integrity: sha512-qnSTXvOBNj1EHhp2qTWSX8MS5q3AwYU5LKlt5fBvSbCjgmTr2j0URHCv+ydrwO55KvsojIkTMgeMOh4YuY4fCA==} 73 + 74 + '@atproto-labs/identity-resolver@0.3.6': 75 + resolution: {integrity: sha512-qoWqBDRobln0NR8L8dQjSp79E0chGkBhibEgxQa2f9WD+JbJdjQ0YvwwO5yeQn05pJoJmAwmI2wyJ45zjU7aWg==} 76 + 77 + '@atproto-labs/pipe@0.1.1': 78 + resolution: {integrity: sha512-hdNw2oUs2B6BN1lp+32pF7cp8EMKuIN5Qok2Vvv/aOpG/3tNSJ9YkvfI0k6Zd188LeDDYRUpYpxcoFIcGH/FNg==} 79 + 80 + '@atproto-labs/simple-store-memory@0.1.4': 81 + resolution: {integrity: sha512-3mKY4dP8I7yKPFj9VKpYyCRzGJOi5CEpOLPlRhoJyLmgs3J4RzDrjn323Oakjz2Aj2JzRU/AIvWRAZVhpYNJHw==} 82 + 83 + '@atproto-labs/simple-store@0.3.0': 84 + resolution: {integrity: sha512-nOb6ONKBRJHRlukW1sVawUkBqReLlLx6hT35VS3imaNPwiXDxLnTK7lxw3Lrl9k5yugSBDQAkZAq3MPTEFSUBQ==} 85 + 86 + '@atproto/api@0.19.9': 87 + resolution: {integrity: sha512-+sUYNuiA1Rv8HemMCURHwRkMp2D7cq6nNquefjosu6UB54IzkD0MLK3YY383poLRShiApouOxRse2OKK25dbQw==} 88 + 89 + '@atproto/common-web@0.4.21': 90 + resolution: {integrity: sha512-Odq+wdk3YNasGCjjlpl3bCIPvqYHige5DLfMkIffNv/2PI/iIj5ZvAvMvJlJ59OhReKSxtpI0invx5UQPc3+fw==} 91 + 92 + '@atproto/did@0.3.0': 93 + resolution: {integrity: sha512-raUPzUGegtW/6OxwCmM8bhZvuIMzxG5t9oWsth6Tp91Kb5fTnHV2h/KKNF1C82doeA4BdXCErTyg7ISwLbQkzA==} 94 + 95 + '@atproto/jwk-jose@0.1.11': 96 + resolution: {integrity: sha512-i4Fnr2sTBYmMmHXl7NJh8GrCH+tDQEVWrcDMDnV5DjJfkgT17wIqvojIw9SNbSL4Uf0OtfEv6AgG0A+mgh8b5Q==} 97 + 98 + '@atproto/jwk-webcrypto@0.2.0': 99 + resolution: {integrity: sha512-UmgRrrEAkWvxwhlwe30UmDOdTEFidlIzBC7C3cCbeJMcBN1x8B3KH+crXrsTqfWQBG58mXgt8wgSK3Kxs2LhFg==} 100 + 101 + '@atproto/jwk@0.6.0': 102 + resolution: {integrity: sha512-bDoJPvt7TrQVi/rBfBrSSpGykhtIriKxeYCYQTiPRKFfyRhbgpElF0wPXADjIswnbzZdOwbY63az4E/CFVT3Tw==} 103 + 104 + '@atproto/lex-data@0.0.15': 105 + resolution: {integrity: sha512-ZsbGiaM5S3CnGrcTMbDGON3bLZzCi/Mx9UvcMREKSRujnF68eHgMiXxJqvykP7+QpOX6tYCK93axZkuJVhtSEw==} 106 + 107 + '@atproto/lex-json@0.0.16': 108 + resolution: {integrity: sha512-IgLgQ0krshVlrIYZ+heTBDbCnM3LmAgWvsaYn5MxvKA3LcBot3PG3ptdO8VOweVZ+WgCLuo39cz9EbUmIbqdtg==} 109 + 110 + '@atproto/lexicon@0.6.2': 111 + resolution: {integrity: sha512-p3Ly6hinVZW0ETuAXZMeUGwuMm3g8HvQMQ41yyEE6AL0hAkfeKFaZKos6BdBrr6CjkpbrDZqE8M+5+QOceysMw==} 112 + 113 + '@atproto/oauth-client-node@0.3.17': 114 + resolution: {integrity: sha512-67LNuKAlC35Exe7CB5S0QCAnEqr6fKV9Nvp64jAHFof1N+Vc9Ltt1K9oekE5Ctf7dvpGByrHRF0noUw9l9sWLA==} 115 + engines: {node: '>=18.7.0'} 116 + 117 + '@atproto/oauth-client@0.6.0': 118 + resolution: {integrity: sha512-F7ZTKzFptXgyihMkd7QTdRSkrh4XqrS+qTw+V81k5Q6Bh3MB1L3ypvfSJ6v7SSUJa6XxoZYJTCahHC1e+ndE6Q==} 119 + 120 + '@atproto/oauth-types@0.6.3': 121 + resolution: {integrity: sha512-jdKuoPknJuh/WjI+mYk7agSbx9mNVMbS6Dr3k1z2YMY2oRiCQjxYBuo4MLKATbxj05nMQaZRWlHRUazoAu5Cng==} 122 + 123 + '@atproto/syntax@0.5.4': 124 + resolution: {integrity: sha512-9XJOpMAgsGFxMEIp8nJ8AIWv+krrY1xQMj+wULbbXhQztQV+9aZ0TbG9Jtn3Op2or8Kr6OqyWR4ga9Z189kKDw==} 125 + 126 + '@atproto/xrpc@0.7.7': 127 + resolution: {integrity: sha512-K1ZyO/BU8JNtXX5dmPp7b5UrkLMMqpsIa/Lrj5D3Su+j1Xwq1m6QJ2XJ1AgjEjkI1v4Muzm7klianLE6XGxtmA==} 56 128 57 129 '@babel/code-frame@7.29.0': 58 130 resolution: {integrity: sha512-9NhCeYjq9+3uxgdtp20LSiJXJvN0FeCtNGpJxuMFZ1Kv3cWUNb6DOhJwUvcVCzKGR66cw4njwM6hrJLqgOwbcw==} ··· 659 731 cpu: [x64] 660 732 os: [win32] 661 733 662 - '@types/better-sqlite3@7.6.13': 663 - resolution: {integrity: sha512-NMv9ASNARoKksWtsq/SHakpYAYnhBrQgGD8zkLYk/jaK8jUGn08CfEdTRgYhMypUQAfzSP8W6gNLe0q19/t4VA==} 664 - 665 734 '@types/estree@1.0.8': 666 735 resolution: {integrity: sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==} 667 736 ··· 676 745 resolution: {integrity: sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==} 677 746 engines: {node: '>=8'} 678 747 748 + await-lock@2.2.2: 749 + resolution: {integrity: sha512-aDczADvlvTGajTDjcjpJMqRkOF6Qdz3YbPZm/PyW6tKPkx2hlYBzxMhEywM/tU72HrVZjgl5VCdRuMlA7pZ8Gw==} 750 + 679 751 babel-plugin-transform-hook-names@1.0.2: 680 752 resolution: {integrity: sha512-5gafyjyyBTTdX/tQQ0hRgu4AhNHG/hqWi0ZZmg2xvs2FgRkJXzDNKBZCyoYqgFkovfDrgM8OoKg8karoUvWeCw==} 681 753 peerDependencies: 682 754 '@babel/core': ^7.12.10 683 755 684 - base64-js@1.5.1: 685 - resolution: {integrity: sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==} 686 - 687 756 baseline-browser-mapping@2.10.20: 688 757 resolution: {integrity: sha512-1AaXxEPfXT+GvTBJFuy4yXVHWJBXa4OdbIebGN/wX5DlsIkU0+wzGnd2lOzokSk51d5LUmqjgBLRLlypLUqInQ==} 689 758 engines: {node: '>=6.0.0'} 690 759 hasBin: true 691 760 692 - better-sqlite3@11.10.0: 693 - resolution: {integrity: sha512-EwhOpyXiOEL/lKzHz9AW1msWFNzGc/z+LzeB3/jnFJpxu+th2yqvzsSWas1v9jgs9+xiXJcD5A8CJxAG2TaghQ==} 694 - 695 - bindings@1.5.0: 696 - resolution: {integrity: sha512-p2q/t/mhvuOj/UeLlV6566GD/guowlr0hHxClI0W9m7MWYkL1F0hLo+0Aexs9HSPCtR1SXQ0TD3MMKrXZajbiQ==} 697 - 698 - bl@4.1.0: 699 - resolution: {integrity: sha512-1W07cM9gS6DcLperZfFSj+bWLtaPGSOHWhPiGzXmvVJbRLdG82sH/Kn8EtW1VqWVA54AKf2h5k5BbnIbwF3h6w==} 700 - 701 761 boolbase@1.0.0: 702 762 resolution: {integrity: sha512-JZOSA7Mo9sNGB8+UjSgzdLtokWAky1zbztM3WRLCbZ70/3cTANmQmOdR7y2g+J0e2WXywy1yS468tY+IruqEww==} 703 763 ··· 706 766 engines: {node: ^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7} 707 767 hasBin: true 708 768 709 - buffer@5.7.1: 710 - resolution: {integrity: sha512-EHcyIPBQ4BSGlvjB16k5KgAJ27CIsHY/2JBmCRReo48y9rQ3MaUzWX3KVlBa4U7MyX02HdVj0K7C3WaB3ju7FQ==} 711 - 712 769 caniuse-lite@1.0.30001788: 713 770 resolution: {integrity: sha512-6q8HFp+lOQtcf7wBK+uEenxymVWkGKkjFpCvw5W25cmMwEDU45p1xQFBQv8JDlMMry7eNxyBaR+qxgmTUZkIRQ==} 714 771 715 772 chalk@4.1.2: 716 773 resolution: {integrity: sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==} 717 774 engines: {node: '>=10'} 718 - 719 - chownr@1.1.4: 720 - resolution: {integrity: sha512-jJ0bqzaylmJtVnNgzTeSOs8DPavpbYgEr/b0YL8/2GO3xJEhInFmhKMUnEJQjZumK7KXGFhUy89PrsJWlakBVg==} 721 775 722 776 cliui@8.0.1: 723 777 resolution: {integrity: sha512-BSeNnyus75C4//NQ9gQt1/csTXyo/8Sb+afLAkzAptFuMsod9HFokGNudZpi/oQV73hnVK+sR+5PVRMd+Dr7YQ==} ··· 738 792 convert-source-map@2.0.0: 739 793 resolution: {integrity: sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==} 740 794 795 + core-js@3.49.0: 796 + resolution: {integrity: sha512-es1U2+YTtzpwkxVLwAFdSpaIMyQaq0PBgm3YD1W3Qpsn1NAmO3KSgZfu+oGSWVu6NvLHoHCV/aYcsE5wiB7ALg==} 797 + 741 798 css-select@5.2.2: 742 799 resolution: {integrity: sha512-TizTzUddG/xYLA3NXodFM0fSbNizXjOKhqiQQwvhlspadZokn1KDy0NZFS0wuEubIYAV5/c1/lAr0TaaFXEXzw==} 743 800 ··· 757 814 supports-color: 758 815 optional: true 759 816 760 - decompress-response@6.0.0: 761 - resolution: {integrity: sha512-aW35yZM6Bb/4oJlZncMH2LCoZtJXTRxES17vE3hoRiowU2kWHaJKFkSBDnDR+cm9J+9QhXmREyIfv0pji9ejCQ==} 762 - engines: {node: '>=10'} 763 - 764 - deep-extend@0.6.0: 765 - resolution: {integrity: sha512-LOHxIOaPYdHlJRtCQfDIVZtfw/ufM8+rVj649RIHzcm/vGwQRXFt6OPqIFWsm2XEMrNIEtWR64sY1LEKD2vAOA==} 766 - engines: {node: '>=4.0.0'} 767 - 768 - detect-libc@2.1.2: 769 - resolution: {integrity: sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==} 770 - engines: {node: '>=8'} 771 - 772 817 dom-serializer@2.0.0: 773 818 resolution: {integrity: sha512-wIkAryiqt/nV5EQKqQpo3SToSOV9J0DnbJqwK7Wv/Trc92zIAYZ4FlMu+JPFW1DfGFt81ZTCGgDEabffXeLyJg==} 774 819 ··· 792 837 emoji-regex@8.0.0: 793 838 resolution: {integrity: sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==} 794 839 795 - end-of-stream@1.4.5: 796 - resolution: {integrity: sha512-ooEGc6HP26xXq/N+GCGOT0JKCLDGrq2bQUZrQ7gyrJiZANJ/8YDTxTpQBXGMn+WbIQXNVpyWymm7KYVICQnyOg==} 797 - 798 840 entities@4.5.0: 799 841 resolution: {integrity: sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw==} 800 842 engines: {node: '>=0.12'} ··· 820 862 estree-walker@2.0.2: 821 863 resolution: {integrity: sha512-Rfkk/Mp/DL7JVje3u18FxFujQlTNR2q6QfMSMB7AvCBx91NGj/ba3kCfza0f6dVDbw7YlRf/nDrn7pQrCCyQ/w==} 822 864 823 - expand-template@2.0.3: 824 - resolution: {integrity: sha512-XYfuKMvj4O35f/pOXLObndIRvyQ+/+6AhODh+OKWj9S9498pHHn/IMszH+gt0fBCRWMNfk1ZSp5x3AifmnI2vg==} 825 - engines: {node: '>=6'} 826 - 827 865 fdir@6.5.0: 828 866 resolution: {integrity: sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==} 829 867 engines: {node: '>=12.0.0'} ··· 833 871 picomatch: 834 872 optional: true 835 873 836 - file-uri-to-path@1.0.0: 837 - resolution: {integrity: sha512-0Zt+s3L7Vf1biwWZ29aARiVYLx7iMGnEUl9x33fbB/j3jR81u/O2LbqK+Bm1CDSNDKVtJ/YjwY7TUd5SkeLQLw==} 838 - 839 - fs-constants@1.0.0: 840 - resolution: {integrity: sha512-y6OAwoSIf7FyjMIv94u+b5rdheZEjzR63GTyZJm5qh4Bi+2YgwLCcI/fPFZkL5PSixOt6ZNKm+w+Hfp/Bciwow==} 841 - 842 874 fsevents@2.3.3: 843 875 resolution: {integrity: sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==} 844 876 engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0} ··· 855 887 get-tsconfig@4.14.0: 856 888 resolution: {integrity: sha512-yTb+8DXzDREzgvYmh6s9vHsSVCHeC0G3PI5bEXNBHtmshPnO+S5O7qgLEOn0I5QvMy6kpZN8K1NKGyilLb93wA==} 857 889 858 - github-from-package@0.0.0: 859 - resolution: {integrity: sha512-SyHy3T1v2NUXn29OsWdxmK6RwHD+vkj3v8en8AOBZ1wBQ/hCAQ5bAQTD02kW4W9tUp/3Qh6J8r9EvntiyCmOOw==} 860 - 861 890 has-flag@4.0.0: 862 891 resolution: {integrity: sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==} 863 892 engines: {node: '>=8'} ··· 876 905 htmlparser2@10.1.0: 877 906 resolution: {integrity: sha512-VTZkM9GWRAtEpveh7MSF6SjjrpNVNNVJfFup7xTY3UpFtm67foy9HDVXneLtFVt4pMz5kZtgNcvCniNFb1hlEQ==} 878 907 879 - ieee754@1.2.1: 880 - resolution: {integrity: sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA==} 881 - 882 - inherits@2.0.4: 883 - resolution: {integrity: sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==} 884 - 885 - ini@1.3.8: 886 - resolution: {integrity: sha512-JV/yugV2uzW5iMRSiZAyDtQd+nxtUnjeLt0acNdw98kKLrvuRVyB80tsREOE7yvGVgalhZ6RNXCmEHkUKBKxew==} 908 + ipaddr.js@2.3.0: 909 + resolution: {integrity: sha512-Zv/pA+ciVFbCSBBjGfaKUya/CcGmUHzTydLMaTwrUUEM2DIEO3iZvueGxmacvmN50fGpGVKeTXpb2LcYQxeVdg==} 910 + engines: {node: '>= 10'} 887 911 888 912 is-fullwidth-code-point@3.0.0: 889 913 resolution: {integrity: sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==} 890 914 engines: {node: '>=8'} 915 + 916 + iso-datestring-validator@2.2.2: 917 + resolution: {integrity: sha512-yLEMkBbLZTlVQqOnQ4FiMujR6T4DEcCb1xizmvXS+OxuhwcbtynoosRzdMA69zZCShCNAbi+gJ71FxZBBXx1SA==} 918 + 919 + jose@5.10.0: 920 + resolution: {integrity: sha512-s+3Al/p9g32Iq+oqXxkW//7jk2Vig6FF1CFqzVXoTUXt2qz89YWbL+OwS17NFYEvxC35n0FKeGO2LGYSxeM2Gg==} 891 921 892 922 js-tokens@4.0.0: 893 923 resolution: {integrity: sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==} ··· 913 943 peerDependenciesMeta: 914 944 canvas: 915 945 optional: true 946 + 947 + lru-cache@10.4.3: 948 + resolution: {integrity: sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==} 916 949 917 950 lru-cache@5.1.1: 918 951 resolution: {integrity: sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==} ··· 920 953 magic-string@0.30.21: 921 954 resolution: {integrity: sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==} 922 955 923 - mimic-response@3.1.0: 924 - resolution: {integrity: sha512-z0yWI+4FDrrweS8Zmt4Ej5HdJmky15+L2e6Wgn3+iK5fWzb6T3fhNFq2+MeTRb064c6Wr4N/wv0DzQTjNzHNGQ==} 925 - engines: {node: '>=10'} 926 - 927 - minimist@1.2.8: 928 - resolution: {integrity: sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==} 929 - 930 - mkdirp-classic@0.5.3: 931 - resolution: {integrity: sha512-gKLcREMhtuZRwRAfqP3RFW+TK4JqApVBtOIftVgjuABpAtpxhPGaDcfvbhNvD0B8iD1oUr/txX35NjcaY6Ns/A==} 932 - 933 956 ms@2.1.3: 934 957 resolution: {integrity: sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==} 958 + 959 + multiformats@9.9.0: 960 + resolution: {integrity: sha512-HoMUjhH9T8DDBNT+6xzkrd9ga/XiBI4xLr58LJACwK6G3HTOPeMz4nB4KJs33L2BelrIJa7P0VuNaVF3hMYfjg==} 935 961 936 962 nanoid@3.3.11: 937 963 resolution: {integrity: sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==} 938 964 engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1} 939 965 hasBin: true 940 966 941 - napi-build-utils@2.0.0: 942 - resolution: {integrity: sha512-GEbrYkbfF7MoNaoh2iGG84Mnf/WZfB0GdGEsM8wz7Expx/LlWf5U8t9nvJKXSp3qr5IsEbK04cBGhol/KwOsWA==} 943 - 944 - node-abi@3.89.0: 945 - resolution: {integrity: sha512-6u9UwL0HlAl21+agMN3YAMXcKByMqwGx+pq+P76vii5f7hTPtKDp08/H9py6DY+cfDw7kQNTGEj/rly3IgbNQA==} 946 - engines: {node: '>=10'} 947 - 948 967 node-html-parser@6.1.13: 949 968 resolution: {integrity: sha512-qIsTMOY4C/dAa5Q5vsobRpOOvPfC4pB61UVW2uSwZNUp0QU/jCekTal1vMmbO0DgdHeLUJpv/ARmDqErVxA3Sg==} 950 969 ··· 953 972 954 973 nth-check@2.1.1: 955 974 resolution: {integrity: sha512-lqjrjmaOoAnWfMmBPL+XNnynZh2+swxiX3WUE0s4yEHI6m+AwrK2UZOimIRl3X/4QctVqS8AiZjFqyOGrMXb/w==} 956 - 957 - once@1.4.0: 958 - resolution: {integrity: sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==} 959 975 960 976 picocolors@1.1.1: 961 977 resolution: {integrity: sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==} ··· 975 991 preact@10.29.1: 976 992 resolution: {integrity: sha512-gQCLc/vWroE8lIpleXtdJhTFDogTdZG9AjMUpVkDf2iTCNwYNWA+u16dL41TqUDJO4gm2IgrcMv3uTpjd4Pwmg==} 977 993 978 - prebuild-install@7.1.3: 979 - resolution: {integrity: sha512-8Mf2cbV7x1cXPUILADGI3wuhfqWvtiLA1iclTDbFRZkgRQS0NqsPZphna9V+HyTEadheuPmjaJMsbzKQFOzLug==} 980 - engines: {node: '>=10'} 981 - deprecated: No longer maintained. Please contact the author of the relevant native addon; alternatives are available. 982 - hasBin: true 983 - 984 - pump@3.0.4: 985 - resolution: {integrity: sha512-VS7sjc6KR7e1ukRFhQSY5LM2uBWAUPiOPa/A3mkKmiMwSmRFUITt0xuj+/lesgnCv+dPIEYlkzrcyXgquIHMcA==} 986 - 987 - rc@1.2.8: 988 - resolution: {integrity: sha512-y3bGgqKj3QBdxLbLkomlohkvsA8gdAiUQlSBJnBhfn+BPxg4bc62d8TcBW15wavDfgexCgccckhcZvywyQYPOw==} 989 - hasBin: true 990 - 991 - readable-stream@3.6.2: 992 - resolution: {integrity: sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==} 993 - engines: {node: '>= 6'} 994 - 995 994 require-directory@2.1.1: 996 995 resolution: {integrity: sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q==} 997 996 engines: {node: '>=0.10.0'} ··· 1007 1006 rxjs@7.8.2: 1008 1007 resolution: {integrity: sha512-dhKf903U/PQZY6boNNtAGdWbG85WAbjT/1xYoZIC7FAY0yWapOBQVsVrDl58W86//e1VpMNBtRV4MaXfdMySFA==} 1009 1008 1010 - safe-buffer@5.2.1: 1011 - resolution: {integrity: sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==} 1012 - 1013 1009 semver@6.3.1: 1014 1010 resolution: {integrity: sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==} 1015 1011 hasBin: true 1016 1012 1017 - semver@7.7.4: 1018 - resolution: {integrity: sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA==} 1019 - engines: {node: '>=10'} 1020 - hasBin: true 1021 - 1022 1013 shell-quote@1.8.3: 1023 1014 resolution: {integrity: sha512-ObmnIF4hXNg1BqhnHmgbDETF8dLPCggZWBjkQfhZpbszZnYur5DUljTcCHii5LC3J5E0yeO/1LIMyH+UvHQgyw==} 1024 1015 engines: {node: '>= 0.4'} ··· 1026 1017 simple-code-frame@1.3.0: 1027 1018 resolution: {integrity: sha512-MB4pQmETUBlNs62BBeRjIFGeuy/x6gGKh7+eRUemn1rCFhqo7K+4slPqsyizCbcbYLnaYqaoZ2FWsZ/jN06D8w==} 1028 1019 1029 - simple-concat@1.0.1: 1030 - resolution: {integrity: sha512-cSFtAPtRhljv69IK0hTVZQ+OfE9nePi/rtJmw5UjHeVyVroEqJXP1sFztKUy1qU+xvz3u/sfYJLa947b7nAN2Q==} 1031 - 1032 - simple-get@4.0.1: 1033 - resolution: {integrity: sha512-brv7p5WgH0jmQJr1ZDDfKDOSeWWg+OVypG99A/5vYGPqJ6pxiaHLy8nxtFjBA7oMa01ebA9gfh1uMCFqOuXxvA==} 1034 - 1035 1020 source-map-js@1.2.1: 1036 1021 resolution: {integrity: sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==} 1037 1022 engines: {node: '>=0.10.0'} ··· 1047 1032 string-width@4.2.3: 1048 1033 resolution: {integrity: sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==} 1049 1034 engines: {node: '>=8'} 1050 - 1051 - string_decoder@1.3.0: 1052 - resolution: {integrity: sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA==} 1053 1035 1054 1036 strip-ansi@6.0.1: 1055 1037 resolution: {integrity: sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==} 1056 1038 engines: {node: '>=8'} 1057 1039 1058 - strip-json-comments@2.0.1: 1059 - resolution: {integrity: sha512-4gB8na07fecVVkOI6Rs4e7T6NOTki5EmL7TUduTs6bu3EdnSycntVJ4re8kgZA+wx9IueI2Y11bfbgwtzuE0KQ==} 1060 - engines: {node: '>=0.10.0'} 1061 - 1062 1040 supports-color@7.2.0: 1063 1041 resolution: {integrity: sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==} 1064 1042 engines: {node: '>=8'} ··· 1067 1045 resolution: {integrity: sha512-MpUEN2OodtUzxvKQl72cUF7RQ5EiHsGvSsVG0ia9c5RbWGL2CI4C7EpPS8UTBIplnlzZiNuV56w+FuNxy3ty2Q==} 1068 1046 engines: {node: '>=10'} 1069 1047 1070 - tar-fs@2.1.4: 1071 - resolution: {integrity: sha512-mDAjwmZdh7LTT6pNleZ05Yt65HC3E+NiQzl672vQG38jIrehtJk/J3mNwIg+vShQPcLF/LV7CMnDW6vjj6sfYQ==} 1072 - 1073 - tar-stream@2.2.0: 1074 - resolution: {integrity: sha512-ujeqbceABgwMZxEJnk2HDY2DlnUZ+9oEcb1KzTVfYHio0UE6dG71n60d8D2I4qNvleWrrXpmjpt7vZeF1LnMZQ==} 1075 - engines: {node: '>=6'} 1076 - 1077 1048 tinyglobby@0.2.16: 1078 1049 resolution: {integrity: sha512-pn99VhoACYR8nFHhxqix+uvsbXineAasWm5ojXoN8xEwK5Kd3/TrhNn1wByuD52UxWRLy8pu+kRMniEi6Eq9Zg==} 1079 1050 engines: {node: '>=12.0.0'} 1051 + 1052 + tlds@1.261.0: 1053 + resolution: {integrity: sha512-QXqwfEl9ddlGBaRFXIvNKK6OhipSiLXuRuLJX5DErz0o0Q0rYxulWLdFryTkV5PkdZct5iMInwYEGe/eR++1AA==} 1054 + hasBin: true 1080 1055 1081 1056 tree-kill@1.2.2: 1082 1057 resolution: {integrity: sha512-L0Orpi8qGpRG//Nd+H90vFB+3iHnue1zSSGmNOOCh1GLJ7rUKVwV2HvijphGQS2UmhUZewS9VgvxYIdgr+fG1A==} ··· 1089 1064 resolution: {integrity: sha512-5C1sg4USs1lfG0GFb2RLXsdpXqBSEhAaA/0kPL01wxzpMqLILNxIxIOKiILz+cdg/pLnOUxFYOR5yhHU666wbw==} 1090 1065 engines: {node: '>=18.0.0'} 1091 1066 hasBin: true 1092 - 1093 - tunnel-agent@0.6.0: 1094 - resolution: {integrity: sha512-McnNiV1l8RYeY8tBgEpuodCC1mLUdbSN+CYBL7kJsJNInOP8UjDDEwdk6Mw60vdLLrr5NHKZhMAOSrR2NZuQ+w==} 1095 1067 1096 1068 typescript@5.9.3: 1097 1069 resolution: {integrity: sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==} ··· 1101 1073 uhyphen@0.2.0: 1102 1074 resolution: {integrity: sha512-qz3o9CHXmJJPGBdqzab7qAYuW8kQGKNEuoHFYrBwV6hWIMcpAmxDLXojcHfFr9US1Pe6zUswEIJIbLI610fuqA==} 1103 1075 1076 + uint8arrays@3.0.0: 1077 + resolution: {integrity: sha512-HRCx0q6O9Bfbp+HHSfQQKD7wU70+lydKVt4EghkdOvlK/NlrF90z+eXV34mUd48rNvVJXwkrMSPpCATkct8fJA==} 1078 + 1104 1079 undici-types@6.21.0: 1105 1080 resolution: {integrity: sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==} 1106 1081 1082 + undici@6.25.0: 1083 + resolution: {integrity: sha512-ZgpWDC5gmNiuY9CnLVXEH8rl50xhRCuLNA97fAUnKi8RRuV4E6KG31pDTsLVUKnohJE0I3XDrTeEydAXRw47xg==} 1084 + engines: {node: '>=18.17'} 1085 + 1086 + unicode-segmenter@0.14.5: 1087 + resolution: {integrity: sha512-jHGmj2LUuqDcX3hqY12Ql+uhUTn8huuxNZGq7GvtF6bSybzH3aFgedYu/KTzQStEgt1Ra2F3HxadNXsNjb3m3g==} 1088 + 1107 1089 update-browserslist-db@1.2.3: 1108 1090 resolution: {integrity: sha512-Js0m9cx+qOgDxo0eMiFGEueWztz+d4+M3rGlmKPT+T4IS/jP4ylw3Nwpu6cpTTP8R1MAC1kF4VbdLt3ARf209w==} 1109 1091 hasBin: true 1110 1092 peerDependencies: 1111 1093 browserslist: '>= 4.21.0' 1112 - 1113 - util-deprecate@1.0.2: 1114 - resolution: {integrity: sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==} 1115 1094 1116 1095 vite-prerender-plugin@0.5.13: 1117 1096 resolution: {integrity: sha512-IKSpYkzDBsKAxa05naRbj7GvNVMSdww/Z/E89oO3xndz+gWnOBOKOAbEXv7qDhktY/j3vHgJmoV1pPzqU2tx9g==} ··· 1162 1141 resolution: {integrity: sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==} 1163 1142 engines: {node: '>=10'} 1164 1143 1165 - wrappy@1.0.2: 1166 - resolution: {integrity: sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==} 1167 - 1168 1144 y18n@5.0.8: 1169 1145 resolution: {integrity: sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA==} 1170 1146 engines: {node: '>=10'} ··· 1183 1159 zimmerframe@1.1.4: 1184 1160 resolution: {integrity: sha512-B58NGBEoc8Y9MWWCQGl/gq9xBCe4IiKM0a2x7GZdQKOW5Exr8S1W24J6OgM1njK8xCRGvAJIL/MxXHf6SkmQKQ==} 1185 1161 1162 + zod@3.25.76: 1163 + resolution: {integrity: sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==} 1164 + 1186 1165 snapshots: 1187 1166 1167 + '@atproto-labs/did-resolver@0.2.6': 1168 + dependencies: 1169 + '@atproto-labs/fetch': 0.2.3 1170 + '@atproto-labs/pipe': 0.1.1 1171 + '@atproto-labs/simple-store': 0.3.0 1172 + '@atproto-labs/simple-store-memory': 0.1.4 1173 + '@atproto/did': 0.3.0 1174 + zod: 3.25.76 1175 + 1176 + '@atproto-labs/fetch-node@0.2.0': 1177 + dependencies: 1178 + '@atproto-labs/fetch': 0.2.3 1179 + '@atproto-labs/pipe': 0.1.1 1180 + ipaddr.js: 2.3.0 1181 + undici: 6.25.0 1182 + 1183 + '@atproto-labs/fetch@0.2.3': 1184 + dependencies: 1185 + '@atproto-labs/pipe': 0.1.1 1186 + 1187 + '@atproto-labs/handle-resolver-node@0.1.25': 1188 + dependencies: 1189 + '@atproto-labs/fetch-node': 0.2.0 1190 + '@atproto-labs/handle-resolver': 0.3.6 1191 + '@atproto/did': 0.3.0 1192 + 1193 + '@atproto-labs/handle-resolver@0.3.6': 1194 + dependencies: 1195 + '@atproto-labs/simple-store': 0.3.0 1196 + '@atproto-labs/simple-store-memory': 0.1.4 1197 + '@atproto/did': 0.3.0 1198 + zod: 3.25.76 1199 + 1200 + '@atproto-labs/identity-resolver@0.3.6': 1201 + dependencies: 1202 + '@atproto-labs/did-resolver': 0.2.6 1203 + '@atproto-labs/handle-resolver': 0.3.6 1204 + 1205 + '@atproto-labs/pipe@0.1.1': {} 1206 + 1207 + '@atproto-labs/simple-store-memory@0.1.4': 1208 + dependencies: 1209 + '@atproto-labs/simple-store': 0.3.0 1210 + lru-cache: 10.4.3 1211 + 1212 + '@atproto-labs/simple-store@0.3.0': {} 1213 + 1214 + '@atproto/api@0.19.9': 1215 + dependencies: 1216 + '@atproto/common-web': 0.4.21 1217 + '@atproto/lexicon': 0.6.2 1218 + '@atproto/syntax': 0.5.4 1219 + '@atproto/xrpc': 0.7.7 1220 + await-lock: 2.2.2 1221 + multiformats: 9.9.0 1222 + tlds: 1.261.0 1223 + zod: 3.25.76 1224 + 1225 + '@atproto/common-web@0.4.21': 1226 + dependencies: 1227 + '@atproto/lex-data': 0.0.15 1228 + '@atproto/lex-json': 0.0.16 1229 + '@atproto/syntax': 0.5.4 1230 + zod: 3.25.76 1231 + 1232 + '@atproto/did@0.3.0': 1233 + dependencies: 1234 + zod: 3.25.76 1235 + 1236 + '@atproto/jwk-jose@0.1.11': 1237 + dependencies: 1238 + '@atproto/jwk': 0.6.0 1239 + jose: 5.10.0 1240 + 1241 + '@atproto/jwk-webcrypto@0.2.0': 1242 + dependencies: 1243 + '@atproto/jwk': 0.6.0 1244 + '@atproto/jwk-jose': 0.1.11 1245 + zod: 3.25.76 1246 + 1247 + '@atproto/jwk@0.6.0': 1248 + dependencies: 1249 + multiformats: 9.9.0 1250 + zod: 3.25.76 1251 + 1252 + '@atproto/lex-data@0.0.15': 1253 + dependencies: 1254 + multiformats: 9.9.0 1255 + tslib: 2.8.1 1256 + uint8arrays: 3.0.0 1257 + unicode-segmenter: 0.14.5 1258 + 1259 + '@atproto/lex-json@0.0.16': 1260 + dependencies: 1261 + '@atproto/lex-data': 0.0.15 1262 + tslib: 2.8.1 1263 + 1264 + '@atproto/lexicon@0.6.2': 1265 + dependencies: 1266 + '@atproto/common-web': 0.4.21 1267 + '@atproto/syntax': 0.5.4 1268 + iso-datestring-validator: 2.2.2 1269 + multiformats: 9.9.0 1270 + zod: 3.25.76 1271 + 1272 + '@atproto/oauth-client-node@0.3.17': 1273 + dependencies: 1274 + '@atproto-labs/did-resolver': 0.2.6 1275 + '@atproto-labs/handle-resolver-node': 0.1.25 1276 + '@atproto-labs/simple-store': 0.3.0 1277 + '@atproto/did': 0.3.0 1278 + '@atproto/jwk': 0.6.0 1279 + '@atproto/jwk-jose': 0.1.11 1280 + '@atproto/jwk-webcrypto': 0.2.0 1281 + '@atproto/oauth-client': 0.6.0 1282 + '@atproto/oauth-types': 0.6.3 1283 + 1284 + '@atproto/oauth-client@0.6.0': 1285 + dependencies: 1286 + '@atproto-labs/did-resolver': 0.2.6 1287 + '@atproto-labs/fetch': 0.2.3 1288 + '@atproto-labs/handle-resolver': 0.3.6 1289 + '@atproto-labs/identity-resolver': 0.3.6 1290 + '@atproto-labs/simple-store': 0.3.0 1291 + '@atproto-labs/simple-store-memory': 0.1.4 1292 + '@atproto/did': 0.3.0 1293 + '@atproto/jwk': 0.6.0 1294 + '@atproto/oauth-types': 0.6.3 1295 + '@atproto/xrpc': 0.7.7 1296 + core-js: 3.49.0 1297 + multiformats: 9.9.0 1298 + zod: 3.25.76 1299 + 1300 + '@atproto/oauth-types@0.6.3': 1301 + dependencies: 1302 + '@atproto/did': 0.3.0 1303 + '@atproto/jwk': 0.6.0 1304 + zod: 3.25.76 1305 + 1306 + '@atproto/syntax@0.5.4': 1307 + dependencies: 1308 + tslib: 2.8.1 1309 + 1310 + '@atproto/xrpc@0.7.7': 1311 + dependencies: 1312 + '@atproto/lexicon': 0.6.2 1313 + zod: 3.25.76 1314 + 1188 1315 '@babel/code-frame@7.29.0': 1189 1316 dependencies: 1190 1317 '@babel/helper-validator-identifier': 7.28.5 ··· 1622 1749 '@rollup/rollup-win32-x64-msvc@4.60.2': 1623 1750 optional: true 1624 1751 1625 - '@types/better-sqlite3@7.6.13': 1626 - dependencies: 1627 - '@types/node': 22.19.17 1628 - 1629 1752 '@types/estree@1.0.8': {} 1630 1753 1631 1754 '@types/node@22.19.17': ··· 1638 1761 dependencies: 1639 1762 color-convert: 2.0.1 1640 1763 1764 + await-lock@2.2.2: {} 1765 + 1641 1766 babel-plugin-transform-hook-names@1.0.2(@babel/core@7.29.0): 1642 1767 dependencies: 1643 1768 '@babel/core': 7.29.0 1644 1769 1645 - base64-js@1.5.1: {} 1646 - 1647 1770 baseline-browser-mapping@2.10.20: {} 1648 1771 1649 - better-sqlite3@11.10.0: 1650 - dependencies: 1651 - bindings: 1.5.0 1652 - prebuild-install: 7.1.3 1653 - 1654 - bindings@1.5.0: 1655 - dependencies: 1656 - file-uri-to-path: 1.0.0 1657 - 1658 - bl@4.1.0: 1659 - dependencies: 1660 - buffer: 5.7.1 1661 - inherits: 2.0.4 1662 - readable-stream: 3.6.2 1663 - 1664 1772 boolbase@1.0.0: {} 1665 1773 1666 1774 browserslist@4.28.2: ··· 1671 1779 node-releases: 2.0.37 1672 1780 update-browserslist-db: 1.2.3(browserslist@4.28.2) 1673 1781 1674 - buffer@5.7.1: 1675 - dependencies: 1676 - base64-js: 1.5.1 1677 - ieee754: 1.2.1 1678 - 1679 1782 caniuse-lite@1.0.30001788: {} 1680 1783 1681 1784 chalk@4.1.2: 1682 1785 dependencies: 1683 1786 ansi-styles: 4.3.0 1684 1787 supports-color: 7.2.0 1685 - 1686 - chownr@1.1.4: {} 1687 1788 1688 1789 cliui@8.0.1: 1689 1790 dependencies: ··· 1708 1809 1709 1810 convert-source-map@2.0.0: {} 1710 1811 1812 + core-js@3.49.0: {} 1813 + 1711 1814 css-select@5.2.2: 1712 1815 dependencies: 1713 1816 boolbase: 1.0.0 ··· 1724 1827 dependencies: 1725 1828 ms: 2.1.3 1726 1829 1727 - decompress-response@6.0.0: 1728 - dependencies: 1729 - mimic-response: 3.1.0 1730 - 1731 - deep-extend@0.6.0: {} 1732 - 1733 - detect-libc@2.1.2: {} 1734 - 1735 1830 dom-serializer@2.0.0: 1736 1831 dependencies: 1737 1832 domelementtype: 2.3.0 ··· 1756 1851 1757 1852 emoji-regex@8.0.0: {} 1758 1853 1759 - end-of-stream@1.4.5: 1760 - dependencies: 1761 - once: 1.4.0 1762 - 1763 1854 entities@4.5.0: {} 1764 1855 1765 1856 entities@7.0.1: {} ··· 1826 1917 1827 1918 estree-walker@2.0.2: {} 1828 1919 1829 - expand-template@2.0.3: {} 1830 - 1831 1920 fdir@6.5.0(picomatch@4.0.4): 1832 1921 optionalDependencies: 1833 1922 picomatch: 4.0.4 1834 1923 1835 - file-uri-to-path@1.0.0: {} 1836 - 1837 - fs-constants@1.0.0: {} 1838 - 1839 1924 fsevents@2.3.3: 1840 1925 optional: true 1841 1926 ··· 1846 1931 get-tsconfig@4.14.0: 1847 1932 dependencies: 1848 1933 resolve-pkg-maps: 1.0.0 1849 - 1850 - github-from-package@0.0.0: {} 1851 1934 1852 1935 has-flag@4.0.0: {} 1853 1936 ··· 1864 1947 domutils: 3.2.2 1865 1948 entities: 7.0.1 1866 1949 1867 - ieee754@1.2.1: {} 1950 + ipaddr.js@2.3.0: {} 1868 1951 1869 - inherits@2.0.4: {} 1952 + is-fullwidth-code-point@3.0.0: {} 1870 1953 1871 - ini@1.3.8: {} 1954 + iso-datestring-validator@2.2.2: {} 1872 1955 1873 - is-fullwidth-code-point@3.0.0: {} 1956 + jose@5.10.0: {} 1874 1957 1875 1958 js-tokens@4.0.0: {} 1876 1959 ··· 1888 1971 htmlparser2: 10.1.0 1889 1972 uhyphen: 0.2.0 1890 1973 1974 + lru-cache@10.4.3: {} 1975 + 1891 1976 lru-cache@5.1.1: 1892 1977 dependencies: 1893 1978 yallist: 3.1.1 ··· 1896 1981 dependencies: 1897 1982 '@jridgewell/sourcemap-codec': 1.5.5 1898 1983 1899 - mimic-response@3.1.0: {} 1900 - 1901 - minimist@1.2.8: {} 1902 - 1903 - mkdirp-classic@0.5.3: {} 1904 - 1905 1984 ms@2.1.3: {} 1906 1985 1907 - nanoid@3.3.11: {} 1908 - 1909 - napi-build-utils@2.0.0: {} 1986 + multiformats@9.9.0: {} 1910 1987 1911 - node-abi@3.89.0: 1912 - dependencies: 1913 - semver: 7.7.4 1988 + nanoid@3.3.11: {} 1914 1989 1915 1990 node-html-parser@6.1.13: 1916 1991 dependencies: ··· 1923 1998 dependencies: 1924 1999 boolbase: 1.0.0 1925 2000 1926 - once@1.4.0: 1927 - dependencies: 1928 - wrappy: 1.0.2 1929 - 1930 2001 picocolors@1.1.1: {} 1931 2002 1932 2003 picomatch@2.3.2: {} ··· 1941 2012 1942 2013 preact@10.29.1: {} 1943 2014 1944 - prebuild-install@7.1.3: 1945 - dependencies: 1946 - detect-libc: 2.1.2 1947 - expand-template: 2.0.3 1948 - github-from-package: 0.0.0 1949 - minimist: 1.2.8 1950 - mkdirp-classic: 0.5.3 1951 - napi-build-utils: 2.0.0 1952 - node-abi: 3.89.0 1953 - pump: 3.0.4 1954 - rc: 1.2.8 1955 - simple-get: 4.0.1 1956 - tar-fs: 2.1.4 1957 - tunnel-agent: 0.6.0 1958 - 1959 - pump@3.0.4: 1960 - dependencies: 1961 - end-of-stream: 1.4.5 1962 - once: 1.4.0 1963 - 1964 - rc@1.2.8: 1965 - dependencies: 1966 - deep-extend: 0.6.0 1967 - ini: 1.3.8 1968 - minimist: 1.2.8 1969 - strip-json-comments: 2.0.1 1970 - 1971 - readable-stream@3.6.2: 1972 - dependencies: 1973 - inherits: 2.0.4 1974 - string_decoder: 1.3.0 1975 - util-deprecate: 1.0.2 1976 - 1977 2015 require-directory@2.1.1: {} 1978 2016 1979 2017 resolve-pkg-maps@1.0.0: {} ··· 2013 2051 dependencies: 2014 2052 tslib: 2.8.1 2015 2053 2016 - safe-buffer@5.2.1: {} 2017 - 2018 2054 semver@6.3.1: {} 2019 - 2020 - semver@7.7.4: {} 2021 2055 2022 2056 shell-quote@1.8.3: {} 2023 2057 ··· 2025 2059 dependencies: 2026 2060 kolorist: 1.8.0 2027 2061 2028 - simple-concat@1.0.1: {} 2029 - 2030 - simple-get@4.0.1: 2031 - dependencies: 2032 - decompress-response: 6.0.0 2033 - once: 1.4.0 2034 - simple-concat: 1.0.1 2035 - 2036 2062 source-map-js@1.2.1: {} 2037 2063 2038 2064 source-map@0.7.6: {} ··· 2045 2071 is-fullwidth-code-point: 3.0.0 2046 2072 strip-ansi: 6.0.1 2047 2073 2048 - string_decoder@1.3.0: 2049 - dependencies: 2050 - safe-buffer: 5.2.1 2051 - 2052 2074 strip-ansi@6.0.1: 2053 2075 dependencies: 2054 2076 ansi-regex: 5.0.1 2055 - 2056 - strip-json-comments@2.0.1: {} 2057 2077 2058 2078 supports-color@7.2.0: 2059 2079 dependencies: ··· 2063 2083 dependencies: 2064 2084 has-flag: 4.0.0 2065 2085 2066 - tar-fs@2.1.4: 2067 - dependencies: 2068 - chownr: 1.1.4 2069 - mkdirp-classic: 0.5.3 2070 - pump: 3.0.4 2071 - tar-stream: 2.2.0 2072 - 2073 - tar-stream@2.2.0: 2074 - dependencies: 2075 - bl: 4.1.0 2076 - end-of-stream: 1.4.5 2077 - fs-constants: 1.0.0 2078 - inherits: 2.0.4 2079 - readable-stream: 3.6.2 2080 - 2081 2086 tinyglobby@0.2.16: 2082 2087 dependencies: 2083 2088 fdir: 6.5.0(picomatch@4.0.4) 2084 2089 picomatch: 4.0.4 2090 + 2091 + tlds@1.261.0: {} 2085 2092 2086 2093 tree-kill@1.2.2: {} 2087 2094 ··· 2094 2101 optionalDependencies: 2095 2102 fsevents: 2.3.3 2096 2103 2097 - tunnel-agent@0.6.0: 2098 - dependencies: 2099 - safe-buffer: 5.2.1 2100 - 2101 2104 typescript@5.9.3: {} 2102 2105 2103 2106 uhyphen@0.2.0: {} 2104 2107 2108 + uint8arrays@3.0.0: 2109 + dependencies: 2110 + multiformats: 9.9.0 2111 + 2105 2112 undici-types@6.21.0: {} 2106 2113 2114 + undici@6.25.0: {} 2115 + 2116 + unicode-segmenter@0.14.5: {} 2117 + 2107 2118 update-browserslist-db@1.2.3(browserslist@4.28.2): 2108 2119 dependencies: 2109 2120 browserslist: 4.28.2 2110 2121 escalade: 3.2.0 2111 2122 picocolors: 1.1.1 2112 - 2113 - util-deprecate@1.0.2: {} 2114 2123 2115 2124 vite-prerender-plugin@0.5.13(vite@6.4.2(@types/node@22.19.17)(tsx@4.21.0)): 2116 2125 dependencies: ··· 2141 2150 string-width: 4.2.3 2142 2151 strip-ansi: 6.0.1 2143 2152 2144 - wrappy@1.0.2: {} 2145 - 2146 2153 y18n@5.0.8: {} 2147 2154 2148 2155 yallist@3.1.1: {} ··· 2160 2167 yargs-parser: 21.1.1 2161 2168 2162 2169 zimmerframe@1.1.4: {} 2170 + 2171 + zod@3.25.76: {}
+132
src/server/atproto.ts
··· 1 + import { Agent } from "@atproto/api"; 2 + import type { OAuthSession } from "@atproto/oauth-client-node"; 3 + import { 4 + FEED_NSID, 5 + SAVE_NSID, 6 + type FeedRecord, 7 + type FeedView, 8 + type SaveRecord, 9 + type SaveView, 10 + } from "../shared/lexicons.js"; 11 + 12 + export class AtprotoRepo { 13 + constructor(private session: OAuthSession) {} 14 + 15 + private agent(): Agent { 16 + return new Agent(this.session); 17 + } 18 + 19 + get did(): string { 20 + return this.session.did; 21 + } 22 + 23 + async listFeeds(): Promise<FeedView[]> { 24 + return listAll<FeedRecord>(this.agent(), this.did, FEED_NSID); 25 + } 26 + 27 + async createFeed(url: string, title?: string): Promise<void> { 28 + await this.agent().com.atproto.repo.createRecord({ 29 + repo: this.did, 30 + collection: FEED_NSID, 31 + record: { 32 + $type: FEED_NSID, 33 + url, 34 + ...(title ? { title } : {}), 35 + createdAt: new Date().toISOString(), 36 + } satisfies FeedRecord, 37 + }); 38 + } 39 + 40 + async deleteFeed(rkey: string): Promise<void> { 41 + await this.agent().com.atproto.repo.deleteRecord({ 42 + repo: this.did, 43 + collection: FEED_NSID, 44 + rkey, 45 + }); 46 + } 47 + 48 + async listSaves(): Promise<SaveView[]> { 49 + return listAll<SaveRecord>(this.agent(), this.did, SAVE_NSID); 50 + } 51 + 52 + async createSave(url: string, title?: string): Promise<SaveView> { 53 + const record: SaveRecord = { 54 + $type: SAVE_NSID, 55 + url, 56 + ...(title ? { title } : {}), 57 + createdAt: new Date().toISOString(), 58 + }; 59 + const res = await this.agent().com.atproto.repo.createRecord({ 60 + repo: this.did, 61 + collection: SAVE_NSID, 62 + record, 63 + }); 64 + const rkey = extractRkey(res.data.uri); 65 + return { rkey, record }; 66 + } 67 + 68 + async deleteSave(rkey: string): Promise<void> { 69 + await this.agent().com.atproto.repo.deleteRecord({ 70 + repo: this.did, 71 + collection: SAVE_NSID, 72 + rkey, 73 + }); 74 + } 75 + 76 + async getSave(rkey: string): Promise<SaveView | null> { 77 + try { 78 + const res = await this.agent().com.atproto.repo.getRecord({ 79 + repo: this.did, 80 + collection: SAVE_NSID, 81 + rkey, 82 + }); 83 + return { rkey, record: res.data.value as SaveRecord }; 84 + } catch { 85 + return null; 86 + } 87 + } 88 + 89 + async markSaveRead(rkey: string, read: boolean): Promise<void> { 90 + const existing = await this.getSave(rkey); 91 + if (!existing) return; 92 + const record: SaveRecord = { 93 + ...existing.record, 94 + readAt: read ? new Date().toISOString() : undefined, 95 + }; 96 + if (!read) delete (record as Partial<SaveRecord>).readAt; 97 + await this.agent().com.atproto.repo.putRecord({ 98 + repo: this.did, 99 + collection: SAVE_NSID, 100 + rkey, 101 + record, 102 + }); 103 + } 104 + } 105 + 106 + async function listAll<R>( 107 + agent: Agent, 108 + repo: string, 109 + collection: string, 110 + ): Promise<Array<{ rkey: string; record: R }>> { 111 + const out: Array<{ rkey: string; record: R }> = []; 112 + let cursor: string | undefined; 113 + do { 114 + const res = await agent.com.atproto.repo.listRecords({ 115 + repo, 116 + collection, 117 + limit: 100, 118 + cursor, 119 + }); 120 + for (const r of res.data.records) { 121 + out.push({ rkey: extractRkey(r.uri), record: r.value as R }); 122 + } 123 + cursor = res.data.cursor; 124 + } while (cursor); 125 + return out; 126 + } 127 + 128 + function extractRkey(atUri: string): string { 129 + // at://did:plc:.../collection/rkey 130 + const parts = atUri.split("/"); 131 + return parts[parts.length - 1] ?? ""; 132 + }
+58
src/server/body-cache.ts
··· 1 + import { fetchAndExtract } from "./readability.js"; 2 + 3 + type Entry = { 4 + title: string; 5 + body: string; 6 + fetchedAt: number; 7 + }; 8 + 9 + // Simple LRU with TTL. E-reader pagination hits the same key many times in a 10 + // short window; this avoids re-fetching and re-extracting on every page request. 11 + export class BodyCache { 12 + private map = new Map<string, Entry>(); 13 + private inflight = new Map<string, Promise<Entry>>(); 14 + 15 + constructor( 16 + private maxEntries: number = 64, 17 + private ttlMs: number = 60 * 60 * 1000, 18 + ) {} 19 + 20 + async get(url: string): Promise<Entry> { 21 + const hit = this.map.get(url); 22 + const now = Date.now(); 23 + if (hit && now - hit.fetchedAt < this.ttlMs) { 24 + // Touch for LRU ordering 25 + this.map.delete(url); 26 + this.map.set(url, hit); 27 + return hit; 28 + } 29 + 30 + const existing = this.inflight.get(url); 31 + if (existing) return existing; 32 + 33 + const p = fetchAndExtract(url) 34 + .then(({ title, body }): Entry => ({ 35 + title, 36 + body, 37 + fetchedAt: Date.now(), 38 + })) 39 + .then((entry) => { 40 + this.map.set(url, entry); 41 + this.evictIfNeeded(); 42 + return entry; 43 + }) 44 + .finally(() => { 45 + this.inflight.delete(url); 46 + }); 47 + this.inflight.set(url, p); 48 + return p; 49 + } 50 + 51 + private evictIfNeeded(): void { 52 + while (this.map.size > this.maxEntries) { 53 + const oldest = this.map.keys().next().value; 54 + if (oldest === undefined) break; 55 + this.map.delete(oldest); 56 + } 57 + } 58 + }
+48
src/server/config.ts
··· 1 + import { mkdirSync } from "node:fs"; 2 + import { resolve } from "node:path"; 3 + import "dotenv/config"; 4 + 5 + function required(name: string): string { 6 + const v = process.env[name]; 7 + if (!v) { 8 + console.error(`missing env var: ${name}`); 9 + process.exit(1); 10 + } 11 + return v; 12 + } 13 + 14 + export const config = { 15 + port: Number(process.env.NIGHTSHADE_PORT ?? 8787), 16 + dataDir: resolve(process.env.NIGHTSHADE_DATA_DIR ?? "./data"), 17 + publicUrl: process.env.NIGHTSHADE_PUBLIC_URL?.replace(/\/$/, "") ?? null, 18 + miniflux: { 19 + url: required("MINIFLUX_URL"), 20 + token: 21 + process.env.MINIFLUX_TOKEN ?? 22 + (process.env.MINIFLUX_TOKEN_FILE 23 + ? readTokenFile(process.env.MINIFLUX_TOKEN_FILE) 24 + : ""), 25 + }, 26 + }; 27 + 28 + if (!config.miniflux.token) { 29 + console.error("missing MINIFLUX_TOKEN or MINIFLUX_TOKEN_FILE"); 30 + process.exit(1); 31 + } 32 + 33 + mkdirSync(config.dataDir, { recursive: true }); 34 + 35 + function readTokenFile(path: string): string { 36 + // Defer the filesystem read to avoid needing fs at module top. 37 + // eslint-disable-next-line @typescript-eslint/no-require-imports 38 + const { readFileSync } = require("node:fs") as typeof import("node:fs"); 39 + return readFileSync(path, "utf8").trim(); 40 + } 41 + 42 + export function isLoopback(): boolean { 43 + return config.publicUrl === null; 44 + } 45 + 46 + export function publicBase(): string { 47 + return config.publicUrl ?? `http://127.0.0.1:${config.port}`; 48 + }
-84
src/server/db.ts
··· 1 - import Database from "better-sqlite3"; 2 - import type { SavedItem } from "../shared/types.js"; 3 - 4 - export class Store { 5 - private db: Database.Database; 6 - 7 - constructor(path: string) { 8 - this.db = new Database(path); 9 - this.db.pragma("journal_mode = WAL"); 10 - this.db.exec(` 11 - CREATE TABLE IF NOT EXISTS saves ( 12 - id INTEGER PRIMARY KEY AUTOINCREMENT, 13 - url TEXT UNIQUE NOT NULL, 14 - title TEXT NOT NULL DEFAULT '', 15 - body TEXT NOT NULL DEFAULT '', 16 - fetched_at INTEGER NOT NULL, 17 - read INTEGER NOT NULL DEFAULT 0 18 - ); 19 - CREATE INDEX IF NOT EXISTS idx_saves_fetched ON saves(fetched_at DESC); 20 - `); 21 - } 22 - 23 - insertSave(url: string, title: string, body: string, ts: number): number { 24 - const info = this.db 25 - .prepare( 26 - "INSERT INTO saves (url, title, body, fetched_at) VALUES (?, ?, ?, ?)", 27 - ) 28 - .run(url, title, body, ts); 29 - return Number(info.lastInsertRowid); 30 - } 31 - 32 - saveExists(url: string): boolean { 33 - const row = this.db 34 - .prepare("SELECT 1 AS x FROM saves WHERE url = ?") 35 - .get(url); 36 - return row !== undefined; 37 - } 38 - 39 - getSave(id: number): SavedItem | null { 40 - const row = this.db 41 - .prepare( 42 - "SELECT id, url, title, body, fetched_at AS fetchedAt, read FROM saves WHERE id = ?", 43 - ) 44 - .get(id) as 45 - | { 46 - id: number; 47 - url: string; 48 - title: string; 49 - body: string; 50 - fetchedAt: number; 51 - read: number; 52 - } 53 - | undefined; 54 - if (!row) return null; 55 - return { ...row, read: row.read === 1 }; 56 - } 57 - 58 - listSaves(unreadOnly: boolean, limit: number): SavedItem[] { 59 - const where = unreadOnly ? "WHERE read = 0" : ""; 60 - const rows = this.db 61 - .prepare( 62 - `SELECT id, url, title, body, fetched_at AS fetchedAt, read FROM saves ${where} ORDER BY fetched_at DESC LIMIT ?`, 63 - ) 64 - .all(limit) as Array<{ 65 - id: number; 66 - url: string; 67 - title: string; 68 - body: string; 69 - fetchedAt: number; 70 - read: number; 71 - }>; 72 - return rows.map((r) => ({ ...r, read: r.read === 1 })); 73 - } 74 - 75 - markSaveRead(id: number, read: boolean): void { 76 - this.db 77 - .prepare("UPDATE saves SET read = ? WHERE id = ?") 78 - .run(read ? 1 : 0, id); 79 - } 80 - 81 - deleteSave(id: number): void { 82 - this.db.prepare("DELETE FROM saves WHERE id = ?").run(id); 83 - } 84 - }
+50 -29
src/server/index.ts
··· 4 4 import { serveStatic } from "@hono/node-server/serve-static"; 5 5 import { Hono } from "hono"; 6 6 import { logger } from "hono/logger"; 7 - import "dotenv/config"; 8 7 9 - import { Store } from "./db.js"; 8 + import { config, isLoopback, publicBase } from "./config.js"; 10 9 import { MinifluxClient } from "./miniflux.js"; 10 + import { BodyCache } from "./body-cache.js"; 11 + import { buildOAuth } from "./oauth.js"; 12 + import { AtprotoRepo } from "./atproto.js"; 13 + import { Syncer } from "./sync.js"; 14 + import { authRoutes } from "./routes-auth.js"; 11 15 import { apiRoutes } from "./routes-api.js"; 12 16 import { deviceRoutes } from "./routes-device.js"; 13 17 14 - const port = Number(process.env.NIGHTSHADE_PORT ?? 8787); 15 - const dbPath = process.env.NIGHTSHADE_DB ?? "./nightshade.sqlite3"; 16 - const mfUrl = process.env.MINIFLUX_URL; 17 - const mfToken = 18 - process.env.MINIFLUX_TOKEN ?? 19 - (process.env.MINIFLUX_TOKEN_FILE 20 - ? readFileSync(process.env.MINIFLUX_TOKEN_FILE, "utf8").trim() 21 - : undefined); 22 - 23 - if (!mfUrl || !mfToken) { 24 - console.error( 25 - "MINIFLUX_URL and (MINIFLUX_TOKEN or MINIFLUX_TOKEN_FILE) must be set", 26 - ); 27 - process.exit(1); 28 - } 29 - 30 - const store = new Store(dbPath); 31 - const mf = new MinifluxClient(mfUrl, mfToken); 18 + const mf = new MinifluxClient(config.miniflux.url, config.miniflux.token); 19 + const bodies = new BodyCache(); 20 + const oauth = await buildOAuth(); 32 21 33 22 const app = new Hono(); 34 23 app.use(logger()); 35 24 36 - app.route("/api", apiRoutes(store, mf)); 37 - app.route("/device", deviceRoutes(store, mf)); 25 + // Production OAuth needs the client metadata document served at a stable URL. 26 + if (!isLoopback()) { 27 + app.get("/client-metadata.json", (c) => { 28 + return c.json(oauth.client.clientMetadata); 29 + }); 30 + app.get("/jwks.json", (c) => { 31 + return c.json(oauth.client.jwks); 32 + }); 33 + } 34 + 35 + app.route("/auth", authRoutes(oauth)); 36 + app.route("/api", apiRoutes(oauth, mf)); 37 + app.route("/device", deviceRoutes(oauth, mf, bodies)); 38 38 39 39 const publicDir = resolve("./dist/public"); 40 40 const indexPath = resolve(publicDir, "index.html"); ··· 42 42 43 43 app.use("/*", serveStatic({ root: "./dist/public" })); 44 44 45 - // SPA fallback 46 45 app.get("*", (c) => { 47 - if (!indexHtml) { 48 - return c.text("Frontend not built. Run `pnpm build:web`.\n", 200); 49 - } 46 + if (!indexHtml) return c.text("Frontend not built. Run `pnpm build`.\n"); 50 47 return c.html(indexHtml); 51 48 }); 52 49 53 - serve({ fetch: app.fetch, port, hostname: "0.0.0.0" }, (info) => { 54 - console.log(`nightshade listening on http://${info.address}:${info.port}`); 55 - }); 50 + // Background sync every 5 minutes when a session exists. 51 + setInterval( 52 + async () => { 53 + const session = await oauth.getActiveSession(); 54 + if (!session) return; 55 + const syncer = new Syncer(config.dataDir, new AtprotoRepo(session), mf); 56 + try { 57 + const res = await syncer.run(); 58 + if (res.added || res.removed) { 59 + console.log(`sync: +${res.added} -${res.removed}`); 60 + } 61 + } catch (e) { 62 + console.error("sync failed:", e); 63 + } 64 + }, 65 + 5 * 60 * 1000, 66 + ); 67 + 68 + serve( 69 + { fetch: app.fetch, port: config.port, hostname: "0.0.0.0" }, 70 + (info) => { 71 + console.log( 72 + `nightshade on http://${info.address}:${info.port} ` + 73 + `(public: ${publicBase()}, ${isLoopback() ? "loopback OAuth" : "production OAuth"})`, 74 + ); 75 + }, 76 + );
+65
src/server/oauth-stores.ts
··· 1 + import { 2 + readFileSync, 3 + writeFileSync, 4 + existsSync, 5 + mkdirSync, 6 + renameSync, 7 + unlinkSync, 8 + readdirSync, 9 + } from "node:fs"; 10 + import { resolve, dirname } from "node:path"; 11 + import type { 12 + NodeSavedSession, 13 + NodeSavedSessionStore, 14 + NodeSavedState, 15 + NodeSavedStateStore, 16 + } from "@atproto/oauth-client-node"; 17 + 18 + class FileStore<T> { 19 + constructor(protected baseDir: string) { 20 + mkdirSync(baseDir, { recursive: true }); 21 + } 22 + 23 + protected pathFor(key: string): string { 24 + return resolve(this.baseDir, encodeURIComponent(key) + ".json"); 25 + } 26 + 27 + async get(key: string): Promise<T | undefined> { 28 + const p = this.pathFor(key); 29 + if (!existsSync(p)) return undefined; 30 + try { 31 + return JSON.parse(readFileSync(p, "utf8")) as T; 32 + } catch { 33 + return undefined; 34 + } 35 + } 36 + 37 + async set(key: string, value: T): Promise<void> { 38 + const p = this.pathFor(key); 39 + mkdirSync(dirname(p), { recursive: true }); 40 + const tmp = p + ".tmp"; 41 + writeFileSync(tmp, JSON.stringify(value), { mode: 0o600 }); 42 + renameSync(tmp, p); 43 + } 44 + 45 + async del(key: string): Promise<void> { 46 + const p = this.pathFor(key); 47 + if (existsSync(p)) unlinkSync(p); 48 + } 49 + } 50 + 51 + export class StateStore 52 + extends FileStore<NodeSavedState> 53 + implements NodeSavedStateStore {} 54 + 55 + export class SessionStore 56 + extends FileStore<NodeSavedSession> 57 + implements NodeSavedSessionStore 58 + { 59 + listKeys(): string[] { 60 + if (!existsSync(this.baseDir)) return []; 61 + return readdirSync(this.baseDir) 62 + .filter((f) => f.endsWith(".json")) 63 + .map((f) => decodeURIComponent(f.slice(0, -".json".length))); 64 + } 65 + }
+99
src/server/oauth.ts
··· 1 + import { resolve } from "node:path"; 2 + import { NodeOAuthClient, type OAuthSession } from "@atproto/oauth-client-node"; 3 + import { config, isLoopback, publicBase } from "./config.js"; 4 + import { SessionStore, StateStore } from "./oauth-stores.js"; 5 + import { FEED_NSID, SAVE_NSID } from "../shared/lexicons.js"; 6 + 7 + // Granular scopes: one per collection we read/write, plus the mandatory 8 + // atproto identity scope. The PDS grants access only to these NSIDs in the 9 + // user's repo — no read/write access to anything else. 10 + const SCOPE = ["atproto", `repo:${FEED_NSID}`, `repo:${SAVE_NSID}`].join(" "); 11 + 12 + export type NightshadeOAuth = { 13 + client: NodeOAuthClient; 14 + sessionStore: SessionStore; 15 + getActiveSession: () => Promise<OAuthSession | null>; 16 + authorize: (handle: string) => Promise<URL>; 17 + callback: (params: URLSearchParams) => Promise<OAuthSession>; 18 + revoke: () => Promise<void>; 19 + }; 20 + 21 + export async function buildOAuth(): Promise<NightshadeOAuth> { 22 + const stateStore = new StateStore(resolve(config.dataDir, "oauth-state")); 23 + const sessionStore = new SessionStore( 24 + resolve(config.dataDir, "oauth-sessions"), 25 + ); 26 + 27 + const base = publicBase(); 28 + const redirectUri = `${base}/auth/callback`; 29 + 30 + let clientMetadata: ConstructorParameters<typeof NodeOAuthClient>[0]["clientMetadata"]; 31 + 32 + if (isLoopback()) { 33 + // Loopback/native client: PDS synthesizes metadata from query params. 34 + // client_id must be http://localhost with scope + redirect_uri in the query. 35 + const loopbackId = new URL("http://localhost"); 36 + loopbackId.searchParams.set("redirect_uri", redirectUri); 37 + loopbackId.searchParams.set("scope", SCOPE); 38 + clientMetadata = { 39 + client_id: loopbackId.toString(), 40 + client_name: "Nightshade (dev)", 41 + redirect_uris: [redirectUri], 42 + scope: SCOPE, 43 + grant_types: ["authorization_code", "refresh_token"], 44 + response_types: ["code"], 45 + application_type: "web", 46 + token_endpoint_auth_method: "none", 47 + dpop_bound_access_tokens: true, 48 + }; 49 + } else { 50 + clientMetadata = { 51 + client_id: `${base}/client-metadata.json`, 52 + client_name: "Nightshade", 53 + client_uri: base, 54 + redirect_uris: [redirectUri], 55 + scope: SCOPE, 56 + grant_types: ["authorization_code", "refresh_token"], 57 + response_types: ["code"], 58 + application_type: "web", 59 + token_endpoint_auth_method: "none", 60 + dpop_bound_access_tokens: true, 61 + }; 62 + } 63 + 64 + const client = new NodeOAuthClient({ 65 + clientMetadata, 66 + stateStore, 67 + sessionStore, 68 + }); 69 + 70 + async function getActiveSession(): Promise<OAuthSession | null> { 71 + const dids = sessionStore.listKeys(); 72 + if (dids.length === 0) return null; 73 + // Single-user tool: pick the most-recently-written session. 74 + // `list()` returns in directory order; for now just use the first. 75 + const did = dids[0]!; 76 + try { 77 + return await client.restore(did); 78 + } catch { 79 + return null; 80 + } 81 + } 82 + 83 + async function authorize(handle: string): Promise<URL> { 84 + return client.authorize(handle, { scope: SCOPE }); 85 + } 86 + 87 + async function callback(params: URLSearchParams): Promise<OAuthSession> { 88 + const { session } = await client.callback(params); 89 + return session; 90 + } 91 + 92 + async function revoke(): Promise<void> { 93 + for (const did of sessionStore.listKeys()) { 94 + await sessionStore.del(did); 95 + } 96 + } 97 + 98 + return { client, sessionStore, getActiveSession, authorize, callback, revoke }; 99 + }
+91 -38
src/server/routes-api.ts
··· 1 1 import { Hono } from "hono"; 2 - import type { Store } from "./db.js"; 2 + import { AtprotoRepo } from "./atproto.js"; 3 3 import type { MinifluxClient } from "./miniflux.js"; 4 + import { Syncer } from "./sync.js"; 4 5 import { fetchAndExtract } from "./readability.js"; 6 + import type { NightshadeOAuth } from "./oauth.js"; 7 + import { config } from "./config.js"; 5 8 6 - export function apiRoutes(store: Store, mf: MinifluxClient) { 7 - const app = new Hono(); 9 + type Env = { Variables: { repo: AtprotoRepo } }; 10 + 11 + export function apiRoutes(oauth: NightshadeOAuth, mf: MinifluxClient) { 12 + const app = new Hono<Env>(); 13 + 14 + const makeSyncer = (repo: AtprotoRepo) => 15 + new Syncer(config.dataDir, repo, mf); 16 + 17 + app.use("/*", async (c, next) => { 18 + const session = await oauth.getActiveSession(); 19 + if (!session) return c.json({ error: "not authenticated" }, 401); 20 + c.set("repo", new AtprotoRepo(session)); 21 + return next(); 22 + }); 8 23 9 - // --- Saved URLs --- 10 - app.get("/saves", (c) => { 11 - const unread = c.req.query("all") === undefined; 12 - const limit = Number(c.req.query("limit") ?? 100); 13 - return c.json(store.listSaves(unread, limit)); 24 + // --- Saves --- 25 + app.get("/saves", async (c) => { 26 + const repo = c.get("repo"); 27 + const unreadOnly = c.req.query("all") === undefined; 28 + const limit = Number(c.req.query("limit") ?? 200); 29 + const views = await repo.listSaves(); 30 + const filtered = unreadOnly 31 + ? views.filter((v) => !v.record.readAt) 32 + : views; 33 + filtered.sort((a, b) => 34 + b.record.createdAt.localeCompare(a.record.createdAt), 35 + ); 36 + return c.json(filtered.slice(0, limit)); 14 37 }); 15 38 16 39 app.post("/saves", async (c) => { 40 + const repo = c.get("repo"); 17 41 const { url } = await c.req.json<{ url: string }>(); 18 42 if (!url) return c.json({ error: "missing url" }, 400); 19 - if (store.saveExists(url)) return c.json({ error: "already saved" }, 409); 43 + let title: string | undefined; 20 44 try { 21 - const { title, body } = await fetchAndExtract(url); 22 - const id = store.insertSave( 23 - url, 24 - title, 25 - body, 26 - Math.floor(Date.now() / 1000), 27 - ); 28 - return c.json({ id, title }); 29 - } catch (e) { 30 - return c.json({ error: String((e as Error).message ?? e) }, 502); 45 + title = (await fetchAndExtract(url)).title; 46 + } catch { 47 + // Non-fatal: allow saving even if initial fetch fails. Title will be 48 + // filled in on the next successful body fetch from the e-reader path. 31 49 } 50 + const view = await repo.createSave(url, title); 51 + return c.json(view); 32 52 }); 33 53 34 - app.delete("/saves/:id", (c) => { 35 - store.deleteSave(Number(c.req.param("id"))); 54 + app.delete("/saves/:rkey", async (c) => { 55 + const repo = c.get("repo"); 56 + await repo.deleteSave(c.req.param("rkey")); 36 57 return c.body(null, 204); 37 58 }); 38 59 39 - app.post("/saves/:id/read", (c) => { 40 - store.markSaveRead(Number(c.req.param("id")), true); 60 + app.post("/saves/:rkey/read", async (c) => { 61 + const repo = c.get("repo"); 62 + await repo.markSaveRead(c.req.param("rkey"), true); 41 63 return c.body(null, 204); 42 64 }); 43 65 44 - app.post("/saves/:id/unread", (c) => { 45 - store.markSaveRead(Number(c.req.param("id")), false); 66 + app.post("/saves/:rkey/unread", async (c) => { 67 + const repo = c.get("repo"); 68 + await repo.markSaveRead(c.req.param("rkey"), false); 46 69 return c.body(null, 204); 47 70 }); 48 71 49 - // --- Miniflux passthrough --- 72 + // --- Feeds (atproto-canonical, mirrored to Miniflux) --- 50 73 app.get("/feeds", async (c) => { 51 - const feeds = await mf.listFeeds(); 52 - return c.json(feeds); 74 + const repo = c.get("repo"); 75 + const [feeds, mfFeeds] = await Promise.all([ 76 + repo.listFeeds(), 77 + mf.listFeeds().catch(() => []), 78 + ]); 79 + const siteByFeedUrl = new Map<string, string>(); 80 + const titleByFeedUrl = new Map<string, string>(); 81 + for (const f of mfFeeds) { 82 + if (f.site_url) siteByFeedUrl.set(f.feed_url, f.site_url); 83 + if (f.title) titleByFeedUrl.set(f.feed_url, f.title); 84 + } 85 + const enriched = feeds.map((f) => ({ 86 + rkey: f.rkey, 87 + record: f.record, 88 + siteUrl: siteByFeedUrl.get(f.record.url) ?? null, 89 + minifluxTitle: titleByFeedUrl.get(f.record.url) ?? null, 90 + })); 91 + enriched.sort((a, b) => { 92 + const at = a.record.title ?? a.minifluxTitle ?? a.record.url; 93 + const bt = b.record.title ?? b.minifluxTitle ?? b.record.url; 94 + return at.localeCompare(bt); 95 + }); 96 + return c.json(enriched); 53 97 }); 54 98 55 99 app.post("/feeds", async (c) => { 56 - const { url, category_id } = await c.req.json<{ 57 - url: string; 58 - category_id?: number; 59 - }>(); 100 + const repo = c.get("repo"); 101 + const { url, title } = await c.req.json<{ url: string; title?: string }>(); 60 102 if (!url) return c.json({ error: "missing url" }, 400); 103 + await repo.createFeed(url, title); 61 104 try { 62 - const result = await mf.subscribe(url, category_id ?? 1); 63 - return c.json(result); 105 + await makeSyncer(repo).run(); 64 106 } catch (e) { 65 - return c.json({ error: String((e as Error).message ?? e) }, 502); 107 + console.error("post-add sync failed:", e); 66 108 } 109 + return c.body(null, 204); 67 110 }); 68 111 69 - app.delete("/feeds/:id", async (c) => { 70 - await mf.deleteFeed(Number(c.req.param("id"))); 112 + app.delete("/feeds/:rkey", async (c) => { 113 + const repo = c.get("repo"); 114 + await repo.deleteFeed(c.req.param("rkey")); 115 + try { 116 + await makeSyncer(repo).run(); 117 + } catch (e) { 118 + console.error("post-delete sync failed:", e); 119 + } 71 120 return c.body(null, 204); 72 121 }); 73 122 ··· 76 125 return c.body(null, 204); 77 126 }); 78 127 79 - app.get("/categories", async (c) => c.json(await mf.getCategories())); 128 + app.post("/sync", async (c) => { 129 + const repo = c.get("repo"); 130 + const result = await makeSyncer(repo).run(); 131 + return c.json(result); 132 + }); 80 133 81 134 return app; 82 135 }
+40
src/server/routes-auth.ts
··· 1 + import { Hono } from "hono"; 2 + import type { NightshadeOAuth } from "./oauth.js"; 3 + 4 + export function authRoutes(oauth: NightshadeOAuth) { 5 + const app = new Hono(); 6 + 7 + app.get("/status", async (c) => { 8 + const session = await oauth.getActiveSession(); 9 + if (!session) return c.json({ authenticated: false }); 10 + return c.json({ authenticated: true, did: session.did }); 11 + }); 12 + 13 + app.post("/login", async (c) => { 14 + const { handle } = await c.req.json<{ handle: string }>(); 15 + if (!handle) return c.json({ error: "missing handle" }, 400); 16 + try { 17 + const url = await oauth.authorize(handle); 18 + return c.json({ url: url.toString() }); 19 + } catch (e) { 20 + return c.json({ error: String((e as Error).message ?? e) }, 400); 21 + } 22 + }); 23 + 24 + app.get("/callback", async (c) => { 25 + const params = new URLSearchParams(c.req.url.split("?")[1] ?? ""); 26 + try { 27 + await oauth.callback(params); 28 + return c.redirect("/"); 29 + } catch (e) { 30 + return c.text(`OAuth callback failed: ${(e as Error).message}`, 400); 31 + } 32 + }); 33 + 34 + app.post("/logout", async (c) => { 35 + await oauth.revoke(); 36 + return c.body(null, 204); 37 + }); 38 + 39 + return app; 40 + }
+41 -21
src/server/routes-device.ts
··· 1 1 import { Hono } from "hono"; 2 - import type { Store } from "./db.js"; 2 + import { parseHTML } from "linkedom"; 3 + import type { AtprotoRepo } from "./atproto.js"; 4 + import type { BodyCache } from "./body-cache.js"; 3 5 import type { MinifluxClient } from "./miniflux.js"; 6 + import type { NightshadeOAuth } from "./oauth.js"; 4 7 import { htmlToText } from "./readability.js"; 5 - import { parseHTML } from "linkedom"; 6 8 import { renderItem, renderList } from "./reader-format.js"; 7 9 import type { UnifiedItem } from "../shared/types.js"; 8 10 9 - export function deviceRoutes(store: Store, mf: MinifluxClient) { 11 + export function deviceRoutes( 12 + oauth: NightshadeOAuth, 13 + mf: MinifluxClient, 14 + bodies: BodyCache, 15 + ) { 10 16 const app = new Hono(); 11 17 18 + async function getRepo(): Promise<AtprotoRepo | null> { 19 + const session = await oauth.getActiveSession(); 20 + if (!session) return null; 21 + const { AtprotoRepo } = await import("./atproto.js"); 22 + return new AtprotoRepo(session); 23 + } 24 + 12 25 app.get("/list", async (c) => { 26 + const repo = await getRepo(); 27 + if (!repo) return c.text("not authenticated\n", 401); 13 28 const all = c.req.query("all") !== undefined; 14 29 const limit = Number(c.req.query("limit") ?? 50); 15 30 ··· 20 35 order: "published_at", 21 36 direction: "desc", 22 37 }), 23 - Promise.resolve(store.listSaves(!all, limit)), 38 + repo.listSaves(), 24 39 ]); 25 40 26 41 const items: UnifiedItem[] = []; ··· 34 49 read: e.status === "read", 35 50 }); 36 51 } 37 - for (const s of saves) { 52 + const filteredSaves = all ? saves : saves.filter((s) => !s.record.readAt); 53 + for (const s of filteredSaves) { 38 54 items.push({ 39 55 source: "save", 40 - id: `s${s.id}`, 41 - url: s.url, 42 - title: s.title, 43 - fetchedAt: s.fetchedAt, 44 - read: s.read, 56 + id: `s${s.rkey}`, 57 + url: s.record.url, 58 + title: s.record.title ?? s.record.url, 59 + fetchedAt: Math.floor(new Date(s.record.createdAt).getTime() / 1000), 60 + read: !!s.record.readAt, 45 61 }); 46 62 } 47 63 items.sort((a, b) => b.fetchedAt - a.fetchedAt); 48 - const sliced = items.slice(0, limit); 49 - 50 - return c.text(renderList(sliced), 200, { 64 + return c.text(renderList(items.slice(0, limit)), 200, { 51 65 "Content-Type": "text/plain; charset=utf-8", 52 66 }); 53 67 }); ··· 57 71 const page = Number(c.req.query("page") ?? 1); 58 72 try { 59 73 if (rawId.startsWith("s")) { 60 - const save = store.getSave(Number(rawId.slice(1))); 74 + const repo = await getRepo(); 75 + if (!repo) return c.text("not authenticated\n", 401); 76 + const rkey = rawId.slice(1); 77 + const save = await repo.getSave(rkey); 61 78 if (!save) return c.text("not found\n", 404); 79 + const cached = await bodies.get(save.record.url); 62 80 return c.text( 63 81 renderItem( 64 82 rawId, 65 83 "save", 66 - save.title, 67 - save.url, 68 - save.fetchedAt, 69 - save.body, 84 + save.record.title ?? cached.title, 85 + save.record.url, 86 + Math.floor(new Date(save.record.createdAt).getTime() / 1000), 87 + cached.body, 70 88 page, 71 89 ), 72 90 200, ··· 75 93 } 76 94 const entry = await mf.getEntry(Number(rawId)); 77 95 const body = entry.content 78 - ? extractFromStoredHtml(entry.content, entry.url) 96 + ? extractFromStoredHtml(entry.content) 79 97 : ""; 80 98 const ts = Math.floor(new Date(entry.published_at).getTime() / 1000); 81 99 return c.text( ··· 91 109 app.post("/item/:id/read", async (c) => { 92 110 const rawId = c.req.param("id"); 93 111 if (rawId.startsWith("s")) { 94 - store.markSaveRead(Number(rawId.slice(1)), true); 112 + const repo = await getRepo(); 113 + if (!repo) return c.text("not authenticated\n", 401); 114 + await repo.markSaveRead(rawId.slice(1), true); 95 115 } else { 96 116 await mf.markEntries([Number(rawId)], "read"); 97 117 } ··· 101 121 return app; 102 122 } 103 123 104 - function extractFromStoredHtml(html: string, _url: string): string { 124 + function extractFromStoredHtml(html: string): string { 105 125 try { 106 126 const { document } = parseHTML( 107 127 `<!doctype html><html><body>${html}</body></html>`,
+142
src/server/sync.ts
··· 1 + import { readFileSync, writeFileSync, existsSync, renameSync } from "node:fs"; 2 + import { resolve } from "node:path"; 3 + import type { AtprotoRepo } from "./atproto.js"; 4 + import type { MinifluxClient } from "./miniflux.js"; 5 + 6 + type Snapshot = { 7 + atproto: Record<string, string>; // url -> rkey 8 + miniflux: Record<string, number>; // url -> feed id 9 + }; 10 + 11 + const EMPTY: Snapshot = { atproto: {}, miniflux: {} }; 12 + 13 + export class Syncer { 14 + private statePath: string; 15 + private running = false; 16 + 17 + constructor( 18 + dataDir: string, 19 + private repo: AtprotoRepo, 20 + private mf: MinifluxClient, 21 + ) { 22 + this.statePath = resolve(dataDir, "sync-state.json"); 23 + } 24 + 25 + async run(): Promise<{ added: number; removed: number }> { 26 + if (this.running) return { added: 0, removed: 0 }; 27 + this.running = true; 28 + try { 29 + return await this.runInner(); 30 + } finally { 31 + this.running = false; 32 + } 33 + } 34 + 35 + private async runInner(): Promise<{ added: number; removed: number }> { 36 + const prev = this.readSnapshot(); 37 + 38 + const [atList, mfList] = await Promise.all([ 39 + this.repo.listFeeds(), 40 + this.mf.listFeeds(), 41 + ]); 42 + 43 + const curA: Record<string, string> = {}; 44 + for (const f of atList) curA[f.record.url] = f.rkey; 45 + 46 + const curM: Record<string, number> = {}; 47 + for (const f of mfList) curM[f.feed_url] = f.id; 48 + 49 + let added = 0; 50 + let removed = 0; 51 + 52 + // Deltas vs last snapshot 53 + const atAdded = setDiff(Object.keys(curA), Object.keys(prev.atproto)); 54 + const atRemoved = setDiff(Object.keys(prev.atproto), Object.keys(curA)); 55 + const mfAdded = setDiff(Object.keys(curM), Object.keys(prev.miniflux)); 56 + const mfRemoved = setDiff(Object.keys(prev.miniflux), Object.keys(curM)); 57 + 58 + // atproto additions → miniflux 59 + for (const url of atAdded) { 60 + if (!(url in curM)) { 61 + try { 62 + await this.mf.subscribe(url); 63 + added++; 64 + } catch (e) { 65 + console.error(`sync: failed to subscribe ${url}:`, e); 66 + } 67 + } 68 + } 69 + 70 + // miniflux additions → atproto 71 + for (const url of mfAdded) { 72 + if (!(url in curA)) { 73 + const feed = mfList.find((f) => f.feed_url === url); 74 + try { 75 + await this.repo.createFeed(url, feed?.title); 76 + added++; 77 + } catch (e) { 78 + console.error(`sync: failed to create atproto feed ${url}:`, e); 79 + } 80 + } 81 + } 82 + 83 + // atproto deletions → miniflux 84 + for (const url of atRemoved) { 85 + const id = curM[url]; 86 + if (id !== undefined) { 87 + try { 88 + await this.mf.deleteFeed(id); 89 + removed++; 90 + } catch (e) { 91 + console.error(`sync: failed to delete miniflux feed ${url}:`, e); 92 + } 93 + } 94 + } 95 + 96 + // miniflux deletions → atproto 97 + for (const url of mfRemoved) { 98 + const rkey = curA[url]; 99 + if (rkey !== undefined) { 100 + try { 101 + await this.repo.deleteFeed(rkey); 102 + removed++; 103 + } catch (e) { 104 + console.error(`sync: failed to delete atproto feed ${url}:`, e); 105 + } 106 + } 107 + } 108 + 109 + // Re-snapshot after mutations so next run sees converged state 110 + const [finalAt, finalMf] = await Promise.all([ 111 + this.repo.listFeeds(), 112 + this.mf.listFeeds(), 113 + ]); 114 + const nextSnap: Snapshot = { 115 + atproto: Object.fromEntries(finalAt.map((f) => [f.record.url, f.rkey])), 116 + miniflux: Object.fromEntries(finalMf.map((f) => [f.feed_url, f.id])), 117 + }; 118 + this.writeSnapshot(nextSnap); 119 + 120 + return { added, removed }; 121 + } 122 + 123 + private readSnapshot(): Snapshot { 124 + if (!existsSync(this.statePath)) return EMPTY; 125 + try { 126 + return JSON.parse(readFileSync(this.statePath, "utf8")) as Snapshot; 127 + } catch { 128 + return EMPTY; 129 + } 130 + } 131 + 132 + private writeSnapshot(snap: Snapshot): void { 133 + const tmp = this.statePath + ".tmp"; 134 + writeFileSync(tmp, JSON.stringify(snap, null, 2)); 135 + renameSync(tmp, this.statePath); 136 + } 137 + } 138 + 139 + function setDiff(a: string[], bArr: string[]): string[] { 140 + const b = new Set(bArr); 141 + return a.filter((x) => !b.has(x)); 142 + }
+25
src/shared/lexicons.ts
··· 1 + export const FEED_NSID = "net.solanaceae.nightshade.feed"; 2 + export const SAVE_NSID = "net.solanaceae.nightshade.save"; 3 + 4 + export type FeedRecord = { 5 + $type?: typeof FEED_NSID; 6 + url: string; 7 + title?: string; 8 + createdAt: string; 9 + }; 10 + 11 + export type SaveRecord = { 12 + $type?: typeof SAVE_NSID; 13 + url: string; 14 + title?: string; 15 + createdAt: string; 16 + readAt?: string; 17 + }; 18 + 19 + export type FeedView = { 20 + rkey: string; 21 + record: FeedRecord; 22 + siteUrl?: string | null; 23 + minifluxTitle?: string | null; 24 + }; 25 + export type SaveView = { rkey: string; record: SaveRecord };
-10
src/shared/types.ts
··· 2 2 3 3 export type UnifiedItem = { 4 4 source: ItemSource; 5 - // For rss: miniflux entry id. For save: local saves table id, prefixed with "s". 6 5 id: string; 7 6 url: string; 8 7 title: string; 9 - fetchedAt: number; 10 - read: boolean; 11 - }; 12 - 13 - export type SavedItem = { 14 - id: number; 15 - url: string; 16 - title: string; 17 - body: string; 18 8 fetchedAt: number; 19 9 read: boolean; 20 10 };
+125 -35
src/web/App.tsx
··· 1 1 import { useEffect, useState } from "preact/hooks"; 2 - import { api } from "./api.js"; 3 - import type { MinifluxFeed, SavedItem } from "../shared/types.js"; 2 + import { api, type AuthStatus } from "./api.js"; 3 + import type { FeedView, SaveView } from "../shared/lexicons.js"; 4 4 5 5 type Tab = "saves" | "feeds"; 6 6 7 7 export function App() { 8 8 const [tab, setTab] = useState<Tab>("saves"); 9 + const [auth, setAuth] = useState<AuthStatus | null>(null); 10 + 11 + const loadAuth = async () => { 12 + try { 13 + setAuth(await api.authStatus()); 14 + } catch { 15 + setAuth({ authenticated: false }); 16 + } 17 + }; 18 + 19 + useEffect(() => { 20 + void loadAuth(); 21 + }, []); 22 + 23 + if (!auth) return <div class="loading">loading…</div>; 24 + if (!auth.authenticated) return <LoginGate />; 9 25 10 26 return ( 11 27 <> ··· 24 40 > 25 41 Feeds 26 42 </button> 43 + <button 44 + class="ghost" 45 + title={`Signed in: ${auth.did}`} 46 + onClick={async () => { 47 + await api.logout(); 48 + await loadAuth(); 49 + }} 50 + > 51 + sign out 52 + </button> 27 53 </nav> 28 54 </header> 29 55 {tab === "saves" ? <SavesView /> : <FeedsView />} ··· 31 57 ); 32 58 } 33 59 60 + function LoginGate() { 61 + const [handle, setHandle] = useState(""); 62 + const [busy, setBusy] = useState(false); 63 + const [err, setErr] = useState<string | null>(null); 64 + 65 + const submit = async (e: Event) => { 66 + e.preventDefault(); 67 + if (!handle.trim()) return; 68 + setBusy(true); 69 + setErr(null); 70 + try { 71 + const { url } = await api.login(handle.trim()); 72 + window.location.href = url; 73 + } catch (e) { 74 + setErr((e as Error).message); 75 + setBusy(false); 76 + } 77 + }; 78 + 79 + return ( 80 + <> 81 + <header> 82 + <h1>Nightshade</h1> 83 + </header> 84 + <p>Sign in with your atproto handle to get started.</p> 85 + <form class="add" onSubmit={submit}> 86 + <input 87 + type="text" 88 + placeholder="you.bsky.social" 89 + value={handle} 90 + onInput={(e) => setHandle((e.target as HTMLInputElement).value)} 91 + required 92 + disabled={busy} 93 + autoFocus 94 + /> 95 + <button class="primary" type="submit" disabled={busy}> 96 + {busy ? "…" : "sign in"} 97 + </button> 98 + </form> 99 + {err && <div class="error">{err}</div>} 100 + </> 101 + ); 102 + } 103 + 34 104 function useAsync<T>(fn: () => Promise<T>, deps: unknown[] = []) { 35 105 const [data, setData] = useState<T | null>(null); 36 106 const [err, setErr] = useState<string | null>(null); ··· 57 127 58 128 function SavesView() { 59 129 const [unreadOnly, setUnreadOnly] = useState(true); 60 - const { data, err, loading, reload } = useAsync<SavedItem[]>( 130 + const { data, err, loading, reload } = useAsync<SaveView[]>( 61 131 () => api.listSaves(!unreadOnly), 62 132 [unreadOnly], 63 133 ); ··· 86 156 {loading && !data && <div class="loading">loading…</div>} 87 157 {data && data.length === 0 && ( 88 158 <div class="empty"> 89 - {unreadOnly ? "Nothing unread." : "No saved items."} 159 + {unreadOnly ? "Nothing unread." : "No saves."} 90 160 </div> 91 161 )} 92 162 {data && data.length > 0 && ( 93 163 <ul class="list"> 94 164 {data.map((s) => ( 95 - <SaveRow key={s.id} item={s} onChange={reload} /> 165 + <SaveRow key={s.rkey} view={s} onChange={reload} /> 96 166 ))} 97 167 </ul> 98 168 )} ··· 142 212 } 143 213 144 214 function SaveRow({ 145 - item, 215 + view, 146 216 onChange, 147 217 }: { 148 - item: SavedItem; 218 + view: SaveView; 149 219 onChange: () => void; 150 220 }) { 221 + const read = !!view.record.readAt; 222 + const title = view.record.title ?? view.record.url; 151 223 const toggleRead = async () => { 152 - await api.markSaveRead(item.id, !item.read); 224 + await api.markSaveRead(view.rkey, !read); 153 225 onChange(); 154 226 }; 155 227 const remove = async () => { 156 - if (!confirm(`Delete "${item.title}"?`)) return; 157 - await api.deleteSave(item.id); 228 + if (!confirm(`Delete "${title}"?`)) return; 229 + await api.deleteSave(view.rkey); 158 230 onChange(); 159 231 }; 232 + let hostname = view.record.url; 233 + try { 234 + hostname = new URL(view.record.url).hostname; 235 + } catch {} 160 236 return ( 161 - <li class={item.read ? "read" : ""}> 237 + <li class={read ? "read" : ""}> 162 238 <div> 163 - <a class="item-title" href={item.url} target="_blank" rel="noreferrer"> 164 - {item.title || item.url} 239 + <a 240 + class="item-title" 241 + href={view.record.url} 242 + target="_blank" 243 + rel="noreferrer" 244 + > 245 + {title} 165 246 </a> 166 247 <div class="item-meta"> 167 - {new URL(item.url).hostname} · {formatDate(item.fetchedAt)} 248 + {hostname} · {formatDate(view.record.createdAt)} 168 249 </div> 169 250 </div> 170 251 <div class="actions"> 171 252 <button class="ghost" onClick={toggleRead}> 172 - {item.read ? "mark unread" : "mark read"} 253 + {read ? "mark unread" : "mark read"} 173 254 </button> 174 255 <button class="danger" onClick={remove} title="Delete"> 175 256 ··· 180 261 } 181 262 182 263 function FeedsView() { 183 - const { data, err, loading, reload } = useAsync<MinifluxFeed[]>( 264 + const { data, err, loading, reload } = useAsync<FeedView[]>( 184 265 () => api.listFeeds(), 185 266 [], 186 267 ); 187 268 const [refreshing, setRefreshing] = useState(false); 269 + const [syncing, setSyncing] = useState(false); 188 270 189 271 const refreshAll = async () => { 190 272 setRefreshing(true); ··· 196 278 } 197 279 }; 198 280 281 + const syncNow = async () => { 282 + setSyncing(true); 283 + try { 284 + await api.syncNow(); 285 + } finally { 286 + setSyncing(false); 287 + reload(); 288 + } 289 + }; 290 + 199 291 return ( 200 292 <> 201 293 <AddFeed onAdded={reload} /> 202 294 <div class="toolbar"> 203 295 <div class="filters">{data ? `${data.length} subscribed` : ""}</div> 204 296 <div class="actions"> 297 + <button class="ghost" onClick={syncNow} disabled={syncing}> 298 + {syncing ? "syncing…" : "sync"} 299 + </button> 205 300 <button class="ghost" onClick={refreshAll} disabled={refreshing}> 206 301 {refreshing ? "refreshing…" : "refresh all"} 207 302 </button> ··· 213 308 {err && <div class="error">{err}</div>} 214 309 {loading && !data && <div class="loading">loading…</div>} 215 310 {data && data.length === 0 && ( 216 - <div class="empty">No feeds subscribed.</div> 311 + <div class="empty">No feeds yet.</div> 217 312 )} 218 313 {data && data.length > 0 && ( 219 314 <ul class="list"> 220 315 {data.map((f) => ( 221 - <FeedRow key={f.id} feed={f} onChange={reload} /> 316 + <FeedRow key={f.rkey} view={f} onChange={reload} /> 222 317 ))} 223 318 </ul> 224 319 )} ··· 268 363 } 269 364 270 365 function FeedRow({ 271 - feed, 366 + view, 272 367 onChange, 273 368 }: { 274 - feed: MinifluxFeed; 369 + view: FeedView; 275 370 onChange: () => void; 276 371 }) { 372 + const title = 373 + view.record.title ?? view.minifluxTitle ?? view.siteUrl ?? view.record.url; 374 + const homepage = view.siteUrl ?? view.record.url; 277 375 const remove = async () => { 278 - if (!confirm(`Unsubscribe from "${feed.title}"?`)) return; 279 - await api.unsubscribe(feed.id); 376 + if (!confirm(`Unsubscribe from "${title}"?`)) return; 377 + await api.unsubscribe(view.rkey); 280 378 onChange(); 281 379 }; 282 380 return ( 283 381 <li> 284 382 <div> 285 - <a 286 - class="item-title" 287 - href={feed.site_url || feed.feed_url} 288 - target="_blank" 289 - rel="noreferrer" 290 - > 291 - {feed.title} 383 + <a class="item-title" href={homepage} target="_blank" rel="noreferrer"> 384 + {title} 292 385 </a> 293 - <div class="item-meta"> 294 - {feed.feed_url} 295 - {feed.category?.title ? ` · ${feed.category.title}` : ""} 296 - </div> 386 + <div class="item-meta">{view.record.url}</div> 297 387 </div> 298 388 <div class="actions"> 299 389 <button class="danger" onClick={remove} title="Unsubscribe"> ··· 304 394 ); 305 395 } 306 396 307 - function formatDate(unix: number): string { 308 - const d = new Date(unix * 1000); 397 + function formatDate(iso: string): string { 398 + const d = new Date(iso); 309 399 return d.toLocaleDateString(undefined, { 310 400 year: "numeric", 311 401 month: "short",
+39 -15
src/web/api.ts
··· 1 - import type { MinifluxFeed, SavedItem } from "../shared/types.js"; 1 + import type { FeedView, SaveView } from "../shared/lexicons.js"; 2 2 3 3 async function req<T>(path: string, init?: RequestInit): Promise<T> { 4 4 const res = await fetch(path, { ··· 12 12 const text = await res.text().catch(() => ""); 13 13 let msg = text; 14 14 try { 15 - const parsed = JSON.parse(text); 16 - msg = parsed.error ?? text; 15 + msg = (JSON.parse(text) as { error?: string }).error ?? text; 17 16 } catch {} 18 - throw new Error(msg || `${res.status} ${res.statusText}`); 17 + const err = new Error(msg || `${res.status} ${res.statusText}`) as Error & { 18 + status?: number; 19 + }; 20 + err.status = res.status; 21 + throw err; 19 22 } 20 23 if (res.status === 204) return undefined as T; 21 24 const ct = res.headers.get("content-type") ?? ""; ··· 23 26 return undefined as T; 24 27 } 25 28 29 + export type AuthStatus = 30 + | { authenticated: false } 31 + | { authenticated: true; did: string }; 32 + 26 33 export const api = { 27 - listSaves: (all: boolean): Promise<SavedItem[]> => 34 + authStatus: (): Promise<AuthStatus> => req("/auth/status"), 35 + 36 + login: (handle: string): Promise<{ url: string }> => 37 + req("/auth/login", { 38 + method: "POST", 39 + body: JSON.stringify({ handle }), 40 + }), 41 + 42 + logout: (): Promise<void> => 43 + req("/auth/logout", { method: "POST" }), 44 + 45 + listSaves: (all: boolean): Promise<SaveView[]> => 28 46 req(`/api/saves${all ? "?all=1" : ""}`), 29 47 30 - saveUrl: (url: string): Promise<{ id: number; title: string }> => 48 + saveUrl: (url: string): Promise<SaveView> => 31 49 req("/api/saves", { method: "POST", body: JSON.stringify({ url }) }), 32 50 33 - deleteSave: (id: number): Promise<void> => 34 - req(`/api/saves/${id}`, { method: "DELETE" }), 51 + deleteSave: (rkey: string): Promise<void> => 52 + req(`/api/saves/${rkey}`, { method: "DELETE" }), 35 53 36 - markSaveRead: (id: number, read: boolean): Promise<void> => 37 - req(`/api/saves/${id}/${read ? "read" : "unread"}`, { method: "POST" }), 54 + markSaveRead: (rkey: string, read: boolean): Promise<void> => 55 + req(`/api/saves/${rkey}/${read ? "read" : "unread"}`, { method: "POST" }), 38 56 39 - listFeeds: (): Promise<MinifluxFeed[]> => req("/api/feeds"), 57 + listFeeds: (): Promise<FeedView[]> => req("/api/feeds"), 40 58 41 - subscribe: (url: string): Promise<{ feed_id: number }> => 42 - req("/api/feeds", { method: "POST", body: JSON.stringify({ url }) }), 59 + subscribe: (url: string, title?: string): Promise<void> => 60 + req("/api/feeds", { 61 + method: "POST", 62 + body: JSON.stringify({ url, title }), 63 + }), 43 64 44 - unsubscribe: (id: number): Promise<void> => 45 - req(`/api/feeds/${id}`, { method: "DELETE" }), 65 + unsubscribe: (rkey: string): Promise<void> => 66 + req(`/api/feeds/${rkey}`, { method: "DELETE" }), 46 67 47 68 refreshAll: (): Promise<void> => 48 69 req("/api/feeds/refresh", { method: "POST" }), 70 + 71 + syncNow: (): Promise<{ added: number; removed: number }> => 72 + req("/api/sync", { method: "POST" }), 49 73 };