openvibers/openvibe.work README.md

README.md

main 23.5 KB · 284 lines · markdown

1# OpenVibe.Work
2
3> Find work on open job boards.
4
5**Live site:** https://openvibe.work · **API docs:** https://openvibe.services · **For AI:** https://openvibe.work/llms.txt
6**Run it:** `npm ci && npm run dev` (Node 22 or newer) · **Tests:** `npm test`
7
8**Status:** the listings, the search and the agent pillar are live (listings gathered from four public job boards (two of them read through OpenVibe.Sources),
9search, saved searches, job applications with their status history, help preparing one from the person's OpenVibe.Actor
10agent, an API and the pages around them).
11**Domain:** `openvibe.work` · **Port:** 4960 · **Service id:** `work` · **Env prefix:** `WORK`
12**License:** AGPL-3.0 (same as every OpenVibe service).
13
14## Purpose
15
16OpenVibe.Work is the network's job-listings service: it gathers listings from public job boards, keeps each one's
17provenance and a short plain-text excerpt, and makes them searchable without an account. A signed-in person can keep a
18search, see how many listings have appeared since they last looked, track the jobs they apply to and ask their
19OpenVibe.Actor agent to help prepare one. Nothing is republished — every listing links back to the board, where the
20reader reads the full listing and applies.
21
22## What it does
23
24Job listings gathered from public job boards, each one carrying its provenance — the board it came from and a link
25back to the original listing — searchable, filterable, and readable without an account. A signed-in person can keep a
26search, see how many listings have appeared since they last looked, track the jobs they apply to (with notes, dates,
27next steps and a status history), and ask their OpenVibe.Actor agent to help prepare an application. Nothing is
28republished: Work stores a short plain-text excerpt and sends the reader to the board for the full listing and to apply.
29
30## What is here
31
32| Piece | Where | What it does |
33|---|---|---|
34| Config | [server/config.js](server/config.js) | Every value from the environment; `load(env)` is pure so tests build a config without touching `process.env` |
35| App | [server/app.js](server/app.js) | helmet (CSP, frame-ancestors none), the Network session middleware, `/auth`, the legal pages, static assets, the `/api/v1` mount, the 404 and the error handler |
36| Sign-in | [server/auth/sso.js](server/auth/sso.js) | OAuth 2 authorization code with PKCE (S256) against OpenVibe.Network; httpOnly `work_at` / `work_rt` cookies; `/auth/me` for the shared navbar |
37| Sources | [server/jobs/sources.js](server/jobs/sources.js) | The boards: where each is fetched, its terms, how its payload becomes a listing, and what is refused |
38| Ingest | [server/ingest/](server/ingest/) | The background read (timer, User-Agent, 15 s timeout, upsert by source + the board's id, expiry) |
39| Listings | [server/jobs/store.js](server/jobs/store.js), [migrations/0002_listings.sql](migrations/0002_listings.sql) | Search, facets, the cursor, saved searches, the fetch record |
40| Text | [server/jobs/text.js](server/jobs/text.js) | HTML → plain text, and the ~300-character excerpt that is all we keep |
41| Applications | [server/applications.js](server/applications.js), [migrations/0005_applications.sql](migrations/0005_applications.sql) | The jobs a person tracks: statuses, notes, dates, next steps and the append-only status history |
42| Actor | [server/actor.js](server/actor.js) | `POST /api/v1/tasks` on the person's OpenVibe.Actor agent with Work's own service token |
43| API | [server/http/api.js](server/http/api.js) | `/api/v1`: jobs, one job, the sources, saved searches, applications; problem+json errors |
44| Pages | [server/http/pages.js](server/http/pages.js), [server/render/](server/render/) | `/`, `/jobs`, `/jobs/:id`, `/saved`, `/applications`, `/applications/:id`, `/sources`, `/docs`, `/updates` — server-rendered, no JavaScript needed |
45| Discovery | [server/http/discovery.js](server/http/discovery.js) | `robots.txt`, `sitemap.xml` (the newest listings too), `llms.txt`, `llms-full.txt`, JSON-LD; with `INDEXNOW_KEY`, each ingest run's new listings are sent to IndexNow |
46| Limits | [server/http/caller-limits.js](server/http/caller-limits.js), [deploy/nginx/](deploy/nginx/) | Per-caller limits at the API (the search and the saved-search writes have their own) and per-address limits at nginx |
47| Health | [server/observability.js](server/observability.js) | `/api/health`, a truthful `/api/ready` (the database is required) and Prometheus metrics on loopback only |
48| Telemetry | [server/app.js](server/app.js), [server/index.js](server/index.js), [migrations/0006_telemetry.sql](migrations/0006_telemetry.sql) | Every request aggregated by route template, method and status class, never by raw URL; once a minute one `http.request` sample per key, plus the active-requests, p95-latency and event-loop-lag gauges, written to this service's own `telemetry_samples` table (openvibe-sdk 0.46 telemetry, the `platform.telemetry-sample@1` shape); rows older than 14 days are deleted daily; `/shared` files, static assets, health, readiness and `/metrics` are not counted. `app.js` builds the collector (`createHttpTelemetry`) and mounts its middleware; `index.js` flushes it every minute and on stop and runs the retention |
49| Database | [server/db.js](server/db.js), [migrations/](migrations/) | PostgreSQL through `openvibe-sdk/db`; `NNNN_*.sql` applied at boot; PGlite in development |
50| Process | [server/index.js](server/index.js) | Listens on `PORT`, starts the ingest when it is on, and stops gracefully through `openvibe-sdk/service` |
51| Deploy | [deploy/nginx/openvibe.work.conf](deploy/nginx/openvibe.work.conf), [deploy/systemd/openvibe-work.service](deploy/systemd/openvibe-work.service) | nginx vhost and systemd unit (port 4960, `/opt/openvibe.work`, `/etc/openvibe/work.env`) |
52| Tests | [test/](test/) | `npm test`: every `test/*.test.js` in its own process, on a temp PGlite database, with a mock OpenVibe.Network and a stand-in for each job board |
53
54## Owns
55
56Its own PostgreSQL tables, created by [migrations/](migrations/) and written by nothing else:
57
58- `work_listings` (0002) — one row per listing, upserted on `(source, source_id)`, with the board's own id, the
59 original `url` to link back to, the fields the board gives, and a short plain-text excerpt. Expired listings are
60 kept but hidden.
61- `work_saved_searches` (0002) — a search a person saved, keyed `subject = user:usr_…`, with when they last looked.
62- `work_source_fetches` (0002) — what each board's last fetch did, so `/sources` can say when it last ran and how.
63- `work_applications` (0005) — a job application a person tracks, keyed `subject_id = user:usr_…`, with its status,
64 notes, dates, next step and the last Actor task it asked for. One per person per listing.
65- `work_application_events` (0005) — the append-only history of an application's status changes.
66- `account_data_events` (0003) — the receipts of the ADR-033 export and deletion deliveries this service applied, so a
67 redelivered event changes nothing.
68
690001 creates no tables. The service is the authority for these rows and for nothing else.
70
71## Does not own
72
73- **Identity and accounts** — OpenVibe.Network: sign-in (OAuth client `work`, PKCE S256), the signing keys (JWKS) and
74 the canonical subject. Work holds only `user:usr_…` on its own rows.
75- **Event delivery** — OpenVibe.Events: Work receives `network.account.export_requested` and `network.account.deleted`
76 and creates its two subscriptions at boot; it does not own the topics or the delivery.
77- **The listings themselves** — the boards (Arbeitnow, We Work Remotely, RemoteOK, Hacker News "Who is hiring" and Remotive): Work keeps a short excerpt and a link back,
78 never the full description, and reads each board only on the terms it publishes. A board that writes a listing as
79 one pipe-separated line (Hacker News) has that line split into company, role(s), place, terms and pay
80 (`server/jobs/hiring-line.js`); the line itself stays at the front of the excerpt.
81
82## Depends on
83
84- **OpenVibe.Network** — SSO sign-in and JWKS verification, and the internal routes an export part or a deletion
85 confirmation is pushed to (`OV_NETWORK_URL`, `OV_NETWORK_INTERNAL_URL`, `OV_OAUTH_CLIENT_ID`,
86 `OV_OAUTH_CLIENT_SECRET`, `OV_NETWORK_ISSUER`, `OV_SESSION_AUDIENCE`, `WORK_AUDIENCE`).
87- **OpenVibe.Events** — the two account subscriptions created at boot and the delivery posted to this service's
88 loopback `/internal/events` (`WORK_EVENTS_URL` or `EVENTS_URL`, `WORK_EVENTS_SECRET`, `WORK_EVENTS_ENDPOINT`,
89 `WORK_EVENTS_SUBSCRIBE`).
90- **OpenVibe.Actor** — the person's agent: Work posts `POST /api/v1/tasks` with its own service token (capability
91 `actor.task.create`, audience `openvibe.actor`) when a person asks for help preparing an application
92 (`WORK_ACTOR_URL`, `WORK_ACTOR_PUBLIC_URL`, `WORK_ACTOR_TIMEOUT_MS`).
93- **The job boards** — Remotive is the only host Work itself fetches (`WORK_REMOTIVE_URL`, `WORK_USER_AGENT`), because
94 its API terms grant a named, linked use while its robots.txt disallows `/api/*`. Arbeitnow, RemoteOK, We Work Remotely
95 and Hacker News "Who is hiring" arrive through OpenVibe.Sources (`server/ingest/from-sources.js`).
96- **PostgreSQL** (`DATABASE_URL`, `DATABASE_DIRECT_URL`; an embedded PGlite database in development via
97 `WORK_PGLITE_DIR`) and **Valkey** for shared limit counters (`VALKEY_URL`, `VALKEY_PREFIX`).
98- **Packages**: `openvibe-contracts` v0.289.1, `openvibe-sdk` v0.161.0 (`db`, `auth`, `account-data`, `limits`,
99 `valkey`, `service`) and `openvibe-shared` v4.179.0 (`frame`, `legal`, `serve`, `release`, `metrics`, `ready`,
100 `seo`, `shell`, `cache-policy`, `showcase`, `app-icon`).
101
102## Capabilities
103
104[server/http/principal.js](server/http/principal.js) declares one capability, `work.application.manage`: an app or
105agent token may manage job applications with it for the person it acts for (a person needs no capability for their
106own). The service manifest (`work`, openvibe-contracts) has listed it since 0.147.0, from the proposal in
107[docs/contracts-proposal/work.application.manage.md](docs/contracts-proposal/work.application.manage.md). The
108person-only saved-search routes still name no capability.
109
110Work calls one capability on another service: `actor.task.create` (audience `openvibe.actor`) when a person asks their
111agent to help prepare an application ([server/actor.js](server/actor.js)). It also uses two machine-to-machine
112surfaces: OpenVibe.Events' subscription API (scope `events.subscription.manage`) to create its two account-event
113subscriptions at boot ([server/events-consumer.js](server/events-consumer.js)), and OpenVibe.Network's internal account
114export/deletion routes, pushed with this service's own client-credentials token
115([server/identity/account-data.js](server/identity/account-data.js)).
116
117## API
118
119| Route | Who | |
120|---|---|---|
121| `GET /api/v1/ping` | anyone | `{ ok: true, service: "work" }` |
122| `GET /api/v1/jobs` | anyone | Search: `q`, `remote`, `type`, `location`, `limit` (to 100), `cursor` (the `next` of the previous page). Newest first, 30 a page, with facets |
123| `GET /api/v1/jobs/:id` | anyone | One listing: the board's fields, the excerpt, the source, and `url` — the original to apply on |
124| `GET /jobs.atom` | anyone | The same search as an Atom feed (`q`, `remote`, `type`, `location`): the newest 25 listings, each linking its page here, cached 5 minutes. The search page links it in its head, and **Get alerts** (on the search page and on each saved search) opens OpenVibe.Watch's feed form with it filled in, so Watch tells you, or runs your agent, when a listing that fits appears |
125| `GET /api/v1/sources` | anyone | The boards, each one's terms as applied, its listing count and its last fetch |
126| `GET /api/v1/saved-searches` | a person | Your saved searches, each with `new_count` — new since you last looked |
127| `POST /api/v1/saved-searches` | a person | `{ q?, remote?, type?, location? }` (at least one). 201 with the saved search |
128| `DELETE /api/v1/saved-searches/:id` | a person | Forget one. 204 |
129| `GET /api/v1/me/applications` | a person (or app with `work.application.manage`) | Your applications, filtered by `status`, newest first, cursor-paginated |
130| `POST /api/v1/me/applications` | a person (or app with `work.application.manage`) | `{ listing_id }` or `{ title, company, url }`. 201 with the application; a repeat listing answers 200 with the existing one |
131| `GET /api/v1/me/applications/:id` | a person (or app with `work.application.manage`) | One application with its status-change history |
132| `PATCH /api/v1/me/applications/:id` | a person (or app with `work.application.manage`) | `{ status?, notes?, applied_at?, next_step?, next_step_at? }`; a status change appends an event |
133| `DELETE /api/v1/me/applications/:id` | a person (or app with `work.application.manage`) | Forget one. 204 |
134| `POST /api/v1/me/applications/:id/assist` | a person | `{ ask? }` — ask the person's OpenVibe.Actor agent; `{ task_id, task_url }` |
135
136**Who can call it:** reading the listings is public. A saved search belongs to a person, so those three routes need a
137signed-in person — their Network token as a Bearer, or this site's session — and answer 401 otherwise. An app or agent
138token cannot hold one (403): a saved search is not a project's. Applications are a person's too, but an app or agent
139token with `work.application.manage` may manage them; asking the person's agent (`…/assist`) is always the person
140themselves. A write made with the session cookie must come from `openvibe.work` itself (`request.cross_site`
141otherwise).
142
143**Errors** are RFC 9457 `application/problem+json` with a stable `code` (`work.jobs.not_found`,
144`work.saved_searches.empty`, `work.saved_searches.limit`, `work.applications_full`, `work.actor_unavailable`,
145`rate_limited`, …).
146
147## Data sources and their terms
148
149Each board is read through its own public API, and its terms are applied on every page that shows one of its listings
150— they are also served by `GET /api/v1/sources` and written out on [/sources](server/http/pages.js).
151
152| Source | API | Terms, as applied here |
153|---|---|---|
154| **Arbeitnow** | `GET https://www.arbeitnow.com/api/job-board-api?page=N` | Read by OpenVibe.Sources (source `arbeitnow-job-board`), never by Work: its first three pages are fetched under the board's robots and terms, and Work follows that change feed (`server/ingest/from-sources.js`). Arbeitnow is named on every listing and the listing links to its page on arbeitnow.com; a job the board stops showing is retired by Sources after a week. |
155| **Remotive** | `GET https://remotive.com/api/remote-jobs?limit=100` | Remotive must be named as the source and the listing linked. The API is called about four times a day — the six-hour interval is exactly that. It stays Work's own fetch because remotive.com/robots.txt disallows `/api/*`, so OpenVibe.Sources may not read it, while Remotive's API terms grant the named, linked use. |
156| **RemoteOK** | `GET https://remoteok.com/api` | Read by OpenVibe.Sources (source `remoteok-job-board`), never by Work: the API is fetched under remoteok.com's robots.txt and its terms. RemoteOK must be named as the source and the listing linked; the response's first element is a legal notice, not a listing, and Sources' mapping skips it. |
157
158What is stored per listing: `source`, the board's own id, the original `url`, title, company, location, the remote
159flag, tags, job type, salary text when the board gives one, when it was posted, when we fetched it, when it was first
160seen and last seen, and a **short plain-text excerpt** — never the full description. The listing page says *"Read the
161full listing on <source>"* and links there; that is where a reader applies.
162
163Listings are upserted on `(source, source_id)` and **expire** — hidden from the search, the pages and the sitemap —
164when no board has shown one for 30 days. An ingest never invents a listing: one without a title, a company, an id or
165an http(s) URL to link back to is refused.
166
167## Configuration
168
169See [.env.example](.env.example). Required in production: `OV_OAUTH_CLIENT_SECRET` (the `work` OAuth client on the
170Network), `BASE_URL`, `DATABASE_URL` and `DATABASE_DIRECT_URL`. The database is the only required readiness check; the
171Network signing key, the OAuth client and Valkey are optional (the service says so, per check, on `/api/ready`).
172
173The ingest is **off** unless `WORK_INGEST=on`, so a development run and the test suite never call a job board. When it
174is on, `WORK_INGEST_INTERVAL_HOURS` (6), `WORK_INGEST_LIMIT` (100), `WORK_INGEST_TIMEOUT_MS` (15000),
175`WORK_RETENTION_DAYS` (30), `WORK_REMOTIVE_URL` and `OV_SOURCES_INTERNAL_URL` decide what it does.
176
177The agent pillar is driven by `WORK_ACTOR_URL` (default `http://127.0.0.1:4950`) — unset it (an empty value) and
178`POST …/assist` answers 503 `work.actor_unavailable` — and `WORK_ACTOR_PUBLIC_URL` (default `https://openvibe.actor`),
179the origin a `task_url` points at. `WORK_APPLICATIONS_MAX` (500) caps how many applications a person may track, and
180`WORK_APPLICATIONS_ASSISTS_PER_DAY` (10) caps how often a person may ask their agent.
181
182## Development
183
184```bash
185npm install
186fnm exec --using=22 npm test # every test/*.test.js, on temp PGlite databases with a mock Network and stand-in boards
187fnm exec --using=22 npm run dev # http://localhost:4960 (no ingest unless WORK_INGEST=on)
188fnm exec --using=22 npm run ingest # read every board once, then exit
189```
190
191Without `DATABASE_URL` development uses an embedded PGlite database in `data/pglite` (one process only). `npm run
192test:pg` runs the same suite through PostgreSQL and PgBouncer (see [.github/workflows/ci.yml](.github/workflows/ci.yml)).
193
194## Acceptance
195
196`npm test` runs every `test/*.test.js` in its own process on a temp PGlite database with a mock OpenVibe.Network and a
197stand-in for each job board ([test/helpers/boards.js](test/helpers/boards.js)); `npm run test:pg` runs the same suite
198through PostgreSQL and PgBouncer. No test reaches the internet. The main files:
199
200- [test/ingest.test.js](test/ingest.test.js) — the three stand-in boards end to end: what is kept and what is refused,
201 a re-read updating rather than duplicating, a failing board recorded without stopping the others, the
202 ~300-character excerpt only, and expiry after the retention window.
203- [test/jobs.test.js](test/jobs.test.js) — search, filters, facets and the cursor, one listing with its provenance and
204 JSON-LD, escaping of a hostile title, and the discovery files that carry the listings and the sources.
205- [test/saved.test.js](test/saved.test.js) — a saved search belongs to a person: anonymous is 401, an app token is
206 403, a signed-in write from another site is refused, and nobody sees anybody else's.
207- [test/account-data.test.js](test/account-data.test.js) — ADR-033 export and deletion through `/internal/events` with
208 a stand-in Network: only that person's rows, applied once, a bad signature and a forwarded request refused.
209- [test/auth-ops.test.js](test/auth-ops.test.js) — PKCE sign-in, truthful readiness, `/release.json`, loopback-only
210 `/metrics` and pages useful without JavaScript.
211- [test/auth-jwks.test.js](test/auth-jwks.test.js) — the Network signing key fetched and verified, an outage survived
212 on cached keys, and a rotation honoured.
213- [test/caller-limits.test.js](test/caller-limits.test.js) — per-caller limits, the 429 `rate_limited` before any
214 work, and the product's own route budgets.
215- and the rest: [test/security-session.test.js](test/security-session.test.js),
216 [test/security-secrets.test.js](test/security-secrets.test.js), [test/discovery.test.js](test/discovery.test.js),
217 [test/layout.test.js](test/layout.test.js), [test/open-redirect.test.js](test/open-redirect.test.js),
218 [test/no-internal-key.test.js](test/no-internal-key.test.js),
219 [test/nginx-auth-limit.test.js](test/nginx-auth-limit.test.js),
220 [test/asset-cache.test.js](test/asset-cache.test.js), [test/perf-budget.test.js](test/perf-budget.test.js),
221 [test/service-kit.test.js](test/service-kit.test.js).
222
223## Deploy (for the lead)
224
225- **Deploy:** `sudo ovhost deploy work` on the host (git checkout at `/opt/openvibe.work`, unit
226 `openvibe-work.service` on 127.0.0.1:4960, env `/etc/openvibe/work.env`, database `ov_work` on the data role).
227 Set `WORK_INGEST=on` in `/etc/openvibe/work.env` or no listing will ever arrive.
228- **nginx:** [deploy/nginx/openvibe.work.conf](deploy/nginx/openvibe.work.conf), installed with `ov-vhost-install`.
229- **Rollback:** ovhost puts the previous sha back by itself when `/api/ready` does not answer after the restart.
230- Register the service and its capabilities in **OpenVibe.Contracts** (`contracts-service: work` in CI) and with
231 **OpenVibe.Services** before the first deploy.
232
233## Account export and deletion
234
235A person's account at OpenVibe.Network can be exported and deleted, and every service holding their rows answers its
236part (ADR-033). Work receives `network.account.export_requested` and `network.account.deleted` at `POST /internal/events`
237(loopback only) — the person's tables are mapped in [server/identity/account-data.js](server/identity/account-data.js),
238and the boot-time subscriptions are created by [server/events-consumer.js](server/events-consumer.js):
239
240- **Exported:** the searches a person saved (`saved_searches.json`), the applications they tracked (`applications.json`)
241 and each application's status history (`application_events.json`), pushed to
242 `POST /internal/account-exports/:id/parts` with this service's own token. Nothing here is a secret — Work stores no
243 token, key or credential.
244- **Erased:** a saved search and an application are the person's own and nothing anyone else's page hangs under them,
245 so they are deleted whole (an application's events go with it) and nothing is kept. Work then confirms with
246 `POST /internal/account-deletions/:id/confirmations` and the counts.
247- **Anonymized:** nothing. There is no row Work keeps that was written by this person for another person to read.
248
249The listings (`work_listings`) and the per-source fetch record (`work_source_fetches`) hold no person's rows: neither
250has a subject column, so neither is exported nor erased.
251
252Environment: `WORK_EVENTS_SECRET` (comma-separated for rotation, 32+ characters each; unset makes the route answer
253503), `WORK_EVENTS_URL` (or `EVENTS_URL`) is where the two subscriptions are created at boot (off when unset), and
254`WORK_EVENTS_ENDPOINT` overrides the loopback endpoint; `WORK_EVENTS_SUBSCRIBE=0` turns the boot-time subscription off.
255
256## Security (threat notes)
257
258Reporting a vulnerability: [SECURITY.md](SECURITY.md).
259
260- Session tokens are httpOnly cookies; a FedCM assertion or an app or service token is never a session.
261- Secrets live only in the env file; only environment variable names appear in code and docs, and no secret is logged.
262- Request bodies are never logged.
263- Nothing in a request may decide a URL this service fetches: the ingest fetches only the three base URLs from the
264 configuration, with this service's User-Agent, a timeout and a size cap, and stores only http(s) listing URLs.
265- A board's text is data, never markup: every value goes through the `html` tagged template, and a listing URL that
266 is not http(s) is refused rather than linked.
267
268## Not yet
269
270- **Approval at each step**: the agent help is one-shot today — Work posts a task and hands back its link. An approval
271 loop (the agent proposing an action and the person approving it) comes with the rest of the agent pillar.
272- More boards, and a per-source schedule finer than "the same six hours for everyone".
273
274---
275
276Part of the [OpenVibe network](https://openvibe.network). Built in the open by [OpenVibers](https://github.com/OpenVibers).
277
278<!-- versions:start -->
279- openvibe-contracts: v0.419.0
280- openvibe-sdk: v0.161.0
281- openvibe-shared: v4.241.0
282- openvibe-publishing: v1.7.0
283<!-- versions:end -->
284