| … | … | @@ -1,329 +1,58 @@ |
| 1 | 1 | # Hypernote |
| 2 | 2 | |
| 3 | | P2P note-taking server with per-notebook Hyperdrives, end-to-end encryption, wiki-links, and a built-in WYSIWYG editor. |
| 3 | P2P note-taking server with per-notebook Hyperdrives, end-to-end encryption, wiki-links, and multi-writer collaboration. |
| 4 | 4 | |
| 5 | | Built on the [Holepunch](https://holepunch.to) stack: Hypercore, Hyperdrive, Hyperbee, and Hyperswarm. |
| 5 | Built on [Holepunch](https://holepunch.to): Hypercore, Hyperdrive, Hyperbee, Hyperswarm, Autobase. |
| 6 | 6 | |
| 7 | 7 | ## Features |
| 8 | 8 | |
| 9 | | - **Per-notebook Hyperdrives** -- each notebook is an isolated Hyperdrive that can be selectively shared and replicated |
| 10 | | - **P2P replication** -- announce notebooks on Hyperswarm for real-time peer-to-peer sync |
| 11 | | - **Hyper:// site publishing** -- auto-publishes a read/write site drive accessible via [Agregore](https://agregore.mauve.moe/) browser |
| 12 | | - **End-to-end encryption** -- per-notebook encryption with Argon2id key derivation and XSalsa20-Poly1305 |
| 13 | | - **Ed25519 identity** -- passwordless auth via auto-generated browser keypairs with challenge-response signing |
| 14 | | - **Wiki-links & backlinks** -- `[[Note Title]]` syntax with automatic backlink indexing and knowledge graph |
| 15 | | - **WYSIWYG editor** -- Toast UI Editor with markdown, image upload, and video support |
| 16 | | - **Media upload** -- images, video, and audio stored directly in notebook Hyperdrives |
| 17 | | - **Public sharing** -- `/:username/:notebook/:noteId` URLs for shared notebooks |
| 18 | | - **Note merge & split** -- combine notes or split at heading boundaries |
| 19 | | - **Full-text search** -- search across all notebooks with context snippets |
| 20 | | - **Zero CDN dependencies** -- all assets served locally (works offline and in hyper://) |
| 21 | | - **CLI + Web UI + REST API** -- three ways to interact |
| 22 | | |
| 23 | | ## Install |
| 24 | | |
| 25 | | ### From pear-git (P2P) |
| 26 | | |
| 27 | | ```bash |
| 28 | | # 1. Install pear-git (if you don't have pgh) |
| 29 | | npm install -g pear |
| 30 | | pear dump pear://dpjhhzqgm6wqxc9nmw1y3m7eaptfufr6o5p7r4xegjghju8mkweo ~/.local/share/pear-git |
| 31 | | cat > ~/.local/bin/pgh << 'EOF' |
| 32 | | #!/bin/sh |
| 33 | | exec bare ~/.local/share/pear-git/index.js pgh "$@" |
| 34 | | EOF |
| 35 | | chmod +x ~/.local/bin/pgh |
| 36 | | |
| 37 | | # 2. Clone hypernote |
| 38 | | pgh repo clone punch://1290678432f9573b463d4472a9426c5408b34579bf8576e26957a1321fd5e836/hyper-note |
| 39 | | |
| 40 | | # 3. Install and run |
| 41 | | cd hyper-note |
| 42 | | npm install |
| 43 | | bare hypernote-server.js serve |
| 44 | | ``` |
| 45 | | |
| 46 | | ### From local checkout |
| 47 | | |
| 48 | | ```bash |
| 49 | | git clone <repo-url> hyper-note |
| 50 | | cd hyper-note |
| 51 | | npm install |
| 52 | | bare hypernote-server.js serve |
| 53 | | ``` |
| 54 | | |
| 55 | | ### As a Pear app (auto-updates) |
| 56 | | |
| 57 | | ```bash |
| 58 | | pgh app install pear://p84snzmpxca3ii7nfdqjbd5wxjdqzccpoj9xjwzz96wpe4m557zo --name hypernote |
| 59 | | hypernote serve |
| 60 | | ``` |
| 9 | - Per-notebook Hyperdrives with selective P2P replication |
| 10 | - Multi-writer collaboration via Autobase + Yjs CRDTs |
| 11 | - End-to-end encryption (Argon2id + XSalsa20-Poly1305) |
| 12 | - Ed25519 browser identity with challenge-response auth |
| 13 | - Wiki-links, backlinks, and knowledge graph |
| 14 | - WYSIWYG editor (Toast UI), media upload, gallery |
| 15 | - Hyper:// site publishing for Agregore browser |
| 16 | - Zero CDN dependencies (works offline) |
| 61 | 17 | |
| 62 | 18 | ## Quick Start |
| 63 | 19 | |
| 64 | 20 | ```bash |
| 65 | | # Start server (localhost:4040) |
| 21 | npm install |
| 66 | 22 | bare hypernote-server.js serve |
| 67 | 23 | |
| 68 | | # Start on LAN (accessible from other devices) |
| 24 | # LAN accessible |
| 69 | 25 | bare hypernote-server.js serve 4040 --network |
| 70 | 26 | |
| 71 | | # Custom port, storage, and URL prefix |
| 72 | | bare hypernote-server.js serve 5555 --storage=/path/to/data --prefix=/hypernote |
| 73 | | |
| 74 | | # Promote a user to admin (CLI) |
| 75 | | bare hypernote-server.js promote <publicKey|username> |
| 76 | | ``` |
| 77 | | |
| 78 | | On first start, the server prints an admin bearer token to the console. Open `http://localhost:4040` in your browser -- an ed25519 identity is generated automatically and the first user is promoted to admin. |
| 79 | | |
| 80 | | ### Reverse Proxy |
| 81 | | |
| 82 | | Use `--prefix` when running behind a reverse proxy: |
| 83 | | |
| 84 | | ```bash |
| 27 | # Behind reverse proxy |
| 85 | 28 | bare hypernote-server.js serve 4040 --network --prefix=/hypernote |
| 86 | 29 | ``` |
| 87 | 30 | |
| 88 | | Access at `http://yourhost/hypernote/`. The prefix is stripped from API routes automatically. |
| 89 | | |
| 90 | | ### Agregore (hyper://) |
| 31 | On first start, an admin token is printed to the console. Open `http://localhost:4040` -- an ed25519 identity is generated automatically and the first user is promoted to admin. |
| 91 | 32 | |
| 92 | | The server auto-publishes a site drive on Hyperswarm. Access via Agregore browser: |
| 33 | ## Install via pear-git |
| 93 | 34 | |
| 35 | ```bash |
| 36 | pgh app install pear://p84snzmpxca3ii7nfdqjbd5wxjdqzccpoj9xjwzz96wpe4m557zo --name hypernote |
| 37 | hypernote serve |
| 94 | 38 | ``` |
| 95 | | hyper://SITE_KEY/ |
| 96 | | ``` |
| 97 | | |
| 98 | | The site key is shown in the server logs and available at `GET /api/site`. When accessed via `hyper://`, the app connects back to the local HTTP server for full read/write access (not read-only). |
| 99 | 39 | |
| 100 | 40 | ## CLI |
| 101 | 41 | |
| 102 | 42 | ```bash |
| 103 | | # Notebooks |
| 104 | 43 | hn notebook list |
| 105 | 44 | hn notebook create "Work Notes" |
| 106 | | hn notebook delete work-notes |
| 107 | | hn notebook rename work-notes new-slug |
| 108 | | |
| 109 | | # Notes |
| 110 | | hn note list work-notes |
| 111 | 45 | hn note create work-notes --title "Meeting" --content "# Agenda\n..." |
| 112 | | hn note get work-notes <noteId> |
| 113 | | hn note update work-notes <noteId> --title "Updated" |
| 114 | | hn note delete work-notes <noteId> |
| 115 | | hn note move work-notes <noteId> --to personal |
| 116 | | |
| 117 | | # Search |
| 118 | | hn search "query" --notebook work-notes |
| 119 | | |
| 120 | | # Encryption |
| 121 | | hn share encrypt work-notes --password secret |
| 122 | | hn share decrypt work-notes --password secret |
| 123 | | hn share unlock work-notes --password secret |
| 124 | | |
| 125 | | # Links |
| 126 | | hn links work-notes <noteId> |
| 127 | | |
| 128 | | # P2P Sync |
| 46 | hn search "query" |
| 129 | 47 | hn sync status |
| 130 | | hn sync announce work-notes |
| 131 | | hn sync add <driveKey> --slug remote-notes |
| 132 | | hn sync remove remote-notes |
| 133 | | ``` |
| 134 | | |
| 135 | | Configure the CLI: |
| 136 | | |
| 137 | | ```bash |
| 138 | | # Stored in ~/.config/hypernote/cli.json |
| 139 | | hn config set server http://localhost:4040 |
| 140 | | hn config set token <bearer-token> |
| 141 | | ``` |
| 142 | | |
| 143 | | ## Architecture |
| 144 | | |
| 145 | | ``` |
| 146 | | Browser (SPA) Server (Bare runtime) |
| 147 | | identity.js AuthManager |
| 148 | | ed25519 keypair WebIdentityProvider |
| 149 | | challenge signing BearerTokenProvider |
| 150 | | fetch interception KeetIdentityProvider |
| 151 | | api.js NoteManager |
| 152 | | HTTP client per-notebook CRUD |
| 153 | | hyper:// dual-mode DriveManager |
| 154 | | app.js Hyperdrive lifecycle |
| 155 | | hash router ReplicationManager |
| 156 | | Toast UI Editor Hyperswarm P2P sync |
| 157 | | IdentityRegistry |
| 158 | | user accounts + permissions |
| 159 | | LinkManager |
| 160 | | wiki-links + backlinks |
| 161 | | NotebookCrypto |
| 162 | | per-notebook encryption |
| 163 | | SitePublisher |
| 164 | | hyper:// site drive |
| 165 | | ``` |
| 166 | | |
| 167 | | ### Storage Layout |
| 168 | | |
| 169 | | ``` |
| 170 | | ~/.config/hypernote/ |
| 171 | | storage-locator.json # breadcrumb to find storage |
| 172 | | server-data/ |
| 173 | | server-identity.json # device ed25519 keypair |
| 174 | | device-mnemonic.enc # encrypted BIP39 mnemonic vault |
| 175 | | corestore/ |
| 176 | | registry/ # Hyperbee (users, tokens, metadata) |
| 177 | | notebook-general/ # Hyperdrive for "general" notebook |
| 178 | | notebook-work/ # Hyperdrive for "work" notebook |
| 179 | | site-public/ # Hyperdrive for hyper:// site |
| 180 | | ... |
| 181 | | ``` |
| 182 | | |
| 183 | | Each notebook Hyperdrive contains: |
| 184 | | |
| 185 | | ``` |
| 186 | | /metadata.json # notebook name, description, flags |
| 187 | | /notes/{noteId}.json # individual notes (title, content, tags) |
| 188 | | /media/{filename} # uploaded images, video, audio |
| 189 | 48 | ``` |
| 190 | 49 | |
| 191 | | ### Authentication |
| 192 | | |
| 193 | | Three auth methods, tried in order: |
| 194 | | |
| 195 | | 1. **Web Identity** -- browser generates ed25519 keypair **client-side** using @noble/ed25519 (loaded locally, no CDN). Signs server challenges. Compatible with wirecore/wiredove ANProto format. Headers: `X-Wirecore-PublicKey`, `X-Wirecore-Signature`, `X-Wirecore-Challenge` |
| 196 | | 2. **Bearer Token** -- `Authorization: Bearer <token>` for CLI and API clients |
| 197 | | 3. **Keet Identity** -- BIP39 mnemonic-based, for cross-device portability |
| 198 | | |
| 199 | | ### P2P Replication |
| 200 | | |
| 201 | | Each notebook is a Hyperdrive. To share: |
| 202 | | |
| 203 | | 1. **Announce** a notebook on Hyperswarm (`POST /api/notebooks/:slug/announce`) |
| 204 | | 2. Peers discover it via the discovery key |
| 205 | | 3. Remote peers add it by drive key (`POST /api/sync/add`) |
| 206 | | 4. Hyperdrive handles incremental replication automatically |
| 207 | | |
| 208 | | Only announced notebooks are discoverable. The registry (users, tokens, permissions) is never replicated. |
| 209 | | |
| 210 | | ### Multi-Writer / Device Linking |
| 211 | | |
| 212 | | Hypernote supports linking multiple instances via [Autobase](https://github.com/holepunchto/autobase) for multi-device and multi-user collaboration. |
| 213 | | |
| 214 | | **How it works:** |
| 215 | | 1. An admin on instance A creates a link invite (`POST /api/multiwriter/link`) and gets an invite hex |
| 216 | | 2. An admin on instance B joins with that invite (`POST /api/multiwriter/join`) |
| 217 | | 3. Both instances share a replicated registry (users, permissions) via Autobase |
| 218 | | 4. Mirror sync copies notebooks between instances in the background |
| 219 | | |
| 220 | | **Key endpoints:** |
| 221 | | - `GET /api/multiwriter/status` -- check linking state and peers |
| 222 | | - `POST /api/multiwriter/link` / `POST /api/multiwriter/join` -- instance linking |
| 223 | | - `POST /api/multiwriter/sync` -- trigger manual mirror sync |
| 224 | | - `POST /api/multiwriter/mirror` -- start/stop background mirror sync |
| 225 | | - `POST /api/notebooks/:nb/share` -- toggle Autobase sharing on a notebook |
| 226 | | - Shared notebooks get their own CRDT-based note endpoints (`/shared-notes`, Yjs updates) |
| 227 | | |
| 228 | | See [docs/api.md](docs/api.md) for the complete multiwriter API reference. |
| 229 | | |
| 230 | | ### Site Publisher |
| 231 | | |
| 232 | | The server automatically publishes a Hyperdrive containing: |
| 233 | | - All web UI files (`/index.html`, `/web/*`) |
| 234 | | - Notebook data as static JSON (`/api/notebooks.json`, `/api/notebooks/:slug/*`) |
| 235 | | - Media files (`/api/media/:slug/*`) |
| 236 | | - Recent notes feed (`/api/recent.json`) |
| 237 | | |
| 238 | | The site drive is announced on Hyperswarm and accessible via `hyper://SITE_KEY/` in Agregore. When the app detects hyper:// protocol, it routes API calls to the configured local HTTP server for full read/write access. |
| 239 | | |
| 240 | | ## API |
| 241 | | |
| 242 | | See [docs/api.md](docs/api.md) for the complete REST API reference. |
| 243 | | |
| 244 | | ### Quick Reference |
| 245 | | |
| 246 | | | Method | Endpoint | Description | |
| 247 | | |--------|----------|-------------| |
| 248 | | | GET | `/api/health` | Server health check | |
| 249 | | | GET | `/api/status` | Server status + identity + site key | |
| 250 | | | GET | `/api/whoami` | Current user info | |
| 251 | | | GET | `/api/site` | Site drive key + hyper:// URL | |
| 252 | | | POST | `/api/site/publish` | Force republish site (admin) | |
| 253 | | | GET | `/api/recent` | Recent notes feed | |
| 254 | | | GET | `/api/notebooks` | List notebooks (?mine=true, ?owner=) | |
| 255 | | | POST | `/api/notebooks` | Create notebook | |
| 256 | | | GET | `/api/notebooks/:nb` | Get notebook metadata | |
| 257 | | | PATCH | `/api/notebooks/:nb` | Update notebook | |
| 258 | | | DELETE | `/api/notebooks/:nb` | Delete notebook | |
| 259 | | | POST | `/api/notebooks/:nb/rename` | Rename notebook slug | |
| 260 | | | GET | `/api/notebooks/:nb/notes` | List notes (?limit, ?offset) | |
| 261 | | | POST | `/api/notebooks/:nb/notes` | Create note | |
| 262 | | | GET | `/api/notebooks/:nb/notes/:id` | Get note | |
| 263 | | | PATCH | `/api/notebooks/:nb/notes/:id` | Update note | |
| 264 | | | DELETE | `/api/notebooks/:nb/notes/:id` | Delete note | |
| 265 | | | POST | `/api/notebooks/:nb/notes/:id/move` | Move note to another notebook | |
| 266 | | | POST | `/api/notebooks/:nb/media` | Upload media | |
| 267 | | | GET | `/api/notebooks/:nb/media/:file` | Serve media file (no auth) | |
| 268 | | | GET | `/api/search?q=term` | Full-text search | |
| 269 | | | POST | `/api/notebooks/:nb/encrypt` | Encrypt notebook | |
| 270 | | | POST | `/api/notebooks/:nb/decrypt` | Decrypt notebook | |
| 271 | | | POST | `/api/notebooks/:nb/unlock` | Unlock encrypted notebook | |
| 272 | | | POST | `/api/notebooks/:nb/share` | Share notebook (token or multiwriter) | |
| 273 | | | GET | `/api/notebooks/:nb/shares` | List share tokens (admin) | |
| 274 | | | DELETE | `/api/notebooks/:nb/shares/:hash` | Revoke share token (admin) | |
| 275 | | | GET | `/api/notebooks/:nb/notes/:id/links` | Get outgoing wiki-links | |
| 276 | | | GET | `/api/notebooks/:nb/notes/:id/backlinks` | Get backlinks | |
| 277 | | | GET | `/api/graph` | Knowledge graph (?notebook=) | |
| 278 | | | POST | `/api/notebooks/:nb/merge` | Merge two notes | |
| 279 | | | POST | `/api/notebooks/:nb/notes/:id/split` | Split note at headings | |
| 280 | | | POST | `/api/notebooks/:nb/announce` | Announce on Hyperswarm | |
| 281 | | | POST | `/api/notebooks/:nb/unannounce` | Stop announcing | |
| 282 | | | GET | `/api/notebooks/:nb/drive-info` | Hyperdrive info | |
| 283 | | | GET | `/api/sync/status` | P2P sync status | |
| 284 | | | POST | `/api/sync/add` | Add remote notebook | |
| 285 | | | DELETE | `/api/sync/remote/:nb` | Remove remote notebook | |
| 286 | | | POST | `/api/auth/challenge` | Get signing challenge | |
| 287 | | | GET | `/api/user/profile` | Get user profile | |
| 288 | | | PATCH | `/api/user/profile` | Update display name/theme | |
| 289 | | | POST | `/api/user/username` | Set username | |
| 290 | | | GET | `/api/user/username/:u/available` | Check username availability | |
| 291 | | | GET | `/api/users` | Public user directory | |
| 292 | | | GET | `/api/admin/users` | List all users (admin) | |
| 293 | | | DELETE | `/api/admin/users/:id` | Delete user (admin) | |
| 294 | | | GET | `/api/admin/stats` | Server stats (admin) | |
| 295 | | | POST | `/api/admin/promote` | Promote user (admin) | |
| 296 | | | POST | `/api/admin/demote` | Demote user (admin) | |
| 297 | | | POST | `/api/admin/notebooks/:nb/transfer` | Transfer ownership (admin) | |
| 298 | | | GET | `/api/multiwriter/status` | Multi-writer status (admin) | |
| 299 | | | POST | `/api/multiwriter/link` | Create link invite (admin) | |
| 300 | | | POST | `/api/multiwriter/join` | Join with invite (admin) | |
| 301 | | | POST | `/api/multiwriter/add-writer` | Add writer key (admin) | |
| 302 | | | POST | `/api/multiwriter/sync` | Trigger mirror sync (admin) | |
| 303 | | | POST | `/api/multiwriter/mirror` | Start/stop mirror (admin) | |
| 304 | | | GET | `/api/notebooks/:nb/shared-notes` | List shared notes | |
| 305 | | | POST | `/api/notebooks/:nb/shared-notes` | Create shared note | |
| 306 | | | POST | `/api/notebooks/:nb/shared-notes/:id/yjs` | Apply Yjs update | |
| 307 | | | GET | `/api/notebooks/:nb/shared-notes/:id/content` | Get shared note content | |
| 308 | | | GET | `/api/debug/identity-chain` | Identity chain (localhost) | |
| 50 | Configure: `hn config set server http://localhost:4040` |
| 309 | 51 | |
| 310 | 52 | ## Deployment |
| 311 | 53 | |
| 312 | | ### pear-git CI/CD |
| 313 | | |
| 314 | | Hypernote deploys automatically via pear-git: |
| 315 | | |
| 316 | 54 | ```bash |
| 317 | | git push pear master # triggers auto-deploy |
| 318 | | pgh deploy log hyper-note # check deploy status |
| 319 | | ``` |
| 320 | | |
| 321 | | The CI pipeline (`.pear-git/ci.yaml`) installs dependencies, then pear-git runs `pear stage` -> `pear release` -> `pear seed`. |
| 322 | | |
| 323 | | ### Manual |
| 324 | | |
| 325 | | ```bash |
| 326 | | bare hypernote-server.js serve 4040 --network |
| 55 | git push pear master # triggers auto-deploy via pear-git CI/CD |
| 327 | 56 | ``` |
| 328 | 57 | |
| 329 | 58 | For production, run in tmux or as a systemd service. |
| … | … | @@ -331,84 +60,13 @@ For production, run in tmux or as a systemd service. |
| 331 | 60 | ## Testing |
| 332 | 61 | |
| 333 | 62 | ```bash |
| 334 | | # Run all tests (150+ assertions) |
| 335 | | npm test |
| 336 | | |
| 337 | | # Run specific test |
| 338 | | bare node_modules/.bin/brittle test/unit/note-manager.test.js |
| 63 | npm test # 150+ assertions across 17 test files |
| 339 | 64 | ``` |
| 340 | 65 | |
| 341 | | Test coverage: identity, auth, notebooks, notes, encryption, sharing, links, merge/split, replication, rate limiting, storage, CLI config, web routes. |
| 342 | | |
| 343 | | ## Project Structure |
| 66 | ## Documentation |
| 344 | 67 | |
| 345 | | ``` |
| 346 | | hypernote/ |
| 347 | | hypernote-server.js # server entry point |
| 348 | | package.json |
| 349 | | bin/hn # CLI entry point |
| 350 | | .pear-git/ci.yaml # pear-git CI config |
| 351 | | lib/ |
| 352 | | auth.js # AuthManager (multi-method) |
| 353 | | note-manager.js # notebook + note CRUD |
| 354 | | drive-manager.js # per-notebook Hyperdrive lifecycle |
| 355 | | replication.js # Hyperswarm P2P sync |
| 356 | | site-publisher.js # hyper:// site drive publisher |
| 357 | | notebook-crypto.js # per-notebook encryption |
| 358 | | link-manager.js # wiki-links + backlinks + graph |
| 359 | | anproto.js # ANProto ed25519 signatures |
| 360 | | storage-locator.js # storage breadcrumb system |
| 361 | | errors.js # custom error classes |
| 362 | | response.js # HTTP response helpers |
| 363 | | multiwriter/ |
| 364 | | manager.js # multi-writer orchestration |
| 365 | | instance-linker.js # invite-based instance linking |
| 366 | | shared-registry.js # Autobase-backed shared user registry |
| 367 | | registry-apply.js # registry operation application |
| 368 | | auth/ |
| 369 | | base-provider.js # auth provider interface |
| 370 | | bearer-token.js # bearer token auth |
| 371 | | web-identity.js # browser ed25519 auth |
| 372 | | keet-identity.js # BIP39 mnemonic auth |
| 373 | | challenge-manager.js # challenge-response protocol |
| 374 | | identity-registry.js # user + identity storage |
| 375 | | rate-limiter.js # per-IP rate limiting |
| 376 | | identity/ |
| 377 | | identity-manager.js # device keypair management |
| 378 | | secure-storage.js # encrypted mnemonic vault |
| 379 | | routes/ |
| 380 | | index.js # HTTP router |
| 381 | | auth.js # identity + auth endpoints |
| 382 | | notebooks.js # notebook + note + media endpoints |
| 383 | | sharing.js # encryption + sharing endpoints |
| 384 | | links.js # links + backlinks + graph endpoints |
| 385 | | sync.js # P2P sync endpoints |
| 386 | | multiwriter.js # instance linking + shared notebooks |
| 387 | | web.js # static files + vendor + public URLs |
| 388 | | cli/ |
| 389 | | client.js # HTTP client for CLI |
| 390 | | config.js # CLI config file |
| 391 | | output.js # CLI output formatting |
| 392 | | commands/ # CLI subcommands |
| 393 | | web/ |
| 394 | | index.html # SPA entry point |
| 395 | | js/ |
| 396 | | app.js # main app + router + views |
| 397 | | api.js # HTTP client (dual-mode: HTTP + hyper://) |
| 398 | | identity.js # ed25519 auth + fetch interception |
| 399 | | markdown.js # markdown rendering |
| 400 | | css/ |
| 401 | | app.css # dark theme styles |
| 402 | | vendor/ |
| 403 | | toastui-editor-all.min.js # Toast UI editor (local, no CDN) |
| 404 | | toastui-editor.min.css # Toast UI styles (local, no CDN) |
| 405 | | test/ |
| 406 | | unit/ # 17 test files (brittle) |
| 407 | | helpers/ |
| 408 | | fixtures.js # test helpers |
| 409 | | docs/ |
| 410 | | api.md # REST API reference |
| 411 | | ``` |
| 68 | - [REST API Reference](docs/api.md) -- all 65+ endpoints |
| 69 | - [Architecture](docs/architecture.md) -- system design, storage layout, auth, security model, project structure |
| 412 | 70 | |
| 413 | 71 | ## License |
| 414 | 72 | |