openvibers/openvibe.cam AGENTS.md
AGENTS.md
| 1 | # AGENTS.md — OpenVibe.Cam |
| 2 | |
| 3 | ## Project overview |
| 4 | |
| 5 | The camera app, the private wall for cameras you own, and the API Live's cam button calls. A photo or |
| 6 | a still is saved to **OpenVibe.Pics** (the MediaHub); Cam keeps the link, the filter label and the |
| 7 | caption, **never the bytes**. A camera is private to its owner, and a viewer sees it only while the |
| 8 | grant the owner gave is active and not revoked. Node.js/Express, PostgreSQL, CommonJS, no build step. |
| 9 | |
| 10 | - Origin `https://openvibe.cam`, port `5160`, service id `cam`, env prefix `CAM`, unit |
| 11 | `openvibe-cam.service`, release root `/opt/openvibe.cam`. |
| 12 | - v1 is written and tested ([STATUS.json](STATUS.json)); the README records that it is not deployed by |
| 13 | the change that added it. |
| 14 | |
| 15 | ## What it owns |
| 16 | |
| 17 | - `cam_cameras` (`cam_` + ULID, owner `usr_…`, source `browser|phone|pi|ip`, status, Live stream id |
| 18 | unique per owner, slot, note) and `cam_grants` (`cmg_`, one canonical viewer subject, expiry, |
| 19 | revocation); `cam_photos`, `cam_stills`; account-data receipts and telemetry. All in |
| 20 | [migrations/0001_initial.sql](migrations/0001_initial.sql). |
| 21 | - **There is no image/blob/byte column anywhere.** The `pics_slug` and links are the picture's address |
| 22 | in Pics; Cam and its database cannot serve the bytes. |
| 23 | - The capture app (browser preview, timer, 3-shot burst, grid, on-device colour/light/softness), the |
| 24 | private wall `/w/:id`, `/my`, `/my/cameras/:id`, the still `/s/:id` and the `<ov-camera>` element. |
| 25 | - Revocable viewer grants: `@username` resolved through Network (`identity.subject.resolve`), a |
| 26 | subject id accepted without it, default 7 days and at most 90, a re-grant extends instead of making a |
| 27 | second grant, revocation writes a timestamp checked on every read. |
| 28 | - The Live cam button: `POST /api/v1/stills` fetches Media's public `GET /live/:slot/frame.jpg` |
| 29 | (only `image/jpeg` accepted), uploads it to Pics as the person and returns the still plus the exact |
| 30 | line Live posts. |
| 31 | |
| 32 | Not owned: identity (Network), the live frame (Media), the picture bytes and their visibility |
| 33 | (Pics/MediaHub), the stream (Live), Studio (the edit link points at `openvibe.studio`). |
| 34 | |
| 35 | ## Who calls it, and whom it calls |
| 36 | |
| 37 | - Callers: people in the browser; **OpenVibe.Live**'s cam button (`POST /api/v1/stills` with the |
| 38 | viewer's own token); any site embedding `<ov-camera>`. |
| 39 | - Depends on: |
| 40 | - **Network** — sign-in (OAuth client `cam`, PKCE S256, cookies `cam_at`/`cam_rt`/`cam_oauth`), |
| 41 | JWKS, token exchange/refresh/revoke, account export/deletion, `identity.subject.resolve`. |
| 42 | - **Media** — the public live frame (`OV_MEDIA_URL`, `GET /live/:sel/frame.jpg?w=640`). |
| 43 | - **Live** — `GET /api/streams/:id` to check a paired stream is on the owner's channel |
| 44 | (`live.stream.read`). |
| 45 | - **Pics / MediaHub** — `POST /api/v1/images`, called with the **person's own** Network token |
| 46 | (`pics.image.upload`); Cam never holds a service token for Pics, and MediaHub refuses one there. |
| 47 | - **Events** — account deliveries; PostgreSQL (`DATABASE_URL`, owner `DATABASE_DIRECT_URL` for |
| 48 | migrations); Valkey optional. |
| 49 | |
| 50 | ## Repository map |
| 51 | |
| 52 | - **Entry:** [server/index.js](server/index.js) — `createApp()`, listen, key warm-up, graceful stop. |
| 53 | - **App:** [server/app.js](server/app.js) — helmet/CSP, `/auth`, `/internal/events`, `/shared`, |
| 54 | `/api/v1`, pages (`/`, `/app`, `/my`, `/w/:id`, `/s/:id`, `/developers`), machine endpoints. |
| 55 | - **Config/db:** [server/config.js](server/config.js), [server/db.js](server/db.js). |
| 56 | - **Domain:** [server/cameras.js](server/cameras.js) (cameras, grants, photos, stills), |
| 57 | [server/store.js](server/store.js), [server/upstreams.js](server/upstreams.js) (Media/Live/Pics), |
| 58 | [server/events/pipeline.js](server/events/pipeline.js). |
| 59 | - **HTTP:** [server/http/api.js](server/http/api.js), `pages.js`, `principal.js`, `discovery.js`, |
| 60 | `openapi-routes.js`, `router.js`; views in [server/views/index.js](server/views/index.js). |
| 61 | - **Auth:** [server/auth/sso.js](server/auth/sso.js), [server/auth/keys.js](server/auth/keys.js). |
| 62 | - **Browser:** [public/js/developers.js](public/js/developers.js) and the camera pages/view strings; |
| 63 | the filter runs on a canvas in the person's browser. |
| 64 | - **Proof seed:** [scripts/seed-proof.js](scripts/seed-proof.js). |
| 65 | - **Deploy:** [deploy/systemd/openvibe-cam.service](deploy/systemd/openvibe-cam.service), |
| 66 | [deploy/nginx/openvibe.cam.conf](deploy/nginx/openvibe.cam.conf) (TLS, www→apex, `/auth/me` and |
| 67 | `/my/` rate-limited, `/internal` and `/metrics` 404, 16 MB body cap). |
| 68 | |
| 69 | ## Commands |
| 70 | |
| 71 | ```bash |
| 72 | npm install |
| 73 | npm run dev # NODE_ENV=development node server/index.js |
| 74 | npm start # node server/index.js, http://127.0.0.1:5160 |
| 75 | npm test # test/run.js, PGlite, mock Network + Media/Live/Pics fixtures |
| 76 | OV_ALLOW_SUITE=1 npm test # the README's guarded form |
| 77 | npm run test:pg # same on PostgreSQL (CAM_TEST_STORE=pg) |
| 78 | npm test -- cameras # filter by file name; --strict makes a skip fail |
| 79 | npm run seed:proof # CAM_PROOF_OWNER=usr_… CAM_PROOF_VIEWER=usr_… node scripts/seed-proof.js |
| 80 | ov test # the harness's sharded run of what the change reaches |
| 81 | ``` |
| 82 | |
| 83 | ## Database and migrations |
| 84 | |
| 85 | Cam's own PostgreSQL database on the host's data role (`DATABASE_URL` through PgBouncer; owner |
| 86 | `DATABASE_DIRECT_URL` for migrations; PGlite under `CAM_PGLITE_DIR` in development). Migrations are |
| 87 | `migrations/NNNN_*.sql`, applied at boot; never edit an applied one, add a new number. Constraints are |
| 88 | part of the privacy model (one unrevoked grant per camera+viewer, owner/stream uniqueness) — do not |
| 89 | drop or relax them for a feature. |
| 90 | |
| 91 | ## Auth and privacy |
| 92 | |
| 93 | - People sign in with Network SSO. The API takes the signed-in person's token; `POST /api/v1/stills` |
| 94 | forwards **the viewer's own token** to Pics; there is no Cam service-to-Pics credential. |
| 95 | - A wall is for its owner and an active, unrevoked, unexpired grantee — **staff have no bypass** |
| 96 | (404 for everyone else, including admins). |
| 97 | - A grant is one canonical subject with an expiry; revocation is immediate on the next read. `@username` |
| 98 | resolution is Network's; a subject id works when Network is not reachable. |
| 99 | - The `<ov-camera>` element holds no session and uploads nothing unless the site asks; the camera is |
| 100 | a browser API, not a server capture. |
| 101 | - Account export/deletion go through `/internal/events` with this service's own client token. |
| 102 | |
| 103 | ## Frontend loading model |
| 104 | |
| 105 | Server-rendered pages and plain browser JavaScript; the **filter, timer, burst and grid run in the |
| 106 | person's browser on a canvas** — the server never applies an effect and does no image analysis. The |
| 107 | embeddable `<ov-camera>` is one script from `https://openvibe.cam/embed/ov-camera.js`; there is no SPA, |
| 108 | bundler or transpiler. `openvibe-shared` supplies the Frame/legal/SEO and `/shared/*` from the pinned |
| 109 | node_modules. CSP is in [server/app.js](server/app.js). |
| 110 | |
| 111 | ## Deploy |
| 112 | |
| 113 | Unit [deploy/systemd/openvibe-cam.service](deploy/systemd/openvibe-cam.service) runs |
| 114 | `/opt/openvibe.cam/server/index.js` on `127.0.0.1:5160`, env `/etc/openvibe/cam.env`; vhost |
| 115 | [deploy/nginx/openvibe.cam.conf](deploy/nginx/openvibe.cam.conf). The README does not record an |
| 116 | `ovhost deploy cam` command and says this release is not deployed — use the host's own service |
| 117 | installer, and do not claim the domain is live until the owner's launch step runs. |
| 118 | |
| 119 | ## Tests |
| 120 | |
| 121 | `npm test` runs each `test/*.test.js` in its own process (90 s) with a temp PGlite, a mock Network and |
| 122 | a Media/Live/Pics fixture server. It covers the migration and its constraints; pairing and stream |
| 123 | ownership; grants (username resolution, extension, revocation, expiry, **no staff bypass**); the still's |
| 124 | Media→Pics path and refusals; photo upload and limits; pages/cache headers; sign-in; account |
| 125 | export/deletion; and the home page must not put any camera or grant on the public page. `ov test` runs |
| 126 | the same. |
| 127 | |
| 128 | ## Conventions |
| 129 | |
| 130 | - CommonJS, 4-space indent, single quotes, semicolons; factories with injectable config/store/clock/ |
| 131 | fetch/log; rendering in `server/render/` and `server/views/`. |
| 132 | - IDs are prefixed ULIDs (`cam_`, `cmg_`, `cmp_`, `cms_`); timestamps are the store's clock. |
| 133 | - Errors are small JSON problems or 404s; never reveal that a camera exists to someone without a grant. |
| 134 | - No secret in a log, a response or the database; Pics' slugs are not secrets but are not enumerable. |
| 135 | |
| 136 | ## Pitfalls |
| 137 | |
| 138 | - **No bytes in Cam.** Do not add an image/blob column or proxy the picture: Pics owns the bytes and |
| 139 | its own visibility; Cam stores only the slug and links. |
| 140 | - **No face recognition, no server-side filter, no image analysis.** The README's promise is part of |
| 141 | the product; a "smart" server feature breaks it. |
| 142 | - **The wall is private even from staff.** A grant is the only door (besides the owner); a 404, not a |
| 143 | 403, is the answer. |
| 144 | - **Only `image/jpeg` from Media's frame is accepted** for a still; do not accept arbitrary content |
| 145 | types or arbitrary URLs. |
| 146 | - **Check stream ownership through Live's public API** before pairing a stream; the slot defaults to |
| 147 | the stream's managed slot. |
| 148 | - **Grants have a maximum of 90 days**; a re-grant extends the active row, never duplicates it. |
| 149 | - **The Live cam button must use the viewer's token** to upload to Pics; a service token is refused |
| 150 | by MediaHub and would attribute the still to Cam. |
| 151 | - **A photo of someone else's Pics object is 404 in Studio**, not a redirect or a preview. |
| 152 | |
| 153 | ## Documentation |
| 154 | |
| 155 | - [README.md](README.md) — purpose, the tables, API, Live cam button, `<ov-camera>`, pages, proof, |
| 156 | run/test and deploy files. |
| 157 | - [STATUS.json](STATUS.json) — what works and what does not. |
| 158 | - OpenVibe.Contracts holds the `cam.*` capability shapes; Pics/Media own the image contract. |
| 159 |