openvibers/openvibe.locker AGENTS.md
AGENTS.md
| 1 | # AGENTS.md — OpenVibe.Locker |
| 2 | |
| 3 | ## Project Overview |
| 4 | |
| 5 | OpenVibe.Locker is the front door for private files, passwords and expiring Sends. It stores **ciphertext and opaque |
| 6 | access metadata only**: the server never seals, opens, indexes, previews, transcodes or runs AI on content. Names, |
| 7 | MIME types and bytes are inside the envelope, sealed and opened in the browser by the device UI and |
| 8 | `openvibe-sdk/locker`; the browser worker writes to OPFS in 1 MiB chunks. |
| 9 | |
| 10 | - **Domain:** `openvibe.locker` · **Port:** 5145 (drill 15145) · **Service id:** `locker` · **Env prefix:** `LOCKER`/`OV_` |
| 11 | - **Runtime:** Node 22, Express 4, PostgreSQL 18 through `openvibe-sdk/db` (PGlite in development/tests), Playwright |
| 12 | for browser tests. |
| 13 | - **Read first:** [README.md](README.md), [docs/BLIND-FILL.md](docs/BLIND-FILL.md), |
| 14 | [deploy/LAUNCH.md](deploy/LAUNCH.md), [SECURITY.md](SECURITY.md), [public/openapi.json](public/openapi.json). |
| 15 | |
| 16 | **Network context:** identity, sign-in, staff roles, account export/deletion and the Companion token validation are |
| 17 | OpenVibe.Network (`locker` OAuth client; the Companion token needs `locker.login.manage`; `/internal/identity/resolve` |
| 18 | with `trust=1` decides standing). Ciphertext lives in a private OpenVibe.Media tenant (`locked.ovlk`, |
| 19 | `application/octet-stream`); Locker queues durable erasure there. Events are OpenVibe.Events. Share UI and the SDK |
| 20 | envelope are OpenVibe.Shared/SDK, not reimplemented here. |
| 21 | |
| 22 | ## Commands |
| 23 | |
| 24 | ```bash |
| 25 | npm ci # install (contracts/sdk/shared pinned by tag) |
| 26 | npm start # http://localhost:5145, PGlite under data/pglite |
| 27 | npm test # node --test --test-concurrency=1 test/*.test.js (real WebCrypto, local adapters) |
| 28 | npm run check # syntax check (scripts/check.js) |
| 29 | npm run test:browser # starts a local instance, real browser worker, 390/1280 screenshots in artifacts/ |
| 30 | node --test test/companion.test.js # one file (companion isolation and ciphertext-only checks) |
| 31 | ``` |
| 32 | |
| 33 | There is **no `test:pg` script**: the suite uses isolated PGlite databases. Browser checks use Playwright's Chromium |
| 34 | or local Chrome. There is no build step; the page JS in [public/js/](public/js/) (`app.js`, `passwords.js`, |
| 35 | `payload.js`, `seal-worker.js`) is served as-is. |
| 36 | |
| 37 | ## Architecture at a Glance |
| 38 | |
| 39 | - **Entry:** [server/index.js](server/index.js) — creates the app, starts the due jobs |
| 40 | ([server/jobs.js](server/jobs.js)) and the two ADR-033 subscriptions, listens, and on SIGTERM stops jobs/subscriptions |
| 41 | and closes the database. Production refuses to start without the service credentials and event secret. |
| 42 | - **App factory:** [server/app.js](server/app.js) — helmet/CSP (no-referrer, worker-src self), `/internal/events`, |
| 43 | `/api/v1/companion`, `/auth`, optional auth, the same-origin + `X-Requested-With` CSRF gate on writes, `/api/v1`, |
| 44 | `/shared` + static, pages, error handler. `createApp({ db, network, media, sso, now })` is injectable for tests. |
| 45 | - **Config/db:** [server/config.js](server/config.js) (pure load), [server/db.js](server/db.js) — migrations |
| 46 | [migrations/](migrations/) at boot; no table is created at runtime. |
| 47 | - **Core:** [server/service.js](server/service.js) (accounts, quota, items, viewing rules, rates, removal), |
| 48 | [server/envelope.js](server/envelope.js) (shape, prefix, exact length), [server/media.js](server/media.js) (private |
| 49 | Media objects and multipart), [server/network.js](server/network.js) (identity/standing), |
| 50 | [server/account-data.js](server/account-data.js), [server/sharing.js](server/sharing.js) (the Share sheet), |
| 51 | [server/uploads.js](server/uploads.js) (resumable sessions), [server/companion.js](server/companion.js). |
| 52 | - **Pages:** [server/pages.js](server/pages.js) — `/`, `/drive`, `/passwords`, `/files/:id`, `/sends`, |
| 53 | `/s/:id#k=…`, `/recovery`, `/staff`, `/docs`; [public/llms.txt](public/llms.txt), |
| 54 | [public/llms-full.txt](public/llms-full.txt), [public/robots.txt](public/robots.txt), |
| 55 | [public/.well-known/agent.json](public/.well-known/agent.json). |
| 56 | - **Database:** [migrations/](migrations/): `locker_accounts`, `locker_items`, `locker_uploads`, |
| 57 | `locker_upload_parts`, `locker_upload_results`, `locker_reports`, `locker_moderation`, `locker_rates`, `locker_gc`. |
| 58 | - **Deploy:** [deploy/systemd/openvibe-locker.service](deploy/systemd/openvibe-locker.service) (PORT 5145, |
| 59 | `/etc/openvibe/locker.env`, `/opt/openvibe.locker`) and [deploy/nginx/openvibe.locker.conf](deploy/nginx/openvibe.locker.conf) |
| 60 | (33 MiB parts, 91 MiB legacy single envelopes, 64 KiB for everything else, buffering off, long read timeouts, no |
| 61 | query strings/cookies/bodies in logs). Install through `ov-vhost-install`; database `ov_locker`; Media multipart |
| 62 | must allow at least 2304 MiB of padded ciphertext. |
| 63 | |
| 64 | ## Ownership and Boundaries |
| 65 | |
| 66 | Locker owns its accounts/quota, items and access rules, upload sessions/results, reports, moderation and rate rows, and |
| 67 | the access/moderation history it retains as security evidence. It does **not** own identity, sign-in, Wrapped keys |
| 68 | (Network), the ciphertext bytes (Media), event delivery (Events), or the envelope/crypto format (`openvibe-sdk/locker`; |
| 69 | payload framing in [public/js/payload.js](public/js/payload.js) is an application payload inside that envelope). It |
| 70 | never sees a filename, MIME type or plaintext byte. A folder is organisational: sharing a folder does not grant its |
| 71 | children. |
| 72 | |
| 73 | ## Access, Crypto and Moderation |
| 74 | |
| 75 | - **Payload:** 4-byte big-endian metadata length (≤65536) + UTF-8 JSON `{ version:1, name, type, size, folder? }` + |
| 76 | exactly `size` content bytes. `prefix`/`pack`/`unpack` are in [public/js/payload.js](public/js/payload.js); the worker |
| 77 | feeds chunks to `locker.sealStream` and verifies before commit. |
| 78 | - **Access:** a Send is a link lock (`/s/:id#k=…`, the key never leaves the fragment; 1 hour–30 days and an optional |
| 79 | attempt limit); a drive item is a people lock with the owner's slot and the current sharing list. Each content |
| 80 | request consumes one claim, including disconnected downloads; GET metadata and HEAD do not. |
| 81 | - **Sharing:** seal a new envelope for the owner and all remaining recipients, complete a resumable `reseal` (or an |
| 82 | inline `POST /files/:id/reseals` for ≤90 MiB), then `PUT /files/:id/share` with `{ members, envelope_ref }` (and the |
| 83 | Share GET's ETag in `If-Match` where possible). The Media reference, quota and ACL commit together; an unconsumed |
| 84 | receipt expires after an hour and old objects are queued for erasure. A departing member's old key cannot open the |
| 85 | new envelope (they may still keep a prior copy). |
| 86 | - **Limits:** `LOCKER_QUOTA_BYTES` 5 GiB, `LOCKER_MAX_FILE_BYTES` 2 GiB plaintext (browser-enforced; the server can |
| 87 | only enforce the padded size), 60 uploads/hour, 20 Sends/hour, 32 MiB parts, `LOCKER_MIN_ACCOUNT_DAYS=7`; sign-in, |
| 88 | age and Network ban/deletion, local restrictions and optional `LOCKER_ALLOWLIST_ONLY` all apply. |
| 89 | - **Companion:** only a Bearer token Network validates live per request; `GET /keys`, `GET /logins`, |
| 90 | `GET /logins/:id/content`, `POST /logins` (≤2 MiB, kind fixed `login`). The scoped token cannot reach the general |
| 91 | file API, key updates or blind fill. Any change to this surface must be reviewed as security-sensitive. |
| 92 | - **Reports** carry a reason code only (`abuse`, `illegal`, `malware`, `other`) — never a key or plaintext. Staff |
| 93 | (Network `global_mod`+) disable items and restrict uploads; every change is recorded locally. |
| 94 | - **Account export/deletion** (ADR-033) provides an opaque manifest with separately downloadable envelopes while the |
| 95 | account exists; deletion denies access immediately, queues ciphertext erasure, cancels deliveries and removes |
| 96 | memberships. Access/moderation history and the deletion marker remain as evidence, with no plaintext or keys. |
| 97 | - Errors use stable `locker.*` codes and never echo an envelope's contents. Hostile input stays text. |
| 98 | |
| 99 | ## Key Pitfalls |
| 100 | |
| 101 | - **The server must never decrypt, sniff, index or log content.** Do not add an MIME check by content, a preview, an |
| 102 | AI call, a search index or a filename column; "unknown plaintext metadata is rejected" is enforced on the envelope |
| 103 | header, not by opening it. |
| 104 | - **Exact length:** the server checks the envelope shape and the exact expected ciphertext length while counting actual |
| 105 | received bytes; a mismatch is a 400 `locker.length`. The 64 KiB header bound is part of the protocol. |
| 106 | - **CSRF:** writes require the same origin **and** `X-Requested-With`; a bearer-only or cross-site form post must not |
| 107 | pass. Do not relax it for the "convenience" of a client. |
| 108 | - **Sends:** never rewrite the fragment into a query or a server log; `#k=` stays client-side. HEAD/GET metadata must |
| 109 | not consume a download claim; content requests must. |
| 110 | - **Resumable uploads:** parts are immutable and retryable, numbered from zero, each exactly 32 MiB except the last; |
| 111 | completion is idempotent for 24 h and rechecks standing/revision/recipients. A lost completion response is retried |
| 112 | by querying, not by re-uploading blindly. |
| 113 | - **Expiry/erasure is due-based and bounded:** access ends at `due_at` even during a Media outage; cleanup retries |
| 114 | failures. Never restore or reuse a deleted envelope. |
| 115 | - **CSP/`Referrer-Policy: no-referrer`** is security-relevant: no third-party script, no referrer carrying a fragment, |
| 116 | no `frameAncestors` widening. The only named external origins are Network, Media, Chat, Billing, Events, Cloudflare |
| 117 | insights and HIBP (for the first five SHA-1 hex chars, after asking). |
| 118 | - **No fake backend or sample content** in production; development uses PGlite and real WebCrypto. |
| 119 | - **Drill:** port 15145, its own writable directory and drill database — never point a drill at the live database. |
| 120 | |
| 121 | ## Testing |
| 122 | |
| 123 | `npm test` runs `node --test` over [test/](test/): locker access/quota/leniency, resumable uploads, passwords/login |
| 124 | envelopes, sharing, Media adapter, companion isolation/ciphertext-only, public pages and real browser checks. |
| 125 | `npm run test:browser` starts a local instance and exercises the real worker, saving 390/1280 screenshots in both themes |
| 126 | to `artifacts/`. `npm run check` checks syntax. There is no PostgreSQL suite; state that plainly rather than inventing |
| 127 | one. |
| 128 | |
| 129 | ## Documentation |
| 130 | |
| 131 | - [README.md](README.md) — run/verify, pages, private payload, storage/access, companion, versions. |
| 132 | - [deploy/LAUNCH.md](deploy/LAUNCH.md) — the operator handoff: PRs, database, OAuth client, env, drill, units, limits. |
| 133 | - [docs/BLIND-FILL.md](docs/BLIND-FILL.md) — the Run/Companion/Run blind-fill flow and its security requirements. |
| 134 | - [public/openapi.json](public/openapi.json), [public/llms.txt](public/llms.txt), [SECURITY.md](SECURITY.md). |
| 135 |