openvibers/openvibe.work Commits
Agent layer: /.well-known/agent.json and Markdown twins of the public pages (#116)
Changes
@@@ -90,7 +90,7 @@ Its own PostgreSQL tables, created by [migrations/](migrations/) and written by- **PostgreSQL** (`DATABASE_URL`, `DATABASE_DIRECT_URL`; an embedded PGlite database in development via`WORK_PGLITE_DIR`) and **Valkey** for shared limit counters (`VALKEY_URL`, `VALKEY_PREFIX`).- **Packages**: `openvibe-contracts` v0.222.0, `openvibe-sdk` v0.67.0 (`db`, `auth`, `account-data`, `limits`,-- `valkey`, `service`) and `openvibe-shared` v4.33.0 (`frame`, `legal`, `serve`, `release`, `metrics`, `ready`,++ `valkey`, `service`) and `openvibe-shared` v4.34.0 (`frame`, `legal`, `serve`, `release`, `metrics`, `ready`,`seo`, `shell`, `cache-policy`, `showcase`, `app-icon`).## Capabilities@@@ -273,6 +273,6 @@ Part of the [OpenVibe network](https://openvibe.network). Built in the open by [<!-- versions:start -->- openvibe-contracts: v0.222.0- openvibe-sdk: v0.67.0--- openvibe-shared: v4.33.0++- openvibe-shared: v4.34.0- openvibe-publishing: v1.4.0<!-- versions:end -->@@@ -9,11 +9,11 @@"domain": "openvibe.work","contracts": "openvibe-contracts v0.222.0","sdk": "openvibe-sdk v0.67.0",-- "shared": "openvibe-shared v4.33.0",++ "shared": "openvibe-shared v4.34.0","packages": ["openvibe-contracts v0.222.0","openvibe-sdk v0.67.0",-- "openvibe-shared v4.33.0",++ "openvibe-shared v4.34.0","openvibe-publishing v1.4.0"],"summary": "OpenVibe.Work gathers job listings from three public job boards (Arbeitnow, Remotive, RemoteOK) under their terms — the source is named and the original listing is linked on every page, and only a short excerpt is kept. It searches them with facets, shows one listing with a schema.org JobPosting whose url is the original, and lets a signed-in person keep a search, see what is new since they last looked, get alerts through OpenVibe.Watch, track the jobs they apply to with a status history, and ask their OpenVibe.Actor agent to help prepare an application. Public reads need no token.",@@@ -18,7 +18,7 @@"openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.222.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.67.0",-- "openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v4.33.0",++ "openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v4.34.0","pg": "^8.23.0"},"devDependencies": {@@@ -28,6 +28,12 @@"node": ">=22.0.0"}},++ "node_modules/@acemir/cssom": {++ "version": "0.9.32",++ "resolved": "https://registry.npmjs.org/@acemir/cssom/-/cssom-0.9.32.tgz",++ "integrity": "sha512-OijrAUu7rrJhSIoHNg6LMPS5e344nwUHA+1EFYYjrh64D+GmytUsfBQjci45TjT+rd5Spsp8B5xsZDT+TgyXXQ==",++ "license": "MIT"++ },"node_modules/@electric-sql/pglite": {"version": "0.5.8","resolved": "https://registry.npmjs.org/@electric-sql/pglite/-/pglite-0.5.8.tgz",@@@ -117,6 +123,19 @@"npm": "1.2.8000 || >= 1.4.16"}},++ "node_modules/boolbase": {++ "version": "2.0.0",++ "resolved": "https://registry.npmjs.org/boolbase/-/boolbase-2.0.0.tgz",++ "integrity": "sha512-DkVaaQHymRhpYEYo9x1oo7Q7B0Y6KJUsjm3c9eTyFDby4MHLBTwZ6ZDWBel5zrYxj1WsZgC5oLpiz+93MluXeA==",++ "license": "ISC",++ "engines": {++ "node": ">=20.19.0"++ },++ "funding": {++ "type": "github",++ "url": "https://github.com/sponsors/fb55"++ }++ },"node_modules/buffer-equal-constant-time": {"version": "1.0.1","resolved": "https://registry.npmjs.org/buffer-equal-constant-time/-/buffer-equal-constant-time-1.0.1.tgz",@@@ -219,6 +238,39 @@"integrity": "sha512-QADzlaHc8icV8I7vbaJXJwod9HWYp8uCqf1xa4OfNu1T7JVxQIrUgOWtHdNDtPiywmFbiS12VjotIXLrKM3orQ==","license": "MIT"},++ "node_modules/css-select": {++ "version": "7.0.0",++ "resolved": "https://registry.npmjs.org/css-select/-/css-select-7.0.0.tgz",++ "integrity": "sha512-snmjEVXy+1LnwXdxhYvTMj1d9tOh4HxkA1YmoayVBeeyR2C14Pum7fcxJIm4SswYspVy866eYNwlH6xC3/VH5g==",++ "license": "BSD-2-Clause",++ "dependencies": {++ "boolbase": "^2.0.0",++ "css-what": "^8.0.0",++ "domhandler": "^6.0.1",++ "domutils": "^4.0.2",++ "nth-check": "^3.0.1"++ },++ "engines": {++ "node": ">=20.19.0"++ },++ "funding": {++ "type": "github",++ "url": "https://github.com/sponsors/fb55"++ }++ },++ "node_modules/css-what": {++ "version": "8.0.0",++ "resolved": "https://registry.npmjs.org/css-what/-/css-what-8.0.0.tgz",++ "integrity": "sha512-DH0Bqq3DNp5tdOReuNyAA+Ev4Y2GS5FMbZpeTLP6C4CDi0h5nL0BmUPChXw3o/qbHLDWHl49sbNqQVY7bMSDdw==",++ "license": "BSD-2-Clause",++ "engines": {++ "node": ">=20.19.0"++ },++ "funding": {++ "type": "github",++ "url": "https://github.com/sponsors/fb55"++ }++ },"node_modules/debug": {"version": "2.6.9","resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz",@@@ -256,6 +308,73 @@"npm": "1.2.8000 || >= 1.4.16"}},++ "node_modules/dom-serializer": {++ "version": "3.1.1",++ "resolved": "https://registry.npmjs.org/dom-serializer/-/dom-serializer-3.1.1.tgz",++ "integrity": "sha512-4MEa38/QexBob6gFNwu+EGdWvhJ1OKuNwdYY3Y3NyeWDQfnGeDYQUDfIRzWu5B5gsv03so2Uxd28YC6zrsx3Lw==",++ "license": "MIT",++ "dependencies": {++ "domelementtype": "^3.0.0",++ "domhandler": "^6.0.0",++ "entities": "^8.0.0"++ },++ "engines": {++ "node": ">=20.19.0"++ },++ "funding": {++ "type": "github",++ "url": "https://github.com/cheeriojs/dom-serializer?sponsor=1"++ }++ },++ "node_modules/domelementtype": {++ "version": "3.0.0",++ "resolved": "https://registry.npmjs.org/domelementtype/-/domelementtype-3.0.0.tgz",++ "integrity": "sha512-umCQid3jKbDmVjx8jGaW7uUykm4DEUeyV21hPxNMo2nV955DhUThwqyOIDtreepP31hl84X7G5U9ZfsWvIB3Pg==",++ "funding": [++ {++ "type": "github",++ "url": "https://github.com/sponsors/fb55"++ }++ ],++ "license": "BSD-2-Clause",++ "engines": {++ "node": ">=20.19.0"++ }++ },++ "node_modules/domhandler": {++ "version": "6.0.1",++ "resolved": "https://registry.npmjs.org/domhandler/-/domhandler-6.0.1.tgz",++ "integrity": "sha512-gYzvtM72ZtxQO0T048kd6HWSbbGCNOUwcnfQ01cqIJ4X2IYKFFHZ5mKvrQETcFXxsRObZulDaKmy//R7TPtsBg==",++ "license": "BSD-2-Clause",++ "dependencies": {++ "domelementtype": "^3.0.0"++ },++ "engines": {++ "node": ">=20.19.0"++ },++ "funding": {++ "type": "github",++ "url": "https://github.com/fb55/domhandler?sponsor=1"++ }++ },++ "node_modules/domutils": {++ "version": "4.0.2",++ "resolved": "https://registry.npmjs.org/domutils/-/domutils-4.0.2.tgz",++ "integrity": "sha512-qI4JLRKnSzqFqr7hAlS5xQDusBCjKSEG4t4+7aNrIQMHBcsC2TGEhuyABJdYkgSewL57PNLYEiibY2iPKhKpaA==",++ "license": "BSD-2-Clause",++ "dependencies": {++ "dom-serializer": "^3.0.0",++ "domelementtype": "^3.0.0",++ "domhandler": "^6.0.0"++ },++ "engines": {++ "node": ">=20.19.0"++ },++ "funding": {++ "type": "github",++ "url": "https://github.com/fb55/domutils?sponsor=1"++ }++ },"node_modules/dotenv": {"version": "16.6.1","resolved": "https://registry.npmjs.org/dotenv/-/dotenv-16.6.1.tgz",@@@ -306,6 +425,18 @@"node": ">= 0.8"}},++ "node_modules/entities": {++ "version": "8.1.0",++ "resolved": "https://registry.npmjs.org/entities/-/entities-8.1.0.tgz",++ "integrity": "sha512-kxL7msIffSuh9aaFAMD7rxAIuTRMAHMeBtgHW2yUdWw732ZNh4MehkF2gdjvtdmikkaIP9bFDDJOPlsvm7avrA==",++ "license": "BSD-2-Clause",++ "engines": {++ "node": ">=20.19.0"++ },++ "funding": {++ "url": "https://github.com/fb55/entities?sponsor=1"++ }++ },"node_modules/es-define-property": {"version": "1.0.1","resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz",@@@ -564,6 +695,110 @@"url": "https://github.com/sponsors/EvanHahn"}},++ "node_modules/html-escaper": {++ "version": "3.0.3",++ "resolved": "https://registry.npmjs.org/html-escaper/-/html-escaper-3.0.3.tgz",++ "integrity": "sha512-RuMffC89BOWQoY0WKGpIhn5gX3iI54O6nRA0yC124NYVtzjmFWBIiFd8M0x+ZdX0P9R4lADg1mgP8C7PxGOWuQ==",++ "license": "MIT"++ },++ "node_modules/htmlparser2": {++ "version": "10.1.0",++ "resolved": "https://registry.npmjs.org/htmlparser2/-/htmlparser2-10.1.0.tgz",++ "integrity": "sha512-VTZkM9GWRAtEpveh7MSF6SjjrpNVNNVJfFup7xTY3UpFtm67foy9HDVXneLtFVt4pMz5kZtgNcvCniNFb1hlEQ==",++ "funding": [++ "https://github.com/fb55/htmlparser2?sponsor=1",++ {++ "type": "github",++ "url": "https://github.com/sponsors/fb55"++ }++ ],++ "license": "MIT",++ "dependencies": {++ "domelementtype": "^2.3.0",++ "domhandler": "^5.0.3",++ "domutils": "^3.2.2",++ "entities": "^7.0.1"++ }++ },++ "node_modules/htmlparser2/node_modules/dom-serializer": {++ "version": "2.0.0",++ "resolved": "https://registry.npmjs.org/dom-serializer/-/dom-serializer-2.0.0.tgz",++ "integrity": "sha512-wIkAryiqt/nV5EQKqQpo3SToSOV9J0DnbJqwK7Wv/Trc92zIAYZ4FlMu+JPFW1DfGFt81ZTCGgDEabffXeLyJg==",++ "license": "MIT",++ "dependencies": {++ "domelementtype": "^2.3.0",++ "domhandler": "^5.0.2",++ "entities": "^4.2.0"++ },++ "funding": {++ "url": "https://github.com/cheeriojs/dom-serializer?sponsor=1"++ }++ },++ "node_modules/htmlparser2/node_modules/dom-serializer/node_modules/entities": {++ "version": "4.5.0",++ "resolved": "https://registry.npmjs.org/entities/-/entities-4.5.0.tgz",++ "integrity": "sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw==",++ "license": "BSD-2-Clause",++ "engines": {++ "node": ">=0.12"++ },++ "funding": {++ "url": "https://github.com/fb55/entities?sponsor=1"++ }++ },++ "node_modules/htmlparser2/node_modules/domelementtype": {++ "version": "2.3.0",++ "resolved": "https://registry.npmjs.org/domelementtype/-/domelementtype-2.3.0.tgz",++ "integrity": "sha512-OLETBj6w0OsagBwdXnPdN0cnMfF9opN69co+7ZrbfPGrdpPVNBUj02spi6B1N7wChLQiPn4CSH/zJvXw56gmHw==",++ "funding": [++ {++ "type": "github",++ "url": "https://github.com/sponsors/fb55"++ }++ ],++ "license": "BSD-2-Clause"++ },++ "node_modules/htmlparser2/node_modules/domhandler": {++ "version": "5.0.3",++ "resolved": "https://registry.npmjs.org/domhandler/-/domhandler-5.0.3.tgz",++ "integrity": "sha512-cgwlv/1iFQiFnU96XXgROh8xTeetsnJiDsTc7TYCLFd9+/WNkIqPTxiM/8pSd8VIrhXGTf1Ny1q1hquVqDJB5w==",++ "license": "BSD-2-Clause",++ "dependencies": {++ "domelementtype": "^2.3.0"++ },++ "engines": {++ "node": ">= 4"++ },++ "funding": {++ "url": "https://github.com/fb55/domhandler?sponsor=1"++ }++ },++ "node_modules/htmlparser2/node_modules/domutils": {++ "version": "3.2.2",++ "resolved": "https://registry.npmjs.org/domutils/-/domutils-3.2.2.tgz",++ "integrity": "sha512-6kZKyUajlDuqlHKVX1w7gyslj9MPIXzIFiz/rGu35uC1wMi+kMhQwGhl4lt9unC9Vb9INnY9Z3/ZA3+FhASLaw==",++ "license": "BSD-2-Clause",++ "dependencies": {++ "dom-serializer": "^2.0.0",++ "domelementtype": "^2.3.0",++ "domhandler": "^5.0.3"++ },++ "funding": {++ "url": "https://github.com/fb55/domutils?sponsor=1"++ }++ },++ "node_modules/htmlparser2/node_modules/entities": {++ "version": "7.0.1",++ "resolved": "https://registry.npmjs.org/entities/-/entities-7.0.1.tgz",++ "integrity": "sha512-TWrgLOFUQTH994YUyl1yT4uyavY5nNB5muff+RtWaqNVCAK408b5ZnnbNAUEWLTCpum9w6arT70i1XdQ4UeOPA==",++ "license": "BSD-2-Clause",++ "engines": {++ "node": ">=0.12"++ },++ "funding": {++ "url": "https://github.com/fb55/entities?sponsor=1"++ }++ },"node_modules/http-errors": {"version": "2.0.1","resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.1.tgz",@@@ -709,6 +944,30 @@"safe-buffer": "^5.0.1"}},++ "node_modules/linkedom": {++ "version": "0.18.14",++ "resolved": "https://registry.npmjs.org/linkedom/-/linkedom-0.18.14.tgz",++ "integrity": "sha512-BRUnkOm4YR5vo+qI3j0EbungB0vZPSCue5AdnTQRaxAGSbsOZiBFRO0rh4zlo8qXUhli8pxvnpEXDklMnlq2Rg==",++ "license": "ISC",++ "dependencies": {++ "@acemir/cssom": "^0.9.32",++ "css-select": "^7.0.0",++ "html-escaper": "^3.0.3",++ "htmlparser2": "^10.1.0",++ "uhyphen": "^0.2.0"++ },++ "engines": {++ "node": ">=16"++ },++ "peerDependencies": {++ "canvas": ">= 2"++ },++ "peerDependenciesMeta": {++ "canvas": {++ "optional": true++ }++ }++ },"node_modules/lodash.defaults": {"version": "4.2.0","resolved": "https://registry.npmjs.org/lodash.defaults/-/lodash.defaults-4.2.0.tgz",@@@ -847,6 +1106,22 @@"node": ">= 0.6"}},++ "node_modules/nth-check": {++ "version": "3.0.1",++ "resolved": "https://registry.npmjs.org/nth-check/-/nth-check-3.0.1.tgz",++ "integrity": "sha512-GX0gsdbGVCgnRgbeGaubfjpBXyYRWOOCVeYh08bSQvDZqxz5ndXs1OTfAt/h36G1xvI94YIspsI0sVFqAV9+RQ==",++ "license": "BSD-2-Clause",++ "dependencies": {++ "boolbase": "^2.0.0"++ },++ "engines": {++ "node": ">=20.19.0"++ },++ "funding": {++ "type": "github",++ "url": "https://github.com/fb55/nth-check?sponsor=1"++ }++ },"node_modules/object-inspect": {"version": "1.13.4","resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.4.tgz",@@@ -934,12 +1209,13 @@}},"node_modules/openvibe-shared": {-- "version": "4.33.0",-- "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v4.33.0",-- "integrity": "sha512-t9XvhrQ+lQebOrFTjMCzI6/7/i4iQn9dmMr/8HVTNW1XoV1exyJa7IH/HfyyKuLxjA8xaowrDfD28LwKIibBNg==",++ "version": "4.34.0",++ "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v4.34.0",++ "integrity": "sha512-Rj5uRO3MbWkyIYAm+vK4Y94ugDNYEjEgGkYnPjSej4yMqhalpaPCkU96dzphMGiOUGP1PNWsiKhwaBj8tewgBg==","license": "MIT","dependencies": {-- "jsonwebtoken": "^9.0.0"++ "jsonwebtoken": "^9.0.0",++ "linkedom": "^0.18.13"},"bin": {"openvibe-build-assets": "scripts/build-assets.js"@@@ -1382,6 +1658,12 @@"node": ">= 0.6"}},++ "node_modules/uhyphen": {++ "version": "0.2.0",++ "resolved": "https://registry.npmjs.org/uhyphen/-/uhyphen-0.2.0.tgz",++ "integrity": "sha512-qz3o9CHXmJJPGBdqzab7qAYuW8kQGKNEuoHFYrBwV6hWIMcpAmxDLXojcHfFr9US1Pe6zUswEIJIbLI610fuqA==",++ "license": "ISC"++ },"node_modules/unpipe": {"version": "1.0.0","resolved": "https://registry.npmjs.org/unpipe/-/unpipe-1.0.0.tgz",@@@ -2,7 +2,7 @@"name": "openvibe-work","version": "0.2.0","private": true,-- "description": "OpenVibe.Work \u2014 Find work, with an agent on your side.",++ "description": "OpenVibe.Work — Find work, with an agent on your side.","main": "server/index.js","scripts": {"start": "node server/index.js",@@@ -26,7 +26,7 @@"openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.222.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.67.0",-- "openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v4.33.0",++ "openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v4.34.0","pg": "^8.23.0"},"engines": {nnew file mode 100644@@@ -0,0 +1,18 @@++'use strict';++// What openvibe.work tells an agent about itself (openvibe-shared/agent-layer): /.well-known/agent.json.++// Every field is something this host really serves: the public pages (the home, the jobs search and one listing,++// companies and one company, the sources, the API reference and the update log), the JSON API under /api/v1 and++// its /docs reference, llms.txt and the sitemap. Reading the listings is public; a saved search is a person's, so++// the API also takes a Network sign-in. No OpenAPI document or MCP endpoint is published here, so none is named.++module.exports = {++ id: 'work',++ name: 'OpenVibe.Work',++ purpose: 'Find work on open job boards: listings from Arbeitnow, Remotive and RemoteOK under their terms, searchable with facets and each linking back to the original, plus saved searches and OpenVibe.Watch alerts for a signed-in person.',++ home: 'https://openvibe.work',++ site: 'openvibe.work',++ api: '/api/v1',++ docs: '/docs',++ policy: {++ auth: ['none', 'Network OIDC'],++ },++};nnew file mode 100644@@@ -0,0 +1,75 @@++'use strict';++// The Markdown twins of OpenVibe.Work's public pages (openvibe-shared/agent-layer): the home, the jobs search and++// one listing, companies and one company, the sources, the API reference and the update log.++//++// A twin is the page's own render: the page router answers the request in-process, signed out, so a twin cannot++// drift from the page a browser gets and never carries a signed-in person's view. A saved search, the applications++// and a missing listing or company answer nothing and have no twin.++const TWIN_PATH_RE = /^\/(?:jobs(?:\/[^/]+)?|companies(?:\/[^/]+)?|sources|docs|updates)?$/;++++/** A request as an anonymous browser makes it: no session, no cookies. */++function anonymousRequest(path, query, host) {++ const search = query && query.toString() ? `?${query.toString()}` : '';++ const url = `${path}${search}`;++ return {++ method: 'GET', url, originalUrl: url, path, params: {}, query: query ? Object.fromEntries(query) : {},++ headers: { accept: 'text/html', host }, cookies: {}, body: undefined, protocol: 'https', secure: true,++ ip: '127.0.0.1', ips: ['127.0.0.1'], hostname: host,++ get(name) { return this.headers[String(name || '').toLowerCase()] || ''; },++ accepts(types) {++ if (!Array.isArray(types)) return 'text/html';++ return types.some((t) => t === 'html' || t === 'text/html') ? 'html' : false;++ },++ is() { return false; },++ };++}++++/** A response that keeps what the page wrote, so the twin can be built from it. */++function captureResponse() {++ return {++ statusCode: 200, headers: {}, body: null, headersSent: false,++ status(code) { this.statusCode = code; return this; },++ set(name, value) { this.headers[String(name)] = value; return this; },++ setHeader(name, value) { this.headers[String(name)] = value; return this; },++ getHeader(name) { return this.headers[String(name)]; },++ get(name) { return this.headers[String(name)]; },++ removeHeader(name) { delete this.headers[String(name)]; },++ type(value) { this.headers['Content-Type'] = value === 'html' ? 'text/html; charset=utf-8' : String(value); return this; },++ location(url) { this.headers.Location = url; return this; },++ send(body) { this.body = body; this.headersSent = true; return this; },++ json(body) { this.body = JSON.stringify(body); this.headersSent = true; return this; },++ redirect(code, url) { this.statusCode = code; this.headers.Location = url; this.body = ''; this.headersSent = true; return this; },++ end(body) { if (body !== undefined) this.body = body; this.headersSent = true; return this; },++ cookie() { return this; }, clearCookie() { return this; },++ };++}++++function createAgentPages({ router, host }) {++ async function renderPublicPage(path, { query } = {}) {++ const p = String(path || '/').replace(/\/+$/, '') || '/';++ if (!TWIN_PATH_RE.test(p)) return null;++ const req = anonymousRequest(p, query, host);++ req.viewer = { kind: 'anonymous' };++ const res = captureResponse();++ await new Promise((resolve) => {++ let settled = false;++ const finish = () => { if (!settled) { settled = true; resolve(); } };++ const send = res.send.bind(res);++ res.send = (body) => { send(body); finish(); return res; };++ const redirect = res.redirect.bind(res);++ res.redirect = (code, url) => { redirect(code, url); finish(); return res; };++ try { router(req, res, () => finish()); } catch { finish(); }++ const timer = setTimeout(finish, 10000);++ if (timer.unref) timer.unref();++ });++ // A page that needs a person, a missing listing or company, or a page that is not public has no twin.++ if (res.statusCode !== 200 || res.body == null) return null;++ const html = Buffer.isBuffer(res.body) ? res.body.toString('utf8') : String(res.body);++ if (!/^text\/html/i.test(String(res.headers['Content-Type'] || ''))) return null;++ return { html };++ }++++ return { renderPublicPage };++}++++module.exports = { createAgentPages, TWIN_PATH_RE };@@@ -185,7 +185,28 @@ async function createApp(opts = {}) {// ── Pages ───────────────────────────────────────────────app.use(rateLimit({ windowMs: 60_000, limit: Number(process.env.WORK_RATE_LIMIT_PER_MIN) || 300, standardHeaders: true, legacyHeaders: false }));-- app.use(createPageRoutes(ctx));++ // The agent layer (openvibe-shared/agent-layer): /.well-known/agent.json, and the Markdown twins of the public++ // pages. Entered only for the manifest exactly (the rest of /.well-known/ belongs to the app) and a twin request —++ // a `.md` path, ?format=markdown or an explicit text/markdown preference — because the layer renders every page++ // signed out and a Work page is not one page (saved searches, applications, the account bar). It comes before the++ // page routes so `/jobs/x.md` is not taken for a listing called `x.md`; the app's own pages name their twins in++ // the head and the Link header (server/render/layout.js).++ const pageRoutes = createPageRoutes(ctx);++ {++ const agentRouter = express.Router();++ require('openvibe-shared/agent-layer').mount(agentRouter, {++ descriptor: require('./agent-descriptor'),++ render: require('./agent-pages').createAgentPages({ router: pageRoutes, host: new URL(config.baseUrl).hostname }).renderPublicPage,++ });++ app.use((req, res, next) => {++ if (req.path === '/.well-known/agent.json') return agentRouter(req, res, next);++ const p = String(req.path || '');++ if (p.endsWith('.md') || (req.query && req.query.format === 'markdown')++ || req.accepts(['html', 'text/markdown']) === 'text/markdown') return agentRouter(req, res, next);++ return next();++ });++ }++ app.use(pageRoutes);app.use((req, res) => send(res, 404, { viewer: req.viewer, config, path: req.originalUrl, title: 'Not found', body: html`<h1>Not found</h1><p>No page here. Try <a href="/">the home page</a> or <a href="/updates">the update log</a>.</p>` }));// eslint-disable-next-line no-unused-vars@@@ -68,7 +68,8 @@ function createDiscoveryRoutes(ctx) {r.get('/robots.txt', (_req, res) => {res.type('text/plain').set('Cache-Control', TEXT).send('# openvibe.work: the public pages are for search and AI crawlers; sign-in, the API and saved searches are not.\n'-- + seo.robotsTxt({ sitemaps: [abs('/sitemap.xml')], disallow: DISALLOW }));++ + seo.robotsTxt({ sitemaps: [abs('/sitemap.xml')], disallow: DISALLOW })++ + `# Agent manifest: ${abs('/.well-known/agent.json')}\n`);});r.get('/llms.txt', (_req, res) => {@@@ -17,6 +17,8 @@ const frame = require('openvibe-shared/frame');const shell = require('openvibe-shared/shell');const cache = require('openvibe-shared/cache-policy');const { html, raw, esc } = require('./html');++const { TWIN_PATH_RE } = require('../agent-pages');++const { linkHeaderTags } = require('openvibe-shared/agent-layer');const NETWORK_URL = 'https://openvibe.network';const SITE_NAME = 'OpenVibe.Work';@@@ -56,6 +58,8 @@ function renderPage(o) {const pathNow = o.path || '/';const loginNext = encodeURIComponent(pathNow);const canonical = `${o.config.baseUrl}${pathNow.split('?')[0]}`;++ // A public page names its Markdown twin (openvibe-shared/agent-layer); a signed-in page and a 404 do not.++ const hasTwin = TWIN_PATH_RE.test(pathNow.split('?')[0]) && o.status !== 404;const navLinks = NAV.filter((l) => !l.signedIn || signedIn);const nav = {service: 'work',@@@ -85,6 +89,9 @@ function renderPage(o) {siteName: SITE_NAME,description: o.description || `${SITE_NAME} — ${TAGLINE}`,canonical,++ // A public page names its Markdown twin (openvibe-shared/agent-layer).++ agentLayer: hasTwin,++ llms: '/llms.txt',robots: o.index ? 'index, follow' : 'noindex, nofollow',jsonLd: (o.jsonLd || []).filter(Boolean),home: '/',@@@ -125,7 +132,11 @@ function send(res, status, o) {const personal = o.viewer && o.viewer.kind === 'user';if (!res.get('Cache-Control')) res.set('Cache-Control', !personal && o.cache ? o.cache : cache.htmlHeaders({ private: true }));res.set('Vary', 'Cookie');-- res.status(status).type('html').send(renderPage(o));++ // The Link header names the same twin as the head, query included, where the site sets headers.++ if (TWIN_PATH_RE.test(String(o.path || '/').split('?')[0]) && status !== 404) {++ res.set('Link', linkHeaderTags(`${o.config.baseUrl}${o.path || '/'}`, '/llms.txt').header);++ }++ res.status(status).type('html').send(renderPage({ ...o, status }));}module.exports = { renderPage, send, asset, assetVersion, setRelease, SITE_NAME, NETWORK_URL, NAV, raw, html };
@@ -90,7 +90,7 @@ Its own PostgreSQL tables, created by [migrations/](migrations/) and written by
@@ -90,7 +90,7 @@ Its own PostgreSQL tables, created by [migrations/](migrations/) and written by
- **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
`WORK_PGLITE_DIR`) and **Valkey** for shared limit counters (`VALKEY_URL`, `VALKEY_PREFIX`).
`WORK_PGLITE_DIR`) and **Valkey** for shared limit counters (`VALKEY_URL`, `VALKEY_PREFIX`).
- **Packages**: `openvibe-contracts` v0.222.0, `openvibe-sdk` v0.67.0 (`db`, `auth`, `account-data`, `limits`,
- **Packages**: `openvibe-contracts` v0.222.0, `openvibe-sdk` v0.67.0 (`db`, `auth`, `account-data`, `limits`,
- `valkey`, `service`) and `openvibe-shared` v4.33.0 (`frame`, `legal`, `serve`, `release`, `metrics`, `ready`,
+ `valkey`, `service`) and `openvibe-shared` v4.34.0 (`frame`, `legal`, `serve`, `release`, `metrics`, `ready`,
`seo`, `shell`, `cache-policy`, `showcase`, `app-icon`).
`seo`, `shell`, `cache-policy`, `showcase`, `app-icon`).
## Capabilities
## Capabilities
@@ -273,6 +273,6 @@ Part of the [OpenVibe network](https://openvibe.network). Built in the open by [
@@ -273,6 +273,6 @@ Part of the [OpenVibe network](https://openvibe.network). Built in the open by [
<!-- versions:start -->
<!-- versions:start -->
- openvibe-contracts: v0.222.0
- openvibe-contracts: v0.222.0
- openvibe-sdk: v0.67.0
- openvibe-sdk: v0.67.0
-- openvibe-shared: v4.33.0
+- openvibe-shared: v4.34.0
- openvibe-publishing: v1.4.0
- openvibe-publishing: v1.4.0
<!-- versions:end -->
<!-- versions:end -->
@@ -9,11 +9,11 @@
@@ -9,11 +9,11 @@
"domain": "openvibe.work",
"domain": "openvibe.work",
"contracts": "openvibe-contracts v0.222.0",
"contracts": "openvibe-contracts v0.222.0",
"sdk": "openvibe-sdk v0.67.0",
"sdk": "openvibe-sdk v0.67.0",
- "shared": "openvibe-shared v4.33.0",
+ "shared": "openvibe-shared v4.34.0",
"packages": [
"packages": [
"openvibe-contracts v0.222.0",
"openvibe-contracts v0.222.0",
"openvibe-sdk v0.67.0",
"openvibe-sdk v0.67.0",
- "openvibe-shared v4.33.0",
+ "openvibe-shared v4.34.0",
"openvibe-publishing v1.4.0"
"openvibe-publishing v1.4.0"
],
],
"summary": "OpenVibe.Work gathers job listings from three public job boards (Arbeitnow, Remotive, RemoteOK) under their terms — the source is named and the original listing is linked on every page, and only a short excerpt is kept. It searches them with facets, shows one listing with a schema.org JobPosting whose url is the original, and lets a signed-in person keep a search, see what is new since they last looked, get alerts through OpenVibe.Watch, track the jobs they apply to with a status history, and ask their OpenVibe.Actor agent to help prepare an application. Public reads need no token.",
"summary": "OpenVibe.Work gathers job listings from three public job boards (Arbeitnow, Remotive, RemoteOK) under their terms — the source is named and the original listing is linked on every page, and only a short excerpt is kept. It searches them with facets, shows one listing with a schema.org JobPosting whose url is the original, and lets a signed-in person keep a search, see what is new since they last looked, get alerts through OpenVibe.Watch, track the jobs they apply to with a status history, and ask their OpenVibe.Actor agent to help prepare an application. Public reads need no token.",
@@ -18,7 +18,7 @@
@@ -18,7 +18,7 @@
"openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.222.0",
"openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.222.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.67.0",
"openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.67.0",
- "openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v4.33.0",
+ "openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v4.34.0",
"pg": "^8.23.0"
"pg": "^8.23.0"
},
},
"devDependencies": {
"devDependencies": {
@@ -28,6 +28,12 @@
@@ -28,6 +28,12 @@
"node": ">=22.0.0"
"node": ">=22.0.0"
}
}
},
},
+ "node_modules/@acemir/cssom": {
+ "version": "0.9.32",
+ "resolved": "https://registry.npmjs.org/@acemir/cssom/-/cssom-0.9.32.tgz",
+ "integrity": "sha512-OijrAUu7rrJhSIoHNg6LMPS5e344nwUHA+1EFYYjrh64D+GmytUsfBQjci45TjT+rd5Spsp8B5xsZDT+TgyXXQ==",
+ "license": "MIT"
+ },
"node_modules/@electric-sql/pglite": {
"node_modules/@electric-sql/pglite": {
"version": "0.5.8",
"version": "0.5.8",
"resolved": "https://registry.npmjs.org/@electric-sql/pglite/-/pglite-0.5.8.tgz",
"resolved": "https://registry.npmjs.org/@electric-sql/pglite/-/pglite-0.5.8.tgz",
@@ -117,6 +123,19 @@
@@ -117,6 +123,19 @@
"npm": "1.2.8000 || >= 1.4.16"
"npm": "1.2.8000 || >= 1.4.16"
}
}
},
},
+ "node_modules/boolbase": {
+ "version": "2.0.0",
+ "resolved": "https://registry.npmjs.org/boolbase/-/boolbase-2.0.0.tgz",
+ "integrity": "sha512-DkVaaQHymRhpYEYo9x1oo7Q7B0Y6KJUsjm3c9eTyFDby4MHLBTwZ6ZDWBel5zrYxj1WsZgC5oLpiz+93MluXeA==",
+ "license": "ISC",
+ "engines": {
+ "node": ">=20.19.0"
+ },
+ "funding": {
+ "type": "github",
+ "url": "https://github.com/sponsors/fb55"
+ }
+ },
"node_modules/buffer-equal-constant-time": {
"node_modules/buffer-equal-constant-time": {
"version": "1.0.1",
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/buffer-equal-constant-time/-/buffer-equal-constant-time-1.0.1.tgz",
"resolved": "https://registry.npmjs.org/buffer-equal-constant-time/-/buffer-equal-constant-time-1.0.1.tgz",
@@ -219,6 +238,39 @@
@@ -219,6 +238,39 @@
"integrity": "sha512-QADzlaHc8icV8I7vbaJXJwod9HWYp8uCqf1xa4OfNu1T7JVxQIrUgOWtHdNDtPiywmFbiS12VjotIXLrKM3orQ==",
"integrity": "sha512-QADzlaHc8icV8I7vbaJXJwod9HWYp8uCqf1xa4OfNu1T7JVxQIrUgOWtHdNDtPiywmFbiS12VjotIXLrKM3orQ==",
"license": "MIT"
"license": "MIT"
},
},
+ "node_modules/css-select": {
+ "version": "7.0.0",
+ "resolved": "https://registry.npmjs.org/css-select/-/css-select-7.0.0.tgz",
+ "integrity": "sha512-snmjEVXy+1LnwXdxhYvTMj1d9tOh4HxkA1YmoayVBeeyR2C14Pum7fcxJIm4SswYspVy866eYNwlH6xC3/VH5g==",
+ "license": "BSD-2-Clause",
+ "dependencies": {
+ "boolbase": "^2.0.0",
+ "css-what": "^8.0.0",
+ "domhandler": "^6.0.1",
+ "domutils": "^4.0.2",
+ "nth-check": "^3.0.1"
+ },
+ "engines": {
+ "node": ">=20.19.0"
+ },
+ "funding": {
+ "type": "github",
+ "url": "https://github.com/sponsors/fb55"
+ }
+ },
+ "node_modules/css-what": {
+ "version": "8.0.0",
+ "resolved": "https://registry.npmjs.org/css-what/-/css-what-8.0.0.tgz",
+ "integrity": "sha512-DH0Bqq3DNp5tdOReuNyAA+Ev4Y2GS5FMbZpeTLP6C4CDi0h5nL0BmUPChXw3o/qbHLDWHl49sbNqQVY7bMSDdw==",
+ "license": "BSD-2-Clause",
+ "engines": {
+ "node": ">=20.19.0"
+ },
+ "funding": {
+ "type": "github",
+ "url": "https://github.com/sponsors/fb55"
+ }
+ },
"node_modules/debug": {
"node_modules/debug": {
"version": "2.6.9",
"version": "2.6.9",
"resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz",
"resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz",
@@ -256,6 +308,73 @@
@@ -256,6 +308,73 @@
"npm": "1.2.8000 || >= 1.4.16"
"npm": "1.2.8000 || >= 1.4.16"
}
}
},
},
+ "node_modules/dom-serializer": {
+ "version": "3.1.1",
+ "resolved": "https://registry.npmjs.org/dom-serializer/-/dom-serializer-3.1.1.tgz",
+ "integrity": "sha512-4MEa38/QexBob6gFNwu+EGdWvhJ1OKuNwdYY3Y3NyeWDQfnGeDYQUDfIRzWu5B5gsv03so2Uxd28YC6zrsx3Lw==",
+ "license": "MIT",
+ "dependencies": {
+ "domelementtype": "^3.0.0",
+ "domhandler": "^6.0.0",
+ "entities": "^8.0.0"
+ },
+ "engines": {
+ "node": ">=20.19.0"
+ },
+ "funding": {
+ "type": "github",
+ "url": "https://github.com/cheeriojs/dom-serializer?sponsor=1"
+ }
+ },
+ "node_modules/domelementtype": {
+ "version": "3.0.0",
+ "resolved": "https://registry.npmjs.org/domelementtype/-/domelementtype-3.0.0.tgz",
+ "integrity": "sha512-umCQid3jKbDmVjx8jGaW7uUykm4DEUeyV21hPxNMo2nV955DhUThwqyOIDtreepP31hl84X7G5U9ZfsWvIB3Pg==",
+ "funding": [
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/fb55"
+ }
+ ],
+ "license": "BSD-2-Clause",
+ "engines": {
+ "node": ">=20.19.0"
+ }
+ },
+ "node_modules/domhandler": {
+ "version": "6.0.1",
+ "resolved": "https://registry.npmjs.org/domhandler/-/domhandler-6.0.1.tgz",
+ "integrity": "sha512-gYzvtM72ZtxQO0T048kd6HWSbbGCNOUwcnfQ01cqIJ4X2IYKFFHZ5mKvrQETcFXxsRObZulDaKmy//R7TPtsBg==",
+ "license": "BSD-2-Clause",
+ "dependencies": {
+ "domelementtype": "^3.0.0"
+ },
+ "engines": {
+ "node": ">=20.19.0"
+ },
+ "funding": {
+ "type": "github",
+ "url": "https://github.com/fb55/domhandler?sponsor=1"
+ }
+ },
+ "node_modules/domutils": {
+ "version": "4.0.2",
+ "resolved": "https://registry.npmjs.org/domutils/-/domutils-4.0.2.tgz",
+ "integrity": "sha512-qI4JLRKnSzqFqr7hAlS5xQDusBCjKSEG4t4+7aNrIQMHBcsC2TGEhuyABJdYkgSewL57PNLYEiibY2iPKhKpaA==",
+ "license": "BSD-2-Clause",
+ "dependencies": {
+ "dom-serializer": "^3.0.0",
+ "domelementtype": "^3.0.0",
+ "domhandler": "^6.0.0"
+ },
+ "engines": {
+ "node": ">=20.19.0"
+ },
+ "funding": {
+ "type": "github",
+ "url": "https://github.com/fb55/domutils?sponsor=1"
+ }
+ },
"node_modules/dotenv": {
"node_modules/dotenv": {
"version": "16.6.1",
"version": "16.6.1",
"resolved": "https://registry.npmjs.org/dotenv/-/dotenv-16.6.1.tgz",
"resolved": "https://registry.npmjs.org/dotenv/-/dotenv-16.6.1.tgz",
@@ -306,6 +425,18 @@
@@ -306,6 +425,18 @@
"node": ">= 0.8"
"node": ">= 0.8"
}
}
},
},
+ "node_modules/entities": {
+ "version": "8.1.0",
+ "resolved": "https://registry.npmjs.org/entities/-/entities-8.1.0.tgz",
+ "integrity": "sha512-kxL7msIffSuh9aaFAMD7rxAIuTRMAHMeBtgHW2yUdWw732ZNh4MehkF2gdjvtdmikkaIP9bFDDJOPlsvm7avrA==",
+ "license": "BSD-2-Clause",
+ "engines": {
+ "node": ">=20.19.0"
+ },
+ "funding": {
+ "url": "https://github.com/fb55/entities?sponsor=1"
+ }
+ },
"node_modules/es-define-property": {
"node_modules/es-define-property": {
"version": "1.0.1",
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz",
"resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz",
@@ -564,6 +695,110 @@
@@ -564,6 +695,110 @@
"url": "https://github.com/sponsors/EvanHahn"
"url": "https://github.com/sponsors/EvanHahn"
}
}
},
},
+ "node_modules/html-escaper": {
+ "version": "3.0.3",
+ "resolved": "https://registry.npmjs.org/html-escaper/-/html-escaper-3.0.3.tgz",
+ "integrity": "sha512-RuMffC89BOWQoY0WKGpIhn5gX3iI54O6nRA0yC124NYVtzjmFWBIiFd8M0x+ZdX0P9R4lADg1mgP8C7PxGOWuQ==",
+ "license": "MIT"
+ },
+ "node_modules/htmlparser2": {
+ "version": "10.1.0",
+ "resolved": "https://registry.npmjs.org/htmlparser2/-/htmlparser2-10.1.0.tgz",
+ "integrity": "sha512-VTZkM9GWRAtEpveh7MSF6SjjrpNVNNVJfFup7xTY3UpFtm67foy9HDVXneLtFVt4pMz5kZtgNcvCniNFb1hlEQ==",
+ "funding": [
+ "https://github.com/fb55/htmlparser2?sponsor=1",
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/fb55"
+ }
+ ],
+ "license": "MIT",
+ "dependencies": {
+ "domelementtype": "^2.3.0",
+ "domhandler": "^5.0.3",
+ "domutils": "^3.2.2",
+ "entities": "^7.0.1"
+ }
+ },
+ "node_modules/htmlparser2/node_modules/dom-serializer": {
+ "version": "2.0.0",
+ "resolved": "https://registry.npmjs.org/dom-serializer/-/dom-serializer-2.0.0.tgz",
+ "integrity": "sha512-wIkAryiqt/nV5EQKqQpo3SToSOV9J0DnbJqwK7Wv/Trc92zIAYZ4FlMu+JPFW1DfGFt81ZTCGgDEabffXeLyJg==",
+ "license": "MIT",
+ "dependencies": {
+ "domelementtype": "^2.3.0",
+ "domhandler": "^5.0.2",
+ "entities": "^4.2.0"
+ },
+ "funding": {
+ "url": "https://github.com/cheeriojs/dom-serializer?sponsor=1"
+ }
+ },
+ "node_modules/htmlparser2/node_modules/dom-serializer/node_modules/entities": {
+ "version": "4.5.0",
+ "resolved": "https://registry.npmjs.org/entities/-/entities-4.5.0.tgz",
+ "integrity": "sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw==",
+ "license": "BSD-2-Clause",
+ "engines": {
+ "node": ">=0.12"
+ },
+ "funding": {
+ "url": "https://github.com/fb55/entities?sponsor=1"
+ }
+ },
+ "node_modules/htmlparser2/node_modules/domelementtype": {
+ "version": "2.3.0",
+ "resolved": "https://registry.npmjs.org/domelementtype/-/domelementtype-2.3.0.tgz",
+ "integrity": "sha512-OLETBj6w0OsagBwdXnPdN0cnMfF9opN69co+7ZrbfPGrdpPVNBUj02spi6B1N7wChLQiPn4CSH/zJvXw56gmHw==",
+ "funding": [
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/fb55"
+ }
+ ],
+ "license": "BSD-2-Clause"
+ },
+ "node_modules/htmlparser2/node_modules/domhandler": {
+ "version": "5.0.3",
+ "resolved": "https://registry.npmjs.org/domhandler/-/domhandler-5.0.3.tgz",
+ "integrity": "sha512-cgwlv/1iFQiFnU96XXgROh8xTeetsnJiDsTc7TYCLFd9+/WNkIqPTxiM/8pSd8VIrhXGTf1Ny1q1hquVqDJB5w==",
+ "license": "BSD-2-Clause",
+ "dependencies": {
+ "domelementtype": "^2.3.0"
+ },
+ "engines": {
+ "node": ">= 4"
+ },
+ "funding": {
+ "url": "https://github.com/fb55/domhandler?sponsor=1"
+ }
+ },
+ "node_modules/htmlparser2/node_modules/domutils": {
+ "version": "3.2.2",
+ "resolved": "https://registry.npmjs.org/domutils/-/domutils-3.2.2.tgz",
+ "integrity": "sha512-6kZKyUajlDuqlHKVX1w7gyslj9MPIXzIFiz/rGu35uC1wMi+kMhQwGhl4lt9unC9Vb9INnY9Z3/ZA3+FhASLaw==",
+ "license": "BSD-2-Clause",
+ "dependencies": {
+ "dom-serializer": "^2.0.0",
+ "domelementtype": "^2.3.0",
+ "domhandler": "^5.0.3"
+ },
+ "funding": {
+ "url": "https://github.com/fb55/domutils?sponsor=1"
+ }
+ },
+ "node_modules/htmlparser2/node_modules/entities": {
+ "version": "7.0.1",
+ "resolved": "https://registry.npmjs.org/entities/-/entities-7.0.1.tgz",
+ "integrity": "sha512-TWrgLOFUQTH994YUyl1yT4uyavY5nNB5muff+RtWaqNVCAK408b5ZnnbNAUEWLTCpum9w6arT70i1XdQ4UeOPA==",
+ "license": "BSD-2-Clause",
+ "engines": {
+ "node": ">=0.12"
+ },
+ "funding": {
+ "url": "https://github.com/fb55/entities?sponsor=1"
+ }
+ },
"node_modules/http-errors": {
"node_modules/http-errors": {
"version": "2.0.1",
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.1.tgz",
"resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.1.tgz",
@@ -709,6 +944,30 @@
@@ -709,6 +944,30 @@
"safe-buffer": "^5.0.1"
"safe-buffer": "^5.0.1"
}
}
},
},
+ "node_modules/linkedom": {
+ "version": "0.18.14",
+ "resolved": "https://registry.npmjs.org/linkedom/-/linkedom-0.18.14.tgz",
+ "integrity": "sha512-BRUnkOm4YR5vo+qI3j0EbungB0vZPSCue5AdnTQRaxAGSbsOZiBFRO0rh4zlo8qXUhli8pxvnpEXDklMnlq2Rg==",
+ "license": "ISC",
+ "dependencies": {
+ "@acemir/cssom": "^0.9.32",
+ "css-select": "^7.0.0",
+ "html-escaper": "^3.0.3",
+ "htmlparser2": "^10.1.0",
+ "uhyphen": "^0.2.0"
+ },
+ "engines": {
+ "node": ">=16"
+ },
+ "peerDependencies": {
+ "canvas": ">= 2"
+ },
+ "peerDependenciesMeta": {
+ "canvas": {
+ "optional": true
+ }
+ }
+ },
"node_modules/lodash.defaults": {
"node_modules/lodash.defaults": {
"version": "4.2.0",
"version": "4.2.0",
"resolved": "https://registry.npmjs.org/lodash.defaults/-/lodash.defaults-4.2.0.tgz",
"resolved": "https://registry.npmjs.org/lodash.defaults/-/lodash.defaults-4.2.0.tgz",
@@ -847,6 +1106,22 @@
@@ -847,6 +1106,22 @@
"node": ">= 0.6"
"node": ">= 0.6"
}
}
},
},
+ "node_modules/nth-check": {
+ "version": "3.0.1",
+ "resolved": "https://registry.npmjs.org/nth-check/-/nth-check-3.0.1.tgz",
+ "integrity": "sha512-GX0gsdbGVCgnRgbeGaubfjpBXyYRWOOCVeYh08bSQvDZqxz5ndXs1OTfAt/h36G1xvI94YIspsI0sVFqAV9+RQ==",
+ "license": "BSD-2-Clause",
+ "dependencies": {
+ "boolbase": "^2.0.0"
+ },
+ "engines": {
+ "node": ">=20.19.0"
+ },
+ "funding": {
+ "type": "github",
+ "url": "https://github.com/fb55/nth-check?sponsor=1"
+ }
+ },
"node_modules/object-inspect": {
"node_modules/object-inspect": {
"version": "1.13.4",
"version": "1.13.4",
"resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.4.tgz",
"resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.4.tgz",
@@ -934,12 +1209,13 @@
@@ -934,12 +1209,13 @@
}
}
},
},
"node_modules/openvibe-shared": {
"node_modules/openvibe-shared": {
- "version": "4.33.0",
- "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v4.33.0",
- "integrity": "sha512-t9XvhrQ+lQebOrFTjMCzI6/7/i4iQn9dmMr/8HVTNW1XoV1exyJa7IH/HfyyKuLxjA8xaowrDfD28LwKIibBNg==",
+ "version": "4.34.0",
+ "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v4.34.0",
+ "integrity": "sha512-Rj5uRO3MbWkyIYAm+vK4Y94ugDNYEjEgGkYnPjSej4yMqhalpaPCkU96dzphMGiOUGP1PNWsiKhwaBj8tewgBg==",
"license": "MIT",
"license": "MIT",
"dependencies": {
"dependencies": {
- "jsonwebtoken": "^9.0.0"
+ "jsonwebtoken": "^9.0.0",
+ "linkedom": "^0.18.13"
},
},
"bin": {
"bin": {
"openvibe-build-assets": "scripts/build-assets.js"
"openvibe-build-assets": "scripts/build-assets.js"
@@ -1382,6 +1658,12 @@
@@ -1382,6 +1658,12 @@
"node": ">= 0.6"
"node": ">= 0.6"
}
}
},
},
+ "node_modules/uhyphen": {
+ "version": "0.2.0",
+ "resolved": "https://registry.npmjs.org/uhyphen/-/uhyphen-0.2.0.tgz",
+ "integrity": "sha512-qz3o9CHXmJJPGBdqzab7qAYuW8kQGKNEuoHFYrBwV6hWIMcpAmxDLXojcHfFr9US1Pe6zUswEIJIbLI610fuqA==",
+ "license": "ISC"
+ },
"node_modules/unpipe": {
"node_modules/unpipe": {
"version": "1.0.0",
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/unpipe/-/unpipe-1.0.0.tgz",
"resolved": "https://registry.npmjs.org/unpipe/-/unpipe-1.0.0.tgz",
@@ -2,7 +2,7 @@
@@ -2,7 +2,7 @@
"name": "openvibe-work",
"name": "openvibe-work",
"version": "0.2.0",
"version": "0.2.0",
"private": true,
"private": true,
- "description": "OpenVibe.Work \u2014 Find work, with an agent on your side.",
+ "description": "OpenVibe.Work — Find work, with an agent on your side.",
"main": "server/index.js",
"main": "server/index.js",
"scripts": {
"scripts": {
"start": "node server/index.js",
"start": "node server/index.js",
@@ -26,7 +26,7 @@
@@ -26,7 +26,7 @@
"openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.222.0",
"openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.222.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.67.0",
"openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.67.0",
- "openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v4.33.0",
+ "openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v4.34.0",
"pg": "^8.23.0"
"pg": "^8.23.0"
},
},
"engines": {
"engines": {
new file mode 100644
new file mode 100644
@@ -0,0 +1,18 @@
@@ -0,0 +1,18 @@
+'use strict';
+// What openvibe.work tells an agent about itself (openvibe-shared/agent-layer): /.well-known/agent.json.
+// Every field is something this host really serves: the public pages (the home, the jobs search and one listing,
+// companies and one company, the sources, the API reference and the update log), the JSON API under /api/v1 and
+// its /docs reference, llms.txt and the sitemap. Reading the listings is public; a saved search is a person's, so
+// the API also takes a Network sign-in. No OpenAPI document or MCP endpoint is published here, so none is named.
+module.exports = {
+ id: 'work',
+ name: 'OpenVibe.Work',
+ purpose: 'Find work on open job boards: listings from Arbeitnow, Remotive and RemoteOK under their terms, searchable with facets and each linking back to the original, plus saved searches and OpenVibe.Watch alerts for a signed-in person.',
+ home: 'https://openvibe.work',
+ site: 'openvibe.work',
+ api: '/api/v1',
+ docs: '/docs',
+ policy: {
+ auth: ['none', 'Network OIDC'],
+ },
+};
new file mode 100644
new file mode 100644
@@ -0,0 +1,75 @@
@@ -0,0 +1,75 @@
+'use strict';
+// The Markdown twins of OpenVibe.Work's public pages (openvibe-shared/agent-layer): the home, the jobs search and
+// one listing, companies and one company, the sources, the API reference and the update log.
+//
+// A twin is the page's own render: the page router answers the request in-process, signed out, so a twin cannot
+// drift from the page a browser gets and never carries a signed-in person's view. A saved search, the applications
+// and a missing listing or company answer nothing and have no twin.
+const TWIN_PATH_RE = /^\/(?:jobs(?:\/[^/]+)?|companies(?:\/[^/]+)?|sources|docs|updates)?$/;
+
+/** A request as an anonymous browser makes it: no session, no cookies. */
+function anonymousRequest(path, query, host) {
+ const search = query && query.toString() ? `?${query.toString()}` : '';
+ const url = `${path}${search}`;
+ return {
+ method: 'GET', url, originalUrl: url, path, params: {}, query: query ? Object.fromEntries(query) : {},
+ headers: { accept: 'text/html', host }, cookies: {}, body: undefined, protocol: 'https', secure: true,
+ ip: '127.0.0.1', ips: ['127.0.0.1'], hostname: host,
+ get(name) { return this.headers[String(name || '').toLowerCase()] || ''; },
+ accepts(types) {
+ if (!Array.isArray(types)) return 'text/html';
+ return types.some((t) => t === 'html' || t === 'text/html') ? 'html' : false;
+ },
+ is() { return false; },
+ };
+}
+
+/** A response that keeps what the page wrote, so the twin can be built from it. */
+function captureResponse() {
+ return {
+ statusCode: 200, headers: {}, body: null, headersSent: false,
+ status(code) { this.statusCode = code; return this; },
+ set(name, value) { this.headers[String(name)] = value; return this; },
+ setHeader(name, value) { this.headers[String(name)] = value; return this; },
+ getHeader(name) { return this.headers[String(name)]; },
+ get(name) { return this.headers[String(name)]; },
+ removeHeader(name) { delete this.headers[String(name)]; },
+ type(value) { this.headers['Content-Type'] = value === 'html' ? 'text/html; charset=utf-8' : String(value); return this; },
+ location(url) { this.headers.Location = url; return this; },
+ send(body) { this.body = body; this.headersSent = true; return this; },
+ json(body) { this.body = JSON.stringify(body); this.headersSent = true; return this; },
+ redirect(code, url) { this.statusCode = code; this.headers.Location = url; this.body = ''; this.headersSent = true; return this; },
+ end(body) { if (body !== undefined) this.body = body; this.headersSent = true; return this; },
+ cookie() { return this; }, clearCookie() { return this; },
+ };
+}
+
+function createAgentPages({ router, host }) {
+ async function renderPublicPage(path, { query } = {}) {
+ const p = String(path || '/').replace(/\/+$/, '') || '/';
+ if (!TWIN_PATH_RE.test(p)) return null;
+ const req = anonymousRequest(p, query, host);
+ req.viewer = { kind: 'anonymous' };
+ const res = captureResponse();
+ await new Promise((resolve) => {
+ let settled = false;
+ const finish = () => { if (!settled) { settled = true; resolve(); } };
+ const send = res.send.bind(res);
+ res.send = (body) => { send(body); finish(); return res; };
+ const redirect = res.redirect.bind(res);
+ res.redirect = (code, url) => { redirect(code, url); finish(); return res; };
+ try { router(req, res, () => finish()); } catch { finish(); }
+ const timer = setTimeout(finish, 10000);
+ if (timer.unref) timer.unref();
+ });
+ // A page that needs a person, a missing listing or company, or a page that is not public has no twin.
+ if (res.statusCode !== 200 || res.body == null) return null;
+ const html = Buffer.isBuffer(res.body) ? res.body.toString('utf8') : String(res.body);
+ if (!/^text\/html/i.test(String(res.headers['Content-Type'] || ''))) return null;
+ return { html };
+ }
+
+ return { renderPublicPage };
+}
+
+module.exports = { createAgentPages, TWIN_PATH_RE };
@@ -185,7 +185,28 @@ async function createApp(opts = {}) {
@@ -185,7 +185,28 @@ async function createApp(opts = {}) {
// ── Pages ───────────────────────────────────────────────
// ── Pages ───────────────────────────────────────────────
app.use(rateLimit({ windowMs: 60_000, limit: Number(process.env.WORK_RATE_LIMIT_PER_MIN) || 300, standardHeaders: true, legacyHeaders: false }));
app.use(rateLimit({ windowMs: 60_000, limit: Number(process.env.WORK_RATE_LIMIT_PER_MIN) || 300, standardHeaders: true, legacyHeaders: false }));
- app.use(createPageRoutes(ctx));
+ // The agent layer (openvibe-shared/agent-layer): /.well-known/agent.json, and the Markdown twins of the public
+ // pages. Entered only for the manifest exactly (the rest of /.well-known/ belongs to the app) and a twin request —
+ // a `.md` path, ?format=markdown or an explicit text/markdown preference — because the layer renders every page
+ // signed out and a Work page is not one page (saved searches, applications, the account bar). It comes before the
+ // page routes so `/jobs/x.md` is not taken for a listing called `x.md`; the app's own pages name their twins in
+ // the head and the Link header (server/render/layout.js).
+ const pageRoutes = createPageRoutes(ctx);
+ {
+ const agentRouter = express.Router();
+ require('openvibe-shared/agent-layer').mount(agentRouter, {
+ descriptor: require('./agent-descriptor'),
+ render: require('./agent-pages').createAgentPages({ router: pageRoutes, host: new URL(config.baseUrl).hostname }).renderPublicPage,
+ });
+ app.use((req, res, next) => {
+ if (req.path === '/.well-known/agent.json') return agentRouter(req, res, next);
+ const p = String(req.path || '');
+ if (p.endsWith('.md') || (req.query && req.query.format === 'markdown')
+ || req.accepts(['html', 'text/markdown']) === 'text/markdown') return agentRouter(req, res, next);
+ return next();
+ });
+ }
+ app.use(pageRoutes);
app.use((req, res) => send(res, 404, { viewer: req.viewer, config, path: req.originalUrl, title: 'Not found', body: html`<h1>Not found</h1><p>No page here. Try <a href="/">the home page</a> or <a href="/updates">the update log</a>.</p>` }));
app.use((req, res) => send(res, 404, { viewer: req.viewer, config, path: req.originalUrl, title: 'Not found', body: html`<h1>Not found</h1><p>No page here. Try <a href="/">the home page</a> or <a href="/updates">the update log</a>.</p>` }));
// eslint-disable-next-line no-unused-vars
// eslint-disable-next-line no-unused-vars
@@ -68,7 +68,8 @@ function createDiscoveryRoutes(ctx) {
@@ -68,7 +68,8 @@ function createDiscoveryRoutes(ctx) {
r.get('/robots.txt', (_req, res) => {
r.get('/robots.txt', (_req, res) => {
res.type('text/plain').set('Cache-Control', TEXT).send(
res.type('text/plain').set('Cache-Control', TEXT).send(
'# openvibe.work: the public pages are for search and AI crawlers; sign-in, the API and saved searches are not.\n'
'# openvibe.work: the public pages are for search and AI crawlers; sign-in, the API and saved searches are not.\n'
- + seo.robotsTxt({ sitemaps: [abs('/sitemap.xml')], disallow: DISALLOW }));
+ + seo.robotsTxt({ sitemaps: [abs('/sitemap.xml')], disallow: DISALLOW })
+ + `# Agent manifest: ${abs('/.well-known/agent.json')}\n`);
});
});
r.get('/llms.txt', (_req, res) => {
r.get('/llms.txt', (_req, res) => {
@@ -17,6 +17,8 @@ const frame = require('openvibe-shared/frame');
@@ -17,6 +17,8 @@ const frame = require('openvibe-shared/frame');
const shell = require('openvibe-shared/shell');
const shell = require('openvibe-shared/shell');
const cache = require('openvibe-shared/cache-policy');
const cache = require('openvibe-shared/cache-policy');
const { html, raw, esc } = require('./html');
const { html, raw, esc } = require('./html');
+const { TWIN_PATH_RE } = require('../agent-pages');
+const { linkHeaderTags } = require('openvibe-shared/agent-layer');
const NETWORK_URL = 'https://openvibe.network';
const NETWORK_URL = 'https://openvibe.network';
const SITE_NAME = 'OpenVibe.Work';
const SITE_NAME = 'OpenVibe.Work';
@@ -56,6 +58,8 @@ function renderPage(o) {
@@ -56,6 +58,8 @@ 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]}`;
+ // A public page names its Markdown twin (openvibe-shared/agent-layer); a signed-in page and a 404 do not.
+ const hasTwin = TWIN_PATH_RE.test(pathNow.split('?')[0]) && o.status !== 404;
const navLinks = NAV.filter((l) => !l.signedIn || signedIn);
const navLinks = NAV.filter((l) => !l.signedIn || signedIn);
const nav = {
const nav = {
service: 'work',
service: 'work',
@@ -85,6 +89,9 @@ function renderPage(o) {
@@ -85,6 +89,9 @@ function renderPage(o) {
siteName: SITE_NAME,
siteName: SITE_NAME,
description: o.description || `${SITE_NAME} — ${TAGLINE}`,
description: o.description || `${SITE_NAME} — ${TAGLINE}`,
canonical,
canonical,
+ // A public page names its Markdown twin (openvibe-shared/agent-layer).
+ agentLayer: hasTwin,
+ llms: '/llms.txt',
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: '/',
@@ -125,7 +132,11 @@ function send(res, status, o) {
@@ -125,7 +132,11 @@ function send(res, status, o) {
const personal = o.viewer && o.viewer.kind === 'user';
const personal = o.viewer && o.viewer.kind === 'user';
if (!res.get('Cache-Control')) res.set('Cache-Control', !personal && o.cache ? o.cache : cache.htmlHeaders({ private: true }));
if (!res.get('Cache-Control')) res.set('Cache-Control', !personal && o.cache ? o.cache : cache.htmlHeaders({ private: true }));
res.set('Vary', 'Cookie');
res.set('Vary', 'Cookie');
- res.status(status).type('html').send(renderPage(o));
+ // The Link header names the same twin as the head, query included, where the site sets headers.
+ if (TWIN_PATH_RE.test(String(o.path || '/').split('?')[0]) && status !== 404) {
+ res.set('Link', linkHeaderTags(`${o.config.baseUrl}${o.path || '/'}`, '/llms.txt').header);
+ }
+ res.status(status).type('html').send(renderPage({ ...o, status }));
}
}
module.exports = { renderPage, send, asset, assetVersion, setRelease, SITE_NAME, NETWORK_URL, NAV, raw, html };
module.exports = { renderPage, send, asset, assetVersion, setRelease, SITE_NAME, NETWORK_URL, NAV, raw, html };