Mirror of github.com/OpenVibers/OpenVibe.Work
  • JavaScript 97.4%
  • CSS 2.6%
Find a file
Repository files (latest commit first)
Filename Latest commit message Latest commit date
OpenVibers b959051822
openvibe-contracts v0.216.0 (was v0.215.0) (#90)
Opened by scripts/pin-bump.js in OpenVibers/OpenVibe.Shared after the v0.216.0 release.
2026-10-27 05:02:04 -07:00
.github/workflows Generated from the OpenVibe service skeleton 2026-10-08 13:52:52 -07:00
deploy Work: nginx refuses /internal/ and /metrics case-insensitively (#6) 2026-10-09 17:55:10 -07:00
docs/contracts-proposal Work: track your applications, with help from your OpenVibe.Actor agent (#28) 2026-10-11 22:55:59 -07:00
migrations Arbeitnow can come from OpenVibe.Sources (WORK_ARBEITNOW_FROM=sources; plan F2) (#66) 2026-10-22 18:15:50 -07:00
public/css Phones: list grids size their columns with minmax(0, 1fr) (or a min(…, 100%) minimum), so one long line can never widen a page past the screen (#77) 2026-10-25 03:37:13 -07:00
scripts Drop the skeleton's generator and its check (they belong to the skeleton) 2026-10-08 14:18:20 -07:00
server /auth/me is not counted by the sign-in limiter: 60 page views in 15 minutes made a signed-in visitor look signed out (#86) 2026-10-26 22:15:51 -07:00
test openvibe-shared v4.15.0 (was v4.14.0) (#84) 2026-10-26 19:48:24 -07:00
.env.example Arbeitnow can come from OpenVibe.Sources (WORK_ARBEITNOW_FROM=sources; plan F2) (#66) 2026-10-22 18:15:50 -07:00
.gitignore Generated from the OpenVibe service skeleton 2026-10-08 13:52:52 -07:00
CHANGELOG.md OpenVibe.Work: job listings with their provenance, search and saved searches 2026-10-08 14:10:58 -07:00
LICENSE Generated from the OpenVibe service skeleton 2026-10-08 13:52:52 -07:00
package-lock.json openvibe-contracts v0.216.0 (was v0.215.0) (#90) 2026-10-27 05:02:04 -07:00
package.json openvibe-contracts v0.216.0 (was v0.215.0) (#90) 2026-10-27 05:02:04 -07:00
README.md openvibe-contracts v0.216.0 (was v0.215.0) (#90) 2026-10-27 05:02:04 -07:00
SECURITY.md Generated from the OpenVibe service skeleton 2026-10-08 13:52:52 -07:00
STATUS.json openvibe-contracts v0.216.0 (was v0.215.0) (#90) 2026-10-27 05:02:04 -07:00

OpenVibe.Work

Find work on open job boards.

Status: the listings, the search and the agent pillar are live (listings gathered from three public job boards, search, saved searches, job applications with their status history, help preparing one from the person's OpenVibe.Actor agent, an API and the pages around them). Domain: openvibe.work · Port: 4960 · Service id: work · Env prefix: WORK License: AGPL-3.0 (same as every OpenVibe service).

Purpose

OpenVibe.Work is the network's job-listings service: it gathers listings from public job boards, keeps each one's provenance and a short plain-text excerpt, and makes them searchable without an account. A signed-in person can keep a search, see how many listings have appeared since they last looked, track the jobs they apply to and ask their OpenVibe.Actor agent to help prepare one. Nothing is republished — every listing links back to the board, where the reader reads the full listing and applies.

What it does

Job listings gathered from public job boards, each one carrying its provenance — the board it came from and a link back to the original listing — searchable, filterable, and readable without an account. A signed-in person can keep a search, see how many listings have appeared since they last looked, track the jobs they apply to (with notes, dates, next steps and a status history), and ask their OpenVibe.Actor agent to help prepare an application. Nothing is republished: Work stores a short plain-text excerpt and sends the reader to the board for the full listing and to apply.

What is here

Piece Where What it does
Config server/config.js Every value from the environment; load(env) is pure so tests build a config without touching process.env
App 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
Sign-in 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
Sources server/jobs/sources.js The three boards: where each is fetched, its terms, how its payload becomes a listing, and what is refused
Ingest server/ingest/ The background read (timer, User-Agent, 15 s timeout, upsert by source + the board's id, expiry)
Listings server/jobs/store.js, migrations/0002_listings.sql Search, facets, the cursor, saved searches, the fetch record
Text server/jobs/text.js HTML → plain text, and the ~300-character excerpt that is all we keep
Applications server/applications.js, migrations/0005_applications.sql The jobs a person tracks: statuses, notes, dates, next steps and the append-only status history
Actor server/actor.js POST /api/v1/tasks on the person's OpenVibe.Actor agent with Work's own service token
API server/http/api.js /api/v1: jobs, one job, the sources, saved searches, applications; problem+json errors
Pages server/http/pages.js, server/render/ /, /jobs, /jobs/:id, /saved, /applications, /applications/:id, /sources, /docs, /updates — server-rendered, no JavaScript needed
Discovery 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
Limits server/http/caller-limits.js, deploy/nginx/ Per-caller limits at the API (the search and the saved-search writes have their own) and per-address limits at nginx
Health server/observability.js /api/health, a truthful /api/ready (the database is required) and Prometheus metrics on loopback only
Telemetry server/app.js, server/index.js, 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
Database server/db.js, migrations/ PostgreSQL through openvibe-sdk/db; NNNN_*.sql applied at boot; PGlite in development
Process server/index.js Listens on PORT, starts the ingest when it is on, and stops gracefully through openvibe-sdk/service
Deploy deploy/nginx/openvibe.work.conf, deploy/systemd/openvibe-work.service nginx vhost and systemd unit (port 4960, /opt/openvibe.work, /etc/openvibe/work.env)
Tests 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

Owns

Its own PostgreSQL tables, created by migrations/ and written by nothing else:

  • work_listings (0002) — one row per listing, upserted on (source, source_id), with the board's own id, the original url to link back to, the fields the board gives, and a short plain-text excerpt. Expired listings are kept but hidden.
  • work_saved_searches (0002) — a search a person saved, keyed subject = user:usr_…, with when they last looked.
  • work_source_fetches (0002) — what each board's last fetch did, so /sources can say when it last ran and how.
  • work_applications (0005) — a job application a person tracks, keyed subject_id = user:usr_…, with its status, notes, dates, next step and the last Actor task it asked for. One per person per listing.
  • work_application_events (0005) — the append-only history of an application's status changes.
  • account_data_events (0003) — the receipts of the ADR-033 export and deletion deliveries this service applied, so a redelivered event changes nothing.

0001 creates no tables. The service is the authority for these rows and for nothing else.

Does not own

  • Identity and accounts — OpenVibe.Network: sign-in (OAuth client work, PKCE S256), the signing keys (JWKS) and the canonical subject. Work holds only user:usr_… on its own rows.
  • Event delivery — OpenVibe.Events: Work receives network.account.export_requested and network.account.deleted and creates its two subscriptions at boot; it does not own the topics or the delivery.
  • The listings themselves — the boards (Arbeitnow, Remotive, RemoteOK): Work keeps a short excerpt and a link back, never the full description, and reads each board only on the terms it publishes.

Depends on

  • OpenVibe.Network — SSO sign-in and JWKS verification, and the internal routes an export part or a deletion confirmation is pushed to (OV_NETWORK_URL, OV_NETWORK_INTERNAL_URL, OV_OAUTH_CLIENT_ID, OV_OAUTH_CLIENT_SECRET, OV_NETWORK_ISSUER, OV_SESSION_AUDIENCE, WORK_AUDIENCE).
  • OpenVibe.Events — the two account subscriptions created at boot and the delivery posted to this service's loopback /internal/events (WORK_EVENTS_URL or EVENTS_URL, WORK_EVENTS_SECRET, WORK_EVENTS_ENDPOINT, WORK_EVENTS_SUBSCRIBE).
  • OpenVibe.Actor — the person's agent: Work posts POST /api/v1/tasks with its own service token (capability actor.task.create, audience openvibe.actor) when a person asks for help preparing an application (WORK_ACTOR_URL, WORK_ACTOR_PUBLIC_URL, WORK_ACTOR_TIMEOUT_MS).
  • The three job boards — the only hosts the ingest fetches: Arbeitnow, Remotive and RemoteOK's public APIs (WORK_ARBEITNOW_URL, WORK_REMOTIVE_URL, WORK_REMOTEOK_URL, WORK_USER_AGENT).
  • PostgreSQL (DATABASE_URL, DATABASE_DIRECT_URL; an embedded PGlite database in development via WORK_PGLITE_DIR) and Valkey for shared limit counters (VALKEY_URL, VALKEY_PREFIX).
  • Packages: openvibe-contracts v0.216.0, openvibe-sdk v0.65.0 (db, auth, account-data, limits, valkey, service) and openvibe-shared v4.15.2 (frame, legal, serve, release, metrics, ready, seo, shell, cache-policy, showcase, app-icon).

Capabilities

server/http/principal.js declares one capability, work.application.manage: an app or agent token may manage job applications with it for the person it acts for (a person needs no capability for their own). The service manifest (work, openvibe-contracts) has listed it since 0.147.0, from the proposal in docs/contracts-proposal/work.application.manage.md. The person-only saved-search routes still name no capability.

Work calls one capability on another service: actor.task.create (audience openvibe.actor) when a person asks their agent to help prepare an application (server/actor.js). It also uses two machine-to-machine surfaces: OpenVibe.Events' subscription API (scope events.subscription.manage) to create its two account-event subscriptions at boot (server/events-consumer.js), and OpenVibe.Network's internal account export/deletion routes, pushed with this service's own client-credentials token (server/identity/account-data.js).

API

Route Who
GET /api/v1/ping anyone { ok: true, service: "work" }
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
GET /api/v1/jobs/:id anyone One listing: the board's fields, the excerpt, the source, and url — the original to apply on
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
GET /api/v1/sources anyone The boards, each one's terms as applied, its listing count and its last fetch
GET /api/v1/saved-searches a person Your saved searches, each with new_count — new since you last looked
POST /api/v1/saved-searches a person { q?, remote?, type?, location? } (at least one). 201 with the saved search
DELETE /api/v1/saved-searches/:id a person Forget one. 204
GET /api/v1/me/applications a person (or app with work.application.manage) Your applications, filtered by status, newest first, cursor-paginated
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
GET /api/v1/me/applications/:id a person (or app with work.application.manage) One application with its status-change history
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
DELETE /api/v1/me/applications/:id a person (or app with work.application.manage) Forget one. 204
POST /api/v1/me/applications/:id/assist a person { ask? } — ask the person's OpenVibe.Actor agent; { task_id, task_url }

Who can call it: reading the listings is public. A saved search belongs to a person, so those three routes need a signed-in person — their Network token as a Bearer, or this site's session — and answer 401 otherwise. An app or agent token cannot hold one (403): a saved search is not a project's. Applications are a person's too, but an app or agent token with work.application.manage may manage them; asking the person's agent (…/assist) is always the person themselves. A write made with the session cookie must come from openvibe.work itself (request.cross_site otherwise).

Errors are RFC 9457 application/problem+json with a stable code (work.jobs.not_found, work.saved_searches.empty, work.saved_searches.limit, work.applications_full, work.actor_unavailable, rate_limited, …).

Data sources and their terms

Each board is read through its own public API, and its terms are applied on every page that shows one of its listings — they are also served by GET /api/v1/sources and written out on /sources.

Source API Terms, as applied here
Arbeitnow GET https://www.arbeitnow.com/api/job-board-api?page=N Free to use. Arbeitnow is named on every listing and the listing links to its page on arbeitnow.com. Its first pages (configurable) are read. With WORK_ARBEITNOW_FROM=sources Work reads none: it follows OpenVibe.Sources' arbeitnow-job-board items (server/ingest/from-sources.js), and a job the board stops showing is retired by Sources after a week.
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.
RemoteOK GET https://remoteok.com/api RemoteOK must be named as the source and the listing linked. The response's first element is a legal notice, not a listing, and is skipped.

What is stored per listing: source, the board's own id, the original url, title, company, location, the remote flag, tags, job type, salary text when the board gives one, when it was posted, when we fetched it, when it was first seen and last seen, and a short plain-text excerpt — never the full description. The listing page says "Read the full listing on <source>" and links there; that is where a reader applies.

Listings are upserted on (source, source_id) and expire — hidden from the search, the pages and the sitemap — when no board has shown one for 30 days. An ingest never invents a listing: one without a title, a company, an id or an http(s) URL to link back to is refused.

Configuration

See .env.example. Required in production: OV_OAUTH_CLIENT_SECRET (the work OAuth client on the Network), BASE_URL, DATABASE_URL and DATABASE_DIRECT_URL. The database is the only required readiness check; the Network signing key, the OAuth client and Valkey are optional (the service says so, per check, on /api/ready).

The ingest is off unless WORK_INGEST=on, so a development run and the test suite never call a job board. When it is on, WORK_INGEST_INTERVAL_HOURS (6), WORK_INGEST_PAGES (3), WORK_INGEST_LIMIT (100), WORK_INGEST_TIMEOUT_MS (15000), WORK_RETENTION_DAYS (30) and the per-source base URLs (WORK_ARBEITNOW_URL, WORK_REMOTIVE_URL, WORK_REMOTEOK_URL) decide what it does.

The agent pillar is driven by WORK_ACTOR_URL (default http://127.0.0.1:4950) — unset it (an empty value) and POST …/assist answers 503 work.actor_unavailable — and WORK_ACTOR_PUBLIC_URL (default https://openvibe.actor), the origin a task_url points at. WORK_APPLICATIONS_MAX (500) caps how many applications a person may track, and WORK_APPLICATIONS_ASSISTS_PER_DAY (10) caps how often a person may ask their agent.

Development

npm install
fnm exec --using=22 npm test        # every test/*.test.js, on temp PGlite databases with a mock Network and stand-in boards
fnm exec --using=22 npm run dev     # http://localhost:4960 (no ingest unless WORK_INGEST=on)
fnm exec --using=22 npm run ingest  # read every board once, then exit

Without DATABASE_URL development uses an embedded PGlite database in data/pglite (one process only). npm run test:pg runs the same suite through PostgreSQL and PgBouncer (see .github/workflows/ci.yml).

Acceptance

npm test runs 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 (test/helpers/boards.js); npm run test:pg runs the same suite through PostgreSQL and PgBouncer. No test reaches the internet. The main files:

Deploy (for the lead)

  • Deploy: sudo ovhost deploy work on the host (git checkout at /opt/openvibe.work, unit openvibe-work.service on 127.0.0.1:4960, env /etc/openvibe/work.env, database ov_work on the data role). Set WORK_INGEST=on in /etc/openvibe/work.env or no listing will ever arrive.
  • nginx: deploy/nginx/openvibe.work.conf, installed with ov-vhost-install.
  • Rollback: ovhost puts the previous sha back by itself when /api/ready does not answer after the restart.
  • Register the service and its capabilities in OpenVibe.Contracts (contracts-service: work in CI) and with OpenVibe.Services before the first deploy.

Account export and deletion

A person's account at OpenVibe.Network can be exported and deleted, and every service holding their rows answers its part (ADR-033). Work receives network.account.export_requested and network.account.deleted at POST /internal/events (loopback only) — the person's tables are mapped in server/identity/account-data.js, and the boot-time subscriptions are created by server/events-consumer.js:

  • Exported: the searches a person saved (saved_searches.json), the applications they tracked (applications.json) and each application's status history (application_events.json), pushed to POST /internal/account-exports/:id/parts with this service's own token. Nothing here is a secret — Work stores no token, key or credential.
  • Erased: a saved search and an application are the person's own and nothing anyone else's page hangs under them, so they are deleted whole (an application's events go with it) and nothing is kept. Work then confirms with POST /internal/account-deletions/:id/confirmations and the counts.
  • Anonymized: nothing. There is no row Work keeps that was written by this person for another person to read.

The listings (work_listings) and the per-source fetch record (work_source_fetches) hold no person's rows: neither has a subject column, so neither is exported nor erased.

Environment: WORK_EVENTS_SECRET (comma-separated for rotation, 32+ characters each; unset makes the route answer 503), WORK_EVENTS_URL (or EVENTS_URL) is where the two subscriptions are created at boot (off when unset), and WORK_EVENTS_ENDPOINT overrides the loopback endpoint; WORK_EVENTS_SUBSCRIBE=0 turns the boot-time subscription off.

Security (threat notes)

Reporting a vulnerability: SECURITY.md.

  • Session tokens are httpOnly cookies; a FedCM assertion or an app or service token is never a session.
  • Secrets live only in the env file; only environment variable names appear in code and docs, and no secret is logged.
  • Request bodies are never logged.
  • Nothing in a request may decide a URL this service fetches: the ingest fetches only the three base URLs from the configuration, with this service's User-Agent, a timeout and a size cap, and stores only http(s) listing URLs.
  • A board's text is data, never markup: every value goes through the html tagged template, and a listing URL that is not http(s) is refused rather than linked.

Not yet

  • Approval at each step: the agent help is one-shot today — Work posts a task and hands back its link. An approval loop (the agent proposing an action and the person approving it) comes with the rest of the agent pillar.
  • More boards, and a per-source schedule finer than "the same six hours for everyone".

Part of the OpenVibe network. Built in the open by OpenVibers.

  • openvibe-contracts: v0.216.0
  • openvibe-sdk: v0.65.0
  • openvibe-shared: v4.15.2
  • openvibe-publishing: v1.4.0