openvibers/openvibe.work Commits
Work: track your applications, with help from your OpenVibe.Actor agent (#28)
Changes
@@@ -28,6 +28,16 @@ OV_OAUTH_CLIENT_SECRET=# The audience app, agent and service tokens for this service's API carry.# WORK_AUDIENCE=openvibe.work++# The agent pillar: job applications (server/applications.js) and the OpenVibe.Actor client (server/actor.js).++# WORK_APPLICATIONS_MAX caps how many applications one person may track (default 500). WORK_ACTOR_URL unset means++# "no agent": POST …/assist answers 503 work.actor_unavailable rather than call anywhere. WORK_ACTOR_PUBLIC_URL is++# the human-facing origin a task_url points at.++#WORK_APPLICATIONS_MAX=500++#WORK_APPLICATIONS_ASSISTS_PER_DAY=10++#WORK_ACTOR_URL=http://127.0.0.1:4950++#WORK_ACTOR_PUBLIC_URL=https://openvibe.actor++#WORK_ACTOR_TIMEOUT_MS=15000++# OpenVibe.Events → Work (ADR-033 account export and deletion): the secret signs a delivery (comma-separated for# rotation, 32+ characters each; unset makes POST /internal/events answer 503); the url is where the two subscriptions# are created at boot (off when unset); WORK_EVENTS_SUBSCRIBE=0 turns that off.@@@ -2,9 +2,9 @@> Find work on open job boards.--**Status:** the listings and the search are live (listings gathered from three public job boards, search, saved--searches, an API and the pages around them). The agent pillar — help preparing and tracking an application — is not--written yet.++**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).@@@ -12,16 +12,17 @@ written yet.OpenVibe.Work is the network's job-listings service: it gathers listings from public job boards, keeps each one'sprovenance and a short plain-text excerpt, and makes them searchable without an account. A signed-in person can keep a--search and see how many listings have appeared since they last looked. Nothing is republished — every listing links--back to the board, where the reader reads the full listing and applies. The agent pillar (helping prepare and track an--application) is not written yet.++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 doesJob listings gathered from public job boards, each one carrying its provenance — the board it came from and a linkback to the original listing — searchable, filterable, and readable without an account. A signed-in person can keep a--search and see how many listings have appeared since they last looked. Nothing is republished: Work stores a short--plain-text excerpt and sends the reader to the board for the full listing and to apply.++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@@@ -34,8 +35,10 @@ plain-text excerpt and sends the reader to the board for the full listing and to| Ingest | [server/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](server/jobs/store.js), [migrations/0002_listings.sql](migrations/0002_listings.sql) | Search, facets, the cursor, saved searches, the fetch record || Text | [server/jobs/text.js](server/jobs/text.js) | HTML → plain text, and the ~300-character excerpt that is all we keep |--| API | [server/http/api.js](server/http/api.js) | `/api/v1`: jobs, one job, the sources, saved searches; problem+json errors |--| Pages | [server/http/pages.js](server/http/pages.js), [server/render/](server/render/) | `/`, `/jobs`, `/jobs/:id`, `/saved`, `/sources`, `/docs`, `/updates` — server-rendered, no JavaScript needed |++| 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 |++| Actor | [server/actor.js](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](server/http/api.js) | `/api/v1`: jobs, one job, the sources, saved searches, applications; problem+json errors |++| 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 || Discovery | [server/http/discovery.js](server/http/discovery.js) | `robots.txt`, `sitemap.xml` (the newest listings too), `llms.txt`, `llms-full.txt`, JSON-LD || 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 || Health | [server/observability.js](server/observability.js) | `/api/health`, a truthful `/api/ready` (the database is required) and Prometheus metrics on loopback only |@@@ -53,6 +56,9 @@ Its own PostgreSQL tables, created by [migrations/](migrations/) and written bykept 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 aredelivered event changes nothing.@@@ -75,6 +81,9 @@ Its own PostgreSQL tables, created by [migrations/](migrations/) and written by- **OpenVibe.Events** — the two account subscriptions created at boot and the delivery posted to this service'sloopback `/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@@@ -85,14 +94,17 @@ Its own PostgreSQL tables, created by [migrations/](migrations/) and written by## Capabilities--The service manifest (`work`, openvibe-contracts) lists none, and [server/http/principal.js](server/http/principal.js)--declares none either (`CAPABILITIES` is empty): no route names a capability, so an app, agent or service token cannot--call the person-only saved-search routes (403) and the public reads need no token. Work calls no capability on another--service.----It does use 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](server/events-consumer.js)), and--OpenVibe.Network's internal account export/deletion routes, pushed with this service's own client-credentials token++[server/http/principal.js](server/http/principal.js) declares one capability, `work.application.manage`: an app or++agent token may manage job applications with it (a person needs no capability for their own). The service manifest++(`work`, openvibe-contracts) lists it in a later contract release, proposed in++[docs/contracts-proposal/work.application.manage.md](docs/contracts-proposal/work.application.manage.md); until then an++app token is refused 403 with `capability.unknown`. 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](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](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](server/identity/account-data.js)).## API@@@ -106,14 +118,23 @@ OpenVibe.Network's internal account export/deletion routes, pushed with this ser| `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 asigned-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. A write made with the session cookie must come from--`openvibe.work` itself (`request.cross_site` otherwise).++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`, `rate_limited`, …).++`work.saved_searches.empty`, `work.saved_searches.limit`, `work.applications_full`, `work.actor_unavailable`,++`rate_limited`, …).## Data sources and their terms@@@ -146,6 +167,11 @@ is on, `WORK_INGEST_INTERVAL_HOURS` (6), `WORK_INGEST_PAGES` (3), `WORK_INGEST_L(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```bash@@@ -201,14 +227,16 @@ through PostgreSQL and PgBouncer. No test reaches the internet. The main files:A person's account at OpenVibe.Network can be exported and deleted, and every service holding their rows answers itspart (ADR-033). Work receives `network.account.export_requested` and `network.account.deleted` at `POST /internal/events`--(loopback only) — the one table is mapped in [server/identity/account-data.js](server/identity/account-data.js), and the--boot-time subscriptions are created by [server/events-consumer.js](server/events-consumer.js):++(loopback only) — the person's tables are mapped in [server/identity/account-data.js](server/identity/account-data.js),++and the boot-time subscriptions are created by [server/events-consumer.js](server/events-consumer.js):--- **Exported:** the searches a person saved (`saved_searches.json`), pushed to++- **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 notoken, key or credential.--- **Erased:** a saved search is the person's own and nothing anyone else's page hangs under it, so it is deleted whole-- and nothing is kept. Work then confirms with `POST /internal/account-deletions/:id/confirmations` and the counts.++- **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@@@ -232,9 +260,10 @@ Reporting a vulnerability: [SECURITY.md](SECURITY.md).## Not yet--- **The agent**: drafting, tailoring and tracking an application, with the person's approval at each step.--- **Alerts**: being told when a listing that fits appears — that comes through OpenVibe.Watch with the agent pillar.-- Today a saved search is a search you do not have to type again, plus a count of what is new.++- **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.++- **Alerts**: being told when a listing that fits appears — that comes through OpenVibe.Watch. Today a saved search is++ a search you do not have to type again, plus a count of what is new.- More boards, and a per-source schedule finer than "the same six hours for everyone".---@@@ -242,7 +271,7 @@ Reporting a vulnerability: [SECURITY.md](SECURITY.md).Part of the [OpenVibe network](https://openvibe.network). Built in the open by [OpenVibers](https://github.com/OpenVibers).<!-- versions:start -->--- openvibe-contracts: v0.143.0++- openvibe-contracts: v0.147.0- openvibe-sdk: v0.44.0- openvibe-shared: v4.0.2- openvibe-publishing: v1.4.0@@@ -7,11 +7,11 @@"runtime": "node 22, express 4, PostgreSQL 18 (openvibe-sdk/db, async) + Valkey 9","port": "4960","domain": "openvibe.work",-- "contracts": "openvibe-contracts v0.143.0",++ "contracts": "openvibe-contracts v0.147.0","sdk": "openvibe-sdk v0.44.0","shared": "openvibe-shared v4.0.2","packages": [-- "openvibe-contracts v0.143.0",++ "openvibe-contracts v0.147.0","openvibe-sdk v0.44.0","openvibe-shared v4.0.2","openvibe-publishing v1.4.0"@@@ -54,5 +54,5 @@"deploy files"],"deployment": "openvibe-ovh: unit openvibe-work.service on 127.0.0.1:4960 (git checkout /opt/openvibe.work, env /etc/openvibe/work.env, database ov_work), deployed with `sudo ovhost deploy work`; public at https://openvibe.work since 2026-10-08.",-- "updated": "2026-10-11"++ "updated": "2026-10-12"}nnew file mode 100644@@@ -0,0 +1,204 @@++# Proposal: `work.application.manage`++++Capability `work.application.manage`, owned by `work`, audience `openvibe.work`. A person may manage their own job++applications without a capability; an app or agent token needs this one. Asking the person's OpenVibe.Actor agent++(`POST …/assist`) is always the person themselves and takes no capability.++++The routes below are the agent pillar of OpenVibe.Work. A contract release that ships these schemas in++openvibe-contracts is done separately; this document is the proposal it is released from.++++## Resource: work.application@1++++The application as `GET/POST/PATCH /api/v1/me/applications(/:id)` answer it.++++```json++{++ "$schema": "https://json-schema.org/draft/2020-12/schema",++ "$id": "https://openvibe.network/contracts/work/application.v1.json",++ "title": "WorkApplication",++ "type": "object",++ "additionalProperties": false,++ "required": ["id", "subject_id", "title", "company", "url", "status", "created_at", "updated_at"],++ "properties": {++ "id": { "type": "string", "pattern": "^wap_[0-9A-HJKMNP-TV-Z]{26}$" },++ "subject_id": { "type": "string", "description": "user:usr_…" },++ "listing_id": { "type": ["string", "null"], "description": "job_<base32> when tracked from a listing" },++ "title": { "type": "string", "maxLength": 500 },++ "company": { "type": "string", "maxLength": 200 },++ "url": { "type": "string", "format": "uri", "maxLength": 2000 },++ "status": { "enum": ["saved", "applied", "interviewing", "offer", "rejected", "withdrawn"] },++ "notes": { "type": "string", "maxLength": 5000 },++ "applied_at": { "type": ["string", "null"], "format": "date-time" },++ "next_step": { "type": "string", "maxLength": 200 },++ "next_step_at": { "type": ["string", "null"], "format": "date-time" },++ "actor_task_id": { "type": ["string", "null"] },++ "created_at": { "type": "string", "format": "date-time" },++ "updated_at": { "type": "string", "format": "date-time" }++ }++}++```++++## Resource: work.application-event@1++++One append-only status change.++++```json++{++ "$schema": "https://json-schema.org/draft/2020-12/schema",++ "$id": "https://openvibe.network/contracts/work/application-event.v1.json",++ "title": "WorkApplicationEvent",++ "type": "object",++ "additionalProperties": false,++ "required": ["at", "from_status", "to_status"],++ "properties": {++ "at": { "type": "string", "format": "date-time" },++ "from_status": { "enum": ["saved", "applied", "interviewing", "offer", "rejected", "withdrawn"] },++ "to_status": { "enum": ["saved", "applied", "interviewing", "offer", "rejected", "withdrawn"] }++ }++}++```++++## GET /api/v1/me/applications++++Filter by `status`, newest first, cursor pagination.++++```json++{++ "$schema": "https://json-schema.org/draft/2020-12/schema",++ "title": "WorkApplicationsListResult",++ "type": "object",++ "additionalProperties": false,++ "required": ["applications", "next", "count"],++ "properties": {++ "applications": { "type": "array", "items": { "$ref": "work.application@1" } },++ "next": { "type": ["string", "null"] },++ "count": { "type": "integer", "minimum": 0 }++ }++}++```++++## POST /api/v1/me/applications++++One of `listing_id`, or `title` + `company` + `url`. A second POST for the same listing answers 200 with the existing++application.++++```json++{++ "$schema": "https://json-schema.org/draft/2020-12/schema",++ "title": "WorkApplicationCreateRequest",++ "type": "object",++ "additionalProperties": false,++ "oneOf": [++ { "required": ["listing_id"], "properties": { "listing_id": { "type": "string" } } },++ {++ "required": ["title", "company", "url"],++ "properties": {++ "title": { "type": "string", "minLength": 1, "maxLength": 500 },++ "company": { "type": "string", "minLength": 1, "maxLength": 200 },++ "url": { "type": "string", "format": "uri", "maxLength": 2000 }++ }++ }++ ]++}++```++++## PATCH /api/v1/me/applications/:id++++Any subset of the five editable fields; a status change appends an event.++++```json++{++ "$schema": "https://json-schema.org/draft/2020-12/schema",++ "title": "WorkApplicationPatchRequest",++ "type": "object",++ "additionalProperties": false,++ "minProperties": 1,++ "properties": {++ "status": { "enum": ["saved", "applied", "interviewing", "offer", "rejected", "withdrawn"] },++ "notes": { "type": "string", "maxLength": 5000 },++ "applied_at": { "type": ["string", "null"], "format": "date-time" },++ "next_step": { "type": "string", "maxLength": 200 },++ "next_step_at": { "type": ["string", "null"], "format": "date-time" }++ }++}++```++++## POST /api/v1/me/applications/:id/assist++++An optional `ask` (defaults to the standard preparation prompt), answered by `{ task_id, task_url }`.++++```json++{++ "$schema": "https://json-schema.org/draft/2020-12/schema",++ "title": "WorkApplicationAssistRequest",++ "type": "object",++ "additionalProperties": false,++ "properties": {++ "ask": { "type": "string", "maxLength": 1000 }++ }++}++```++++```json++{++ "$schema": "https://json-schema.org/draft/2020-12/schema",++ "title": "WorkApplicationAssistResult",++ "type": "object",++ "additionalProperties": false,++ "required": ["task_id", "task_url"],++ "properties": {++ "task_id": { "type": "string", "pattern": "^tsk_[0-9A-HJKMNP-TV-Z]{26}$" },++ "task_url": { "type": "string", "format": "uri" }++ }++}++```++++## Example bodies++++### POST create — valid (from a listing)++++```json++{ "listing_id": "job_01JZ0000000000000000000ABC" }++```++++### POST create — valid (by hand)++++```json++{ "title": "Backend Engineer", "company": "Acme", "url": "https://acme.example/jobs/12" }++```++++### POST create — invalid (both modes)++++```json++{ "listing_id": "job_01JZ0000000000000000000ABC", "title": "Backend Engineer" }++```++++### PATCH — valid (status change)++++```json++{ "status": "applied" }++```++++### PATCH — invalid (unknown field)++++```json++{ "status": "applied", "salary": 120000 }++```++++### PATCH — invalid (bad status)++++```json++{ "status": "hired" }++```++++### assist — valid++++```json++{ "ask": "Draft a cover note that emphasises my systems experience." }++```++++### assist — invalid (too long)++++```json++{ "ask": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" }++```nnew file mode 100644@@@ -0,0 +1,35 @@++-- phase: expand++-- OpenVibe.Work: the job applications a signed-in person tracks (server/applications.js), and the append-only++-- history of their status changes. An application is either tracked from a Work listing (listing_id, from which its++-- title, company and url are copied) or given by hand. Nothing here is ever a credential, a key or a token.++++CREATE TABLE work_applications (++ id text COLLATE "C" PRIMARY KEY, -- wap_<ULID>++ subject_id text COLLATE "C" NOT NULL, -- user:usr_… — the person tracking it++ listing_id text COLLATE "C", -- job_<base32> when it came from a listing++ title text NOT NULL,++ company text NOT NULL,++ url text NOT NULL, -- the listing's own URL, or the one given by hand++ status text COLLATE "C" NOT NULL DEFAULT 'saved', -- saved|applied|interviewing|offer|rejected|withdrawn++ notes text NOT NULL DEFAULT '',++ applied_at text COLLATE "C", -- ISO 8601 UTC++ next_step_at text COLLATE "C", -- ISO 8601 UTC++ next_step text NOT NULL DEFAULT '',++ actor_task_id text COLLATE "C", -- the last OpenVibe.Actor task this application asked for++ assists integer NOT NULL DEFAULT 0, -- how many times the person asked their agent (idempotency n)++ created_at text COLLATE "C" NOT NULL,++ updated_at text COLLATE "C" NOT NULL,++ CONSTRAINT work_applications_notes_len CHECK (char_length(notes) <= 5000),++ CONSTRAINT work_applications_next_step_len CHECK (char_length(next_step) <= 200)++);++-- The API lists an application's rows newest-first and pages on this.++CREATE INDEX work_applications_by_subject ON work_applications (subject_id, updated_at);++++-- Append-only: one row per status change, never updated. When an application is erased its events go with it.++CREATE TABLE work_application_events (++ application_id text COLLATE "C" NOT NULL REFERENCES work_applications (id) ON DELETE CASCADE,++ at text COLLATE "C" NOT NULL, -- ISO 8601 UTC++ from_status text COLLATE "C" NOT NULL,++ to_status text COLLATE "C" NOT NULL++);++CREATE INDEX work_application_events_by_app ON work_application_events (application_id, at);@@@ -15,7 +15,7 @@"express-rate-limit": "^7.5.0","helmet": "^8.1.0","iovalkey": "^0.4.0",-- "openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.143.0",++ "openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.147.0","openvibe-publishing": "https://codeload.github.com/OpenVibers/OpenVibe.Publishing/tar.gz/refs/tags/v1.4.0","openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.44.0","openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v4.0.2",@@@ -872,9 +872,9 @@}},"node_modules/openvibe-contracts": {-- "version": "0.143.0",-- "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.143.0",-- "integrity": "sha512-Cdbf+9RoPAUBibpM2zXQvtsiKMugb55dKubTt8pFZvAmxsLtGvX+zaC++yL1vOSub8FX3+GgqcGtoJS6tklrUg==",++ "version": "0.147.0",++ "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.147.0",++ "integrity": "sha512-7JC5c7WWNJWUDGQamrQZy7bsZhlhUfXXKhI2lCEG3PW99B73vks42tKYnq56j+YYRItzotYVDFt4hjzqUS3tPA==","license": "AGPL-3.0-only","dependencies": {"ajv": "^8.17.1",@@@ -23,7 +23,7 @@"express-rate-limit": "^7.5.0","helmet": "^8.1.0","iovalkey": "^0.4.0",-- "openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.143.0",++ "openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.147.0","openvibe-publishing": "https://codeload.github.com/OpenVibers/OpenVibe.Publishing/tar.gz/refs/tags/v1.4.0","openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.44.0","openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v4.0.2",@@@ -91,3 +91,5 @@ button.sc-btn.sc-primary { border: 0; }.saved-form input, .saved-form select { font: inherit; padding: 6px 8px; border-radius: 8px; border: 1px solid var(--border, #1f2d47); background: var(--bg-secondary, #0f1729); color: inherit; }.source-block { border-top: 1px solid var(--border, #1f2d47); padding-top: 12px; margin-top: 18px; }.source-links { margin: 6px 0 12px; }++++.job-track { margin: 8px 0; }nnew file mode 100644@@@ -0,0 +1,36 @@++/* OpenVibe.Work — the applications pages only (/applications, /applications/:id), so app.css stays in its budget. */++.app-summary { display: flex; flex-wrap: wrap; gap: 8px; list-style: none; padding: 0; margin: 12px 0 4px; }++.app-summary li a, .app-summary li span { display: inline-flex; gap: 6px; align-items: baseline; padding: 6px 12px; border-radius: 999px; border: 1px solid var(--border, #1f2d47); background: var(--bg-card, #131c2e); color: var(--text-primary, #e6edf7); text-decoration: none; font-size: .9rem; }++.app-summary li a:hover { border-color: var(--accent, #3b82f6); }++.app-summary strong { font-size: 1rem; }++.app-summary .is-empty { color: var(--text-muted, #7386a3); }++.app-group { margin-top: 22px; }++.app-group h2 { margin: 0 0 8px; font-size: 1.1rem; }++.app-list { list-style: none; margin: 0; padding: 0; display: grid; gap: 10px; grid-template-columns: repeat(auto-fill, minmax(300px, 1fr)); }++.app-item { border: 1px solid var(--border, #1f2d47); border-radius: 12px; background: var(--bg-card, #131c2e); padding: 12px 14px; display: flex; flex-direction: column; gap: 4px; }++.app-item p { margin: 0; }++.app-title { display: flex; justify-content: space-between; align-items: baseline; gap: 8px; }++.app-title a { color: var(--text-primary, #e6edf7); text-decoration: none; font-weight: 600; overflow-wrap: anywhere; }++.app-title a:hover { text-decoration: underline; text-underline-offset: 3px; }++.app-co { color: var(--text-secondary, #96a7c2); }++.app-next { font-size: .9rem; }++.app-head { display: flex; flex-wrap: wrap; align-items: center; gap: 6px 12px; }++.app-head h1 { margin-bottom: .2rem; }++.app-company { margin: 0 0 14px; font-size: 1.05rem; color: var(--text-secondary, #96a7c2); overflow-wrap: anywhere; }++.app-detail { display: grid; gap: 18px; }++@media (min-width: 880px) { .app-detail { grid-template-columns: minmax(0, 3fr) minmax(280px, 2fr); align-items: start; } }++.app-panel { border: 1px solid var(--border, #1f2d47); border-radius: 12px; background: var(--bg-card, #131c2e); padding: 14px 16px; }++.app-panel h2 { margin: 0 0 10px; font-size: 1rem; }++.app-panel + .app-panel { margin-top: 14px; }++.app-agent { background: linear-gradient(160deg, color-mix(in srgb, var(--accent, #3b82f6) 10%, var(--bg-card, #131c2e)), var(--bg-card, #131c2e) 65%); }++.app-history { list-style: none; margin: 0; padding: 0; }++.app-history li { padding: 6px 0; border-bottom: 1px solid var(--border, #1f2d47); font-size: .92rem; }++.app-history li:last-child { border-bottom: 0; }++.app-form label { display: flex; flex-direction: column; gap: 4px; margin: 0 0 12px; font-size: .88rem; color: var(--text-secondary, #96a7c2); }++.app-form input, .app-form select, .app-form textarea { font: inherit; padding: 8px 10px; border-radius: 8px; border: 1px solid var(--border-light, #2c3d5c); background: var(--bg-secondary, #0f1729); color: inherit; max-width: 100%; }++.app-form textarea { resize: vertical; }++.app-row { display: grid; gap: 0 12px; }++@media (min-width: 560px) { .app-row { grid-template-columns: 1fr 1fr; } }++.app-add { margin-top: 26px; max-width: 720px; }++.app-danger { margin-top: 14px; }++.app-danger button { background: none; border: 1px solid var(--error, #ef4444); color: var(--error, #ef4444); border-radius: 8px; padding: 6px 12px; }nnew file mode 100644@@@ -0,0 +1,44 @@++'use strict';++++/**++ * Work's client for OpenVibe.Actor: POST /api/v1/tasks with Work's own service token (audience openvibe.actor,++ * capability actor.task.create) and the person in X-OV-Subject, exactly as OpenVibe.Watch does. The task's own++ * Idempotency-Key is passed in the body, so the request is not retried here: a refusal or outage is the route's to++ * answer (server/http/api.js maps it to 503 work.actor_unavailable, or passes through Actor's 402/429).++ */++const { createClient } = require('openvibe-sdk/core');++const { createServiceTokenClient } = require('openvibe-sdk/auth');++++function create({ config, fetchImpl = globalThis.fetch, log = console } = {}) {++ // Off without Actor's URL or without Work's own OAuth client (development has neither secret nor service token).++ const available = Boolean(config && config.actor && config.actor.url && config.oauth && config.oauth.clientId && config.oauth.clientSecret);++ if (!available) {++ return {++ available: false,++ async createTask() { throw new Error('OpenVibe.Actor is not configured'); },++ };++ }++ const client = createClient({++ fetch: fetchImpl,++ baseUrls: { actor: config.actor.url },++ tokenProvider: createServiceTokenClient({ network: config.networkInternalUrl, clientId: config.oauth.clientId, clientSecret: config.oauth.clientSecret, fetch: fetchImpl }),++ retries: 0,++ timeoutMs: config.actor.timeoutMs || 15000,++ onWarning: (msg) => log.warn(`[OpenVibe.Work] ${msg}`),++ });++ return {++ available: true,++ /** subjectId is usr_…; returns { id, state } on success, or throws an OpenVibeError. */++ async createTask({ subjectId, task, idempotencyKey }) {++ return client.json({++ service: 'actor', baseUrl: config.actor.url, audience: 'openvibe.actor',++ method: 'POST', path: '/api/v1/tasks',++ headers: { 'X-OV-Subject': subjectId },++ json: { task, idempotency_key: idempotencyKey },++ retries: 0, idempotencyKey: false,++ });++ },++ };++}++++module.exports = { create };@@@ -34,6 +34,8 @@ const { createIngest } = require('./ingest');const { createSearchIndex } = require('./search-index');const accountDataLib = require('./identity/account-data');const { createNetworkSender } = require('openvibe-sdk/account-data');++const { createActorLimiter } = require('openvibe-sdk/limits');++const actorLib = require('./actor');const { assetVersion, send } = require('./render/layout');const { html } = require('./render/html');@@@ -42,7 +44,8 @@ const VERSION = require('../package.json').version;/*** opts: config, store, now (clock), fetchImpl (Network), log, limitsNow, callerLimits (false: count nobody,-- * tests only), valkey, accountSend (a stand-in for Network's internal routes; tests only)++ * tests only), valkey, accountSend (a stand-in for Network's internal routes; tests only), actor (a stand-in++ * Actor client; tests only), assistLimitsNow (the assist rate-limit clock; tests only)*/async function createApp(opts = {}) {const config = opts.config || configLib.load();@@@ -73,6 +76,16 @@ async function createApp(opts = {}) {ctx.search = opts.search || createSearchIndex({ config, s, log });ctx.ingest = createIngest({ config, s, fetchImpl, log, afterRun: () => ctx.search.afterIngest() });++ // The person's OpenVibe.Actor agent (server/actor.js) and the per-person daily cap on asking it. A test injects++ // a stand-in client and clock through opts.actor / opts.assistLimitsNow, so the assist route never calls out.++ ctx.actor = opts.actor || actorLib.create({ config, fetchImpl, log });++ ctx.assistLimiter = opts.assistLimiter || createActorLimiter({++ limits: { day: config.applications.assistsPerDay },++ actor: (req) => (req.principal && req.principal.requester) || null,++ now: opts.assistLimitsNow || (() => Date.now()),++ log,++ });++// Account export and deletion (ADR-033, ./identity/account-data.js): the one table that holds a person's rows.// The sender posts to Network's internal export/deletion routes with this service's own client-credentials token;// a test injects a stand-in through opts.accountSend.nnew file mode 100644@@@ -0,0 +1,233 @@++'use strict';++++/**++ * The job applications a signed-in person tracks (migrations/0005_applications.sql) and the append-only history of++ * their status changes. Every function takes the store (server/db.js createStore) first, as everywhere else in this++ * service. An application belongs to the subject that created it (`user:usr_…` for a person; an app or agent acting++ * with work.application.manage writes its own `app:…` / `agent:…` subject), so nobody reads anybody else's rows.++ */++++const { stripHtml } = require('./jobs/text');++++const PAGE_SIZE = 30;++const MAX_PAGE = 100;++const MAX_APPLICATIONS = 500;++const MAX_TITLE = 500;++const MAX_COMPANY = 200;++const MAX_URL = 2000;++const MAX_NOTES = 5000;++const MAX_NEXT_STEP = 200;++const MAX_ASK = 1000;++++const STATUSES = ['saved', 'applied', 'interviewing', 'offer', 'rejected', 'withdrawn'];++const APPLICATION_ID = /^wap_[0-9A-HJKMNP-TV-Z]{26}$/;++const DEFAULT_ASK = 'Help me prepare this application: summarise what the role asks for, list what to emphasise, and draft a short cover note.';++++const hasValue = (v) => v != null && v !== '';++const isHttpUrl = (v) => {++ if (typeof v !== 'string' || !v) return false;++ try { const u = new URL(v); return u.protocol === 'http:' || u.protocol === 'https:'; } catch { return false; }++};++++/** A row → the application as the API and pages see it. */++function toWire(row) {++ return {++ id: row.id,++ subject_id: row.subject_id,++ listing_id: row.listing_id || null,++ title: row.title,++ company: row.company,++ url: row.url,++ status: row.status,++ notes: row.notes || '',++ applied_at: row.applied_at || null,++ next_step: row.next_step || '',++ next_step_at: row.next_step_at || null,++ actor_task_id: row.actor_task_id || null,++ assists: Number(row.assists) || 0,++ created_at: row.created_at,++ updated_at: row.updated_at,++ };++}++++const toEventWire = (row) => ({ at: row.at, from_status: row.from_status, to_status: row.to_status });++++/** An ISO 8601 date-time this service accepts (a null or empty clears the field); undefined means invalid. */++function parseDate(v) {++ if (v == null || v === '') return null;++ if (typeof v !== 'string') return undefined;++ if (!/^\d{4}-\d{2}-\d{2}T[\d:.]+Z$/.test(v)) return undefined;++ return Number.isNaN(Date.parse(v)) ? undefined : v;++}++++/** POST /api/v1/me/applications: listing_id, or title + company + url. Unknown fields are refused. */++function validateCreate(body) {++ const b = body && typeof body === 'object' ? body : {};++ for (const k of Object.keys(b)) {++ if (!['listing_id', 'title', 'company', 'url'].includes(k)) return { error: [400, 'work.applications.unknown_field', `unknown field: ${k}`] };++ }++ if (hasValue(b.listing_id)) {++ if (typeof b.listing_id !== 'string') return { error: [400, 'work.applications.listing_id_invalid', 'listing_id must be a string'] };++ if (hasValue(b.title) || hasValue(b.company) || hasValue(b.url)) return { error: [400, 'work.applications.conflicting_fields', 'Give listing_id, or title + company + url, not both.'] };++ return { fields: { listing_id: b.listing_id } };++ }++ const title = typeof b.title === 'string' ? b.title.trim() : '';++ const company = typeof b.company === 'string' ? b.company.trim() : '';++ if (!title) return { error: [400, 'work.applications.title_required', 'An application needs a title.'] };++ if (title.length > MAX_TITLE) return { error: [400, 'work.applications.title_too_long', `title is at most ${MAX_TITLE} characters.`] };++ if (!company) return { error: [400, 'work.applications.company_required', 'An application needs a company.'] };++ if (company.length > MAX_COMPANY) return { error: [400, 'work.applications.company_too_long', `company is at most ${MAX_COMPANY} characters.`] };++ if (typeof b.url !== 'string' || !isHttpUrl(b.url)) return { error: [400, 'work.applications.url_invalid', 'url must be an http(s) URL.'] };++ if (b.url.length > MAX_URL) return { error: [400, 'work.applications.url_too_long', `url is at most ${MAX_URL} characters.`] };++ return { fields: { title, company, url: b.url } };++}++++/** PATCH /api/v1/me/applications/:id: the five editable fields, nothing else. */++function validatePatch(body) {++ const b = body && typeof body === 'object' ? body : {};++ for (const k of Object.keys(b)) {++ if (!['status', 'notes', 'applied_at', 'next_step', 'next_step_at'].includes(k)) return { error: [400, 'work.applications.unknown_field', `unknown field: ${k}`] };++ }++ const out = {};++ if ('status' in b) {++ if (typeof b.status !== 'string' || !STATUSES.includes(b.status)) return { error: [400, 'work.applications.status_invalid', `status is one of ${STATUSES.join(', ')}.`] };++ out.status = b.status;++ }++ if ('notes' in b) {++ if (typeof b.notes !== 'string') return { error: [400, 'work.applications.notes_invalid', 'notes must be a string.'] };++ if (b.notes.length > MAX_NOTES) return { error: [400, 'work.applications.notes_too_long', `notes is at most ${MAX_NOTES} characters.`] };++ out.notes = b.notes;++ }++ if ('next_step' in b) {++ if (typeof b.next_step !== 'string') return { error: [400, 'work.applications.next_step_invalid', 'next_step must be a string.'] };++ if (b.next_step.length > MAX_NEXT_STEP) return { error: [400, 'work.applications.next_step_too_long', `next_step is at most ${MAX_NEXT_STEP} characters.`] };++ out.next_step = b.next_step;++ }++ if ('applied_at' in b) {++ const d = parseDate(b.applied_at);++ if (d === undefined) return { error: [400, 'work.applications.applied_at_invalid', 'applied_at is an ISO 8601 date-time, or null.'] };++ out.applied_at = d;++ }++ if ('next_step_at' in b) {++ const d = parseDate(b.next_step_at);++ if (d === undefined) return { error: [400, 'work.applications.next_step_at_invalid', 'next_step_at is an ISO 8601 date-time, or null.'] };++ out.next_step_at = d;++ }++ return { fields: out };++}++++/** POST /api/v1/me/applications/:id/assist: an optional `ask`. */++function validateAsk(body) {++ const b = body && typeof body === 'object' ? body : {};++ for (const k of Object.keys(b)) {++ if (k !== 'ask') return { error: [400, 'work.applications.unknown_field', `unknown field: ${k}`] };++ }++ if (!hasValue(b.ask)) return { ask: DEFAULT_ASK };++ if (typeof b.ask !== 'string') return { error: [400, 'work.applications.ask_invalid', 'ask must be a string.'] };++ if (b.ask.length > MAX_ASK) return { error: [400, 'work.applications.ask_too_long', `ask is at most ${MAX_ASK} characters.`] };++ return { ask: b.ask };++}++++/** Resolve a create's fields: a listing copies its title/company/url; a by-hand one is already complete. */++async function resolveCreateFields(s, fields) {++ if (!fields.listing_id) return { fields };++ const listing = await s.db.maybe('SELECT * FROM work_listings WHERE id = $1 AND expired = false', [fields.listing_id]);++ if (!listing) return { error: 'listing_not_found' };++ return { fields: { listing_id: listing.id, title: listing.title, company: listing.company, url: listing.url } };++}++++const listAllApplications = (s, subject) => s.db.many('SELECT * FROM work_applications WHERE subject_id = $1 ORDER BY updated_at DESC, id DESC', [subject]);++++/** One page of a person's applications, newest first, cursor on (updated_at, id). */++async function listApplications(s, subject, { status = null, cursor = null, limit = PAGE_SIZE } = {}) {++ const n = Math.min(MAX_PAGE, Math.max(1, Number(limit) || PAGE_SIZE));++ const args = [subject];++ const where = ['subject_id = $1'];++ if (status) { args.push(status); where.push(`status = $${args.length}`); }++ const at = cursor && cursor.updated_at && cursor.id ? cursor : null;++ if (at) { args.push(at.updated_at, at.id); where.push(`(updated_at, id) < ($${args.length - 1}, $${args.length})`); }++ const rows = await s.db.many(`SELECT * FROM work_applications WHERE ${where.join(' AND ')} ORDER BY updated_at DESC, id DESC LIMIT ${n + 1}`, args);++ const page = rows.slice(0, n);++ const last = page[page.length - 1];++ return { rows: page, next: rows.length > n && last ? { updated_at: last.updated_at, id: last.id } : null };++}++++const getApplication = (s, subject, id) => (APPLICATION_ID.test(String(id)) ? s.db.maybe('SELECT * FROM work_applications WHERE id = $1 AND subject_id = $2', [id, subject]) : Promise.resolve(null));++++const applicationByListing = (s, subject, listingId) => s.db.maybe('SELECT * FROM work_applications WHERE subject_id = $1 AND listing_id = $2', [subject, listingId]);++++/** The counts the /applications page groups by. */++async function countsByStatus(s, subject) {++ const rows = await s.db.many('SELECT status, count(*) AS n FROM work_applications WHERE subject_id = $1 GROUP BY status', [subject]);++ const out = Object.fromEntries(STATUSES.map((st) => [st, 0]));++ for (const r of rows) out[r.status] = Number(r.n);++ return out;++}++++async function createApplication(s, subject, fields, { id, now, max = MAX_APPLICATIONS } = {}) {++ const count = Number(await s.db.value('SELECT count(*) FROM work_applications WHERE subject_id = $1', [subject]));++ if (count >= max) return { error: 'full' };++ const row = await s.db.maybe(++ `INSERT INTO work_applications (id, subject_id, listing_id, title, company, url, status, notes, applied_at, next_step_at, next_step, actor_task_id, assists, created_at, updated_at)++ VALUES ($1, $2, $3, $4, $5, $6, 'saved', '', NULL, NULL, '', NULL, 0, $7, $7) RETURNING *`,++ [id, subject, fields.listing_id || null, fields.title, fields.company, fields.url, now]);++ return { row };++}++++async function updateApplication(s, subject, id, fields, { now }) {++ const current = await getApplication(s, subject, id);++ if (!current) return { error: 'not_found' };++ const next = { ...current };++ const statusChanged = Boolean(fields.status && fields.status !== current.status);++ if (fields.status) next.status = fields.status;++ if (fields.notes !== undefined) next.notes = fields.notes;++ if (fields.next_step !== undefined) next.next_step = fields.next_step;++ if (fields.applied_at !== undefined) next.applied_at = fields.applied_at;++ if (fields.next_step_at !== undefined) next.next_step_at = fields.next_step_at;++ // Setting the status to applied without an applied_at stamps the moment it happened.++ if (fields.status === 'applied' && !('applied_at' in fields) && !current.applied_at) next.applied_at = now;++ next.updated_at = now;++ await s.db.query(++ `UPDATE work_applications SET status = $3, notes = $4, applied_at = $5, next_step = $6, next_step_at = $7, updated_at = $8++ WHERE id = $1 AND subject_id = $2`,++ [id, subject, next.status, next.notes, next.applied_at, next.next_step, next.next_step_at, next.updated_at]);++ if (statusChanged) {++ await s.db.query('INSERT INTO work_application_events (application_id, at, from_status, to_status) VALUES ($1, $2, $3, $4)', [id, now, current.status, fields.status]);++ }++ return { row: next };++}++++const deleteApplication = (s, subject, id) => (APPLICATION_ID.test(String(id)) ? s.db.exec('DELETE FROM work_applications WHERE id = $1 AND subject_id = $2', [id, subject]) : Promise.resolve(0));++++const listEvents = (s, applicationId) => s.db.many('SELECT * FROM work_application_events WHERE application_id = $1 ORDER BY at DESC', [applicationId]);++++const recordAssist = (s, subject, id, taskId, now) => s.db.query(++ 'UPDATE work_applications SET actor_task_id = $3, assists = assists + 1, updated_at = $4 WHERE id = $1 AND subject_id = $2',++ [id, subject, taskId, now]);++++/** The task text the person's agent sees: the application, the listing's excerpt when it has one, notes, then the ask. */++function buildTaskText(app, listing, ask) {++ const lines = [`Title: ${app.title}`, `Company: ${app.company}`, `URL: ${app.url}`];++ if (listing && listing.excerpt) lines.push(`Excerpt: ${stripHtml(listing.excerpt)}`);++ if (app.notes) lines.push(`Notes: ${app.notes}`);++ lines.push(`Ask: ${ask}`);++ return lines.join('\n');++}++++const encodeCursor = (c) => (c ? Buffer.from(JSON.stringify([c.updated_at, c.id]), 'utf8').toString('base64url') : null);++function decodeCursor(v) {++ if (typeof v !== 'string' || !v || v.length > 200) return null;++ try {++ const [at, id] = JSON.parse(Buffer.from(v, 'base64url').toString('utf8'));++ if (typeof at !== 'string' || !/^\d{4}-\d{2}-\d{2}T[\d:.]+Z$/.test(at) || !APPLICATION_ID.test(String(id))) return null;++ return { updated_at: at, id: String(id) };++ } catch { return null; }++}++++module.exports = {++ toWire, toEventWire, validateCreate, validatePatch, validateAsk, resolveCreateFields,++ listAllApplications, listApplications, getApplication, applicationByListing, countsByStatus,++ createApplication, updateApplication, deleteApplication, listEvents, recordAssist, buildTaskText,++ encodeCursor, decodeCursor,++ STATUSES, APPLICATION_ID, MAX_APPLICATIONS, MAX_NOTES, MAX_NEXT_STEP, MAX_ASK, DEFAULT_ASK, PAGE_SIZE, MAX_PAGE,++};@@@ -64,6 +64,19 @@ function load(env = process.env) {endpoint: env.WORK_EVENTS_ENDPOINT || '',},++ // The agent pillar: the job applications a person tracks (server/applications.js) and the OpenVibe.Actor++ // client they ask for help (server/actor.js). WORK_ACTOR_URL unset means "no agent": the assist route answers++ // 503 rather than call anywhere. WORK_ACTOR_PUBLIC_URL is the human-facing link a task_url points at.++ applications: {++ max: Math.max(1, int(env.WORK_APPLICATIONS_MAX, 500)),++ assistsPerDay: Math.max(1, int(env.WORK_APPLICATIONS_ASSISTS_PER_DAY, 10)),++ },++ actor: {++ url: trim(env.WORK_ACTOR_URL === undefined ? 'http://127.0.0.1:4950' : env.WORK_ACTOR_URL),++ publicUrl: trim(env.WORK_ACTOR_PUBLIC_URL || 'https://openvibe.actor'),++ timeoutMs: Math.max(1000, int(env.WORK_ACTOR_TIMEOUT_MS, 15000)),++ },++// The public job boards the ingest reads (server/jobs/sources.js). The base URLs are configurable so a// test points them at a stand-in server; nothing else in the service ever fetches a URL, and never one a// caller typed. Their terms are quoted in the manifest that describes each source, and applied on every@@@ -23,6 +23,7 @@ const { asyncRouter } = require('./router');const { sameOrigin } = require('./principal');const store = require('../jobs/store');const sources = require('../jobs/sources');++const applications = require('../applications');function createApi(ctx) {const { config, s, principal, limits } = ctx;@@@ -55,6 +56,25 @@ function createApi(ctx) {return null;};++ // Applications are a person's own; an app or agent token may manage them only with work.application.manage.++ const requireApp = principal.requireCapability('work.application.manage');++ // Asking the person's agent is always the person themselves: an app token cannot stand in for them.++ const personOnly = (req, res) => {++ if (req.principal.kind === 'user') {++ if (req.principal.viaSession && req.method !== 'GET' && req.method !== 'HEAD' && !sameOrigin(req, config.baseUrl)) {++ problem(req, res, 403, 'request.cross_site', 'A signed-in request that changes something must come from openvibe.work itself.');++ return null;++ }++ return req.principal.requester;++ }++ problem(req, res, req.principal.kind === 'anonymous' ? 401 : 403, req.principal.kind === 'anonymous' ? 'token.required' : 'work.applications.person_only',++ req.principal.kind === 'anonymous'++ ? 'Sign in at openvibe.work to track applications, or send a person\'s Network token as a Bearer.'++ : 'Asking your agent is yours alone; an app or agent token cannot do it for you.');++ return null;++ };++ const assistLimit = ctx.assistLimiter('work.applications.assist');++// ── The listings ────────────────────────────────────────r.get('/jobs', limits.budget('work.jobs.search'), async (req, res) => {const filters = store.filtersOf(req.query);@@@ -132,6 +152,89 @@ function createApi(ctx) {return res.status(204).end();});++ // ── Applications ─────────────────────────────────────────++ r.get('/me/applications', limits.reads('work.api.read'), requireApp, async (req, res) => {++ const subject = req.principal.requester;++ const status = req.query.status;++ if (status != null && status !== '' && !applications.STATUSES.includes(status)) {++ return problem(req, res, 400, 'work.applications.status_invalid', `status is one of ${applications.STATUSES.join(', ')}.`);++ }++ const cursor = applications.decodeCursor(req.query.cursor);++ if (req.query.cursor && !cursor) return problem(req, res, 422, 'work.applications.cursor_invalid', 'cursor: the value the previous page returned as `next`, or absent');++ const page = await applications.listApplications(s, subject, { status: status || null, cursor, limit: req.query.limit });++ return res.json({++ applications: page.rows.map(applications.toWire),++ next: applications.encodeCursor(page.next),++ count: page.rows.length,++ });++ });++++ r.post('/me/applications', limits.budget('work.applications.write'), requireApp, async (req, res) => {++ const subject = req.principal.requester;++ const v = applications.validateCreate(req.body);++ if (v.error) return problem(req, res, v.error[0], v.error[1], v.error[2]);++ const resolved = await applications.resolveCreateFields(s, v.fields);++ if (resolved.error === 'listing_not_found') return problem(req, res, 404, 'work.jobs.not_found', 'No such listing to track. It may have expired from the board it came from.');++ const fields = resolved.fields;++ if (fields.listing_id) {++ const existing = await applications.applicationByListing(s, subject, fields.listing_id);++ if (existing) return res.status(200).json(applications.toWire(existing));++ }++ const out = await applications.createApplication(s, subject, fields, { id: s.newId('wap'), now: s.iso(), max: config.applications.max });++ if (out.error === 'full') return problem(req, res, 409, 'work.applications_full', `You can track up to ${config.applications.max} applications; delete one first.`);++ res.status(201).set('Location', `/api/v1/me/applications/${out.row.id}`).json(applications.toWire(out.row));++ return undefined;++ });++++ r.get('/me/applications/:id', limits.reads('work.api.read'), requireApp, async (req, res) => {++ const subject = req.principal.requester;++ const row = await applications.getApplication(s, subject, req.params.id);++ if (!row) return problem(req, res, 404, 'work.applications.not_found', 'You have no application with that id.');++ const events = await applications.listEvents(s, row.id);++ return res.json({ ...applications.toWire(row), events: events.map(applications.toEventWire) });++ });++++ r.patch('/me/applications/:id', limits.budget('work.applications.write'), requireApp, async (req, res) => {++ const subject = req.principal.requester;++ const v = applications.validatePatch(req.body);++ if (v.error) return problem(req, res, v.error[0], v.error[1], v.error[2]);++ const out = await applications.updateApplication(s, subject, req.params.id, v.fields, { now: s.iso() });++ if (out.error === 'not_found') return problem(req, res, 404, 'work.applications.not_found', 'You have no application with that id.');++ return res.json(applications.toWire(out.row));++ });++++ r.delete('/me/applications/:id', limits.budget('work.applications.write'), requireApp, async (req, res) => {++ const subject = req.principal.requester;++ const gone = await applications.deleteApplication(s, subject, req.params.id);++ if (!gone) return problem(req, res, 404, 'work.applications.not_found', 'You have no application with that id.');++ return res.status(204).end();++ });++++ r.post('/me/applications/:id/assist', assistLimit, async (req, res) => {++ const subject = personOnly(req, res);++ if (!subject) return undefined;++ const app = await applications.getApplication(s, subject, req.params.id);++ if (!app) return problem(req, res, 404, 'work.applications.not_found', 'You have no application with that id.');++ const a = applications.validateAsk(req.body);++ if (a.error) return problem(req, res, a.error[0], a.error[1], a.error[2]);++ const actor = ctx.actor;++ if (!actor || !actor.available) return problem(req, res, 503, 'work.actor_unavailable', 'The OpenVibe.Actor agent is not available right now.');++ const n = Number(app.assists || 0) + 1;++ const idempotencyKey = `work-assist:${app.id}:${n}`;++ const listing = app.listing_id ? await store.getListing(s, app.listing_id) : null;++ const task = applications.buildTaskText(app, listing, a.ask);++ let out;++ try {++ out = await actor.createTask({ subjectId: subject.slice('user:'.length), task, idempotencyKey });++ } catch (err) {++ const status = err && err.status;++ if (status === 402 || status === 429) return problem(req, res, 409, (err.code || 'work.actor_refused'), (err.detail || 'The agent refused this task.'));++ return problem(req, res, 503, 'work.actor_unavailable', 'The agent could not be reached.');++ }++ await applications.recordAssist(s, subject, app.id, out.id, s.iso());++ return res.json({ task_id: out.id, task_url: `${config.actor.publicUrl}/tasks/${out.id}` });++ });++return r;}@@@ -38,6 +38,7 @@ function caller(req) {const BUDGETS = {'work.jobs.search': { minute: 60, hour: 600 },'work.saved_searches.write': { minute: 20, hour: 200 },++ 'work.applications.write': { minute: 30, hour: 300 },};function createCallerLimits({ config, now = () => Date.now(), registry = null, log = console, enabled = true, valkey = null }) {@@@ -21,9 +21,12 @@ const { send } = require('../render/layout');const listing = require('../render/listing');const store = require('../jobs/store');const sources = require('../jobs/sources');++const applications = require('../applications');const SITE_NAME = 'OpenVibe.Work';const TAGLINE = 'Find work on open job boards.';++const STATUS_ORDER = ['saved', 'applied', 'interviewing', 'offer', 'rejected', 'withdrawn'];++const STATUS_LABEL = { saved: 'Saved', applied: 'Applied', interviewing: 'Interviewing', offer: 'Offer', rejected: 'Rejected', withdrawn: 'Withdrawn' };function createPageRoutes(ctx) {const { config, s } = ctx;@@@ -89,6 +92,19 @@ ${facets.type.map((t) => link({ type: t.type }, t.type, filters.type === t.type,/** Which of the three sources a listing came from, named; the ingest keeps to these and nothing else. */const sourceLine = (row) => html`${listing.sourceLink(row, sourceOf(row))}`;++ /** A status's badge and its name, in the one place the pages spell them. */++ const statusBadge = (st) => html`<span class="badge${st === 'offer' ? ' ok' : st === 'rejected' || st === 'withdrawn' ? ' bad' : ''}">${STATUS_LABEL[st] || st}</span>`;++ /** An ISO instant as the YYYY-MM-DD an <input type="date"> wants ('' when there is none). */++ const dateInputValue = (iso) => (iso && typeof iso === 'string' ? iso.slice(0, 10) : '');++ /** An <input type="date"> value back to an ISO instant, or null to clear. */++ const dateFromForm = (v) => {++ if (!v) return null;++ const s = String(v).trim();++ if (!/^\d{4}-\d{2}-\d{2}$/.test(s)) return undefined;++ const d = new Date(`${s}T00:00:00Z`);++ return Number.isNaN(d.getTime()) ? undefined : d.toISOString();++ };++// ── Home ─────────────────────────────────────────────────r.get('/', async (req, res) => {const [latest, counts, rows] = await Promise.all([store.listRecent(s, 6, { remote: true }), store.countsBySource(s), store.search(s, {}, { limit: 1 })]);@@@ -132,7 +148,7 @@ ${raw(showcase.steps({{ title: 'Read it on the board', text: 'The listing page shows the excerpt and sends you to the original listing to read it in full and apply. Nothing is republished.' },{ title: 'Keep a search', text: 'Sign in to save a search; /saved shows each one with the count of listings that appeared since you last looked.' },{ title: 'Nothing behind a paywall', text: 'Reading and searching is free and needs no account. There is no premium tier on the listings, and listings expire when a board stops showing them.' },-- { title: 'An agent, next', text: 'The agent that drafts and tracks applications is the next pillar; alerts through OpenVibe.Watch come with it.' },++ { title: 'Track your applications', text: 'Sign in and press Track this job on a listing: keep your status, notes and next steps in one place, and ask your OpenVibe.Actor agent to help prepare each application.' },],}))}${raw(showcase.cta({ title: 'Prefer the API?', text: 'The same search, the sources and their terms, and your saved searches over HTTP. Reading is public.', actions: [{ label: 'The API', href: '/docs' }, { label: 'The sources', href: '/sources' }] }))}`,@@@ -175,6 +191,7 @@ ${nextUrl ? html`<a class="sc-btn sc-primary" href="${nextUrl}">Older listings</}const src = sourceOf(row);const l = listing.meta(row);++ const tracked = signedIn(req) ? await applications.applicationByListing(s, subjectOf(req), row.id) : null;page(req, res, {index: true, cache: PUBLIC_CACHE,title: `${l.title} at ${l.company}`,@@@ -191,6 +208,11 @@ ${l.tags.length ? html`<ul class="job-tags">${l.tags.map((t) => html`<li>${t}</l<p>${l.excerpt}</p><p class="muted small">This is a short excerpt only. The full listing stays on the board.</p></section><p class="job-apply"><a class="sc-btn sc-primary" href="${l.url}" rel="nofollow noopener external" target="_blank">Read the full listing on ${src.name}</a></p>++${signedIn(req)++ ? (tracked++ ? html`<p class="job-track"><span class="badge ok">Tracked: ${STATUS_LABEL[tracked.status] || tracked.status}</span> <a href="/applications/${tracked.id}">Open your application</a></p>`++ : html`<form class="inline" method="post" action="/jobs/${row.id}/track"><button class="sc-btn" type="submit">Track this job</button></form>`)++ : html`<p class="muted small"><a href="/auth/login?next=%2Fjobs%2F${row.id}">Sign in</a> to track this job and ask your agent for help applying.</p>`}<dl class="job-facts"><dt>Source</dt><dd>${sourceLine(row)} — <a href="/sources#${src.id}">its terms as applied</a></dd>${l.posted_at ? html`<dt>Posted</dt><dd>${time(l.posted_at)}</dd>` : ''}@@@ -242,7 +264,7 @@ ${l.posted_at ? html`<dt>Posted</dt><dd>${time(l.posted_at)}</dd>` : ''}for (const row of rows) await store.touchSaved(s, subject, row.id, now);const initial = store.filtersOf(req.query);page(req, res, {-- title: 'Saved searches', crumbs: [{ label: 'Saved searches' }],++ title: 'Saved searches', crumbs: [{ label: 'Saved searches' }], styles: [showcase.STYLESHEET],body: html`<h1>Saved searches</h1><p>${rows.length ? html`You are keeping ${rows.length} search${rows.length === 1 ? '' : 'es'}. The count is how many listings appeared since you last opened this page.` : 'You are not keeping any search yet.'}</p>${withCounts.length ? html`<ul class="saved-list">${withCounts.map(({ row, count }) => html`<li class="saved-item">@@@ -288,6 +310,191 @@ ${notice('Give the search at least one thing to look for — words, a location,res.redirect(303, '/saved');});++ // ── Applications ─────────────────────────────────────────++ const APP_STYLES = ['css/applications.css'];++ const editForm = (a) => html`<form class="app-form" method="post" action="/applications/${a.id}">++<label>Status <select name="status">${STATUS_ORDER.map((st) => html`<option value="${st}"${a.status === st ? raw(' selected') : ''}>${STATUS_LABEL[st]}</option>`)}</select></label>++<div class="app-row"><label>Applied on <input type="date" name="applied_at" value="${dateInputValue(a.applied_at)}"></label>++<label>Next step by <input type="date" name="next_step_at" value="${dateInputValue(a.next_step_at)}"></label></div>++<label>Next step <input type="text" name="next_step" maxlength="200" value="${a.next_step || ''}" placeholder="Follow up with the recruiter"></label>++<label>Notes <textarea name="notes" maxlength="5000" rows="6">${a.notes || ''}</textarea></label>++<button class="sc-btn sc-primary" type="submit">Save</button>++</form>`;++ const addForm = () => html`<section class="app-panel app-add" aria-labelledby="h-add"><h2 id="h-add">Add one by hand</h2>++<form class="app-form" method="post" action="/applications">++<div class="app-row"><label>Job title <input type="text" name="title" maxlength="500" required></label>++<label>Company <input type="text" name="company" maxlength="200" required></label></div>++<label>Link to the listing <input type="url" name="url" maxlength="2000" required placeholder="https://"></label>++<button class="sc-btn sc-primary" type="submit">Add application</button>++</form>++<p class="muted small">Or press “Track this job” on any <a href="/jobs">listing</a>.</p></section>`;++++ const renderApplication = (req, res, a, { events = [], problem = null, assisted = false } = {}) => page(req, res, {++ title: `${a.title} at ${a.company}`, styles: [showcase.STYLESHEET], localStyles: APP_STYLES,++ crumbs: [{ label: 'Applications', href: '/applications' }, { label: a.title }],++ body: html`<div class="app-head"><h1>${a.title}</h1>${statusBadge(a.status)}</div>++<p class="app-company">${a.company}${a.url ? html` · <a href="${a.url}" rel="nofollow noopener external" target="_blank">the original listing</a>` : ''}${a.listing_id ? html` · <a href="/jobs/${a.listing_id}">this listing on OpenVibe.Work</a>` : ''}</p>++${problem ? notice(problem, 'warn') : ''}++${assisted && a.actor_task_id ? notice(html`Your agent is on it: <a href="${config.actor.publicUrl}/tasks/${a.actor_task_id}">open the task on OpenVibe.Actor</a>.`, 'ok') : ''}++<div class="app-detail">++<section class="app-panel" aria-labelledby="h-edit"><h2 id="h-edit">Your application</h2>${editForm(a)}</section>++<div>++<section class="app-panel app-agent" aria-labelledby="h-agent"><h2 id="h-agent">Help from your agent</h2>++<p class="small">Your OpenVibe.Actor agent reads this listing and your notes, then works on what you ask. It runs on your Actor allowance.</p>++<form class="app-form" method="post" action="/applications/${a.id}/assist">++<label>What do you want help with? <span class="muted">(optional)</span><textarea name="ask" maxlength="1000" rows="3" placeholder="${applications.DEFAULT_ASK}"></textarea></label>++<button class="sc-btn sc-primary" type="submit">Ask my agent</button>++</form>++${a.actor_task_id ? html`<p class="small">Latest task: <a href="${config.actor.publicUrl}/tasks/${a.actor_task_id}">open it on OpenVibe.Actor</a>${a.assists > 1 ? html` (${a.assists} asked so far)` : ''}.</p>` : ''}++</section>++<section class="app-panel" aria-labelledby="h-history"><h2 id="h-history">History</h2>++${events.length ? html`<ul class="app-history">${events.map((e) => html`<li>${time(e.at)} · ${STATUS_LABEL[e.from_status] || e.from_status} → ${STATUS_LABEL[e.to_status] || e.to_status}</li>`)}</ul>` : html`<p class="muted small">No status changes yet. Added ${time(a.created_at)}.</p>`}++</section>++<form class="app-danger" method="post" action="/applications/${a.id}/delete"><button type="submit">Delete this application</button></form>++</div>++</div>`,++ });++++ r.get('/applications', async (req, res) => {++ if (!signedIn(req)) {++ return page(req, res, {++ title: 'Applications', styles: [showcase.STYLESHEET],++ body: html`<h1>Applications</h1>++<p>Track the jobs you apply to, keep your notes and next steps in one place, and ask your OpenVibe.Actor agent to help you prepare each application. Sign in to begin.</p>++<p><a class="sc-btn sc-primary" href="/auth/login?next=%2Fapplications">Sign in with OpenVibe</a> <a class="sc-btn" href="/jobs">Search the listings</a></p>`,++ });++ }++ const subject = subjectOf(req);++ const rows = await applications.listAllApplications(s, subject);++ const groups = STATUS_ORDER.map((st) => ({ status: st, rows: rows.filter((r) => r.status === st) }));++ const total = rows.length;++ page(req, res, {++ title: 'Applications', crumbs: [{ label: 'Applications' }], styles: [showcase.STYLESHEET], localStyles: APP_STYLES,++ body: html`<h1>Applications</h1>++<p>${total ? html`You are tracking ${total} application${total === 1 ? '' : 's'}.` : html`Nothing tracked yet. Press “Track this job” on a <a href="/jobs">listing</a>, or add one by hand below.`}</p>++${total ? html`<ul class="app-summary" aria-label="Applications by status">${groups.map((g) => html`<li>${g.rows.length ? html`<a href="#h-${g.status}">${STATUS_LABEL[g.status]} <strong>${g.rows.length}</strong></a>` : html`<span class="is-empty">${STATUS_LABEL[g.status]} <strong>0</strong></span>`}</li>`)}</ul>` : ''}++${groups.map((g) => (g.rows.length ? html`<section class="app-group" aria-labelledby="h-${g.status}">++<h2 id="h-${g.status}">${STATUS_LABEL[g.status]}</h2>++<ul class="app-list">${g.rows.map((a) => html`<li class="app-item">++<p class="app-title"><a href="/applications/${a.id}">${a.title}</a>${statusBadge(a.status)}</p>++<p class="app-co">${a.company}</p>++${a.next_step ? html`<p class="app-next">Next: ${a.next_step}${a.next_step_at ? html` · by <time datetime="${a.next_step_at}">${dateInputValue(a.next_step_at)}</time>` : ''}</p>` : ''}++<p class="muted small">Updated ${time(a.updated_at)}</p>++</li>`)}</ul>++</section>` : ''))}++${addForm()}`,++ });++ });++++ r.post('/applications', require('express').urlencoded({ extended: false, limit: '8kb' }), async (req, res) => {++ res.set('Cache-Control', cache.htmlHeaders({ private: true }));++ if (!signedIn(req)) return res.redirect(303, '/auth/login?next=%2Fapplications');++ if (!sameOrigin(req, config.baseUrl)) return res.status(403).type('text/plain').send('Adding an application must come from openvibe.work itself.');++ const v = applications.validateCreate(req.body || {});++ if (v.error) {++ return page(req, res, { title: 'Applications', crumbs: [{ label: 'Applications' }], body: html`<h1>Applications</h1>${notice(v.error[2], 'warn')}<p><a href="/applications">Back to your applications</a></p>` }, 400);++ }++ const out = await applications.createApplication(s, subjectOf(req), v.fields, { id: s.newId('wap'), now: s.iso(), max: config.applications.max });++ if (out.error === 'full') {++ return page(req, res, { title: 'Applications', body: html`<h1>Applications</h1>${notice(`You can track up to ${config.applications.max} applications; delete one first.`, 'warn')}<p><a href="/applications">Back to your applications</a></p>` }, 409);++ }++ return res.redirect(303, `/applications/${out.row.id}`);++ });++++ r.get('/applications/:id', async (req, res) => {++ if (!signedIn(req)) {++ return page(req, res, {++ title: 'Application', styles: [showcase.STYLESHEET],++ body: html`<h1>Application</h1><p>Sign in to see your applications.</p><p><a class="sc-btn sc-primary" href="/auth/login?next=%2Fapplications">Sign in with OpenVibe</a></p>`,++ });++ }++ const subject = subjectOf(req);++ const a = await applications.getApplication(s, subject, req.params.id);++ if (!a) {++ return page(req, res, {++ title: 'Application not found',++ body: html`<h1>Application not found</h1><p>No application here with that id. It may have been deleted.</p><p><a href="/applications">Back to your applications</a></p>`,++ }, 404);++ }++ const events = await applications.listEvents(s, a.id);++ return renderApplication(req, res, a, { events, assisted: req.query.assisted === '1' });++ });++++ r.post('/applications/:id', require('express').urlencoded({ extended: false, limit: '16kb' }), async (req, res) => {++ res.set('Cache-Control', cache.htmlHeaders({ private: true }));++ if (!signedIn(req)) return res.redirect(303, `/auth/login?next=${encodeURIComponent(`/applications/${req.params.id}`)}`);++ if (!sameOrigin(req, config.baseUrl)) return res.status(403).type('text/plain').send('Editing an application must come from openvibe.work itself.');++ const subject = subjectOf(req);++ const current = await applications.getApplication(s, subject, req.params.id);++ if (!current) return res.status(404).type('text/plain').send('No such application.');++ const patch = { status: req.body.status, notes: req.body.notes || '', next_step: req.body.next_step || '', next_step_at: dateFromForm(req.body.next_step_at) };++ const applied = req.body.applied_at ? dateFromForm(req.body.applied_at) : undefined;++ if (applied !== undefined && applied !== null) patch.applied_at = applied;++ const v = applications.validatePatch(patch);++ if (v.error) {++ const a = await applications.getApplication(s, subject, req.params.id);++ const events = await applications.listEvents(s, a.id);++ return renderApplication(req, res, a, { events, problem: v.error[2] });++ }++ await applications.updateApplication(s, subject, req.params.id, v.fields, { now: s.iso() });++ return res.redirect(303, `/applications/${req.params.id}`);++ });++++ r.post('/applications/:id/assist', require('express').urlencoded({ extended: false, limit: '8kb' }), async (req, res) => {++ res.set('Cache-Control', cache.htmlHeaders({ private: true }));++ if (!signedIn(req)) return res.redirect(303, `/auth/login?next=${encodeURIComponent(`/applications/${req.params.id}`)}`);++ if (!sameOrigin(req, config.baseUrl)) return res.status(403).type('text/plain').send('Asking your agent must come from openvibe.work itself.');++ const subject = subjectOf(req);++ const app = await applications.getApplication(s, subject, req.params.id);++ if (!app) return res.status(404).type('text/plain').send('No such application.');++ const a = applications.validateAsk(req.body || {});++ if (a.error) {++ const events = await applications.listEvents(s, app.id);++ return renderApplication(req, res, app, { events, problem: a.error[2] });++ }++ const actor = ctx.actor;++ if (!actor || !actor.available) {++ const events = await applications.listEvents(s, app.id);++ return renderApplication(req, res, app, { events, problem: 'The OpenVibe.Actor agent is not available right now.' });++ }++ const n = Number(app.assists || 0) + 1;++ const idempotencyKey = `work-assist:${app.id}:${n}`;++ const listing = app.listing_id ? await store.getListing(s, app.listing_id) : null;++ const text = applications.buildTaskText(app, listing, a.ask);++ let out;++ try {++ out = await actor.createTask({ subjectId: subject.slice('user:'.length), task: text, idempotencyKey });++ } catch (err) {++ const events = await applications.listEvents(s, app.id);++ if (err && (err.status === 402 || err.status === 429)) return renderApplication(req, res, app, { events, problem: (err.detail || 'The agent refused this task.') });++ return renderApplication(req, res, app, { events, problem: 'The agent could not be reached.' });++ }++ await applications.recordAssist(s, subject, app.id, out.id, s.iso());++ return res.redirect(303, `/applications/${app.id}?assisted=1`);++ });++++ r.post('/applications/:id/delete', async (req, res) => {++ res.set('Cache-Control', cache.htmlHeaders({ private: true }));++ if (!signedIn(req)) return res.redirect(303, '/auth/login?next=%2Fapplications');++ if (!sameOrigin(req, config.baseUrl)) return res.status(403).type('text/plain').send('Deleting an application must come from openvibe.work itself.');++ await applications.deleteApplication(s, subjectOf(req), req.params.id);++ return res.redirect(303, '/applications');++ });++++ r.post('/jobs/:id/track', async (req, res) => {++ res.set('Cache-Control', cache.htmlHeaders({ private: true }));++ if (!signedIn(req)) return res.redirect(303, `/auth/login?next=${encodeURIComponent(`/jobs/${req.params.id}`)}`);++ if (!sameOrigin(req, config.baseUrl)) return res.status(403).type('text/plain').send('Tracking must come from openvibe.work itself.');++ const listingRow = await store.getListing(s, req.params.id);++ if (!listingRow) return res.status(404).type('text/plain').send('No such listing to track.');++ const subject = subjectOf(req);++ const existing = await applications.applicationByListing(s, subject, listingRow.id);++ if (existing) return res.redirect(303, `/applications/${existing.id}`);++ const out = await applications.createApplication(s, subject, { listing_id: listingRow.id, title: listingRow.title, company: listingRow.company, url: listingRow.url }, { id: s.newId('wap'), now: s.iso(), max: config.applications.max });++ if (out.error === 'full') return res.redirect(303, '/applications');++ return res.redirect(303, `/applications/${out.row.id}`);++ });++// ── The sources and their terms ──────────────────────────r.get('/sources', async (req, res) => {const [fetches, counts] = await Promise.all([store.sourceFetches(s), store.countsBySource(s)]);@@@ -23,7 +23,7 @@ const PROJECT_RE = /^prj_[0-9A-HJKMNP-TV-Z]{26}$/;// The product fills this in: one entry per capability its routes name, in openvibe-contracts// manifests/capabilities/<work>.*. requireCapability('<name>') refuses a name that is not listed here, so a// route can never be guarded by a capability the service does not declare.--const CAPABILITIES = [];++const CAPABILITIES = ['work.application.manage'];function decodePayload(token) {const parts = String(token || '').split('.');@@@ -1,33 +1,53 @@'use strict';/**-- * Account export and deletion → Work (ADR-033; openvibe-sdk/account-data). Work holds one thing a person made: the-- * search they saved, keyed `subject = user:usr_…` (server/http/api.js builds the subject from req.principal.requester,-- * which server/http/principal.js sets to `user:${subject_id}`; server/jobs/store.js writes it). A saved search is the-- * person's own — nothing anyone else hangs under it — so it is deleted whole and nothing is kept.++ * Account export and deletion → Work (ADR-033; openvibe-sdk/account-data). Work holds two things a person made: the++ * search they saved (`work_saved_searches.subject = user:usr_…`) and the job applications they track++ * (`work_applications.subject_id = user:usr_…`), with the append-only history of each application's status changes++ * (`work_application_events`, keyed by application_id). All three are the person's own — nothing anyone else hangs++ * under them — so they are deleted whole and nothing is kept.*-- * network.account.export_requested the person's saved searches (saved_searches.json), newest first, pushed to-- * Network (POST /internal/account-exports/:id/parts) with this service's token.-- * network.account.deleted the person's saved searches go, and Work confirms with counts.++ * The events table has no subject column of its own, so its export comes through extraExport (the person's events,++ * joined to their applications) and its erase rides on the applications table's ON DELETE CASCADE foreign key: when++ * an application row goes, its events go with it.++ *++ * network.account.export_requested the person's saved searches and applications (and each application's events),++ * pushed to Network (POST /internal/account-exports/:id/parts) with this service's token.++ * network.account.deleted all three go, and Work confirms with counts.** The listings (work_listings) and the per-source ingest bookkeeping (work_source_fetches) hold no person's rows:-- * neither has a subject column (a listing is scraped from a public job board and a fetch record is per source), so-- * neither is exported nor erased (see migrations/0002_listings.sql). Nothing here is a secret: no token, key or-- * credential is stored, so every column of the saved-search table may be exported.++ * neither has a subject column, so neither is exported nor erased (see migrations/0002_listings.sql). Nothing here is++ * a secret: no token, key or credential is stored, so every column may be exported.*/const { createAccountData, TOPICS } = require('openvibe-sdk/account-data');++/** The value a subject column really stores (a person's requester is `user:usr_…`). */++const subjectOf = (usr) => `user:${usr}`;++/**-- * The tables that hold a person's rows, with the value the subject column really stores. A saved search stores-- * `user:usr_…` (server/jobs/store.js takes the subject straight from the principal's requester; server/http/api.js).++ * The tables that hold a person's rows. A saved search stores `user:usr_…` in `subject`; an application stores it in++ * `subject_id` (server/applications.js takes the subject straight from the principal's requester).*/const TABLES = [-- { table: 'work_saved_searches', subject: 'subject', value: (usr) => `user:${usr}`, file: 'saved_searches.json' },++ { table: 'work_saved_searches', subject: 'subject', value: subjectOf, file: 'saved_searches.json' },++ { table: 'work_applications', subject: 'subject_id', value: subjectOf, file: 'applications.json' },];++/** The person's application status-change history, which has no subject column of its own. */++async function extraExport(db, subject) {++ const rows = await db.many(++ `SELECT e.application_id, e.at, e.from_status, e.to_status++ FROM work_application_events e++ JOIN work_applications a ON a.id = e.application_id++ WHERE a.subject_id = $1++ ORDER BY e.at DESC, e.application_id DESC`,++ [subjectOf(subject)]);++ return rows.length ? [{ name: 'application_events.json', content: rows }] : [];++}++/** The account-data handle for Work's store (server/db.js createStore). */function create({ db, log = console } = {}) {-- return createAccountData({ db, service: 'work', tables: TABLES, log });++ return createAccountData({ db, service: 'work', tables: TABLES, extraExport, log });}--module.exports = { create, TABLES, TOPICS };++module.exports = { create, TABLES, extraExport, TOPICS };@@@ -25,6 +25,7 @@ const PUBLIC_DIR = path.join(__dirname, '..', '..', 'public');// The product's own navigation: one entry per public page it serves.const NAV = [{ label: 'Jobs', href: '/jobs' },++ { label: 'Applications', href: '/applications', signedIn: true },{ label: 'Sources', href: '/sources' },{ label: 'API', href: '/docs' },{ label: 'What shipped', href: '/updates' },@@@ -47,7 +48,7 @@ function setRelease(id) { if (id) RELEASE = String(id); }/*** o: title, description, body (html), viewer, config, path, index (default false), scripts [rel],-- * crumbs [{ label, href }], jsonLd [nodes] (serialised by openvibe-shared/seo), styles [openvibe-shared stylesheet names]++ * crumbs [{ label, href }], jsonLd [nodes] (serialised by openvibe-shared/seo), styles [openvibe-shared stylesheet names], localStyles [this service's own extra stylesheets under public/, e.g. css/applications.css]*/function renderPage(o) {const viewer = o.viewer || { kind: 'anonymous' };@@@ -55,10 +56,11 @@ function renderPage(o) {const pathNow = o.path || '/';const loginNext = encodeURIComponent(pathNow);const canonical = `${o.config.baseUrl}${pathNow.split('?')[0]}`;++ const navLinks = NAV.filter((l) => !l.signedIn || signedIn);const nav = {service: 'work',apiBase: NETWORK_URL,-- links: NAV.map((l) => ({ label: l.label, href: l.href, active: pathNow === l.href || pathNow.startsWith(`${l.href}/`) })),++ links: navLinks.map((l) => ({ label: l.label, href: l.href, active: pathNow === l.href || pathNow.startsWith(`${l.href}/`) })),history: { type: 'page', title: o.title || SITE_NAME },silentLogin: `${o.config.baseUrl}/auth/login?silent=1&next={url}`,sessionUrl: '/auth/me',@@@ -85,13 +87,14 @@ function renderPage(o) {robots: o.index ? 'index, follow' : 'noindex, nofollow',jsonLd: (o.jsonLd || []).filter(Boolean),home: '/',-- navLinks: NAV,++ navLinks: navLinks.map((l) => ({ label: l.label, href: l.href })),navbar: nav,footer,head: `<meta name="referrer" content="${o.noReferrer ? 'no-referrer' : 'strict-origin-when-cross-origin'}">${appIcon.headTags({ site: 'work' })}<link rel="stylesheet" href="${asset('css/app.css')}">${(o.styles || []).map((name) => `<link rel="stylesheet" href="${esc(ovServe.url(name))}">`).join('\n')}++${(o.localStyles || []).map((rel) => `<link rel="stylesheet" href="${asset(rel)}">`).join('\n')}${render(scripts)}<meta name="ov-boost" content="work@${esc(RELEASE)}"><script src="${ovServe.url('boost.js')}" data-main="#main" defer></script>`,@@@ -11,6 +11,7 @@ const { boot, check, done } = require('./helpers/boot');const { createNetworkSender } = require('openvibe-sdk/account-data');const { signDeliveryHeaders } = require('openvibe-sdk/events');const jobsStore = require('../server/jobs/store');++const appsStore = require('../server/applications');const A = 'usr_01JZ0000000000000000000AAA';const B = 'usr_01JZ0000000000000000000BBB';@@@ -52,6 +53,16 @@ async function makeRows(t, subject, q) {return out.row;}++/** Track an application for one person, with one status change, through the applications store. */++async function makeApplication(t, subject) {++ const s = t.ctx.s;++ const resolved = await appsStore.resolveCreateFields(s, { title: 'Backend Engineer', company: 'Acme', url: 'https://acme.example/job' });++ const out = await appsStore.createApplication(s, `user:${subject}`, resolved.fields, { id: s.newId('wap'), now: s.iso() });++ assert.ok(out.row, 'the application was created');++ await appsStore.updateApplication(s, `user:${subject}`, out.row.id, { status: 'applied' }, { now: s.iso() });++ return out.row;++}++(async () => {await check('an export part carries only the person\'s saved searches, and no secret', async () => {const stub = await startNetworkStub();@@@ -60,6 +71,8 @@ async function makeRows(t, subject, q) {try {await makeRows(t, A, 'person-a');await makeRows(t, B, 'person-b');++ await makeApplication(t, A);++ await makeApplication(t, B);const res = await t.get('/internal/events', { method: 'POST', body: bodyOf(exportEvent), headers: { 'Content-Type': 'application/json', ...signDeliveryHeaders(bodyOf(exportEvent), SECRET) } });assert.strictEqual(res.status, 200);@@@ -69,10 +82,16 @@ async function makeRows(t, subject, q) {assert.ok(part, 'the part was pushed to Network');assert.strictEqual(part.auth, 'Bearer tok_work', 'with this service\'s own token');assert.strictEqual(part.body.subject, A);-- assert.deepStrictEqual(part.body.files.map((f) => f.name).sort(), ['saved_searches.json']);++ assert.deepStrictEqual(part.body.files.map((f) => f.name).sort(), ['application_events.json', 'applications.json', 'saved_searches.json']);const saved = part.body.files.find((f) => f.name === 'saved_searches.json').content;assert.strictEqual(saved.length, 1);assert.strictEqual(saved[0].query, 'person-a');++ const applications = part.body.files.find((f) => f.name === 'applications.json').content;++ assert.strictEqual(applications.length, 1);++ assert.strictEqual(applications[0].title, 'Backend Engineer');++ const events = part.body.files.find((f) => f.name === 'application_events.json').content;++ assert.strictEqual(events.length, 1);++ assert.deepStrictEqual({ from: events[0].from_status, to: events[0].to_status }, { from: 'saved', to: 'applied' });assert.ok(!JSON.stringify(part.body).includes('person-b'), 'nobody else\'s rows');assert.ok(!/token|secret|password/i.test(JSON.stringify(part.body)), 'no secret is exported');} finally { await t.close(); await stub.close(); }@@@ -85,17 +104,27 @@ async function makeRows(t, subject, q) {try {await makeRows(t, A, 'person-a');await makeRows(t, B, 'person-b');++ await makeApplication(t, A);++ await makeApplication(t, B);const gone = async (subject) => await t.ctx.s.db.value('SELECT count(*)::int FROM work_saved_searches WHERE subject = $1', [`user:${subject}`]);++ const appsGone = async (subject) => await t.ctx.s.db.value('SELECT count(*)::int FROM work_applications WHERE subject_id = $1', [`user:${subject}`]);++ const eventsGone = async (subject) => await t.ctx.s.db.value(++ 'SELECT count(*)::int FROM work_application_events e JOIN work_applications a ON a.id = e.application_id WHERE a.subject_id = $1',++ [`user:${subject}`]);const res = await t.get('/internal/events', { method: 'POST', body: bodyOf(deleteEvent), headers: { 'Content-Type': 'application/json', ...signDeliveryHeaders(bodyOf(deleteEvent), SECRET) } });assert.strictEqual(res.status, 200);assert.strictEqual(res.json().outcome, 'erased');assert.strictEqual(await gone(A), 0, 'the person\'s saved searches are gone');assert.strictEqual(await gone(B), 1, 'someone else\'s saved search stays');++ assert.strictEqual(await appsGone(A), 0, 'the person\'s applications are gone');++ assert.strictEqual(await appsGone(B), 1, 'someone else\'s application stays');++ assert.strictEqual(await eventsGone(A), 0, 'the person\'s application events are gone');++ assert.strictEqual(await eventsGone(B), 1, 'someone else\'s application events stay');const confirmation = stub.calls.find((c) => c.url === `/internal/account-deletions/${DEL}/confirmations`);assert.ok(confirmation, 'the confirmation was sent');-- assert.deepStrictEqual(confirmation.body.erased, { work_saved_searches: 1 });++ assert.deepStrictEqual(confirmation.body.erased, { work_saved_searches: 1, work_applications: 1 });assert.deepStrictEqual(confirmation.body.retained, {});assert.ok(!Number.isNaN(Date.parse(confirmation.body.completed_at)));nnew file mode 100644@@@ -0,0 +1,263 @@++'use strict';++/**++ * Applications: tracking one from a listing or by hand, one per person per listing, the status-change history, the++ * applied_at auto-stamp, validation, the cap, the capability rule for app tokens, asking the person's OpenVibe.Actor++ * agent through an injected client, and the pages. The clock is the test's own, because applied_at is a claim about++ * time.++ */++const assert = require('assert');++const { serviceAuth, ids } = require('openvibe-contracts');++const { boot, check, done } = require('./helpers/boot');++++const SAME = { 'sec-fetch-site': 'same-origin' };++++/** A stand-in OpenVibe.Actor client: records calls and returns (or throws) what each test asks. */++function makeActor() {++ const actor = { available: true, calls: [], impl: null };++ actor.createTask = async (args) => {++ actor.calls.push(args);++ if (actor.impl) return actor.impl(args);++ return { id: 'tsk_01JZ0000000000000000000000TK', state: 'queued' };++ };++ return actor;++}++++/** An app token for audience openvibe.work, with the grants given. */++function appToken(t, cap = []) {++ const now = Math.floor(Date.now() / 1000);++ const project = ids.newId('project');++ return serviceAuth.signServiceToken({++ iss: t.network.url,++ sub: `app:${ids.newId('app')}`,++ actor_type: 'app',++ aud: ['openvibe.work'],++ cap,++ ns: [project],++ project_id: project,++ env: 'production',++ iat: now,++ exp: now + 300,++ jti: `tok_${'a'.repeat(12)}`,++ }, t.network.privatePem);++}++++(async () => {++ const clock = { t: Date.now() };++ const actor = makeActor();++ const t = await boot({ now: () => clock.t, actor });++ const kim = t.network.addUser('kim');++ const lee = t.network.addUser('lee');++++ try {++ await t.ctx.ingest.runAll();++ const firstListing = async () => (await t.get('/api/v1/jobs?limit=1')).json().jobs[0];++++ await check('an application is a person\'s: anonymous is 401, an app token is 403, nobody sees anybody else\'s', async () => {++ assert.strictEqual((await t.get('/api/v1/me/applications')).status, 401);++ assert.strictEqual((await t.get('/api/v1/me/applications')).json().code, 'token.required');++++ assert.strictEqual((await t.get('/api/v1/me/applications', { bearer: appToken(t) })).status, 403, 'an app token without work.application.manage is refused');++++ const made = await t.get('/api/v1/me/applications', { as: kim, json: { title: 'Backend Engineer', company: 'Acme', url: 'https://acme.example/jobs/1' }, headers: SAME });++ assert.strictEqual(made.status, 201, made.text);++ const mine = made.json();++ assert.match(mine.id, /^wap_[0-9A-HJKMNP-TV-Z]{26}$/);++ assert.strictEqual(mine.status, 'saved');++ assert.strictEqual(mine.title, 'Backend Engineer');++++ assert.deepStrictEqual((await t.get('/api/v1/me/applications', { as: lee })).json().applications, [], 'another person saw my application');++ assert.strictEqual((await t.get(`/api/v1/me/applications/${mine.id}`, { as: lee })).status, 404);++ assert.strictEqual((await t.get(`/api/v1/me/applications/${mine.id}`, { as: lee, method: 'DELETE', headers: SAME })).status, 404);++ });++++ await check('creating from a listing copies title/company/url, and a second POST for the same listing answers 200 with the existing one', async () => {++ const listing = await firstListing();++ const made = await t.get('/api/v1/me/applications', { as: kim, json: { listing_id: listing.id }, headers: SAME });++ assert.strictEqual(made.status, 201, made.text);++ const one = made.json();++ assert.strictEqual(one.listing_id, listing.id);++ assert.strictEqual(one.title, listing.title);++ assert.strictEqual(one.company, listing.company);++ assert.strictEqual(one.url, listing.url);++++ const again = await t.get('/api/v1/me/applications', { as: kim, json: { listing_id: listing.id }, headers: SAME });++ assert.strictEqual(again.status, 200, again.text);++ assert.strictEqual(again.json().id, one.id, 'a duplicate listing should return the existing application');++ });++++ await check('a status change appends an event, and setting applied with no applied_at stamps now', async () => {++ const made = (await t.get('/api/v1/me/applications', { as: kim, json: { title: 'Designer', company: 'Raytrace', url: 'https://raytrace.example/jobs/9' }, headers: SAME })).json();++ const patched = await t.get(`/api/v1/me/applications/${made.id}`, { as: kim, method: 'PATCH', json: { status: 'applied' }, headers: SAME });++ assert.strictEqual(patched.status, 200, patched.text);++ const applied = patched.json();++ assert.strictEqual(applied.status, 'applied');++ assert.strictEqual(applied.applied_at, new Date(clock.t).toISOString(), 'applied_at was not auto-stamped');++++ const detail = (await t.get(`/api/v1/me/applications/${made.id}`, { as: kim })).json();++ assert.strictEqual(detail.events.length, 1);++ assert.deepStrictEqual({ from_status: detail.events[0].from_status, to_status: detail.events[0].to_status }, { from_status: 'saved', to_status: 'applied' });++++ const notes = await t.get(`/api/v1/me/applications/${made.id}`, { as: kim, method: 'PATCH', json: { notes: 'hiring manager: Sam', next_step: 'portfolio' }, headers: SAME });++ assert.strictEqual(notes.status, 200);++ assert.strictEqual((await t.get(`/api/v1/me/applications/${made.id}`, { as: kim })).json().events.length, 1, 'a non-status edit appended an event');++++ clock.t += 60_000;++ const interviewing = await t.get(`/api/v1/me/applications/${made.id}`, { as: kim, method: 'PATCH', json: { status: 'interviewing' }, headers: SAME });++ assert.strictEqual(interviewing.status, 200);++ const after = (await t.get(`/api/v1/me/applications/${made.id}`, { as: kim })).json();++ assert.strictEqual(after.events.length, 2);++ assert.deepStrictEqual(after.events[0].from_status, 'applied');++ assert.deepStrictEqual(after.events[0].to_status, 'interviewing');++ });++++ await check('validation: unknown fields, a bad status and over-long notes are 400', async () => {++ const unknown = await t.get('/api/v1/me/applications', { as: kim, json: { title: 'x', company: 'y', url: 'https://y.example', salary: 1 }, headers: SAME });++ assert.strictEqual(unknown.status, 400);++ assert.strictEqual(unknown.json().code, 'work.applications.unknown_field');++++ const noTitle = await t.get('/api/v1/me/applications', { as: kim, json: { company: 'y', url: 'https://y.example' }, headers: SAME });++ assert.strictEqual(noTitle.status, 400);++ assert.strictEqual(noTitle.json().code, 'work.applications.title_required');++++ const badUrl = await t.get('/api/v1/me/applications', { as: kim, json: { title: 'x', company: 'y', url: 'not a url' }, headers: SAME });++ assert.strictEqual(badUrl.status, 400);++ assert.strictEqual(badUrl.json().code, 'work.applications.url_invalid');++++ const made = (await t.get('/api/v1/me/applications', { as: kim, json: { title: 'x', company: 'y', url: 'https://y.example' }, headers: SAME })).json();++ const badStatus = await t.get(`/api/v1/me/applications/${made.id}`, { as: kim, method: 'PATCH', json: { status: 'hired' }, headers: SAME });++ assert.strictEqual(badStatus.status, 400);++ assert.strictEqual(badStatus.json().code, 'work.applications.status_invalid');++++ const longNotes = await t.get(`/api/v1/me/applications/${made.id}`, { as: kim, method: 'PATCH', json: { notes: 'n'.repeat(5001) }, headers: SAME });++ assert.strictEqual(longNotes.status, 400);++ assert.strictEqual(longNotes.json().code, 'work.applications.notes_too_long');++++ const badFilter = await t.get('/api/v1/me/applications?status=hired', { as: kim });++ assert.strictEqual(badFilter.status, 400);++ assert.strictEqual(badFilter.json().code, 'work.applications.status_invalid');++ });++++ await check('the cap: one more than the configured maximum is refused 409', async () => {++ const small = await boot({ env: { WORK_APPLICATIONS_MAX: '2' } });++ const user = small.network.addUser('kim');++ try {++ for (let i = 0; i < 2; i++) {++ const r = await small.get('/api/v1/me/applications', { as: user, json: { title: `job ${i}`, company: 'Acme', url: 'https://acme.example/job' }, headers: SAME });++ assert.strictEqual(r.status, 201, r.text);++ }++ const full = await small.get('/api/v1/me/applications', { as: user, json: { title: 'job 3', company: 'Acme', url: 'https://acme.example/job' }, headers: SAME });++ assert.strictEqual(full.status, 409);++ assert.strictEqual(full.json().code, 'work.applications_full');++ } finally { await small.close(); }++ });++++ await check('asking the agent: idempotency key, task_url, the ask and subject travel to Actor', async () => {++ actor.available = true;++ actor.impl = null;++ actor.calls.length = 0;++ const listing = await firstListing();++ const made = (await t.get('/api/v1/me/applications', { as: kim, json: { listing_id: listing.id }, headers: SAME })).json();++ const res = await t.get(`/api/v1/me/applications/${made.id}/assist`, { as: kim, json: { ask: 'Emphasise my systems experience.' }, headers: SAME });++ assert.strictEqual(res.status, 200, res.text);++ const out = res.json();++ assert.strictEqual(out.task_id, 'tsk_01JZ0000000000000000000000TK');++ assert.strictEqual(out.task_url, 'https://openvibe.actor/tasks/tsk_01JZ0000000000000000000000TK');++++ assert.strictEqual(actor.calls.length, 1);++ assert.strictEqual(actor.calls[0].idempotencyKey, `work-assist:${made.id}:1`);++ assert.strictEqual(actor.calls[0].subjectId, kim.subject);++ assert.ok(actor.calls[0].task.includes(made.title));++ assert.ok(actor.calls[0].task.includes(made.company));++ assert.ok(actor.calls[0].task.includes(made.url));++ assert.ok(actor.calls[0].task.includes('Emphasise my systems experience.'));++ assert.ok(actor.calls[0].task.includes('Excerpt:'), 'the listing excerpt is in the task text');++++ const stored = (await t.get(`/api/v1/me/applications/${made.id}`, { as: kim })).json();++ assert.strictEqual(stored.actor_task_id, out.task_id);++++ const second = await t.get(`/api/v1/me/applications/${made.id}/assist`, { as: kim, json: {}, headers: SAME });++ assert.strictEqual(second.status, 200);++ assert.strictEqual(actor.calls[1].idempotencyKey, `work-assist:${made.id}:2`);++ assert.strictEqual(actor.calls[1].subjectId, kim.subject);++ });++++ await check('assist: Actor unavailable is 503, and Actor\'s 402/429 refusal passes through as 409', async () => {++ const made = (await t.get('/api/v1/me/applications', { as: kim, json: { title: 'SRE', company: 'Acme', url: 'https://acme.example/sre' }, headers: SAME })).json();++++ actor.available = false;++ actor.calls.length = 0;++ const unavailable = await t.get(`/api/v1/me/applications/${made.id}/assist`, { as: kim, json: {}, headers: SAME });++ assert.strictEqual(unavailable.status, 503);++ assert.strictEqual(unavailable.json().code, 'work.actor_unavailable');++ assert.strictEqual(actor.calls.length, 0, 'an unavailable Actor is never called');++++ actor.available = true;++ actor.impl = () => { throw Object.assign(new Error('over budget'), { status: 429, code: 'actor.budget.exceeded', detail: 'over budget' }); };++ const refused = await t.get(`/api/v1/me/applications/${made.id}/assist`, { as: kim, json: {}, headers: SAME });++ assert.strictEqual(refused.status, 409);++ assert.strictEqual(refused.json().code, 'actor.budget.exceeded');++++ actor.impl = () => { throw Object.assign(new Error('boom'), { status: 500 }); };++ const error = await t.get(`/api/v1/me/applications/${made.id}/assist`, { as: kim, json: {}, headers: SAME });++ assert.strictEqual(error.status, 503);++ assert.strictEqual(error.json().code, 'work.actor_unavailable');++ actor.impl = null;++ });++++ await check('asking the agent is limited to 10 per person per day', async () => {++ const t2 = await boot({ actor: makeActor() });++ const user = t2.network.addUser('kim');++ try {++ const made = (await t2.get('/api/v1/me/applications', { as: user, json: { title: 'PM', company: 'Acme', url: 'https://acme.example/pm' }, headers: SAME })).json();++ for (let i = 0; i < 10; i++) {++ const r = await t2.get(`/api/v1/me/applications/${made.id}/assist`, { as: user, json: {}, headers: SAME });++ assert.strictEqual(r.status, 200, `assist ${i + 1}`);++ }++ const over = await t2.get(`/api/v1/me/applications/${made.id}/assist`, { as: user, json: {}, headers: SAME });++ assert.strictEqual(over.status, 429);++ assert.strictEqual(over.json().code, 'rate_limited');++ } finally { await t2.close(); }++ });++++ await check('the pages: signed out is an explanation, signed in lists and edits, and a listing page tracks', async () => {++ const anon = await t.get('/applications');++ assert.strictEqual(anon.status, 200);++ assert.match(anon.text, /Sign in with OpenVibe/);++++ const listing = await firstListing();++ const before = await t.get(`/jobs/${listing.id}`);++ assert.match(before.text, /Sign in/, 'a signed-out listing page offers a sign-in for tracking');++ assert.doesNotMatch(before.text, /Track this job/, 'a signed-out listing page has no tracking form');++++ const trackedPage = await t.get(`/jobs/${listing.id}`, { as: lee });++ assert.match(trackedPage.text, /Track this job/, 'a signed-in listing page offers tracking');++++ const tracked = await t.get(`/jobs/${listing.id}/track`, { as: lee, method: 'POST', headers: SAME });++ assert.strictEqual(tracked.status, 303);++ assert.match(tracked.headers.get('location'), /^\/applications\/wap_/);++++ const again = await t.get(`/jobs/${listing.id}`, { as: lee });++ assert.match(again.text, /Tracked: Saved/, 'an already-tracked listing says so');++ assert.match(again.text, /Open your application/);++++ const list = await t.get('/applications', { as: lee });++ assert.strictEqual(list.status, 200);++ assert.match(list.text, /Applications/);++ assert.match(list.text, /Add one by hand/);++++ const id = tracked.headers.get('location').split('/').pop();++ const detail = await t.get(`/applications/${id}`, { as: lee });++ assert.strictEqual(detail.status, 200);++ assert.match(detail.text, /<select name="status">/);++ assert.match(detail.text, /Ask my agent/);++ assert.match(detail.text, /Delete this application/);++++ const foreign = await t.get(`/applications/${id}/delete`, { as: lee, method: 'POST', headers: { 'sec-fetch-site': 'cross-site' }, redirect: 'manual' });++ assert.strictEqual(foreign.status, 403, 'a cross-site delete is refused');++ const gone = await t.get(`/applications/${id}/delete`, { as: lee, method: 'POST', headers: SAME, redirect: 'manual' });++ assert.strictEqual(gone.status, 303);++ assert.strictEqual((await t.get(`/applications/${id}`, { as: lee })).status, 404, 'the deleted application is gone');++ });++ } finally { await t.close(); }++ done();++})().catch((e) => { console.error(e); process.exit(1); });@@@ -21,8 +21,8 @@ const READ = 'work.api.read';const sam = t.network.addUser('sam');await check('the product declares its own expensive routes in BUDGETS, and only those', () => {-- // Searching the listings and saving a search: the two routes the product gives numbers of their own.-- assert.deepStrictEqual(Object.keys(BUDGETS).sort(), ['work.jobs.search', 'work.saved_searches.write']);++ // Searching the listings, saving a search and writing an application: the product's own numbers.++ assert.deepStrictEqual(Object.keys(BUDGETS).sort(), ['work.applications.write', 'work.jobs.search', 'work.saved_searches.write']);for (const [name, own] of Object.entries(BUDGETS)) {assert.ok(name.startsWith('work.'), name);assert.ok(own.minute > 0 && own.hour >= own.minute, name);@@@ -45,7 +45,7 @@ async function boot(opts = {}) {const { createStore } = require('../../server/db');const testdb = await require('./db').testDb();const store = createStore(testdb.db, { now: opts.now });-- const built = await createApp({ config, store, log, limitsNow: opts.limitsNow, callerLimits: opts.callerLimits === true, accountSend: opts.accountSend });++ const built = await createApp({ config, store, log, limitsNow: opts.limitsNow, callerLimits: opts.callerLimits === true, accountSend: opts.accountSend, actor: opts.actor, assistLimitsNow: opts.assistLimitsNow });await built.ctx.keys.ensure();const server = await new Promise((resolve) => { const s = http.createServer(built.app); s.listen(0, '127.0.0.1', () => resolve(s)); });const base = `http://127.0.0.1:${server.address().port}`;
@@ -28,6 +28,16 @@ OV_OAUTH_CLIENT_SECRET=
@@ -28,6 +28,16 @@ OV_OAUTH_CLIENT_SECRET=
# The audience app, agent and service tokens for this service's API carry.
# The audience app, agent and service tokens for this service's API carry.
# WORK_AUDIENCE=openvibe.work
# WORK_AUDIENCE=openvibe.work
+# The agent pillar: job applications (server/applications.js) and the OpenVibe.Actor client (server/actor.js).
+# WORK_APPLICATIONS_MAX caps how many applications one person may track (default 500). WORK_ACTOR_URL unset means
+# "no agent": POST …/assist answers 503 work.actor_unavailable rather than call anywhere. WORK_ACTOR_PUBLIC_URL is
+# the human-facing origin a task_url points at.
+#WORK_APPLICATIONS_MAX=500
+#WORK_APPLICATIONS_ASSISTS_PER_DAY=10
+#WORK_ACTOR_URL=http://127.0.0.1:4950
+#WORK_ACTOR_PUBLIC_URL=https://openvibe.actor
+#WORK_ACTOR_TIMEOUT_MS=15000
+
# OpenVibe.Events → Work (ADR-033 account export and deletion): the secret signs a delivery (comma-separated for
# OpenVibe.Events → Work (ADR-033 account export and deletion): the secret signs a delivery (comma-separated for
# rotation, 32+ characters each; unset makes POST /internal/events answer 503); the url is where the two subscriptions
# rotation, 32+ characters each; unset makes POST /internal/events answer 503); the url is where the two subscriptions
# are created at boot (off when unset); WORK_EVENTS_SUBSCRIBE=0 turns that off.
# are created at boot (off when unset); WORK_EVENTS_SUBSCRIBE=0 turns that off.
@@ -2,9 +2,9 @@
@@ -2,9 +2,9 @@
> Find work on open job boards.
> Find work on open job boards.
-**Status:** the listings and the search are live (listings gathered from three public job boards, search, saved
-searches, an API and the pages around them). The agent pillar — help preparing and tracking an application — is not
-written yet.
+**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`
**Domain:** `openvibe.work` · **Port:** 4960 · **Service id:** `work` · **Env prefix:** `WORK`
**License:** AGPL-3.0 (same as every OpenVibe service).
**License:** AGPL-3.0 (same as every OpenVibe service).
@@ -12,16 +12,17 @@ written yet.
@@ -12,16 +12,17 @@ written yet.
OpenVibe.Work is the network's job-listings service: it gathers listings from public job boards, keeps each one's
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
provenance and a short plain-text excerpt, and makes them searchable without an account. A signed-in person can keep a
-search and see how many listings have appeared since they last looked. Nothing is republished — every listing links
-back to the board, where the reader reads the full listing and applies. The agent pillar (helping prepare and track an
-application) is not written yet.
+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
## What it does
Job listings gathered from public job boards, each one carrying its provenance — the board it came from and a link
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
back to the original listing — searchable, filterable, and readable without an account. A signed-in person can keep a
-search and see how many listings have appeared since they last looked. Nothing is republished: Work stores a short
-plain-text excerpt and sends the reader to the board for the full listing and to apply.
+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
## What is here
@@ -34,8 +35,10 @@ plain-text excerpt and sends the reader to the board for the full listing and to
@@ -34,8 +35,10 @@ plain-text excerpt and sends the reader to the board for the full listing and to
| Ingest | [server/ingest/](server/ingest/) | The background read (timer, User-Agent, 15 s timeout, upsert by source + the board's id, expiry) |
| Ingest | [server/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](server/jobs/store.js), [migrations/0002_listings.sql](migrations/0002_listings.sql) | Search, facets, the cursor, saved searches, the fetch record |
| 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 |
| Text | [server/jobs/text.js](server/jobs/text.js) | HTML → plain text, and the ~300-character excerpt that is all we keep |
| Text | [server/jobs/text.js](server/jobs/text.js) | HTML → plain text, and the ~300-character excerpt that is all we keep |
-| API | [server/http/api.js](server/http/api.js) | `/api/v1`: jobs, one job, the sources, saved searches; problem+json errors |
-| Pages | [server/http/pages.js](server/http/pages.js), [server/render/](server/render/) | `/`, `/jobs`, `/jobs/:id`, `/saved`, `/sources`, `/docs`, `/updates` — server-rendered, no JavaScript needed |
+| 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 |
+| Actor | [server/actor.js](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](server/http/api.js) | `/api/v1`: jobs, one job, the sources, saved searches, applications; problem+json errors |
+| 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 |
| Discovery | [server/http/discovery.js](server/http/discovery.js) | `robots.txt`, `sitemap.xml` (the newest listings too), `llms.txt`, `llms-full.txt`, JSON-LD |
| Discovery | [server/http/discovery.js](server/http/discovery.js) | `robots.txt`, `sitemap.xml` (the newest listings too), `llms.txt`, `llms-full.txt`, JSON-LD |
| 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 |
| 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 |
| Health | [server/observability.js](server/observability.js) | `/api/health`, a truthful `/api/ready` (the database is required) and Prometheus metrics on loopback only |
| Health | [server/observability.js](server/observability.js) | `/api/health`, a truthful `/api/ready` (the database is required) and Prometheus metrics on loopback only |
@@ -53,6 +56,9 @@ Its own PostgreSQL tables, created by [migrations/](migrations/) and written by
@@ -53,6 +56,9 @@ Its own PostgreSQL tables, created by [migrations/](migrations/) and written by
kept but hidden.
kept but hidden.
- `work_saved_searches` (0002) — a search a person saved, keyed `subject = user:usr_…`, with when they last looked.
- `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_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
- `account_data_events` (0003) — the receipts of the ADR-033 export and deletion deliveries this service applied, so a
redelivered event changes nothing.
redelivered event changes nothing.
@@ -75,6 +81,9 @@ Its own PostgreSQL tables, created by [migrations/](migrations/) and written by
@@ -75,6 +81,9 @@ Its own PostgreSQL tables, created by [migrations/](migrations/) and written by
- **OpenVibe.Events** — the two account subscriptions created at boot and the delivery posted to this service's
- **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`,
loopback `/internal/events` (`WORK_EVENTS_URL` or `EVENTS_URL`, `WORK_EVENTS_SECRET`, `WORK_EVENTS_ENDPOINT`,
`WORK_EVENTS_SUBSCRIBE`).
`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
- **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`).
(`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
- **PostgreSQL** (`DATABASE_URL`, `DATABASE_DIRECT_URL`; an embedded PGlite database in development via
@@ -85,14 +94,17 @@ Its own PostgreSQL tables, created by [migrations/](migrations/) and written by
@@ -85,14 +94,17 @@ Its own PostgreSQL tables, created by [migrations/](migrations/) and written by
## Capabilities
## Capabilities
-The service manifest (`work`, openvibe-contracts) lists none, and [server/http/principal.js](server/http/principal.js)
-declares none either (`CAPABILITIES` is empty): no route names a capability, so an app, agent or service token cannot
-call the person-only saved-search routes (403) and the public reads need no token. Work calls no capability on another
-service.
-
-It does use 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](server/events-consumer.js)), and
-OpenVibe.Network's internal account export/deletion routes, pushed with this service's own client-credentials token
+[server/http/principal.js](server/http/principal.js) declares one capability, `work.application.manage`: an app or
+agent token may manage job applications with it (a person needs no capability for their own). The service manifest
+(`work`, openvibe-contracts) lists it in a later contract release, proposed in
+[docs/contracts-proposal/work.application.manage.md](docs/contracts-proposal/work.application.manage.md); until then an
+app token is refused 403 with `capability.unknown`. 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](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](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](server/identity/account-data.js)).
([server/identity/account-data.js](server/identity/account-data.js)).
## API
## API
@@ -106,14 +118,23 @@ OpenVibe.Network's internal account export/deletion routes, pushed with this ser
@@ -106,14 +118,23 @@ OpenVibe.Network's internal account export/deletion routes, pushed with this ser
| `GET /api/v1/saved-searches` | a person | Your saved searches, each with `new_count` — new since you last looked |
| `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 |
| `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 |
| `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
**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
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. A write made with the session cookie must come from
-`openvibe.work` itself (`request.cross_site` otherwise).
+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`,
**Errors** are RFC 9457 `application/problem+json` with a stable `code` (`work.jobs.not_found`,
-`work.saved_searches.empty`, `work.saved_searches.limit`, `rate_limited`, …).
+`work.saved_searches.empty`, `work.saved_searches.limit`, `work.applications_full`, `work.actor_unavailable`,
+`rate_limited`, …).
## Data sources and their terms
## Data sources and their terms
@@ -146,6 +167,11 @@ is on, `WORK_INGEST_INTERVAL_HOURS` (6), `WORK_INGEST_PAGES` (3), `WORK_INGEST_L
@@ -146,6 +167,11 @@ is on, `WORK_INGEST_INTERVAL_HOURS` (6), `WORK_INGEST_PAGES` (3), `WORK_INGEST_L
(15000), `WORK_RETENTION_DAYS` (30) and the per-source base URLs (`WORK_ARBEITNOW_URL`, `WORK_REMOTIVE_URL`,
(15000), `WORK_RETENTION_DAYS` (30) and the per-source base URLs (`WORK_ARBEITNOW_URL`, `WORK_REMOTIVE_URL`,
`WORK_REMOTEOK_URL`) decide what it does.
`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
## Development
```bash
```bash
@@ -201,14 +227,16 @@ through PostgreSQL and PgBouncer. No test reaches the internet. The main files:
@@ -201,14 +227,16 @@ through PostgreSQL and PgBouncer. No test reaches the internet. The main files:
A person's account at OpenVibe.Network can be exported and deleted, and every service holding their rows answers its
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`
part (ADR-033). Work receives `network.account.export_requested` and `network.account.deleted` at `POST /internal/events`
-(loopback only) — the one table is mapped in [server/identity/account-data.js](server/identity/account-data.js), and the
-boot-time subscriptions are created by [server/events-consumer.js](server/events-consumer.js):
+(loopback only) — the person's tables are mapped in [server/identity/account-data.js](server/identity/account-data.js),
+and the boot-time subscriptions are created by [server/events-consumer.js](server/events-consumer.js):
-- **Exported:** the searches a person saved (`saved_searches.json`), pushed to
+- **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
`POST /internal/account-exports/:id/parts` with this service's own token. Nothing here is a secret — Work stores no
token, key or credential.
token, key or credential.
-- **Erased:** a saved search is the person's own and nothing anyone else's page hangs under it, so it is deleted whole
- and nothing is kept. Work then confirms with `POST /internal/account-deletions/:id/confirmations` and the counts.
+- **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.
- **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
The listings (`work_listings`) and the per-source fetch record (`work_source_fetches`) hold no person's rows: neither
@@ -232,9 +260,10 @@ Reporting a vulnerability: [SECURITY.md](SECURITY.md).
@@ -232,9 +260,10 @@ Reporting a vulnerability: [SECURITY.md](SECURITY.md).
## Not yet
## Not yet
-- **The agent**: drafting, tailoring and tracking an application, with the person's approval at each step.
-- **Alerts**: being told when a listing that fits appears — that comes through OpenVibe.Watch with the agent pillar.
- Today a saved search is a search you do not have to type again, plus a count of what is new.
+- **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.
+- **Alerts**: being told when a listing that fits appears — that comes through OpenVibe.Watch. Today a saved search is
+ a search you do not have to type again, plus a count of what is new.
- More boards, and a per-source schedule finer than "the same six hours for everyone".
- More boards, and a per-source schedule finer than "the same six hours for everyone".
---
---
@@ -242,7 +271,7 @@ Reporting a vulnerability: [SECURITY.md](SECURITY.md).
@@ -242,7 +271,7 @@ Reporting a vulnerability: [SECURITY.md](SECURITY.md).
Part of the [OpenVibe network](https://openvibe.network). Built in the open by [OpenVibers](https://github.com/OpenVibers).
Part of the [OpenVibe network](https://openvibe.network). Built in the open by [OpenVibers](https://github.com/OpenVibers).
<!-- versions:start -->
<!-- versions:start -->
-- openvibe-contracts: v0.143.0
+- openvibe-contracts: v0.147.0
- openvibe-sdk: v0.44.0
- openvibe-sdk: v0.44.0
- openvibe-shared: v4.0.2
- openvibe-shared: v4.0.2
- openvibe-publishing: v1.4.0
- openvibe-publishing: v1.4.0
@@ -7,11 +7,11 @@
@@ -7,11 +7,11 @@
"runtime": "node 22, express 4, PostgreSQL 18 (openvibe-sdk/db, async) + Valkey 9",
"runtime": "node 22, express 4, PostgreSQL 18 (openvibe-sdk/db, async) + Valkey 9",
"port": "4960",
"port": "4960",
"domain": "openvibe.work",
"domain": "openvibe.work",
- "contracts": "openvibe-contracts v0.143.0",
+ "contracts": "openvibe-contracts v0.147.0",
"sdk": "openvibe-sdk v0.44.0",
"sdk": "openvibe-sdk v0.44.0",
"shared": "openvibe-shared v4.0.2",
"shared": "openvibe-shared v4.0.2",
"packages": [
"packages": [
- "openvibe-contracts v0.143.0",
+ "openvibe-contracts v0.147.0",
"openvibe-sdk v0.44.0",
"openvibe-sdk v0.44.0",
"openvibe-shared v4.0.2",
"openvibe-shared v4.0.2",
"openvibe-publishing v1.4.0"
"openvibe-publishing v1.4.0"
@@ -54,5 +54,5 @@
@@ -54,5 +54,5 @@
"deploy files"
"deploy files"
],
],
"deployment": "openvibe-ovh: unit openvibe-work.service on 127.0.0.1:4960 (git checkout /opt/openvibe.work, env /etc/openvibe/work.env, database ov_work), deployed with `sudo ovhost deploy work`; public at https://openvibe.work since 2026-10-08.",
"deployment": "openvibe-ovh: unit openvibe-work.service on 127.0.0.1:4960 (git checkout /opt/openvibe.work, env /etc/openvibe/work.env, database ov_work), deployed with `sudo ovhost deploy work`; public at https://openvibe.work since 2026-10-08.",
- "updated": "2026-10-11"
+ "updated": "2026-10-12"
}
}
new file mode 100644
new file mode 100644
@@ -0,0 +1,204 @@
@@ -0,0 +1,204 @@
+# Proposal: `work.application.manage`
+
+Capability `work.application.manage`, owned by `work`, audience `openvibe.work`. A person may manage their own job
+applications without a capability; an app or agent token needs this one. Asking the person's OpenVibe.Actor agent
+(`POST …/assist`) is always the person themselves and takes no capability.
+
+The routes below are the agent pillar of OpenVibe.Work. A contract release that ships these schemas in
+openvibe-contracts is done separately; this document is the proposal it is released from.
+
+## Resource: work.application@1
+
+The application as `GET/POST/PATCH /api/v1/me/applications(/:id)` answer it.
+
+```json
+{
+ "$schema": "https://json-schema.org/draft/2020-12/schema",
+ "$id": "https://openvibe.network/contracts/work/application.v1.json",
+ "title": "WorkApplication",
+ "type": "object",
+ "additionalProperties": false,
+ "required": ["id", "subject_id", "title", "company", "url", "status", "created_at", "updated_at"],
+ "properties": {
+ "id": { "type": "string", "pattern": "^wap_[0-9A-HJKMNP-TV-Z]{26}$" },
+ "subject_id": { "type": "string", "description": "user:usr_…" },
+ "listing_id": { "type": ["string", "null"], "description": "job_<base32> when tracked from a listing" },
+ "title": { "type": "string", "maxLength": 500 },
+ "company": { "type": "string", "maxLength": 200 },
+ "url": { "type": "string", "format": "uri", "maxLength": 2000 },
+ "status": { "enum": ["saved", "applied", "interviewing", "offer", "rejected", "withdrawn"] },
+ "notes": { "type": "string", "maxLength": 5000 },
+ "applied_at": { "type": ["string", "null"], "format": "date-time" },
+ "next_step": { "type": "string", "maxLength": 200 },
+ "next_step_at": { "type": ["string", "null"], "format": "date-time" },
+ "actor_task_id": { "type": ["string", "null"] },
+ "created_at": { "type": "string", "format": "date-time" },
+ "updated_at": { "type": "string", "format": "date-time" }
+ }
+}
+```
+
+## Resource: work.application-event@1
+
+One append-only status change.
+
+```json
+{
+ "$schema": "https://json-schema.org/draft/2020-12/schema",
+ "$id": "https://openvibe.network/contracts/work/application-event.v1.json",
+ "title": "WorkApplicationEvent",
+ "type": "object",
+ "additionalProperties": false,
+ "required": ["at", "from_status", "to_status"],
+ "properties": {
+ "at": { "type": "string", "format": "date-time" },
+ "from_status": { "enum": ["saved", "applied", "interviewing", "offer", "rejected", "withdrawn"] },
+ "to_status": { "enum": ["saved", "applied", "interviewing", "offer", "rejected", "withdrawn"] }
+ }
+}
+```
+
+## GET /api/v1/me/applications
+
+Filter by `status`, newest first, cursor pagination.
+
+```json
+{
+ "$schema": "https://json-schema.org/draft/2020-12/schema",
+ "title": "WorkApplicationsListResult",
+ "type": "object",
+ "additionalProperties": false,
+ "required": ["applications", "next", "count"],
+ "properties": {
+ "applications": { "type": "array", "items": { "$ref": "work.application@1" } },
+ "next": { "type": ["string", "null"] },
+ "count": { "type": "integer", "minimum": 0 }
+ }
+}
+```
+
+## POST /api/v1/me/applications
+
+One of `listing_id`, or `title` + `company` + `url`. A second POST for the same listing answers 200 with the existing
+application.
+
+```json
+{
+ "$schema": "https://json-schema.org/draft/2020-12/schema",
+ "title": "WorkApplicationCreateRequest",
+ "type": "object",
+ "additionalProperties": false,
+ "oneOf": [
+ { "required": ["listing_id"], "properties": { "listing_id": { "type": "string" } } },
+ {
+ "required": ["title", "company", "url"],
+ "properties": {
+ "title": { "type": "string", "minLength": 1, "maxLength": 500 },
+ "company": { "type": "string", "minLength": 1, "maxLength": 200 },
+ "url": { "type": "string", "format": "uri", "maxLength": 2000 }
+ }
+ }
+ ]
+}
+```
+
+## PATCH /api/v1/me/applications/:id
+
+Any subset of the five editable fields; a status change appends an event.
+
+```json
+{
+ "$schema": "https://json-schema.org/draft/2020-12/schema",
+ "title": "WorkApplicationPatchRequest",
+ "type": "object",
+ "additionalProperties": false,
+ "minProperties": 1,
+ "properties": {
+ "status": { "enum": ["saved", "applied", "interviewing", "offer", "rejected", "withdrawn"] },
+ "notes": { "type": "string", "maxLength": 5000 },
+ "applied_at": { "type": ["string", "null"], "format": "date-time" },
+ "next_step": { "type": "string", "maxLength": 200 },
+ "next_step_at": { "type": ["string", "null"], "format": "date-time" }
+ }
+}
+```
+
+## POST /api/v1/me/applications/:id/assist
+
+An optional `ask` (defaults to the standard preparation prompt), answered by `{ task_id, task_url }`.
+
+```json
+{
+ "$schema": "https://json-schema.org/draft/2020-12/schema",
+ "title": "WorkApplicationAssistRequest",
+ "type": "object",
+ "additionalProperties": false,
+ "properties": {
+ "ask": { "type": "string", "maxLength": 1000 }
+ }
+}
+```
+
+```json
+{
+ "$schema": "https://json-schema.org/draft/2020-12/schema",
+ "title": "WorkApplicationAssistResult",
+ "type": "object",
+ "additionalProperties": false,
+ "required": ["task_id", "task_url"],
+ "properties": {
+ "task_id": { "type": "string", "pattern": "^tsk_[0-9A-HJKMNP-TV-Z]{26}$" },
+ "task_url": { "type": "string", "format": "uri" }
+ }
+}
+```
+
+## Example bodies
+
+### POST create — valid (from a listing)
+
+```json
+{ "listing_id": "job_01JZ0000000000000000000ABC" }
+```
+
+### POST create — valid (by hand)
+
+```json
+{ "title": "Backend Engineer", "company": "Acme", "url": "https://acme.example/jobs/12" }
+```
+
+### POST create — invalid (both modes)
+
+```json
+{ "listing_id": "job_01JZ0000000000000000000ABC", "title": "Backend Engineer" }
+```
+
+### PATCH — valid (status change)
+
+```json
+{ "status": "applied" }
+```
+
+### PATCH — invalid (unknown field)
+
+```json
+{ "status": "applied", "salary": 120000 }
+```
+
+### PATCH — invalid (bad status)
+
+```json
+{ "status": "hired" }
+```
+
+### assist — valid
+
+```json
+{ "ask": "Draft a cover note that emphasises my systems experience." }
+```
+
+### assist — invalid (too long)
+
+```json
+{ "ask": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" }
+```
new file mode 100644
new file mode 100644
@@ -0,0 +1,35 @@
@@ -0,0 +1,35 @@
+-- phase: expand
+-- OpenVibe.Work: the job applications a signed-in person tracks (server/applications.js), and the append-only
+-- history of their status changes. An application is either tracked from a Work listing (listing_id, from which its
+-- title, company and url are copied) or given by hand. Nothing here is ever a credential, a key or a token.
+
+CREATE TABLE work_applications (
+ id text COLLATE "C" PRIMARY KEY, -- wap_<ULID>
+ subject_id text COLLATE "C" NOT NULL, -- user:usr_… — the person tracking it
+ listing_id text COLLATE "C", -- job_<base32> when it came from a listing
+ title text NOT NULL,
+ company text NOT NULL,
+ url text NOT NULL, -- the listing's own URL, or the one given by hand
+ status text COLLATE "C" NOT NULL DEFAULT 'saved', -- saved|applied|interviewing|offer|rejected|withdrawn
+ notes text NOT NULL DEFAULT '',
+ applied_at text COLLATE "C", -- ISO 8601 UTC
+ next_step_at text COLLATE "C", -- ISO 8601 UTC
+ next_step text NOT NULL DEFAULT '',
+ actor_task_id text COLLATE "C", -- the last OpenVibe.Actor task this application asked for
+ assists integer NOT NULL DEFAULT 0, -- how many times the person asked their agent (idempotency n)
+ created_at text COLLATE "C" NOT NULL,
+ updated_at text COLLATE "C" NOT NULL,
+ CONSTRAINT work_applications_notes_len CHECK (char_length(notes) <= 5000),
+ CONSTRAINT work_applications_next_step_len CHECK (char_length(next_step) <= 200)
+);
+-- The API lists an application's rows newest-first and pages on this.
+CREATE INDEX work_applications_by_subject ON work_applications (subject_id, updated_at);
+
+-- Append-only: one row per status change, never updated. When an application is erased its events go with it.
+CREATE TABLE work_application_events (
+ application_id text COLLATE "C" NOT NULL REFERENCES work_applications (id) ON DELETE CASCADE,
+ at text COLLATE "C" NOT NULL, -- ISO 8601 UTC
+ from_status text COLLATE "C" NOT NULL,
+ to_status text COLLATE "C" NOT NULL
+);
+CREATE INDEX work_application_events_by_app ON work_application_events (application_id, at);
@@ -15,7 +15,7 @@
@@ -15,7 +15,7 @@
"express-rate-limit": "^7.5.0",
"express-rate-limit": "^7.5.0",
"helmet": "^8.1.0",
"helmet": "^8.1.0",
"iovalkey": "^0.4.0",
"iovalkey": "^0.4.0",
- "openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.143.0",
+ "openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.147.0",
"openvibe-publishing": "https://codeload.github.com/OpenVibers/OpenVibe.Publishing/tar.gz/refs/tags/v1.4.0",
"openvibe-publishing": "https://codeload.github.com/OpenVibers/OpenVibe.Publishing/tar.gz/refs/tags/v1.4.0",
"openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.44.0",
"openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.44.0",
"openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v4.0.2",
"openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v4.0.2",
@@ -872,9 +872,9 @@
@@ -872,9 +872,9 @@
}
}
},
},
"node_modules/openvibe-contracts": {
"node_modules/openvibe-contracts": {
- "version": "0.143.0",
- "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.143.0",
- "integrity": "sha512-Cdbf+9RoPAUBibpM2zXQvtsiKMugb55dKubTt8pFZvAmxsLtGvX+zaC++yL1vOSub8FX3+GgqcGtoJS6tklrUg==",
+ "version": "0.147.0",
+ "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.147.0",
+ "integrity": "sha512-7JC5c7WWNJWUDGQamrQZy7bsZhlhUfXXKhI2lCEG3PW99B73vks42tKYnq56j+YYRItzotYVDFt4hjzqUS3tPA==",
"license": "AGPL-3.0-only",
"license": "AGPL-3.0-only",
"dependencies": {
"dependencies": {
"ajv": "^8.17.1",
"ajv": "^8.17.1",
@@ -23,7 +23,7 @@
@@ -23,7 +23,7 @@
"express-rate-limit": "^7.5.0",
"express-rate-limit": "^7.5.0",
"helmet": "^8.1.0",
"helmet": "^8.1.0",
"iovalkey": "^0.4.0",
"iovalkey": "^0.4.0",
- "openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.143.0",
+ "openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.147.0",
"openvibe-publishing": "https://codeload.github.com/OpenVibers/OpenVibe.Publishing/tar.gz/refs/tags/v1.4.0",
"openvibe-publishing": "https://codeload.github.com/OpenVibers/OpenVibe.Publishing/tar.gz/refs/tags/v1.4.0",
"openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.44.0",
"openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.44.0",
"openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v4.0.2",
"openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v4.0.2",
@@ -91,3 +91,5 @@ button.sc-btn.sc-primary { border: 0; }
@@ -91,3 +91,5 @@ button.sc-btn.sc-primary { border: 0; }
.saved-form input, .saved-form select { font: inherit; padding: 6px 8px; border-radius: 8px; border: 1px solid var(--border, #1f2d47); background: var(--bg-secondary, #0f1729); color: inherit; }
.saved-form input, .saved-form select { font: inherit; padding: 6px 8px; border-radius: 8px; border: 1px solid var(--border, #1f2d47); background: var(--bg-secondary, #0f1729); color: inherit; }
.source-block { border-top: 1px solid var(--border, #1f2d47); padding-top: 12px; margin-top: 18px; }
.source-block { border-top: 1px solid var(--border, #1f2d47); padding-top: 12px; margin-top: 18px; }
.source-links { margin: 6px 0 12px; }
.source-links { margin: 6px 0 12px; }
+
+.job-track { margin: 8px 0; }
new file mode 100644
new file mode 100644
@@ -0,0 +1,36 @@
@@ -0,0 +1,36 @@
+/* OpenVibe.Work — the applications pages only (/applications, /applications/:id), so app.css stays in its budget. */
+.app-summary { display: flex; flex-wrap: wrap; gap: 8px; list-style: none; padding: 0; margin: 12px 0 4px; }
+.app-summary li a, .app-summary li span { display: inline-flex; gap: 6px; align-items: baseline; padding: 6px 12px; border-radius: 999px; border: 1px solid var(--border, #1f2d47); background: var(--bg-card, #131c2e); color: var(--text-primary, #e6edf7); text-decoration: none; font-size: .9rem; }
+.app-summary li a:hover { border-color: var(--accent, #3b82f6); }
+.app-summary strong { font-size: 1rem; }
+.app-summary .is-empty { color: var(--text-muted, #7386a3); }
+.app-group { margin-top: 22px; }
+.app-group h2 { margin: 0 0 8px; font-size: 1.1rem; }
+.app-list { list-style: none; margin: 0; padding: 0; display: grid; gap: 10px; grid-template-columns: repeat(auto-fill, minmax(300px, 1fr)); }
+.app-item { border: 1px solid var(--border, #1f2d47); border-radius: 12px; background: var(--bg-card, #131c2e); padding: 12px 14px; display: flex; flex-direction: column; gap: 4px; }
+.app-item p { margin: 0; }
+.app-title { display: flex; justify-content: space-between; align-items: baseline; gap: 8px; }
+.app-title a { color: var(--text-primary, #e6edf7); text-decoration: none; font-weight: 600; overflow-wrap: anywhere; }
+.app-title a:hover { text-decoration: underline; text-underline-offset: 3px; }
+.app-co { color: var(--text-secondary, #96a7c2); }
+.app-next { font-size: .9rem; }
+.app-head { display: flex; flex-wrap: wrap; align-items: center; gap: 6px 12px; }
+.app-head h1 { margin-bottom: .2rem; }
+.app-company { margin: 0 0 14px; font-size: 1.05rem; color: var(--text-secondary, #96a7c2); overflow-wrap: anywhere; }
+.app-detail { display: grid; gap: 18px; }
+@media (min-width: 880px) { .app-detail { grid-template-columns: minmax(0, 3fr) minmax(280px, 2fr); align-items: start; } }
+.app-panel { border: 1px solid var(--border, #1f2d47); border-radius: 12px; background: var(--bg-card, #131c2e); padding: 14px 16px; }
+.app-panel h2 { margin: 0 0 10px; font-size: 1rem; }
+.app-panel + .app-panel { margin-top: 14px; }
+.app-agent { background: linear-gradient(160deg, color-mix(in srgb, var(--accent, #3b82f6) 10%, var(--bg-card, #131c2e)), var(--bg-card, #131c2e) 65%); }
+.app-history { list-style: none; margin: 0; padding: 0; }
+.app-history li { padding: 6px 0; border-bottom: 1px solid var(--border, #1f2d47); font-size: .92rem; }
+.app-history li:last-child { border-bottom: 0; }
+.app-form label { display: flex; flex-direction: column; gap: 4px; margin: 0 0 12px; font-size: .88rem; color: var(--text-secondary, #96a7c2); }
+.app-form input, .app-form select, .app-form textarea { font: inherit; padding: 8px 10px; border-radius: 8px; border: 1px solid var(--border-light, #2c3d5c); background: var(--bg-secondary, #0f1729); color: inherit; max-width: 100%; }
+.app-form textarea { resize: vertical; }
+.app-row { display: grid; gap: 0 12px; }
+@media (min-width: 560px) { .app-row { grid-template-columns: 1fr 1fr; } }
+.app-add { margin-top: 26px; max-width: 720px; }
+.app-danger { margin-top: 14px; }
+.app-danger button { background: none; border: 1px solid var(--error, #ef4444); color: var(--error, #ef4444); border-radius: 8px; padding: 6px 12px; }
new file mode 100644
new file mode 100644
@@ -0,0 +1,44 @@
@@ -0,0 +1,44 @@
+'use strict';
+
+/**
+ * Work's client for OpenVibe.Actor: POST /api/v1/tasks with Work's own service token (audience openvibe.actor,
+ * capability actor.task.create) and the person in X-OV-Subject, exactly as OpenVibe.Watch does. The task's own
+ * Idempotency-Key is passed in the body, so the request is not retried here: a refusal or outage is the route's to
+ * answer (server/http/api.js maps it to 503 work.actor_unavailable, or passes through Actor's 402/429).
+ */
+const { createClient } = require('openvibe-sdk/core');
+const { createServiceTokenClient } = require('openvibe-sdk/auth');
+
+function create({ config, fetchImpl = globalThis.fetch, log = console } = {}) {
+ // Off without Actor's URL or without Work's own OAuth client (development has neither secret nor service token).
+ const available = Boolean(config && config.actor && config.actor.url && config.oauth && config.oauth.clientId && config.oauth.clientSecret);
+ if (!available) {
+ return {
+ available: false,
+ async createTask() { throw new Error('OpenVibe.Actor is not configured'); },
+ };
+ }
+ const client = createClient({
+ fetch: fetchImpl,
+ baseUrls: { actor: config.actor.url },
+ tokenProvider: createServiceTokenClient({ network: config.networkInternalUrl, clientId: config.oauth.clientId, clientSecret: config.oauth.clientSecret, fetch: fetchImpl }),
+ retries: 0,
+ timeoutMs: config.actor.timeoutMs || 15000,
+ onWarning: (msg) => log.warn(`[OpenVibe.Work] ${msg}`),
+ });
+ return {
+ available: true,
+ /** subjectId is usr_…; returns { id, state } on success, or throws an OpenVibeError. */
+ async createTask({ subjectId, task, idempotencyKey }) {
+ return client.json({
+ service: 'actor', baseUrl: config.actor.url, audience: 'openvibe.actor',
+ method: 'POST', path: '/api/v1/tasks',
+ headers: { 'X-OV-Subject': subjectId },
+ json: { task, idempotency_key: idempotencyKey },
+ retries: 0, idempotencyKey: false,
+ });
+ },
+ };
+}
+
+module.exports = { create };
@@ -34,6 +34,8 @@ const { createIngest } = require('./ingest');
@@ -34,6 +34,8 @@ const { createIngest } = require('./ingest');
const { createSearchIndex } = require('./search-index');
const { createSearchIndex } = require('./search-index');
const accountDataLib = require('./identity/account-data');
const accountDataLib = require('./identity/account-data');
const { createNetworkSender } = require('openvibe-sdk/account-data');
const { createNetworkSender } = require('openvibe-sdk/account-data');
+const { createActorLimiter } = require('openvibe-sdk/limits');
+const actorLib = require('./actor');
const { assetVersion, send } = require('./render/layout');
const { assetVersion, send } = require('./render/layout');
const { html } = require('./render/html');
const { html } = require('./render/html');
@@ -42,7 +44,8 @@ const VERSION = require('../package.json').version;
@@ -42,7 +44,8 @@ const VERSION = require('../package.json').version;
/**
/**
* opts: config, store, now (clock), fetchImpl (Network), log, limitsNow, callerLimits (false: count nobody,
* opts: config, store, now (clock), fetchImpl (Network), log, limitsNow, callerLimits (false: count nobody,
- * tests only), valkey, accountSend (a stand-in for Network's internal routes; tests only)
+ * tests only), valkey, accountSend (a stand-in for Network's internal routes; tests only), actor (a stand-in
+ * Actor client; tests only), assistLimitsNow (the assist rate-limit clock; tests only)
*/
*/
async function createApp(opts = {}) {
async function createApp(opts = {}) {
const config = opts.config || configLib.load();
const config = opts.config || configLib.load();
@@ -73,6 +76,16 @@ async function createApp(opts = {}) {
@@ -73,6 +76,16 @@ async function createApp(opts = {}) {
ctx.search = opts.search || createSearchIndex({ config, s, log });
ctx.search = opts.search || createSearchIndex({ config, s, log });
ctx.ingest = createIngest({ config, s, fetchImpl, log, afterRun: () => ctx.search.afterIngest() });
ctx.ingest = createIngest({ config, s, fetchImpl, log, afterRun: () => ctx.search.afterIngest() });
+ // The person's OpenVibe.Actor agent (server/actor.js) and the per-person daily cap on asking it. A test injects
+ // a stand-in client and clock through opts.actor / opts.assistLimitsNow, so the assist route never calls out.
+ ctx.actor = opts.actor || actorLib.create({ config, fetchImpl, log });
+ ctx.assistLimiter = opts.assistLimiter || createActorLimiter({
+ limits: { day: config.applications.assistsPerDay },
+ actor: (req) => (req.principal && req.principal.requester) || null,
+ now: opts.assistLimitsNow || (() => Date.now()),
+ log,
+ });
+
// Account export and deletion (ADR-033, ./identity/account-data.js): the one table that holds a person's rows.
// Account export and deletion (ADR-033, ./identity/account-data.js): the one table that holds a person's rows.
// The sender posts to Network's internal export/deletion routes with this service's own client-credentials token;
// The sender posts to Network's internal export/deletion routes with this service's own client-credentials token;
// a test injects a stand-in through opts.accountSend.
// a test injects a stand-in through opts.accountSend.
new file mode 100644
new file mode 100644
@@ -0,0 +1,233 @@
@@ -0,0 +1,233 @@
+'use strict';
+
+/**
+ * The job applications a signed-in person tracks (migrations/0005_applications.sql) and the append-only history of
+ * their status changes. Every function takes the store (server/db.js createStore) first, as everywhere else in this
+ * service. An application belongs to the subject that created it (`user:usr_…` for a person; an app or agent acting
+ * with work.application.manage writes its own `app:…` / `agent:…` subject), so nobody reads anybody else's rows.
+ */
+
+const { stripHtml } = require('./jobs/text');
+
+const PAGE_SIZE = 30;
+const MAX_PAGE = 100;
+const MAX_APPLICATIONS = 500;
+const MAX_TITLE = 500;
+const MAX_COMPANY = 200;
+const MAX_URL = 2000;
+const MAX_NOTES = 5000;
+const MAX_NEXT_STEP = 200;
+const MAX_ASK = 1000;
+
+const STATUSES = ['saved', 'applied', 'interviewing', 'offer', 'rejected', 'withdrawn'];
+const APPLICATION_ID = /^wap_[0-9A-HJKMNP-TV-Z]{26}$/;
+const DEFAULT_ASK = 'Help me prepare this application: summarise what the role asks for, list what to emphasise, and draft a short cover note.';
+
+const hasValue = (v) => v != null && v !== '';
+const isHttpUrl = (v) => {
+ if (typeof v !== 'string' || !v) return false;
+ try { const u = new URL(v); return u.protocol === 'http:' || u.protocol === 'https:'; } catch { return false; }
+};
+
+/** A row → the application as the API and pages see it. */
+function toWire(row) {
+ return {
+ id: row.id,
+ subject_id: row.subject_id,
+ listing_id: row.listing_id || null,
+ title: row.title,
+ company: row.company,
+ url: row.url,
+ status: row.status,
+ notes: row.notes || '',
+ applied_at: row.applied_at || null,
+ next_step: row.next_step || '',
+ next_step_at: row.next_step_at || null,
+ actor_task_id: row.actor_task_id || null,
+ assists: Number(row.assists) || 0,
+ created_at: row.created_at,
+ updated_at: row.updated_at,
+ };
+}
+
+const toEventWire = (row) => ({ at: row.at, from_status: row.from_status, to_status: row.to_status });
+
+/** An ISO 8601 date-time this service accepts (a null or empty clears the field); undefined means invalid. */
+function parseDate(v) {
+ if (v == null || v === '') return null;
+ if (typeof v !== 'string') return undefined;
+ if (!/^\d{4}-\d{2}-\d{2}T[\d:.]+Z$/.test(v)) return undefined;
+ return Number.isNaN(Date.parse(v)) ? undefined : v;
+}
+
+/** POST /api/v1/me/applications: listing_id, or title + company + url. Unknown fields are refused. */
+function validateCreate(body) {
+ const b = body && typeof body === 'object' ? body : {};
+ for (const k of Object.keys(b)) {
+ if (!['listing_id', 'title', 'company', 'url'].includes(k)) return { error: [400, 'work.applications.unknown_field', `unknown field: ${k}`] };
+ }
+ if (hasValue(b.listing_id)) {
+ if (typeof b.listing_id !== 'string') return { error: [400, 'work.applications.listing_id_invalid', 'listing_id must be a string'] };
+ if (hasValue(b.title) || hasValue(b.company) || hasValue(b.url)) return { error: [400, 'work.applications.conflicting_fields', 'Give listing_id, or title + company + url, not both.'] };
+ return { fields: { listing_id: b.listing_id } };
+ }
+ const title = typeof b.title === 'string' ? b.title.trim() : '';
+ const company = typeof b.company === 'string' ? b.company.trim() : '';
+ if (!title) return { error: [400, 'work.applications.title_required', 'An application needs a title.'] };
+ if (title.length > MAX_TITLE) return { error: [400, 'work.applications.title_too_long', `title is at most ${MAX_TITLE} characters.`] };
+ if (!company) return { error: [400, 'work.applications.company_required', 'An application needs a company.'] };
+ if (company.length > MAX_COMPANY) return { error: [400, 'work.applications.company_too_long', `company is at most ${MAX_COMPANY} characters.`] };
+ if (typeof b.url !== 'string' || !isHttpUrl(b.url)) return { error: [400, 'work.applications.url_invalid', 'url must be an http(s) URL.'] };
+ if (b.url.length > MAX_URL) return { error: [400, 'work.applications.url_too_long', `url is at most ${MAX_URL} characters.`] };
+ return { fields: { title, company, url: b.url } };
+}
+
+/** PATCH /api/v1/me/applications/:id: the five editable fields, nothing else. */
+function validatePatch(body) {
+ const b = body && typeof body === 'object' ? body : {};
+ for (const k of Object.keys(b)) {
+ if (!['status', 'notes', 'applied_at', 'next_step', 'next_step_at'].includes(k)) return { error: [400, 'work.applications.unknown_field', `unknown field: ${k}`] };
+ }
+ const out = {};
+ if ('status' in b) {
+ if (typeof b.status !== 'string' || !STATUSES.includes(b.status)) return { error: [400, 'work.applications.status_invalid', `status is one of ${STATUSES.join(', ')}.`] };
+ out.status = b.status;
+ }
+ if ('notes' in b) {
+ if (typeof b.notes !== 'string') return { error: [400, 'work.applications.notes_invalid', 'notes must be a string.'] };
+ if (b.notes.length > MAX_NOTES) return { error: [400, 'work.applications.notes_too_long', `notes is at most ${MAX_NOTES} characters.`] };
+ out.notes = b.notes;
+ }
+ if ('next_step' in b) {
+ if (typeof b.next_step !== 'string') return { error: [400, 'work.applications.next_step_invalid', 'next_step must be a string.'] };
+ if (b.next_step.length > MAX_NEXT_STEP) return { error: [400, 'work.applications.next_step_too_long', `next_step is at most ${MAX_NEXT_STEP} characters.`] };
+ out.next_step = b.next_step;
+ }
+ if ('applied_at' in b) {
+ const d = parseDate(b.applied_at);
+ if (d === undefined) return { error: [400, 'work.applications.applied_at_invalid', 'applied_at is an ISO 8601 date-time, or null.'] };
+ out.applied_at = d;
+ }
+ if ('next_step_at' in b) {
+ const d = parseDate(b.next_step_at);
+ if (d === undefined) return { error: [400, 'work.applications.next_step_at_invalid', 'next_step_at is an ISO 8601 date-time, or null.'] };
+ out.next_step_at = d;
+ }
+ return { fields: out };
+}
+
+/** POST /api/v1/me/applications/:id/assist: an optional `ask`. */
+function validateAsk(body) {
+ const b = body && typeof body === 'object' ? body : {};
+ for (const k of Object.keys(b)) {
+ if (k !== 'ask') return { error: [400, 'work.applications.unknown_field', `unknown field: ${k}`] };
+ }
+ if (!hasValue(b.ask)) return { ask: DEFAULT_ASK };
+ if (typeof b.ask !== 'string') return { error: [400, 'work.applications.ask_invalid', 'ask must be a string.'] };
+ if (b.ask.length > MAX_ASK) return { error: [400, 'work.applications.ask_too_long', `ask is at most ${MAX_ASK} characters.`] };
+ return { ask: b.ask };
+}
+
+/** Resolve a create's fields: a listing copies its title/company/url; a by-hand one is already complete. */
+async function resolveCreateFields(s, fields) {
+ if (!fields.listing_id) return { fields };
+ const listing = await s.db.maybe('SELECT * FROM work_listings WHERE id = $1 AND expired = false', [fields.listing_id]);
+ if (!listing) return { error: 'listing_not_found' };
+ return { fields: { listing_id: listing.id, title: listing.title, company: listing.company, url: listing.url } };
+}
+
+const listAllApplications = (s, subject) => s.db.many('SELECT * FROM work_applications WHERE subject_id = $1 ORDER BY updated_at DESC, id DESC', [subject]);
+
+/** One page of a person's applications, newest first, cursor on (updated_at, id). */
+async function listApplications(s, subject, { status = null, cursor = null, limit = PAGE_SIZE } = {}) {
+ const n = Math.min(MAX_PAGE, Math.max(1, Number(limit) || PAGE_SIZE));
+ const args = [subject];
+ const where = ['subject_id = $1'];
+ if (status) { args.push(status); where.push(`status = $${args.length}`); }
+ const at = cursor && cursor.updated_at && cursor.id ? cursor : null;
+ if (at) { args.push(at.updated_at, at.id); where.push(`(updated_at, id) < ($${args.length - 1}, $${args.length})`); }
+ const rows = await s.db.many(`SELECT * FROM work_applications WHERE ${where.join(' AND ')} ORDER BY updated_at DESC, id DESC LIMIT ${n + 1}`, args);
+ const page = rows.slice(0, n);
+ const last = page[page.length - 1];
+ return { rows: page, next: rows.length > n && last ? { updated_at: last.updated_at, id: last.id } : null };
+}
+
+const getApplication = (s, subject, id) => (APPLICATION_ID.test(String(id)) ? s.db.maybe('SELECT * FROM work_applications WHERE id = $1 AND subject_id = $2', [id, subject]) : Promise.resolve(null));
+
+const applicationByListing = (s, subject, listingId) => s.db.maybe('SELECT * FROM work_applications WHERE subject_id = $1 AND listing_id = $2', [subject, listingId]);
+
+/** The counts the /applications page groups by. */
+async function countsByStatus(s, subject) {
+ const rows = await s.db.many('SELECT status, count(*) AS n FROM work_applications WHERE subject_id = $1 GROUP BY status', [subject]);
+ const out = Object.fromEntries(STATUSES.map((st) => [st, 0]));
+ for (const r of rows) out[r.status] = Number(r.n);
+ return out;
+}
+
+async function createApplication(s, subject, fields, { id, now, max = MAX_APPLICATIONS } = {}) {
+ const count = Number(await s.db.value('SELECT count(*) FROM work_applications WHERE subject_id = $1', [subject]));
+ if (count >= max) return { error: 'full' };
+ const row = await s.db.maybe(
+ `INSERT INTO work_applications (id, subject_id, listing_id, title, company, url, status, notes, applied_at, next_step_at, next_step, actor_task_id, assists, created_at, updated_at)
+ VALUES ($1, $2, $3, $4, $5, $6, 'saved', '', NULL, NULL, '', NULL, 0, $7, $7) RETURNING *`,
+ [id, subject, fields.listing_id || null, fields.title, fields.company, fields.url, now]);
+ return { row };
+}
+
+async function updateApplication(s, subject, id, fields, { now }) {
+ const current = await getApplication(s, subject, id);
+ if (!current) return { error: 'not_found' };
+ const next = { ...current };
+ const statusChanged = Boolean(fields.status && fields.status !== current.status);
+ if (fields.status) next.status = fields.status;
+ if (fields.notes !== undefined) next.notes = fields.notes;
+ if (fields.next_step !== undefined) next.next_step = fields.next_step;
+ if (fields.applied_at !== undefined) next.applied_at = fields.applied_at;
+ if (fields.next_step_at !== undefined) next.next_step_at = fields.next_step_at;
+ // Setting the status to applied without an applied_at stamps the moment it happened.
+ if (fields.status === 'applied' && !('applied_at' in fields) && !current.applied_at) next.applied_at = now;
+ next.updated_at = now;
+ await s.db.query(
+ `UPDATE work_applications SET status = $3, notes = $4, applied_at = $5, next_step = $6, next_step_at = $7, updated_at = $8
+ WHERE id = $1 AND subject_id = $2`,
+ [id, subject, next.status, next.notes, next.applied_at, next.next_step, next.next_step_at, next.updated_at]);
+ if (statusChanged) {
+ await s.db.query('INSERT INTO work_application_events (application_id, at, from_status, to_status) VALUES ($1, $2, $3, $4)', [id, now, current.status, fields.status]);
+ }
+ return { row: next };
+}
+
+const deleteApplication = (s, subject, id) => (APPLICATION_ID.test(String(id)) ? s.db.exec('DELETE FROM work_applications WHERE id = $1 AND subject_id = $2', [id, subject]) : Promise.resolve(0));
+
+const listEvents = (s, applicationId) => s.db.many('SELECT * FROM work_application_events WHERE application_id = $1 ORDER BY at DESC', [applicationId]);
+
+const recordAssist = (s, subject, id, taskId, now) => s.db.query(
+ 'UPDATE work_applications SET actor_task_id = $3, assists = assists + 1, updated_at = $4 WHERE id = $1 AND subject_id = $2',
+ [id, subject, taskId, now]);
+
+/** The task text the person's agent sees: the application, the listing's excerpt when it has one, notes, then the ask. */
+function buildTaskText(app, listing, ask) {
+ const lines = [`Title: ${app.title}`, `Company: ${app.company}`, `URL: ${app.url}`];
+ if (listing && listing.excerpt) lines.push(`Excerpt: ${stripHtml(listing.excerpt)}`);
+ if (app.notes) lines.push(`Notes: ${app.notes}`);
+ lines.push(`Ask: ${ask}`);
+ return lines.join('\n');
+}
+
+const encodeCursor = (c) => (c ? Buffer.from(JSON.stringify([c.updated_at, c.id]), 'utf8').toString('base64url') : null);
+function decodeCursor(v) {
+ if (typeof v !== 'string' || !v || v.length > 200) return null;
+ try {
+ const [at, id] = JSON.parse(Buffer.from(v, 'base64url').toString('utf8'));
+ if (typeof at !== 'string' || !/^\d{4}-\d{2}-\d{2}T[\d:.]+Z$/.test(at) || !APPLICATION_ID.test(String(id))) return null;
+ return { updated_at: at, id: String(id) };
+ } catch { return null; }
+}
+
+module.exports = {
+ toWire, toEventWire, validateCreate, validatePatch, validateAsk, resolveCreateFields,
+ listAllApplications, listApplications, getApplication, applicationByListing, countsByStatus,
+ createApplication, updateApplication, deleteApplication, listEvents, recordAssist, buildTaskText,
+ encodeCursor, decodeCursor,
+ STATUSES, APPLICATION_ID, MAX_APPLICATIONS, MAX_NOTES, MAX_NEXT_STEP, MAX_ASK, DEFAULT_ASK, PAGE_SIZE, MAX_PAGE,
+};
@@ -64,6 +64,19 @@ function load(env = process.env) {
@@ -64,6 +64,19 @@ function load(env = process.env) {
endpoint: env.WORK_EVENTS_ENDPOINT || '',
endpoint: env.WORK_EVENTS_ENDPOINT || '',
},
},
+ // The agent pillar: the job applications a person tracks (server/applications.js) and the OpenVibe.Actor
+ // client they ask for help (server/actor.js). WORK_ACTOR_URL unset means "no agent": the assist route answers
+ // 503 rather than call anywhere. WORK_ACTOR_PUBLIC_URL is the human-facing link a task_url points at.
+ applications: {
+ max: Math.max(1, int(env.WORK_APPLICATIONS_MAX, 500)),
+ assistsPerDay: Math.max(1, int(env.WORK_APPLICATIONS_ASSISTS_PER_DAY, 10)),
+ },
+ actor: {
+ url: trim(env.WORK_ACTOR_URL === undefined ? 'http://127.0.0.1:4950' : env.WORK_ACTOR_URL),
+ publicUrl: trim(env.WORK_ACTOR_PUBLIC_URL || 'https://openvibe.actor'),
+ timeoutMs: Math.max(1000, int(env.WORK_ACTOR_TIMEOUT_MS, 15000)),
+ },
+
// The public job boards the ingest reads (server/jobs/sources.js). The base URLs are configurable so a
// The public job boards the ingest reads (server/jobs/sources.js). The base URLs are configurable so a
// test points them at a stand-in server; nothing else in the service ever fetches a URL, and never one a
// test points them at a stand-in server; nothing else in the service ever fetches a URL, and never one a
// caller typed. Their terms are quoted in the manifest that describes each source, and applied on every
// caller typed. Their terms are quoted in the manifest that describes each source, and applied on every
@@ -23,6 +23,7 @@ const { asyncRouter } = require('./router');
@@ -23,6 +23,7 @@ const { asyncRouter } = require('./router');
const { sameOrigin } = require('./principal');
const { sameOrigin } = require('./principal');
const store = require('../jobs/store');
const store = require('../jobs/store');
const sources = require('../jobs/sources');
const sources = require('../jobs/sources');
+const applications = require('../applications');
function createApi(ctx) {
function createApi(ctx) {
const { config, s, principal, limits } = ctx;
const { config, s, principal, limits } = ctx;
@@ -55,6 +56,25 @@ function createApi(ctx) {
@@ -55,6 +56,25 @@ function createApi(ctx) {
return null;
return null;
};
};
+ // Applications are a person's own; an app or agent token may manage them only with work.application.manage.
+ const requireApp = principal.requireCapability('work.application.manage');
+ // Asking the person's agent is always the person themselves: an app token cannot stand in for them.
+ const personOnly = (req, res) => {
+ if (req.principal.kind === 'user') {
+ if (req.principal.viaSession && req.method !== 'GET' && req.method !== 'HEAD' && !sameOrigin(req, config.baseUrl)) {
+ problem(req, res, 403, 'request.cross_site', 'A signed-in request that changes something must come from openvibe.work itself.');
+ return null;
+ }
+ return req.principal.requester;
+ }
+ problem(req, res, req.principal.kind === 'anonymous' ? 401 : 403, req.principal.kind === 'anonymous' ? 'token.required' : 'work.applications.person_only',
+ req.principal.kind === 'anonymous'
+ ? 'Sign in at openvibe.work to track applications, or send a person\'s Network token as a Bearer.'
+ : 'Asking your agent is yours alone; an app or agent token cannot do it for you.');
+ return null;
+ };
+ const assistLimit = ctx.assistLimiter('work.applications.assist');
+
// ── The listings ────────────────────────────────────────
// ── The listings ────────────────────────────────────────
r.get('/jobs', limits.budget('work.jobs.search'), async (req, res) => {
r.get('/jobs', limits.budget('work.jobs.search'), async (req, res) => {
const filters = store.filtersOf(req.query);
const filters = store.filtersOf(req.query);
@@ -132,6 +152,89 @@ function createApi(ctx) {
@@ -132,6 +152,89 @@ function createApi(ctx) {
return res.status(204).end();
return res.status(204).end();
});
});
+ // ── Applications ─────────────────────────────────────────
+ r.get('/me/applications', limits.reads('work.api.read'), requireApp, async (req, res) => {
+ const subject = req.principal.requester;
+ const status = req.query.status;
+ if (status != null && status !== '' && !applications.STATUSES.includes(status)) {
+ return problem(req, res, 400, 'work.applications.status_invalid', `status is one of ${applications.STATUSES.join(', ')}.`);
+ }
+ const cursor = applications.decodeCursor(req.query.cursor);
+ if (req.query.cursor && !cursor) return problem(req, res, 422, 'work.applications.cursor_invalid', 'cursor: the value the previous page returned as `next`, or absent');
+ const page = await applications.listApplications(s, subject, { status: status || null, cursor, limit: req.query.limit });
+ return res.json({
+ applications: page.rows.map(applications.toWire),
+ next: applications.encodeCursor(page.next),
+ count: page.rows.length,
+ });
+ });
+
+ r.post('/me/applications', limits.budget('work.applications.write'), requireApp, async (req, res) => {
+ const subject = req.principal.requester;
+ const v = applications.validateCreate(req.body);
+ if (v.error) return problem(req, res, v.error[0], v.error[1], v.error[2]);
+ const resolved = await applications.resolveCreateFields(s, v.fields);
+ if (resolved.error === 'listing_not_found') return problem(req, res, 404, 'work.jobs.not_found', 'No such listing to track. It may have expired from the board it came from.');
+ const fields = resolved.fields;
+ if (fields.listing_id) {
+ const existing = await applications.applicationByListing(s, subject, fields.listing_id);
+ if (existing) return res.status(200).json(applications.toWire(existing));
+ }
+ const out = await applications.createApplication(s, subject, fields, { id: s.newId('wap'), now: s.iso(), max: config.applications.max });
+ if (out.error === 'full') return problem(req, res, 409, 'work.applications_full', `You can track up to ${config.applications.max} applications; delete one first.`);
+ res.status(201).set('Location', `/api/v1/me/applications/${out.row.id}`).json(applications.toWire(out.row));
+ return undefined;
+ });
+
+ r.get('/me/applications/:id', limits.reads('work.api.read'), requireApp, async (req, res) => {
+ const subject = req.principal.requester;
+ const row = await applications.getApplication(s, subject, req.params.id);
+ if (!row) return problem(req, res, 404, 'work.applications.not_found', 'You have no application with that id.');
+ const events = await applications.listEvents(s, row.id);
+ return res.json({ ...applications.toWire(row), events: events.map(applications.toEventWire) });
+ });
+
+ r.patch('/me/applications/:id', limits.budget('work.applications.write'), requireApp, async (req, res) => {
+ const subject = req.principal.requester;
+ const v = applications.validatePatch(req.body);
+ if (v.error) return problem(req, res, v.error[0], v.error[1], v.error[2]);
+ const out = await applications.updateApplication(s, subject, req.params.id, v.fields, { now: s.iso() });
+ if (out.error === 'not_found') return problem(req, res, 404, 'work.applications.not_found', 'You have no application with that id.');
+ return res.json(applications.toWire(out.row));
+ });
+
+ r.delete('/me/applications/:id', limits.budget('work.applications.write'), requireApp, async (req, res) => {
+ const subject = req.principal.requester;
+ const gone = await applications.deleteApplication(s, subject, req.params.id);
+ if (!gone) return problem(req, res, 404, 'work.applications.not_found', 'You have no application with that id.');
+ return res.status(204).end();
+ });
+
+ r.post('/me/applications/:id/assist', assistLimit, async (req, res) => {
+ const subject = personOnly(req, res);
+ if (!subject) return undefined;
+ const app = await applications.getApplication(s, subject, req.params.id);
+ if (!app) return problem(req, res, 404, 'work.applications.not_found', 'You have no application with that id.');
+ const a = applications.validateAsk(req.body);
+ if (a.error) return problem(req, res, a.error[0], a.error[1], a.error[2]);
+ const actor = ctx.actor;
+ if (!actor || !actor.available) return problem(req, res, 503, 'work.actor_unavailable', 'The OpenVibe.Actor agent is not available right now.');
+ const n = Number(app.assists || 0) + 1;
+ const idempotencyKey = `work-assist:${app.id}:${n}`;
+ const listing = app.listing_id ? await store.getListing(s, app.listing_id) : null;
+ const task = applications.buildTaskText(app, listing, a.ask);
+ let out;
+ try {
+ out = await actor.createTask({ subjectId: subject.slice('user:'.length), task, idempotencyKey });
+ } catch (err) {
+ const status = err && err.status;
+ if (status === 402 || status === 429) return problem(req, res, 409, (err.code || 'work.actor_refused'), (err.detail || 'The agent refused this task.'));
+ return problem(req, res, 503, 'work.actor_unavailable', 'The agent could not be reached.');
+ }
+ await applications.recordAssist(s, subject, app.id, out.id, s.iso());
+ return res.json({ task_id: out.id, task_url: `${config.actor.publicUrl}/tasks/${out.id}` });
+ });
+
return r;
return r;
}
}
@@ -38,6 +38,7 @@ function caller(req) {
@@ -38,6 +38,7 @@ function caller(req) {
const BUDGETS = {
const BUDGETS = {
'work.jobs.search': { minute: 60, hour: 600 },
'work.jobs.search': { minute: 60, hour: 600 },
'work.saved_searches.write': { minute: 20, hour: 200 },
'work.saved_searches.write': { minute: 20, hour: 200 },
+ 'work.applications.write': { minute: 30, hour: 300 },
};
};
function createCallerLimits({ config, now = () => Date.now(), registry = null, log = console, enabled = true, valkey = null }) {
function createCallerLimits({ config, now = () => Date.now(), registry = null, log = console, enabled = true, valkey = null }) {
@@ -21,9 +21,12 @@ const { send } = require('../render/layout');
@@ -21,9 +21,12 @@ const { send } = require('../render/layout');
const listing = require('../render/listing');
const listing = require('../render/listing');
const store = require('../jobs/store');
const store = require('../jobs/store');
const sources = require('../jobs/sources');
const sources = require('../jobs/sources');
+const applications = require('../applications');
const SITE_NAME = 'OpenVibe.Work';
const SITE_NAME = 'OpenVibe.Work';
const TAGLINE = 'Find work on open job boards.';
const TAGLINE = 'Find work on open job boards.';
+const STATUS_ORDER = ['saved', 'applied', 'interviewing', 'offer', 'rejected', 'withdrawn'];
+const STATUS_LABEL = { saved: 'Saved', applied: 'Applied', interviewing: 'Interviewing', offer: 'Offer', rejected: 'Rejected', withdrawn: 'Withdrawn' };
function createPageRoutes(ctx) {
function createPageRoutes(ctx) {
const { config, s } = ctx;
const { config, s } = ctx;
@@ -89,6 +92,19 @@ ${facets.type.map((t) => link({ type: t.type }, t.type, filters.type === t.type,
@@ -89,6 +92,19 @@ ${facets.type.map((t) => link({ type: t.type }, t.type, filters.type === t.type,
/** Which of the three sources a listing came from, named; the ingest keeps to these and nothing else. */
/** Which of the three sources a listing came from, named; the ingest keeps to these and nothing else. */
const sourceLine = (row) => html`${listing.sourceLink(row, sourceOf(row))}`;
const sourceLine = (row) => html`${listing.sourceLink(row, sourceOf(row))}`;
+ /** A status's badge and its name, in the one place the pages spell them. */
+ const statusBadge = (st) => html`<span class="badge${st === 'offer' ? ' ok' : st === 'rejected' || st === 'withdrawn' ? ' bad' : ''}">${STATUS_LABEL[st] || st}</span>`;
+ /** An ISO instant as the YYYY-MM-DD an <input type="date"> wants ('' when there is none). */
+ const dateInputValue = (iso) => (iso && typeof iso === 'string' ? iso.slice(0, 10) : '');
+ /** An <input type="date"> value back to an ISO instant, or null to clear. */
+ const dateFromForm = (v) => {
+ if (!v) return null;
+ const s = String(v).trim();
+ if (!/^\d{4}-\d{2}-\d{2}$/.test(s)) return undefined;
+ const d = new Date(`${s}T00:00:00Z`);
+ return Number.isNaN(d.getTime()) ? undefined : d.toISOString();
+ };
+
// ── Home ─────────────────────────────────────────────────
// ── Home ─────────────────────────────────────────────────
r.get('/', async (req, res) => {
r.get('/', async (req, res) => {
const [latest, counts, rows] = await Promise.all([store.listRecent(s, 6, { remote: true }), store.countsBySource(s), store.search(s, {}, { limit: 1 })]);
const [latest, counts, rows] = await Promise.all([store.listRecent(s, 6, { remote: true }), store.countsBySource(s), store.search(s, {}, { limit: 1 })]);
@@ -132,7 +148,7 @@ ${raw(showcase.steps({
@@ -132,7 +148,7 @@ ${raw(showcase.steps({
{ title: 'Read it on the board', text: 'The listing page shows the excerpt and sends you to the original listing to read it in full and apply. Nothing is republished.' },
{ title: 'Read it on the board', text: 'The listing page shows the excerpt and sends you to the original listing to read it in full and apply. Nothing is republished.' },
{ title: 'Keep a search', text: 'Sign in to save a search; /saved shows each one with the count of listings that appeared since you last looked.' },
{ title: 'Keep a search', text: 'Sign in to save a search; /saved shows each one with the count of listings that appeared since you last looked.' },
{ title: 'Nothing behind a paywall', text: 'Reading and searching is free and needs no account. There is no premium tier on the listings, and listings expire when a board stops showing them.' },
{ title: 'Nothing behind a paywall', text: 'Reading and searching is free and needs no account. There is no premium tier on the listings, and listings expire when a board stops showing them.' },
- { title: 'An agent, next', text: 'The agent that drafts and tracks applications is the next pillar; alerts through OpenVibe.Watch come with it.' },
+ { title: 'Track your applications', text: 'Sign in and press Track this job on a listing: keep your status, notes and next steps in one place, and ask your OpenVibe.Actor agent to help prepare each application.' },
],
],
}))}
}))}
${raw(showcase.cta({ title: 'Prefer the API?', text: 'The same search, the sources and their terms, and your saved searches over HTTP. Reading is public.', actions: [{ label: 'The API', href: '/docs' }, { label: 'The sources', href: '/sources' }] }))}`,
${raw(showcase.cta({ title: 'Prefer the API?', text: 'The same search, the sources and their terms, and your saved searches over HTTP. Reading is public.', actions: [{ label: 'The API', href: '/docs' }, { label: 'The sources', href: '/sources' }] }))}`,
@@ -175,6 +191,7 @@ ${nextUrl ? html`<a class="sc-btn sc-primary" href="${nextUrl}">Older listings</
@@ -175,6 +191,7 @@ ${nextUrl ? html`<a class="sc-btn sc-primary" href="${nextUrl}">Older listings</
}
}
const src = sourceOf(row);
const src = sourceOf(row);
const l = listing.meta(row);
const l = listing.meta(row);
+ const tracked = signedIn(req) ? await applications.applicationByListing(s, subjectOf(req), row.id) : null;
page(req, res, {
page(req, res, {
index: true, cache: PUBLIC_CACHE,
index: true, cache: PUBLIC_CACHE,
title: `${l.title} at ${l.company}`,
title: `${l.title} at ${l.company}`,
@@ -191,6 +208,11 @@ ${l.tags.length ? html`<ul class="job-tags">${l.tags.map((t) => html`<li>${t}</l
@@ -191,6 +208,11 @@ ${l.tags.length ? html`<ul class="job-tags">${l.tags.map((t) => html`<li>${t}</l
<p>${l.excerpt}</p>
<p>${l.excerpt}</p>
<p class="muted small">This is a short excerpt only. The full listing stays on the board.</p></section>
<p class="muted small">This is a short excerpt only. The full listing stays on the board.</p></section>
<p class="job-apply"><a class="sc-btn sc-primary" href="${l.url}" rel="nofollow noopener external" target="_blank">Read the full listing on ${src.name}</a></p>
<p class="job-apply"><a class="sc-btn sc-primary" href="${l.url}" rel="nofollow noopener external" target="_blank">Read the full listing on ${src.name}</a></p>
+${signedIn(req)
+ ? (tracked
+ ? html`<p class="job-track"><span class="badge ok">Tracked: ${STATUS_LABEL[tracked.status] || tracked.status}</span> <a href="/applications/${tracked.id}">Open your application</a></p>`
+ : html`<form class="inline" method="post" action="/jobs/${row.id}/track"><button class="sc-btn" type="submit">Track this job</button></form>`)
+ : html`<p class="muted small"><a href="/auth/login?next=%2Fjobs%2F${row.id}">Sign in</a> to track this job and ask your agent for help applying.</p>`}
<dl class="job-facts">
<dl class="job-facts">
<dt>Source</dt><dd>${sourceLine(row)} — <a href="/sources#${src.id}">its terms as applied</a></dd>
<dt>Source</dt><dd>${sourceLine(row)} — <a href="/sources#${src.id}">its terms as applied</a></dd>
${l.posted_at ? html`<dt>Posted</dt><dd>${time(l.posted_at)}</dd>` : ''}
${l.posted_at ? html`<dt>Posted</dt><dd>${time(l.posted_at)}</dd>` : ''}
@@ -242,7 +264,7 @@ ${l.posted_at ? html`<dt>Posted</dt><dd>${time(l.posted_at)}</dd>` : ''}
@@ -242,7 +264,7 @@ ${l.posted_at ? html`<dt>Posted</dt><dd>${time(l.posted_at)}</dd>` : ''}
for (const row of rows) await store.touchSaved(s, subject, row.id, now);
for (const row of rows) await store.touchSaved(s, subject, row.id, now);
const initial = store.filtersOf(req.query);
const initial = store.filtersOf(req.query);
page(req, res, {
page(req, res, {
- title: 'Saved searches', crumbs: [{ label: 'Saved searches' }],
+ title: 'Saved searches', crumbs: [{ label: 'Saved searches' }], styles: [showcase.STYLESHEET],
body: html`<h1>Saved searches</h1>
body: html`<h1>Saved searches</h1>
<p>${rows.length ? html`You are keeping ${rows.length} search${rows.length === 1 ? '' : 'es'}. The count is how many listings appeared since you last opened this page.` : 'You are not keeping any search yet.'}</p>
<p>${rows.length ? html`You are keeping ${rows.length} search${rows.length === 1 ? '' : 'es'}. The count is how many listings appeared since you last opened this page.` : 'You are not keeping any search yet.'}</p>
${withCounts.length ? html`<ul class="saved-list">${withCounts.map(({ row, count }) => html`<li class="saved-item">
${withCounts.length ? html`<ul class="saved-list">${withCounts.map(({ row, count }) => html`<li class="saved-item">
@@ -288,6 +310,191 @@ ${notice('Give the search at least one thing to look for — words, a location,
@@ -288,6 +310,191 @@ ${notice('Give the search at least one thing to look for — words, a location,
res.redirect(303, '/saved');
res.redirect(303, '/saved');
});
});
+ // ── Applications ─────────────────────────────────────────
+ const APP_STYLES = ['css/applications.css'];
+ const editForm = (a) => html`<form class="app-form" method="post" action="/applications/${a.id}">
+<label>Status <select name="status">${STATUS_ORDER.map((st) => html`<option value="${st}"${a.status === st ? raw(' selected') : ''}>${STATUS_LABEL[st]}</option>`)}</select></label>
+<div class="app-row"><label>Applied on <input type="date" name="applied_at" value="${dateInputValue(a.applied_at)}"></label>
+<label>Next step by <input type="date" name="next_step_at" value="${dateInputValue(a.next_step_at)}"></label></div>
+<label>Next step <input type="text" name="next_step" maxlength="200" value="${a.next_step || ''}" placeholder="Follow up with the recruiter"></label>
+<label>Notes <textarea name="notes" maxlength="5000" rows="6">${a.notes || ''}</textarea></label>
+<button class="sc-btn sc-primary" type="submit">Save</button>
+</form>`;
+ const addForm = () => html`<section class="app-panel app-add" aria-labelledby="h-add"><h2 id="h-add">Add one by hand</h2>
+<form class="app-form" method="post" action="/applications">
+<div class="app-row"><label>Job title <input type="text" name="title" maxlength="500" required></label>
+<label>Company <input type="text" name="company" maxlength="200" required></label></div>
+<label>Link to the listing <input type="url" name="url" maxlength="2000" required placeholder="https://"></label>
+<button class="sc-btn sc-primary" type="submit">Add application</button>
+</form>
+<p class="muted small">Or press “Track this job” on any <a href="/jobs">listing</a>.</p></section>`;
+
+ const renderApplication = (req, res, a, { events = [], problem = null, assisted = false } = {}) => page(req, res, {
+ title: `${a.title} at ${a.company}`, styles: [showcase.STYLESHEET], localStyles: APP_STYLES,
+ crumbs: [{ label: 'Applications', href: '/applications' }, { label: a.title }],
+ body: html`<div class="app-head"><h1>${a.title}</h1>${statusBadge(a.status)}</div>
+<p class="app-company">${a.company}${a.url ? html` · <a href="${a.url}" rel="nofollow noopener external" target="_blank">the original listing</a>` : ''}${a.listing_id ? html` · <a href="/jobs/${a.listing_id}">this listing on OpenVibe.Work</a>` : ''}</p>
+${problem ? notice(problem, 'warn') : ''}
+${assisted && a.actor_task_id ? notice(html`Your agent is on it: <a href="${config.actor.publicUrl}/tasks/${a.actor_task_id}">open the task on OpenVibe.Actor</a>.`, 'ok') : ''}
+<div class="app-detail">
+<section class="app-panel" aria-labelledby="h-edit"><h2 id="h-edit">Your application</h2>${editForm(a)}</section>
+<div>
+<section class="app-panel app-agent" aria-labelledby="h-agent"><h2 id="h-agent">Help from your agent</h2>
+<p class="small">Your OpenVibe.Actor agent reads this listing and your notes, then works on what you ask. It runs on your Actor allowance.</p>
+<form class="app-form" method="post" action="/applications/${a.id}/assist">
+<label>What do you want help with? <span class="muted">(optional)</span><textarea name="ask" maxlength="1000" rows="3" placeholder="${applications.DEFAULT_ASK}"></textarea></label>
+<button class="sc-btn sc-primary" type="submit">Ask my agent</button>
+</form>
+${a.actor_task_id ? html`<p class="small">Latest task: <a href="${config.actor.publicUrl}/tasks/${a.actor_task_id}">open it on OpenVibe.Actor</a>${a.assists > 1 ? html` (${a.assists} asked so far)` : ''}.</p>` : ''}
+</section>
+<section class="app-panel" aria-labelledby="h-history"><h2 id="h-history">History</h2>
+${events.length ? html`<ul class="app-history">${events.map((e) => html`<li>${time(e.at)} · ${STATUS_LABEL[e.from_status] || e.from_status} → ${STATUS_LABEL[e.to_status] || e.to_status}</li>`)}</ul>` : html`<p class="muted small">No status changes yet. Added ${time(a.created_at)}.</p>`}
+</section>
+<form class="app-danger" method="post" action="/applications/${a.id}/delete"><button type="submit">Delete this application</button></form>
+</div>
+</div>`,
+ });
+
+ r.get('/applications', async (req, res) => {
+ if (!signedIn(req)) {
+ return page(req, res, {
+ title: 'Applications', styles: [showcase.STYLESHEET],
+ body: html`<h1>Applications</h1>
+<p>Track the jobs you apply to, keep your notes and next steps in one place, and ask your OpenVibe.Actor agent to help you prepare each application. Sign in to begin.</p>
+<p><a class="sc-btn sc-primary" href="/auth/login?next=%2Fapplications">Sign in with OpenVibe</a> <a class="sc-btn" href="/jobs">Search the listings</a></p>`,
+ });
+ }
+ const subject = subjectOf(req);
+ const rows = await applications.listAllApplications(s, subject);
+ const groups = STATUS_ORDER.map((st) => ({ status: st, rows: rows.filter((r) => r.status === st) }));
+ const total = rows.length;
+ page(req, res, {
+ title: 'Applications', crumbs: [{ label: 'Applications' }], styles: [showcase.STYLESHEET], localStyles: APP_STYLES,
+ body: html`<h1>Applications</h1>
+<p>${total ? html`You are tracking ${total} application${total === 1 ? '' : 's'}.` : html`Nothing tracked yet. Press “Track this job” on a <a href="/jobs">listing</a>, or add one by hand below.`}</p>
+${total ? html`<ul class="app-summary" aria-label="Applications by status">${groups.map((g) => html`<li>${g.rows.length ? html`<a href="#h-${g.status}">${STATUS_LABEL[g.status]} <strong>${g.rows.length}</strong></a>` : html`<span class="is-empty">${STATUS_LABEL[g.status]} <strong>0</strong></span>`}</li>`)}</ul>` : ''}
+${groups.map((g) => (g.rows.length ? html`<section class="app-group" aria-labelledby="h-${g.status}">
+<h2 id="h-${g.status}">${STATUS_LABEL[g.status]}</h2>
+<ul class="app-list">${g.rows.map((a) => html`<li class="app-item">
+<p class="app-title"><a href="/applications/${a.id}">${a.title}</a>${statusBadge(a.status)}</p>
+<p class="app-co">${a.company}</p>
+${a.next_step ? html`<p class="app-next">Next: ${a.next_step}${a.next_step_at ? html` · by <time datetime="${a.next_step_at}">${dateInputValue(a.next_step_at)}</time>` : ''}</p>` : ''}
+<p class="muted small">Updated ${time(a.updated_at)}</p>
+</li>`)}</ul>
+</section>` : ''))}
+${addForm()}`,
+ });
+ });
+
+ r.post('/applications', require('express').urlencoded({ extended: false, limit: '8kb' }), async (req, res) => {
+ res.set('Cache-Control', cache.htmlHeaders({ private: true }));
+ if (!signedIn(req)) return res.redirect(303, '/auth/login?next=%2Fapplications');
+ if (!sameOrigin(req, config.baseUrl)) return res.status(403).type('text/plain').send('Adding an application must come from openvibe.work itself.');
+ const v = applications.validateCreate(req.body || {});
+ if (v.error) {
+ return page(req, res, { title: 'Applications', crumbs: [{ label: 'Applications' }], body: html`<h1>Applications</h1>${notice(v.error[2], 'warn')}<p><a href="/applications">Back to your applications</a></p>` }, 400);
+ }
+ const out = await applications.createApplication(s, subjectOf(req), v.fields, { id: s.newId('wap'), now: s.iso(), max: config.applications.max });
+ if (out.error === 'full') {
+ return page(req, res, { title: 'Applications', body: html`<h1>Applications</h1>${notice(`You can track up to ${config.applications.max} applications; delete one first.`, 'warn')}<p><a href="/applications">Back to your applications</a></p>` }, 409);
+ }
+ return res.redirect(303, `/applications/${out.row.id}`);
+ });
+
+ r.get('/applications/:id', async (req, res) => {
+ if (!signedIn(req)) {
+ return page(req, res, {
+ title: 'Application', styles: [showcase.STYLESHEET],
+ body: html`<h1>Application</h1><p>Sign in to see your applications.</p><p><a class="sc-btn sc-primary" href="/auth/login?next=%2Fapplications">Sign in with OpenVibe</a></p>`,
+ });
+ }
+ const subject = subjectOf(req);
+ const a = await applications.getApplication(s, subject, req.params.id);
+ if (!a) {
+ return page(req, res, {
+ title: 'Application not found',
+ body: html`<h1>Application not found</h1><p>No application here with that id. It may have been deleted.</p><p><a href="/applications">Back to your applications</a></p>`,
+ }, 404);
+ }
+ const events = await applications.listEvents(s, a.id);
+ return renderApplication(req, res, a, { events, assisted: req.query.assisted === '1' });
+ });
+
+ r.post('/applications/:id', require('express').urlencoded({ extended: false, limit: '16kb' }), async (req, res) => {
+ res.set('Cache-Control', cache.htmlHeaders({ private: true }));
+ if (!signedIn(req)) return res.redirect(303, `/auth/login?next=${encodeURIComponent(`/applications/${req.params.id}`)}`);
+ if (!sameOrigin(req, config.baseUrl)) return res.status(403).type('text/plain').send('Editing an application must come from openvibe.work itself.');
+ const subject = subjectOf(req);
+ const current = await applications.getApplication(s, subject, req.params.id);
+ if (!current) return res.status(404).type('text/plain').send('No such application.');
+ const patch = { status: req.body.status, notes: req.body.notes || '', next_step: req.body.next_step || '', next_step_at: dateFromForm(req.body.next_step_at) };
+ const applied = req.body.applied_at ? dateFromForm(req.body.applied_at) : undefined;
+ if (applied !== undefined && applied !== null) patch.applied_at = applied;
+ const v = applications.validatePatch(patch);
+ if (v.error) {
+ const a = await applications.getApplication(s, subject, req.params.id);
+ const events = await applications.listEvents(s, a.id);
+ return renderApplication(req, res, a, { events, problem: v.error[2] });
+ }
+ await applications.updateApplication(s, subject, req.params.id, v.fields, { now: s.iso() });
+ return res.redirect(303, `/applications/${req.params.id}`);
+ });
+
+ r.post('/applications/:id/assist', require('express').urlencoded({ extended: false, limit: '8kb' }), async (req, res) => {
+ res.set('Cache-Control', cache.htmlHeaders({ private: true }));
+ if (!signedIn(req)) return res.redirect(303, `/auth/login?next=${encodeURIComponent(`/applications/${req.params.id}`)}`);
+ if (!sameOrigin(req, config.baseUrl)) return res.status(403).type('text/plain').send('Asking your agent must come from openvibe.work itself.');
+ const subject = subjectOf(req);
+ const app = await applications.getApplication(s, subject, req.params.id);
+ if (!app) return res.status(404).type('text/plain').send('No such application.');
+ const a = applications.validateAsk(req.body || {});
+ if (a.error) {
+ const events = await applications.listEvents(s, app.id);
+ return renderApplication(req, res, app, { events, problem: a.error[2] });
+ }
+ const actor = ctx.actor;
+ if (!actor || !actor.available) {
+ const events = await applications.listEvents(s, app.id);
+ return renderApplication(req, res, app, { events, problem: 'The OpenVibe.Actor agent is not available right now.' });
+ }
+ const n = Number(app.assists || 0) + 1;
+ const idempotencyKey = `work-assist:${app.id}:${n}`;
+ const listing = app.listing_id ? await store.getListing(s, app.listing_id) : null;
+ const text = applications.buildTaskText(app, listing, a.ask);
+ let out;
+ try {
+ out = await actor.createTask({ subjectId: subject.slice('user:'.length), task: text, idempotencyKey });
+ } catch (err) {
+ const events = await applications.listEvents(s, app.id);
+ if (err && (err.status === 402 || err.status === 429)) return renderApplication(req, res, app, { events, problem: (err.detail || 'The agent refused this task.') });
+ return renderApplication(req, res, app, { events, problem: 'The agent could not be reached.' });
+ }
+ await applications.recordAssist(s, subject, app.id, out.id, s.iso());
+ return res.redirect(303, `/applications/${app.id}?assisted=1`);
+ });
+
+ r.post('/applications/:id/delete', async (req, res) => {
+ res.set('Cache-Control', cache.htmlHeaders({ private: true }));
+ if (!signedIn(req)) return res.redirect(303, '/auth/login?next=%2Fapplications');
+ if (!sameOrigin(req, config.baseUrl)) return res.status(403).type('text/plain').send('Deleting an application must come from openvibe.work itself.');
+ await applications.deleteApplication(s, subjectOf(req), req.params.id);
+ return res.redirect(303, '/applications');
+ });
+
+ r.post('/jobs/:id/track', async (req, res) => {
+ res.set('Cache-Control', cache.htmlHeaders({ private: true }));
+ if (!signedIn(req)) return res.redirect(303, `/auth/login?next=${encodeURIComponent(`/jobs/${req.params.id}`)}`);
+ if (!sameOrigin(req, config.baseUrl)) return res.status(403).type('text/plain').send('Tracking must come from openvibe.work itself.');
+ const listingRow = await store.getListing(s, req.params.id);
+ if (!listingRow) return res.status(404).type('text/plain').send('No such listing to track.');
+ const subject = subjectOf(req);
+ const existing = await applications.applicationByListing(s, subject, listingRow.id);
+ if (existing) return res.redirect(303, `/applications/${existing.id}`);
+ const out = await applications.createApplication(s, subject, { listing_id: listingRow.id, title: listingRow.title, company: listingRow.company, url: listingRow.url }, { id: s.newId('wap'), now: s.iso(), max: config.applications.max });
+ if (out.error === 'full') return res.redirect(303, '/applications');
+ return res.redirect(303, `/applications/${out.row.id}`);
+ });
+
// ── The sources and their terms ──────────────────────────
// ── The sources and their terms ──────────────────────────
r.get('/sources', async (req, res) => {
r.get('/sources', async (req, res) => {
const [fetches, counts] = await Promise.all([store.sourceFetches(s), store.countsBySource(s)]);
const [fetches, counts] = await Promise.all([store.sourceFetches(s), store.countsBySource(s)]);
@@ -23,7 +23,7 @@ const PROJECT_RE = /^prj_[0-9A-HJKMNP-TV-Z]{26}$/;
@@ -23,7 +23,7 @@ const PROJECT_RE = /^prj_[0-9A-HJKMNP-TV-Z]{26}$/;
// The product fills this in: one entry per capability its routes name, in openvibe-contracts
// The product fills this in: one entry per capability its routes name, in openvibe-contracts
// manifests/capabilities/<work>.*. requireCapability('<name>') refuses a name that is not listed here, so a
// manifests/capabilities/<work>.*. requireCapability('<name>') refuses a name that is not listed here, so a
// route can never be guarded by a capability the service does not declare.
// route can never be guarded by a capability the service does not declare.
-const CAPABILITIES = [];
+const CAPABILITIES = ['work.application.manage'];
function decodePayload(token) {
function decodePayload(token) {
const parts = String(token || '').split('.');
const parts = String(token || '').split('.');
@@ -1,33 +1,53 @@
@@ -1,33 +1,53 @@
'use strict';
'use strict';
/**
/**
- * Account export and deletion → Work (ADR-033; openvibe-sdk/account-data). Work holds one thing a person made: the
- * search they saved, keyed `subject = user:usr_…` (server/http/api.js builds the subject from req.principal.requester,
- * which server/http/principal.js sets to `user:${subject_id}`; server/jobs/store.js writes it). A saved search is the
- * person's own — nothing anyone else hangs under it — so it is deleted whole and nothing is kept.
+ * Account export and deletion → Work (ADR-033; openvibe-sdk/account-data). Work holds two things a person made: the
+ * search they saved (`work_saved_searches.subject = user:usr_…`) and the job applications they track
+ * (`work_applications.subject_id = user:usr_…`), with the append-only history of each application's status changes
+ * (`work_application_events`, keyed by application_id). All three are the person's own — nothing anyone else hangs
+ * under them — so they are deleted whole and nothing is kept.
*
*
- * network.account.export_requested the person's saved searches (saved_searches.json), newest first, pushed to
- * Network (POST /internal/account-exports/:id/parts) with this service's token.
- * network.account.deleted the person's saved searches go, and Work confirms with counts.
+ * The events table has no subject column of its own, so its export comes through extraExport (the person's events,
+ * joined to their applications) and its erase rides on the applications table's ON DELETE CASCADE foreign key: when
+ * an application row goes, its events go with it.
+ *
+ * network.account.export_requested the person's saved searches and applications (and each application's events),
+ * pushed to Network (POST /internal/account-exports/:id/parts) with this service's token.
+ * network.account.deleted all three go, and Work confirms with counts.
*
*
* The listings (work_listings) and the per-source ingest bookkeeping (work_source_fetches) hold no person's rows:
* The listings (work_listings) and the per-source ingest bookkeeping (work_source_fetches) hold no person's rows:
- * neither has a subject column (a listing is scraped from a public job board and a fetch record is per source), so
- * neither is exported nor erased (see migrations/0002_listings.sql). Nothing here is a secret: no token, key or
- * credential is stored, so every column of the saved-search table may be exported.
+ * neither has a subject column, so neither is exported nor erased (see migrations/0002_listings.sql). Nothing here is
+ * a secret: no token, key or credential is stored, so every column may be exported.
*/
*/
const { createAccountData, TOPICS } = require('openvibe-sdk/account-data');
const { createAccountData, TOPICS } = require('openvibe-sdk/account-data');
+/** The value a subject column really stores (a person's requester is `user:usr_…`). */
+const subjectOf = (usr) => `user:${usr}`;
+
/**
/**
- * The tables that hold a person's rows, with the value the subject column really stores. A saved search stores
- * `user:usr_…` (server/jobs/store.js takes the subject straight from the principal's requester; server/http/api.js).
+ * The tables that hold a person's rows. A saved search stores `user:usr_…` in `subject`; an application stores it in
+ * `subject_id` (server/applications.js takes the subject straight from the principal's requester).
*/
*/
const TABLES = [
const TABLES = [
- { table: 'work_saved_searches', subject: 'subject', value: (usr) => `user:${usr}`, file: 'saved_searches.json' },
+ { table: 'work_saved_searches', subject: 'subject', value: subjectOf, file: 'saved_searches.json' },
+ { table: 'work_applications', subject: 'subject_id', value: subjectOf, file: 'applications.json' },
];
];
+/** The person's application status-change history, which has no subject column of its own. */
+async function extraExport(db, subject) {
+ const rows = await db.many(
+ `SELECT e.application_id, e.at, e.from_status, e.to_status
+ FROM work_application_events e
+ JOIN work_applications a ON a.id = e.application_id
+ WHERE a.subject_id = $1
+ ORDER BY e.at DESC, e.application_id DESC`,
+ [subjectOf(subject)]);
+ return rows.length ? [{ name: 'application_events.json', content: rows }] : [];
+}
+
/** The account-data handle for Work's store (server/db.js createStore). */
/** The account-data handle for Work's store (server/db.js createStore). */
function create({ db, log = console } = {}) {
function create({ db, log = console } = {}) {
- return createAccountData({ db, service: 'work', tables: TABLES, log });
+ return createAccountData({ db, service: 'work', tables: TABLES, extraExport, log });
}
}
-module.exports = { create, TABLES, TOPICS };
+module.exports = { create, TABLES, extraExport, TOPICS };
@@ -25,6 +25,7 @@ const PUBLIC_DIR = path.join(__dirname, '..', '..', 'public');
@@ -25,6 +25,7 @@ const PUBLIC_DIR = path.join(__dirname, '..', '..', 'public');
// The product's own navigation: one entry per public page it serves.
// The product's own navigation: one entry per public page it serves.
const NAV = [
const NAV = [
{ label: 'Jobs', href: '/jobs' },
{ label: 'Jobs', href: '/jobs' },
+ { label: 'Applications', href: '/applications', signedIn: true },
{ label: 'Sources', href: '/sources' },
{ label: 'Sources', href: '/sources' },
{ label: 'API', href: '/docs' },
{ label: 'API', href: '/docs' },
{ label: 'What shipped', href: '/updates' },
{ label: 'What shipped', href: '/updates' },
@@ -47,7 +48,7 @@ function setRelease(id) { if (id) RELEASE = String(id); }
@@ -47,7 +48,7 @@ function setRelease(id) { if (id) RELEASE = String(id); }
/**
/**
* o: title, description, body (html), viewer, config, path, index (default false), scripts [rel],
* o: title, description, body (html), viewer, config, path, index (default false), scripts [rel],
- * crumbs [{ label, href }], jsonLd [nodes] (serialised by openvibe-shared/seo), styles [openvibe-shared stylesheet names]
+ * crumbs [{ label, href }], jsonLd [nodes] (serialised by openvibe-shared/seo), styles [openvibe-shared stylesheet names], localStyles [this service's own extra stylesheets under public/, e.g. css/applications.css]
*/
*/
function renderPage(o) {
function renderPage(o) {
const viewer = o.viewer || { kind: 'anonymous' };
const viewer = o.viewer || { kind: 'anonymous' };
@@ -55,10 +56,11 @@ function renderPage(o) {
@@ -55,10 +56,11 @@ function renderPage(o) {
const pathNow = o.path || '/';
const pathNow = o.path || '/';
const loginNext = encodeURIComponent(pathNow);
const loginNext = encodeURIComponent(pathNow);
const canonical = `${o.config.baseUrl}${pathNow.split('?')[0]}`;
const canonical = `${o.config.baseUrl}${pathNow.split('?')[0]}`;
+ const navLinks = NAV.filter((l) => !l.signedIn || signedIn);
const nav = {
const nav = {
service: 'work',
service: 'work',
apiBase: NETWORK_URL,
apiBase: NETWORK_URL,
- links: NAV.map((l) => ({ label: l.label, href: l.href, active: pathNow === l.href || pathNow.startsWith(`${l.href}/`) })),
+ links: navLinks.map((l) => ({ label: l.label, href: l.href, active: pathNow === l.href || pathNow.startsWith(`${l.href}/`) })),
history: { type: 'page', title: o.title || SITE_NAME },
history: { type: 'page', title: o.title || SITE_NAME },
silentLogin: `${o.config.baseUrl}/auth/login?silent=1&next={url}`,
silentLogin: `${o.config.baseUrl}/auth/login?silent=1&next={url}`,
sessionUrl: '/auth/me',
sessionUrl: '/auth/me',
@@ -85,13 +87,14 @@ function renderPage(o) {
@@ -85,13 +87,14 @@ function renderPage(o) {
robots: o.index ? 'index, follow' : 'noindex, nofollow',
robots: o.index ? 'index, follow' : 'noindex, nofollow',
jsonLd: (o.jsonLd || []).filter(Boolean),
jsonLd: (o.jsonLd || []).filter(Boolean),
home: '/',
home: '/',
- navLinks: NAV,
+ navLinks: navLinks.map((l) => ({ label: l.label, href: l.href })),
navbar: nav,
navbar: nav,
footer,
footer,
head: `<meta name="referrer" content="${o.noReferrer ? 'no-referrer' : 'strict-origin-when-cross-origin'}">
head: `<meta name="referrer" content="${o.noReferrer ? 'no-referrer' : 'strict-origin-when-cross-origin'}">
${appIcon.headTags({ site: 'work' })}
${appIcon.headTags({ site: 'work' })}
<link rel="stylesheet" href="${asset('css/app.css')}">
<link rel="stylesheet" href="${asset('css/app.css')}">
${(o.styles || []).map((name) => `<link rel="stylesheet" href="${esc(ovServe.url(name))}">`).join('\n')}
${(o.styles || []).map((name) => `<link rel="stylesheet" href="${esc(ovServe.url(name))}">`).join('\n')}
+${(o.localStyles || []).map((rel) => `<link rel="stylesheet" href="${asset(rel)}">`).join('\n')}
${render(scripts)}
${render(scripts)}
<meta name="ov-boost" content="work@${esc(RELEASE)}">
<meta name="ov-boost" content="work@${esc(RELEASE)}">
<script src="${ovServe.url('boost.js')}" data-main="#main" defer></script>`,
<script src="${ovServe.url('boost.js')}" data-main="#main" defer></script>`,
@@ -11,6 +11,7 @@ const { boot, check, done } = require('./helpers/boot');
@@ -11,6 +11,7 @@ const { boot, check, done } = require('./helpers/boot');
const { createNetworkSender } = require('openvibe-sdk/account-data');
const { createNetworkSender } = require('openvibe-sdk/account-data');
const { signDeliveryHeaders } = require('openvibe-sdk/events');
const { signDeliveryHeaders } = require('openvibe-sdk/events');
const jobsStore = require('../server/jobs/store');
const jobsStore = require('../server/jobs/store');
+const appsStore = require('../server/applications');
const A = 'usr_01JZ0000000000000000000AAA';
const A = 'usr_01JZ0000000000000000000AAA';
const B = 'usr_01JZ0000000000000000000BBB';
const B = 'usr_01JZ0000000000000000000BBB';
@@ -52,6 +53,16 @@ async function makeRows(t, subject, q) {
@@ -52,6 +53,16 @@ async function makeRows(t, subject, q) {
return out.row;
return out.row;
}
}
+/** Track an application for one person, with one status change, through the applications store. */
+async function makeApplication(t, subject) {
+ const s = t.ctx.s;
+ const resolved = await appsStore.resolveCreateFields(s, { title: 'Backend Engineer', company: 'Acme', url: 'https://acme.example/job' });
+ const out = await appsStore.createApplication(s, `user:${subject}`, resolved.fields, { id: s.newId('wap'), now: s.iso() });
+ assert.ok(out.row, 'the application was created');
+ await appsStore.updateApplication(s, `user:${subject}`, out.row.id, { status: 'applied' }, { now: s.iso() });
+ return out.row;
+}
+
(async () => {
(async () => {
await check('an export part carries only the person\'s saved searches, and no secret', async () => {
await check('an export part carries only the person\'s saved searches, and no secret', async () => {
const stub = await startNetworkStub();
const stub = await startNetworkStub();
@@ -60,6 +71,8 @@ async function makeRows(t, subject, q) {
@@ -60,6 +71,8 @@ async function makeRows(t, subject, q) {
try {
try {
await makeRows(t, A, 'person-a');
await makeRows(t, A, 'person-a');
await makeRows(t, B, 'person-b');
await makeRows(t, B, 'person-b');
+ await makeApplication(t, A);
+ await makeApplication(t, B);
const res = await t.get('/internal/events', { method: 'POST', body: bodyOf(exportEvent), headers: { 'Content-Type': 'application/json', ...signDeliveryHeaders(bodyOf(exportEvent), SECRET) } });
const res = await t.get('/internal/events', { method: 'POST', body: bodyOf(exportEvent), headers: { 'Content-Type': 'application/json', ...signDeliveryHeaders(bodyOf(exportEvent), SECRET) } });
assert.strictEqual(res.status, 200);
assert.strictEqual(res.status, 200);
@@ -69,10 +82,16 @@ async function makeRows(t, subject, q) {
@@ -69,10 +82,16 @@ async function makeRows(t, subject, q) {
assert.ok(part, 'the part was pushed to Network');
assert.ok(part, 'the part was pushed to Network');
assert.strictEqual(part.auth, 'Bearer tok_work', 'with this service\'s own token');
assert.strictEqual(part.auth, 'Bearer tok_work', 'with this service\'s own token');
assert.strictEqual(part.body.subject, A);
assert.strictEqual(part.body.subject, A);
- assert.deepStrictEqual(part.body.files.map((f) => f.name).sort(), ['saved_searches.json']);
+ assert.deepStrictEqual(part.body.files.map((f) => f.name).sort(), ['application_events.json', 'applications.json', 'saved_searches.json']);
const saved = part.body.files.find((f) => f.name === 'saved_searches.json').content;
const saved = part.body.files.find((f) => f.name === 'saved_searches.json').content;
assert.strictEqual(saved.length, 1);
assert.strictEqual(saved.length, 1);
assert.strictEqual(saved[0].query, 'person-a');
assert.strictEqual(saved[0].query, 'person-a');
+ const applications = part.body.files.find((f) => f.name === 'applications.json').content;
+ assert.strictEqual(applications.length, 1);
+ assert.strictEqual(applications[0].title, 'Backend Engineer');
+ const events = part.body.files.find((f) => f.name === 'application_events.json').content;
+ assert.strictEqual(events.length, 1);
+ assert.deepStrictEqual({ from: events[0].from_status, to: events[0].to_status }, { from: 'saved', to: 'applied' });
assert.ok(!JSON.stringify(part.body).includes('person-b'), 'nobody else\'s rows');
assert.ok(!JSON.stringify(part.body).includes('person-b'), 'nobody else\'s rows');
assert.ok(!/token|secret|password/i.test(JSON.stringify(part.body)), 'no secret is exported');
assert.ok(!/token|secret|password/i.test(JSON.stringify(part.body)), 'no secret is exported');
} finally { await t.close(); await stub.close(); }
} finally { await t.close(); await stub.close(); }
@@ -85,17 +104,27 @@ async function makeRows(t, subject, q) {
@@ -85,17 +104,27 @@ async function makeRows(t, subject, q) {
try {
try {
await makeRows(t, A, 'person-a');
await makeRows(t, A, 'person-a');
await makeRows(t, B, 'person-b');
await makeRows(t, B, 'person-b');
+ await makeApplication(t, A);
+ await makeApplication(t, B);
const gone = async (subject) => await t.ctx.s.db.value('SELECT count(*)::int FROM work_saved_searches WHERE subject = $1', [`user:${subject}`]);
const gone = async (subject) => await t.ctx.s.db.value('SELECT count(*)::int FROM work_saved_searches WHERE subject = $1', [`user:${subject}`]);
+ const appsGone = async (subject) => await t.ctx.s.db.value('SELECT count(*)::int FROM work_applications WHERE subject_id = $1', [`user:${subject}`]);
+ const eventsGone = async (subject) => await t.ctx.s.db.value(
+ 'SELECT count(*)::int FROM work_application_events e JOIN work_applications a ON a.id = e.application_id WHERE a.subject_id = $1',
+ [`user:${subject}`]);
const res = await t.get('/internal/events', { method: 'POST', body: bodyOf(deleteEvent), headers: { 'Content-Type': 'application/json', ...signDeliveryHeaders(bodyOf(deleteEvent), SECRET) } });
const res = await t.get('/internal/events', { method: 'POST', body: bodyOf(deleteEvent), headers: { 'Content-Type': 'application/json', ...signDeliveryHeaders(bodyOf(deleteEvent), SECRET) } });
assert.strictEqual(res.status, 200);
assert.strictEqual(res.status, 200);
assert.strictEqual(res.json().outcome, 'erased');
assert.strictEqual(res.json().outcome, 'erased');
assert.strictEqual(await gone(A), 0, 'the person\'s saved searches are gone');
assert.strictEqual(await gone(A), 0, 'the person\'s saved searches are gone');
assert.strictEqual(await gone(B), 1, 'someone else\'s saved search stays');
assert.strictEqual(await gone(B), 1, 'someone else\'s saved search stays');
+ assert.strictEqual(await appsGone(A), 0, 'the person\'s applications are gone');
+ assert.strictEqual(await appsGone(B), 1, 'someone else\'s application stays');
+ assert.strictEqual(await eventsGone(A), 0, 'the person\'s application events are gone');
+ assert.strictEqual(await eventsGone(B), 1, 'someone else\'s application events stay');
const confirmation = stub.calls.find((c) => c.url === `/internal/account-deletions/${DEL}/confirmations`);
const confirmation = stub.calls.find((c) => c.url === `/internal/account-deletions/${DEL}/confirmations`);
assert.ok(confirmation, 'the confirmation was sent');
assert.ok(confirmation, 'the confirmation was sent');
- assert.deepStrictEqual(confirmation.body.erased, { work_saved_searches: 1 });
+ assert.deepStrictEqual(confirmation.body.erased, { work_saved_searches: 1, work_applications: 1 });
assert.deepStrictEqual(confirmation.body.retained, {});
assert.deepStrictEqual(confirmation.body.retained, {});
assert.ok(!Number.isNaN(Date.parse(confirmation.body.completed_at)));
assert.ok(!Number.isNaN(Date.parse(confirmation.body.completed_at)));
new file mode 100644
new file mode 100644
@@ -0,0 +1,263 @@
@@ -0,0 +1,263 @@
+'use strict';
+/**
+ * Applications: tracking one from a listing or by hand, one per person per listing, the status-change history, the
+ * applied_at auto-stamp, validation, the cap, the capability rule for app tokens, asking the person's OpenVibe.Actor
+ * agent through an injected client, and the pages. The clock is the test's own, because applied_at is a claim about
+ * time.
+ */
+const assert = require('assert');
+const { serviceAuth, ids } = require('openvibe-contracts');
+const { boot, check, done } = require('./helpers/boot');
+
+const SAME = { 'sec-fetch-site': 'same-origin' };
+
+/** A stand-in OpenVibe.Actor client: records calls and returns (or throws) what each test asks. */
+function makeActor() {
+ const actor = { available: true, calls: [], impl: null };
+ actor.createTask = async (args) => {
+ actor.calls.push(args);
+ if (actor.impl) return actor.impl(args);
+ return { id: 'tsk_01JZ0000000000000000000000TK', state: 'queued' };
+ };
+ return actor;
+}
+
+/** An app token for audience openvibe.work, with the grants given. */
+function appToken(t, cap = []) {
+ const now = Math.floor(Date.now() / 1000);
+ const project = ids.newId('project');
+ return serviceAuth.signServiceToken({
+ iss: t.network.url,
+ sub: `app:${ids.newId('app')}`,
+ actor_type: 'app',
+ aud: ['openvibe.work'],
+ cap,
+ ns: [project],
+ project_id: project,
+ env: 'production',
+ iat: now,
+ exp: now + 300,
+ jti: `tok_${'a'.repeat(12)}`,
+ }, t.network.privatePem);
+}
+
+(async () => {
+ const clock = { t: Date.now() };
+ const actor = makeActor();
+ const t = await boot({ now: () => clock.t, actor });
+ const kim = t.network.addUser('kim');
+ const lee = t.network.addUser('lee');
+
+ try {
+ await t.ctx.ingest.runAll();
+ const firstListing = async () => (await t.get('/api/v1/jobs?limit=1')).json().jobs[0];
+
+ await check('an application is a person\'s: anonymous is 401, an app token is 403, nobody sees anybody else\'s', async () => {
+ assert.strictEqual((await t.get('/api/v1/me/applications')).status, 401);
+ assert.strictEqual((await t.get('/api/v1/me/applications')).json().code, 'token.required');
+
+ assert.strictEqual((await t.get('/api/v1/me/applications', { bearer: appToken(t) })).status, 403, 'an app token without work.application.manage is refused');
+
+ const made = await t.get('/api/v1/me/applications', { as: kim, json: { title: 'Backend Engineer', company: 'Acme', url: 'https://acme.example/jobs/1' }, headers: SAME });
+ assert.strictEqual(made.status, 201, made.text);
+ const mine = made.json();
+ assert.match(mine.id, /^wap_[0-9A-HJKMNP-TV-Z]{26}$/);
+ assert.strictEqual(mine.status, 'saved');
+ assert.strictEqual(mine.title, 'Backend Engineer');
+
+ assert.deepStrictEqual((await t.get('/api/v1/me/applications', { as: lee })).json().applications, [], 'another person saw my application');
+ assert.strictEqual((await t.get(`/api/v1/me/applications/${mine.id}`, { as: lee })).status, 404);
+ assert.strictEqual((await t.get(`/api/v1/me/applications/${mine.id}`, { as: lee, method: 'DELETE', headers: SAME })).status, 404);
+ });
+
+ await check('creating from a listing copies title/company/url, and a second POST for the same listing answers 200 with the existing one', async () => {
+ const listing = await firstListing();
+ const made = await t.get('/api/v1/me/applications', { as: kim, json: { listing_id: listing.id }, headers: SAME });
+ assert.strictEqual(made.status, 201, made.text);
+ const one = made.json();
+ assert.strictEqual(one.listing_id, listing.id);
+ assert.strictEqual(one.title, listing.title);
+ assert.strictEqual(one.company, listing.company);
+ assert.strictEqual(one.url, listing.url);
+
+ const again = await t.get('/api/v1/me/applications', { as: kim, json: { listing_id: listing.id }, headers: SAME });
+ assert.strictEqual(again.status, 200, again.text);
+ assert.strictEqual(again.json().id, one.id, 'a duplicate listing should return the existing application');
+ });
+
+ await check('a status change appends an event, and setting applied with no applied_at stamps now', async () => {
+ const made = (await t.get('/api/v1/me/applications', { as: kim, json: { title: 'Designer', company: 'Raytrace', url: 'https://raytrace.example/jobs/9' }, headers: SAME })).json();
+ const patched = await t.get(`/api/v1/me/applications/${made.id}`, { as: kim, method: 'PATCH', json: { status: 'applied' }, headers: SAME });
+ assert.strictEqual(patched.status, 200, patched.text);
+ const applied = patched.json();
+ assert.strictEqual(applied.status, 'applied');
+ assert.strictEqual(applied.applied_at, new Date(clock.t).toISOString(), 'applied_at was not auto-stamped');
+
+ const detail = (await t.get(`/api/v1/me/applications/${made.id}`, { as: kim })).json();
+ assert.strictEqual(detail.events.length, 1);
+ assert.deepStrictEqual({ from_status: detail.events[0].from_status, to_status: detail.events[0].to_status }, { from_status: 'saved', to_status: 'applied' });
+
+ const notes = await t.get(`/api/v1/me/applications/${made.id}`, { as: kim, method: 'PATCH', json: { notes: 'hiring manager: Sam', next_step: 'portfolio' }, headers: SAME });
+ assert.strictEqual(notes.status, 200);
+ assert.strictEqual((await t.get(`/api/v1/me/applications/${made.id}`, { as: kim })).json().events.length, 1, 'a non-status edit appended an event');
+
+ clock.t += 60_000;
+ const interviewing = await t.get(`/api/v1/me/applications/${made.id}`, { as: kim, method: 'PATCH', json: { status: 'interviewing' }, headers: SAME });
+ assert.strictEqual(interviewing.status, 200);
+ const after = (await t.get(`/api/v1/me/applications/${made.id}`, { as: kim })).json();
+ assert.strictEqual(after.events.length, 2);
+ assert.deepStrictEqual(after.events[0].from_status, 'applied');
+ assert.deepStrictEqual(after.events[0].to_status, 'interviewing');
+ });
+
+ await check('validation: unknown fields, a bad status and over-long notes are 400', async () => {
+ const unknown = await t.get('/api/v1/me/applications', { as: kim, json: { title: 'x', company: 'y', url: 'https://y.example', salary: 1 }, headers: SAME });
+ assert.strictEqual(unknown.status, 400);
+ assert.strictEqual(unknown.json().code, 'work.applications.unknown_field');
+
+ const noTitle = await t.get('/api/v1/me/applications', { as: kim, json: { company: 'y', url: 'https://y.example' }, headers: SAME });
+ assert.strictEqual(noTitle.status, 400);
+ assert.strictEqual(noTitle.json().code, 'work.applications.title_required');
+
+ const badUrl = await t.get('/api/v1/me/applications', { as: kim, json: { title: 'x', company: 'y', url: 'not a url' }, headers: SAME });
+ assert.strictEqual(badUrl.status, 400);
+ assert.strictEqual(badUrl.json().code, 'work.applications.url_invalid');
+
+ const made = (await t.get('/api/v1/me/applications', { as: kim, json: { title: 'x', company: 'y', url: 'https://y.example' }, headers: SAME })).json();
+ const badStatus = await t.get(`/api/v1/me/applications/${made.id}`, { as: kim, method: 'PATCH', json: { status: 'hired' }, headers: SAME });
+ assert.strictEqual(badStatus.status, 400);
+ assert.strictEqual(badStatus.json().code, 'work.applications.status_invalid');
+
+ const longNotes = await t.get(`/api/v1/me/applications/${made.id}`, { as: kim, method: 'PATCH', json: { notes: 'n'.repeat(5001) }, headers: SAME });
+ assert.strictEqual(longNotes.status, 400);
+ assert.strictEqual(longNotes.json().code, 'work.applications.notes_too_long');
+
+ const badFilter = await t.get('/api/v1/me/applications?status=hired', { as: kim });
+ assert.strictEqual(badFilter.status, 400);
+ assert.strictEqual(badFilter.json().code, 'work.applications.status_invalid');
+ });
+
+ await check('the cap: one more than the configured maximum is refused 409', async () => {
+ const small = await boot({ env: { WORK_APPLICATIONS_MAX: '2' } });
+ const user = small.network.addUser('kim');
+ try {
+ for (let i = 0; i < 2; i++) {
+ const r = await small.get('/api/v1/me/applications', { as: user, json: { title: `job ${i}`, company: 'Acme', url: 'https://acme.example/job' }, headers: SAME });
+ assert.strictEqual(r.status, 201, r.text);
+ }
+ const full = await small.get('/api/v1/me/applications', { as: user, json: { title: 'job 3', company: 'Acme', url: 'https://acme.example/job' }, headers: SAME });
+ assert.strictEqual(full.status, 409);
+ assert.strictEqual(full.json().code, 'work.applications_full');
+ } finally { await small.close(); }
+ });
+
+ await check('asking the agent: idempotency key, task_url, the ask and subject travel to Actor', async () => {
+ actor.available = true;
+ actor.impl = null;
+ actor.calls.length = 0;
+ const listing = await firstListing();
+ const made = (await t.get('/api/v1/me/applications', { as: kim, json: { listing_id: listing.id }, headers: SAME })).json();
+ const res = await t.get(`/api/v1/me/applications/${made.id}/assist`, { as: kim, json: { ask: 'Emphasise my systems experience.' }, headers: SAME });
+ assert.strictEqual(res.status, 200, res.text);
+ const out = res.json();
+ assert.strictEqual(out.task_id, 'tsk_01JZ0000000000000000000000TK');
+ assert.strictEqual(out.task_url, 'https://openvibe.actor/tasks/tsk_01JZ0000000000000000000000TK');
+
+ assert.strictEqual(actor.calls.length, 1);
+ assert.strictEqual(actor.calls[0].idempotencyKey, `work-assist:${made.id}:1`);
+ assert.strictEqual(actor.calls[0].subjectId, kim.subject);
+ assert.ok(actor.calls[0].task.includes(made.title));
+ assert.ok(actor.calls[0].task.includes(made.company));
+ assert.ok(actor.calls[0].task.includes(made.url));
+ assert.ok(actor.calls[0].task.includes('Emphasise my systems experience.'));
+ assert.ok(actor.calls[0].task.includes('Excerpt:'), 'the listing excerpt is in the task text');
+
+ const stored = (await t.get(`/api/v1/me/applications/${made.id}`, { as: kim })).json();
+ assert.strictEqual(stored.actor_task_id, out.task_id);
+
+ const second = await t.get(`/api/v1/me/applications/${made.id}/assist`, { as: kim, json: {}, headers: SAME });
+ assert.strictEqual(second.status, 200);
+ assert.strictEqual(actor.calls[1].idempotencyKey, `work-assist:${made.id}:2`);
+ assert.strictEqual(actor.calls[1].subjectId, kim.subject);
+ });
+
+ await check('assist: Actor unavailable is 503, and Actor\'s 402/429 refusal passes through as 409', async () => {
+ const made = (await t.get('/api/v1/me/applications', { as: kim, json: { title: 'SRE', company: 'Acme', url: 'https://acme.example/sre' }, headers: SAME })).json();
+
+ actor.available = false;
+ actor.calls.length = 0;
+ const unavailable = await t.get(`/api/v1/me/applications/${made.id}/assist`, { as: kim, json: {}, headers: SAME });
+ assert.strictEqual(unavailable.status, 503);
+ assert.strictEqual(unavailable.json().code, 'work.actor_unavailable');
+ assert.strictEqual(actor.calls.length, 0, 'an unavailable Actor is never called');
+
+ actor.available = true;
+ actor.impl = () => { throw Object.assign(new Error('over budget'), { status: 429, code: 'actor.budget.exceeded', detail: 'over budget' }); };
+ const refused = await t.get(`/api/v1/me/applications/${made.id}/assist`, { as: kim, json: {}, headers: SAME });
+ assert.strictEqual(refused.status, 409);
+ assert.strictEqual(refused.json().code, 'actor.budget.exceeded');
+
+ actor.impl = () => { throw Object.assign(new Error('boom'), { status: 500 }); };
+ const error = await t.get(`/api/v1/me/applications/${made.id}/assist`, { as: kim, json: {}, headers: SAME });
+ assert.strictEqual(error.status, 503);
+ assert.strictEqual(error.json().code, 'work.actor_unavailable');
+ actor.impl = null;
+ });
+
+ await check('asking the agent is limited to 10 per person per day', async () => {
+ const t2 = await boot({ actor: makeActor() });
+ const user = t2.network.addUser('kim');
+ try {
+ const made = (await t2.get('/api/v1/me/applications', { as: user, json: { title: 'PM', company: 'Acme', url: 'https://acme.example/pm' }, headers: SAME })).json();
+ for (let i = 0; i < 10; i++) {
+ const r = await t2.get(`/api/v1/me/applications/${made.id}/assist`, { as: user, json: {}, headers: SAME });
+ assert.strictEqual(r.status, 200, `assist ${i + 1}`);
+ }
+ const over = await t2.get(`/api/v1/me/applications/${made.id}/assist`, { as: user, json: {}, headers: SAME });
+ assert.strictEqual(over.status, 429);
+ assert.strictEqual(over.json().code, 'rate_limited');
+ } finally { await t2.close(); }
+ });
+
+ await check('the pages: signed out is an explanation, signed in lists and edits, and a listing page tracks', async () => {
+ const anon = await t.get('/applications');
+ assert.strictEqual(anon.status, 200);
+ assert.match(anon.text, /Sign in with OpenVibe/);
+
+ const listing = await firstListing();
+ const before = await t.get(`/jobs/${listing.id}`);
+ assert.match(before.text, /Sign in/, 'a signed-out listing page offers a sign-in for tracking');
+ assert.doesNotMatch(before.text, /Track this job/, 'a signed-out listing page has no tracking form');
+
+ const trackedPage = await t.get(`/jobs/${listing.id}`, { as: lee });
+ assert.match(trackedPage.text, /Track this job/, 'a signed-in listing page offers tracking');
+
+ const tracked = await t.get(`/jobs/${listing.id}/track`, { as: lee, method: 'POST', headers: SAME });
+ assert.strictEqual(tracked.status, 303);
+ assert.match(tracked.headers.get('location'), /^\/applications\/wap_/);
+
+ const again = await t.get(`/jobs/${listing.id}`, { as: lee });
+ assert.match(again.text, /Tracked: Saved/, 'an already-tracked listing says so');
+ assert.match(again.text, /Open your application/);
+
+ const list = await t.get('/applications', { as: lee });
+ assert.strictEqual(list.status, 200);
+ assert.match(list.text, /Applications/);
+ assert.match(list.text, /Add one by hand/);
+
+ const id = tracked.headers.get('location').split('/').pop();
+ const detail = await t.get(`/applications/${id}`, { as: lee });
+ assert.strictEqual(detail.status, 200);
+ assert.match(detail.text, /<select name="status">/);
+ assert.match(detail.text, /Ask my agent/);
+ assert.match(detail.text, /Delete this application/);
+
+ const foreign = await t.get(`/applications/${id}/delete`, { as: lee, method: 'POST', headers: { 'sec-fetch-site': 'cross-site' }, redirect: 'manual' });
+ assert.strictEqual(foreign.status, 403, 'a cross-site delete is refused');
+ const gone = await t.get(`/applications/${id}/delete`, { as: lee, method: 'POST', headers: SAME, redirect: 'manual' });
+ assert.strictEqual(gone.status, 303);
+ assert.strictEqual((await t.get(`/applications/${id}`, { as: lee })).status, 404, 'the deleted application is gone');
+ });
+ } finally { await t.close(); }
+ done();
+})().catch((e) => { console.error(e); process.exit(1); });
@@ -21,8 +21,8 @@ const READ = 'work.api.read';
@@ -21,8 +21,8 @@ const READ = 'work.api.read';
const sam = t.network.addUser('sam');
const sam = t.network.addUser('sam');
await check('the product declares its own expensive routes in BUDGETS, and only those', () => {
await check('the product declares its own expensive routes in BUDGETS, and only those', () => {
- // Searching the listings and saving a search: the two routes the product gives numbers of their own.
- assert.deepStrictEqual(Object.keys(BUDGETS).sort(), ['work.jobs.search', 'work.saved_searches.write']);
+ // Searching the listings, saving a search and writing an application: the product's own numbers.
+ assert.deepStrictEqual(Object.keys(BUDGETS).sort(), ['work.applications.write', 'work.jobs.search', 'work.saved_searches.write']);
for (const [name, own] of Object.entries(BUDGETS)) {
for (const [name, own] of Object.entries(BUDGETS)) {
assert.ok(name.startsWith('work.'), name);
assert.ok(name.startsWith('work.'), name);
assert.ok(own.minute > 0 && own.hour >= own.minute, name);
assert.ok(own.minute > 0 && own.hour >= own.minute, name);
@@ -45,7 +45,7 @@ async function boot(opts = {}) {
@@ -45,7 +45,7 @@ async function boot(opts = {}) {
const { createStore } = require('../../server/db');
const { createStore } = require('../../server/db');
const testdb = await require('./db').testDb();
const testdb = await require('./db').testDb();
const store = createStore(testdb.db, { now: opts.now });
const store = createStore(testdb.db, { now: opts.now });
- const built = await createApp({ config, store, log, limitsNow: opts.limitsNow, callerLimits: opts.callerLimits === true, accountSend: opts.accountSend });
+ const built = await createApp({ config, store, log, limitsNow: opts.limitsNow, callerLimits: opts.callerLimits === true, accountSend: opts.accountSend, actor: opts.actor, assistLimitsNow: opts.assistLimitsNow });
await built.ctx.keys.ensure();
await built.ctx.keys.ensure();
const server = await new Promise((resolve) => { const s = http.createServer(built.app); s.listen(0, '127.0.0.1', () => resolve(s)); });
const server = await new Promise((resolve) => { const s = http.createServer(built.app); s.listen(0, '127.0.0.1', () => resolve(s)); });
const base = `http://127.0.0.1:${server.address().port}`;
const base = `http://127.0.0.1:${server.address().port}`;