openvibers/openvibe.work Commits
Work: Hacker News Who is hiring listings through OpenVibe.Sources (#137)
Changes
@@@ -2,7 +2,7 @@> Find work on open job boards.--**Status:** the listings, the search and the agent pillar are live (listings gathered from three public job boards,++**Status:** the listings, the search and the agent pillar are live (listings gathered from four public job boards (two of them read through OpenVibe.Sources),search, saved searches, job applications with their status history, help preparing one from the person's OpenVibe.Actoragent, an API and the pages around them).**Domain:** `openvibe.work` · **Port:** 4960 · **Service id:** `work` · **Env prefix:** `WORK`@@@ -31,7 +31,7 @@ republished: Work stores a short plain-text excerpt and sends the reader to the| Config | [server/config.js](server/config.js) | Every value from the environment; `load(env)` is pure so tests build a config without touching `process.env` || App | [server/app.js](server/app.js) | helmet (CSP, frame-ancestors none), the Network session middleware, `/auth`, the legal pages, static assets, the `/api/v1` mount, the 404 and the error handler || Sign-in | [server/auth/sso.js](server/auth/sso.js) | OAuth 2 authorization code with PKCE (S256) against OpenVibe.Network; httpOnly `work_at` / `work_rt` cookies; `/auth/me` for the shared navbar |--| Sources | [server/jobs/sources.js](server/jobs/sources.js) | The three boards: where each is fetched, its terms, how its payload becomes a listing, and what is refused |++| Sources | [server/jobs/sources.js](server/jobs/sources.js) | The boards: where each is fetched, its terms, how its payload becomes a listing, and what is refused || 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 |@@@ -71,7 +71,7 @@ Its own PostgreSQL tables, created by [migrations/](migrations/) and written bythe canonical subject. Work holds only `user:usr_…` on its own rows.- **Event delivery** — OpenVibe.Events: Work receives `network.account.export_requested` and `network.account.deleted`and creates its two subscriptions at boot; it does not own the topics or the delivery.--- **The listings themselves** — the boards (Arbeitnow, Remotive, RemoteOK): Work keeps a short excerpt and a link back,++- **The listings themselves** — the boards (Arbeitnow, Remotive, RemoteOK and Hacker News "Who is hiring"): Work keeps a short excerpt and a link back,never the full description, and reads each board only on the terms it publishes.## Depends on@@@ -85,8 +85,9 @@ Its own PostgreSQL tables, created by [migrations/](migrations/) and written by- **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`).++- **The job boards** — Arbeitnow, Remotive and RemoteOK are the only hosts Work itself fetches (`WORK_ARBEITNOW_URL`,++ `WORK_REMOTIVE_URL`, `WORK_REMOTEOK_URL`, `WORK_USER_AGENT`). We Work Remotely and Hacker News "Who is hiring" arrive++ through OpenVibe.Sources (`WORK_WWR_FROM=sources`, `WORK_HN_WHOISHIRING_FROM=sources`).- **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.74.0 (`db`, `auth`, `account-data`, `limits`,@@@ -7,7 +7,7 @@module.exports = {id: 'work',name: 'OpenVibe.Work',-- purpose: 'Find work on open job boards: listings from Arbeitnow, We Work Remotely, 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.',++ purpose: 'Find work on open job boards: listings from Arbeitnow, We Work Remotely, Hacker News "Who is hiring", 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',@@@ -93,6 +93,9 @@ function load(env = process.env) {// We Work Remotely reaches Work the same way (WORK_WWR_FROM=sources, the weworkremotely key in Sources);// it is off until the board is read through Sources, and Work never fetches the feed itself.weworkremotelyFrom: String(env.WORK_WWR_FROM || '').trim().toLowerCase() === 'sources' ? 'sources' : 'board',++ // Hacker News "Who is hiring" reaches Work the same way (WORK_HN_WHOISHIRING_FROM=sources, the++ // hn-whoishiring key in Sources); its resolver reads the thread, never Work itself.++ hnWhoishiringFrom: String(env.WORK_HN_WHOISHIRING_FROM || '').trim().toLowerCase() === 'sources' ? 'sources' : 'board',},// The background ingest (server/ingest/): off unless WORK_INGEST=on, so a test and a development run// never call a job board. Once on, each source is fetched at most every intervalMs (Remotive's terms@@@ -25,7 +25,7 @@ const SITEMAP_JOBS = 1000;const PAGE_TEXT = {'/': ['OpenVibe.Work home', 'OpenVibe.Work gathers job listings from public job boards, names the source of every one and links back to the original listing. Search is free and needs no account.'],-- '/jobs': ['Jobs', 'Search job listings gathered from Arbeitnow, We Work Remotely, Remotive and RemoteOK, newest first, filterable by words, workplace, job type and location.'],++ '/jobs': ['Jobs', 'Search job listings gathered from Arbeitnow, We Work Remotely, Hacker News "Who is hiring", Remotive and RemoteOK, newest first, filterable by words, workplace, job type and location.'],'/companies': ['Companies hiring', 'Every company with open listings on the boards OpenVibe.Work reads, most openings first, each with a page of its jobs, where they are and which boards list them.'],'/sources': ['Sources', 'The public job boards OpenVibe.Work gathers from, the terms each is used under as applied here, and when each was last read.'],'/docs': ['API', 'The OpenVibe.Work API: search the listings, read one with its provenance, list the sources and their terms, keep saved searches.'],@@@ -101,6 +101,44 @@ function rawOfWwr(item) {};}++/**++ * A Sources item → the row a Hacker News "Who is hiring" comment makes. The resolver's title is the comment's first++ * line, which posters usually write as "Company | Role | Place" or "Company is hiring a role": the company comes from++ * that, and a comment that does not name one is held by the same thin-listing gate as any other board. The excerpt is++ * Sources' plain-text excerpt, already stripped of email addresses and phone numbers; the full comment is its link.++ */++function rawOfHn(item) {++ const f = (item && item.fields) || {};++ const first = String((item && item.title) || '').replace(/\s+/g, ' ').trim();++ const parts = first.split(/\s*\|\s*/);++ let company = '';++ let title = first;++ if (parts.length >= 2 && parts[0].trim()) {++ company = parts[0].trim();++ title = parts.slice(1).join(' | ').trim() || first;++ } else {++ const m = /^(.+?)\s+(?:is|are)\s+hiring\b/i.exec(first);++ if (m && m[1].trim()) {++ company = m[1].trim();++ title = first.slice(m[0].length).replace(/^[\s:,-]+/, '').trim() || first;++ }++ }++ const summary = String((item && item.summary) || '');++ return {++ source_id: item.identity,++ url: item.canonical_url,++ title,++ company,++ location: null, // the comment rarely states one; the title may, in its own words++ remote: /\bremote\b/i.test(`${first} ${summary}`),++ tags: list(f.tags),++ job_type: null,++ salary: null,++ posted_at: item.published_at || null,++ description_html: summary,++ };++}++/*** The boards Work reads through OpenVibe.Sources: the Sources key an item arrives under, the id the listing is* stored with, the kind of item that source keeps, and how the item becomes a listing.@@@ -108,12 +146,14 @@ function rawOfWwr(item) {const SOURCES = {[SOURCE_KEY]: { source_id: SOURCE_ID, kind: 'job', rawOf },'weworkremotely': { source_id: 'weworkremotely', kind: 'article', rawOf: rawOfWwr },++ 'hn-whoishiring': { source_id: 'hn-whoishiring', kind: 'job', rawOf: rawOfHn },};/** The switch that says a board comes from Sources instead of from its own fetch. */const SWITCH = {arbeitnow: (config) => (config.sources && config.sources.arbeitnowFrom) === 'sources',weworkremotely: (config) => (config.sources && config.sources.weworkremotelyFrom) === 'sources',++ 'hn-whoishiring': (config) => (config.sources && config.sources.hnWhoishiringFrom) === 'sources',};function createFromSources({ config, s, fetchImpl = globalThis.fetch, log = console, source = null, afterRun = null, indexnow = null }) {@@@ -199,4 +239,4 @@ function createFromSources({ config, s, fetchImpl = globalThis.fetch, log = consreturn { enabled: Boolean(client), pull, start, stop, apply, SOURCE_ID, sourceIds: active.map(([, e]) => e.source_id) };}--module.exports = { createFromSources, rawOf, rawOfWwr, SOURCES, SOURCE_ID, SOURCE_KEY };++module.exports = { createFromSources, rawOf, rawOfWwr, rawOfHn, SOURCES, SOURCE_ID, SOURCE_KEY };@@@ -99,6 +99,17 @@ const SOURCES = [requests() { return ['https://weworkremotely.com/remote-jobs.rss']; },parse() { return []; }, // Work never reads this feed itself: the items arrive through Sources},++ {++ id: 'hn-whoishiring',++ name: 'Hacker News: Who is hiring',++ homepage: 'https://news.ycombinator.com/',++ terms: 'Hacker News makes its public data available through its API. OpenVibe.Sources reads the monthly "Ask HN: Who is hiring?" thread\'s top-level comments, one job per comment with emails and phones removed, and links the comment; Work follows that change feed. Attribute Hacker News and the poster.',++ attribution: 'Listing from Hacker News "Who is hiring"',++ remoteDefault: false,++ viaSources: true, // the resolver in OpenVibe.Sources is the only reader++ requests() { return []; },++ parse() { return []; }, // Work never calls the Hacker News API itself++ },{id: 'remotive',name: 'Remotive',nnew file mode 100644@@@ -0,0 +1,85 @@++'use strict';++/**++ * Hacker News "Who is hiring" through OpenVibe.Sources (server/ingest/from-sources.js), against a Sources stand-in:++ * - a comment becomes a listing with its company and role read from the first line ("Company | Role | Place" or++ * "Company is hiring …");++ * - the thin-listing gate still applies: a comment with no company, or a one-letter title/company, is held;++ * - the listing links the HN comment and is attributed to Hacker News "Who is hiring";++ * - Work never calls the Hacker News API itself.++ */++const assert = require('assert');++const { boot, check, done } = require('./helpers/boot');++const store = require('../server/jobs/store');++++const feed = [];++let seq = 0;++const push = (item) => feed.push({ ...item, change_seq: ++seq });++const sourcesClient = {++ enabled: true,++ async listItems({ after = 0, limit = 100 } = {}) {++ const page = feed.filter((i) => i.change_seq > after).slice(0, limit);++ return { items: page, next_after: page.length ? page[page.length - 1].change_seq : after, more: false };++ },++};++++// A recorded item of the resolver's output (a fixture: no live call here).++const hn = (identity, title, summary, over = {}) => ({++ id: `itm_${identity}`, source_key: 'hn-whoishiring', category: 'jobs', kind: 'job', identity,++ canonical_url: `https://news.ycombinator.com/item?id=${identity.replace('hn-', '')}`,++ title, summary, authors: ['acme_founder'], published_at: '2026-10-01T15:00:00.000Z', source_updated_at: null,++ fields: { story_id: '101', story_title: 'Ask HN: Who is hiring? (October 2026)', story_url: 'https://news.ycombinator.com/item?id=101' },++ revision: 1, provenance: { retrieved_at: '2026-10-30T06:00:00.000Z' }, removed: null, ...over,++});++++(async () => {++ const clock = { t: Date.parse('2026-10-30T08:00:00Z') };++ const t = await boot({ now: () => clock.t, env: { WORK_HN_WHOISHIRING_FROM: 'sources' }, sourcesClient });++ const s = t.ctx.s;++ const from = t.ctx.fromSources;++++ try {++ await check('a comment becomes a listing with the company and role from its first line', async () => {++ push(hn('hn-201', 'Acme Corp | Senior Backend Engineer | Remote (US)', 'Acme Corp is hiring. Email [email] or call [phone].'));++ push(hn('hn-203', 'Beta & Co is hiring a product designer', 'Beta & Co is hiring a product designer. Contact [email].'));++ push(hn('hn-205', 'No company here', 'A comment with no company in its first line.'));++ push(hn('hn-206', 'Z | Q', 'A board test post with one-letter fields.'));++ const out = await from.pull();++ assert.deepStrictEqual([out.applied, out.hold, out.removed], [2, 2, 0]);++++ const acme = await s.db.maybe('SELECT * FROM work_listings WHERE id = $1', [store.idOf('hn-whoishiring', 'hn-201')]);++ assert.deepStrictEqual([acme.source, acme.title, acme.company, acme.remote, acme.url],++ ['hn-whoishiring', 'Senior Backend Engineer | Remote (US)', 'Acme Corp', true, 'https://news.ycombinator.com/item?id=201']);++ assert.ok(acme.excerpt.includes('[email]') && acme.excerpt.includes('[phone]'), 'the excerpt is Sources\' privacy-stripped text');++++ const beta = await s.db.maybe('SELECT * FROM work_listings WHERE id = $1', [store.idOf('hn-whoishiring', 'hn-203')]);++ assert.deepStrictEqual([beta.title, beta.company, beta.remote], ['a product designer', 'Beta & Co', false]);++ assert.strictEqual(await s.db.maybe('SELECT id FROM work_listings WHERE id = $1', [store.idOf('hn-whoishiring', 'hn-205')]), null);++ assert.strictEqual(await s.db.maybe('SELECT id FROM work_listings WHERE id = $1', [store.idOf('hn-whoishiring', 'hn-206')]), null);++ });++++ await check('the listing links the HN comment and is attributed to the thread', async () => {++ const id = store.idOf('hn-whoishiring', 'hn-201');++ const list = await t.get('/jobs');++ assert.strictEqual(list.status, 200);++ assert.ok(list.text.includes('Hacker News: Who is hiring'));++ assert.ok(list.text.includes('https://news.ycombinator.com/item?id=201'));++ const page = await t.get(`/jobs/${id}`);++ assert.strictEqual(page.status, 200);++ assert.ok(page.text.includes('Hacker News: Who is hiring'));++ assert.ok(page.text.includes('https://news.ycombinator.com/item?id=201'), 'the original comment is linked');++ });++++ await check('a removed item expires the listing, and Work never reads HN itself', async () => {++ push({ ...hn('hn-201', 'Acme Corp | Senior Backend Engineer | Remote (US)', 'x'), id: 'itm_hn-201', removed: { at: '2026-10-30T07:00:00.000Z', reason: 'gone_from_source' }, revision: 2 });++ const out = await from.pull();++ assert.strictEqual(out.removed, 1);++ const row = await s.db.maybe('SELECT expired FROM work_listings WHERE id = $1', [store.idOf('hn-whoishiring', 'hn-201')]);++ assert.ok(row.expired, 'the listing is expired, not deleted');++ const runs = await t.ctx.ingest.runAll();++ assert.deepStrictEqual(runs.find((o) => o.source === 'hn-whoishiring'), { source: 'hn-whoishiring', skipped: 'from_sources' });++ });++ } finally {++ await t.close();++ }++ done();++})().catch((err) => { console.error(err); process.exit(1); });@@@ -23,9 +23,10 @@ const DAY = 86_400_000;await check('reading the three boards keeps every listing with its source, its id and the original URL', async () => {const out = await ingest.runAll();// We Work Remotely is skipped here: it reaches Work through OpenVibe.Sources, never from its own feed.-- assert.deepStrictEqual(out.map((o) => o.source), ['arbeitnow', 'weworkremotely', 'remotive', 'remoteok']);-- assert.ok(out.filter((o) => o.source !== 'weworkremotely').every((o) => o.ok), JSON.stringify(out.map((o) => o.error)));++ assert.deepStrictEqual(out.map((o) => o.source), ['arbeitnow', 'weworkremotely', 'hn-whoishiring', 'remotive', 'remoteok']);++ assert.ok(out.filter((o) => !['weworkremotely', 'hn-whoishiring'].includes(o.source)).every((o) => o.ok), JSON.stringify(out.map((o) => o.error)));assert.strictEqual(out.find((o) => o.source === 'weworkremotely').skipped, 'from_sources');++ assert.strictEqual(out.find((o) => o.source === 'hn-whoishiring').skipped, 'from_sources');// Arbeitnow's two pages (2 + 1), Remotive's two jobs, RemoteOK's two listings.const counts = await store.countsBySource(s);assert.strictEqual(counts.get('arbeitnow').count, 3);@@@ -185,9 +185,9 @@ const first = async (t, path) => (await t.get(path)).json();await check('every source is listed with its terms as applied and when it was last read', async () => {const api = await first(t, '/api/v1/sources');-- assert.deepStrictEqual(api.sources.map((s) => s.id).sort(), ['arbeitnow', 'remoteok', 'remotive', 'weworkremotely']);++ assert.deepStrictEqual(api.sources.map((s) => s.id).sort(), ['arbeitnow', 'hn-whoishiring', 'remoteok', 'remotive', 'weworkremotely']);// A board OpenVibe.Sources feeds has no read of its own here, so it is not asked for one.-- for (const src of api.sources.filter((x) => x.id !== 'weworkremotely')) {++ for (const src of api.sources.filter((x) => !['weworkremotely', 'hn-whoishiring'].includes(x.id))) {assert.ok(src.terms.length > 40, `${src.id} has no terms`);assert.match(src.terms, /link|attribute|mention|name/i, `${src.id}'s terms do not state the attribution rule`);assert.strictEqual(src.last_fetch.ok, true);@@@ -214,7 +214,7 @@ const first = async (t, path) => (await t.get(path)).json();const llms = (await t.get('/llms.txt')).text;assert.ok(llms.includes('(https://openvibe.work/jobs)'));assert.ok(llms.includes('(https://openvibe.work/sources)'));-- assert.ok(llms.includes('Arbeitnow, We Work Remotely, Remotive, RemoteOK'), 'the sources are not named for a crawler');++ assert.ok(llms.includes('Arbeitnow, We Work Remotely, Hacker News: Who is hiring, Remotive, RemoteOK'), 'the sources are not named for a crawler');assert.ok(!llms.includes('/saved'), 'llms.txt names a private page');const robots = (await t.get('/robots.txt')).text;assert.ok(robots.includes('Disallow: /saved'));@@@ -17,8 +17,8 @@ const { measure, check, format } = require('openvibe-shared/perf-budget');// 18.2 KB raw / 4.3 KB brotli, still two stylesheets and no external file. The home page itself stayed inside its own// budget — the listings replace the skeleton's placeholder sections rather than adding to them.const BUDGETS = {-- htmlRawKB: 39, // 38.1 with the home's pick block and folds (the sections below the search are picked one at a time on a phone, 2026-10-30); 36.5 with openvibe-shared 4.53.0: the footer's columns fold on phones (2026-10-30); 31.2 with the Part of OpenVibe section (Shared 4.13, 2026-10-26); measured 24.9 (the home page: hero, features, steps, cta, JSON-LD)-- htmlBrotliKB: 9.5, // 9.1 with the home's pick block and folds (the sections below the search are picked one at a time on a phone, 2026-10-30); 7.8 with the Part of OpenVibe section (Shared 4.13, 2026-10-26); 6.2++ htmlRawKB: 39, // 38.1 with the home's pick block and folds (the sections below the search are picked one at a time on a phone, 2026-10-30), plus the Hacker News "Who is hiring" source row (2026-10-31); 36.5 with openvibe-shared 4.53.0: the footer's columns fold on phones (2026-10-30); 31.2 with the Part of OpenVibe section (Shared 4.13, 2026-10-26); measured 24.9 (the home page: hero, features, steps, cta, JSON-LD)++ htmlBrotliKB: 9.5, // 9.1 with the home's pick block and folds (the sections below the search are picked one at a time on a phone, 2026-10-30), plus the Hacker News source row; 7.8 with the Part of OpenVibe section (Shared 4.13, 2026-10-26); 6.2jsFiles: 5, // 5 (theme-loader, web-runtime, navbar, footer, boost: openvibe-shared/shell)jsRawKB: 322, // 321.1 with openvibe-shared 4.53.0: the footer's columns fold on phones (2026-10-30); 318.1 with openvibe-shared 4.52.0: the Dock's loader and its Chats tab (2026-10-30); 315.6 with openvibe-shared 4.48.0 (the site switcher on the mark, the menu's Start tab and quick tools; Kit's touch hit areas and the phone navbar fix, 4.44-4.47.3); 307.8 with openvibe-shared 4.43.0 (room.css carries the kit tokens, 4.39.1; the kit's 4.39.0 rules); 300.5 openvibe-shared 4.25.0: the menu's orb beside the brand, both balances on phones, messages, the account sheet on phones and the bar stepping away on phones; 293 openvibe-shared 4.22.0: the navbar's magnifier search button and More dropdown (search.js and loading.js load only when used); 287.2 Shared 4.17.0: OpenCoins and Vibes chips (wallet: true) in the navbar; 273.2 openvibe-shared 4.15 footer: the compact footer opens into the whole one, and the pulse line; 264.3 with Shared 4.13.0 (2026-10-26: account links); 239.8jsBrotliKB: 79, // 78.2 with openvibe-shared 4.52.0: the Dock's loader and its Chats tab (2026-10-30); 77.6 with openvibe-shared 4.48.0 (the site switcher on the mark, the menu's Start tab and quick tools; Kit's touch hit areas and the phone navbar fix, 4.44-4.47.3); 76.1 with openvibe-shared 4.43.0 (room.css carries the kit tokens, 4.39.1; the kit's 4.39.0 rules); 74.2 openvibe-shared 4.25.0: the menu's orb beside the brand, both balances on phones, messages, the account sheet on phones and the bar stepping away on phones; 70.5 Shared 4.17.0: OpenCoins and Vibes chips (wallet: true) in the navbar; 67.9 openvibe-shared 4.15 footer: the compact footer opens into the whole one, and the pulse line; 65.3 Shared 4.12.0: the light/dark switch, the seasonal mark and the theme pairs; 56.5
@@ -2,7 +2,7 @@
@@ -2,7 +2,7 @@
> Find work on open job boards.
> Find work on open job boards.
-**Status:** the listings, the search and the agent pillar are live (listings gathered from three public job boards,
+**Status:** the listings, the search and the agent pillar are live (listings gathered from four public job boards (two of them read through OpenVibe.Sources),
search, saved searches, job applications with their status history, help preparing one from the person's OpenVibe.Actor
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).
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`
@@ -31,7 +31,7 @@ republished: Work stores a short plain-text excerpt and sends the reader to the
@@ -31,7 +31,7 @@ republished: Work stores a short plain-text excerpt and sends the reader to the
| Config | [server/config.js](server/config.js) | Every value from the environment; `load(env)` is pure so tests build a config without touching `process.env` |
| Config | [server/config.js](server/config.js) | Every value from the environment; `load(env)` is pure so tests build a config without touching `process.env` |
| App | [server/app.js](server/app.js) | helmet (CSP, frame-ancestors none), the Network session middleware, `/auth`, the legal pages, static assets, the `/api/v1` mount, the 404 and the error handler |
| App | [server/app.js](server/app.js) | helmet (CSP, frame-ancestors none), the Network session middleware, `/auth`, the legal pages, static assets, the `/api/v1` mount, the 404 and the error handler |
| Sign-in | [server/auth/sso.js](server/auth/sso.js) | OAuth 2 authorization code with PKCE (S256) against OpenVibe.Network; httpOnly `work_at` / `work_rt` cookies; `/auth/me` for the shared navbar |
| Sign-in | [server/auth/sso.js](server/auth/sso.js) | OAuth 2 authorization code with PKCE (S256) against OpenVibe.Network; httpOnly `work_at` / `work_rt` cookies; `/auth/me` for the shared navbar |
-| Sources | [server/jobs/sources.js](server/jobs/sources.js) | The three boards: where each is fetched, its terms, how its payload becomes a listing, and what is refused |
+| Sources | [server/jobs/sources.js](server/jobs/sources.js) | The boards: where each is fetched, its terms, how its payload becomes a listing, and what is refused |
| 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 |
@@ -71,7 +71,7 @@ Its own PostgreSQL tables, created by [migrations/](migrations/) and written by
@@ -71,7 +71,7 @@ Its own PostgreSQL tables, created by [migrations/](migrations/) and written by
the canonical subject. Work holds only `user:usr_…` on its own rows.
the canonical subject. Work holds only `user:usr_…` on its own rows.
- **Event delivery** — OpenVibe.Events: Work receives `network.account.export_requested` and `network.account.deleted`
- **Event delivery** — OpenVibe.Events: Work receives `network.account.export_requested` and `network.account.deleted`
and creates its two subscriptions at boot; it does not own the topics or the delivery.
and creates its two subscriptions at boot; it does not own the topics or the delivery.
-- **The listings themselves** — the boards (Arbeitnow, Remotive, RemoteOK): Work keeps a short excerpt and a link back,
+- **The listings themselves** — the boards (Arbeitnow, Remotive, RemoteOK and Hacker News "Who is hiring"): Work keeps a short excerpt and a link back,
never the full description, and reads each board only on the terms it publishes.
never the full description, and reads each board only on the terms it publishes.
## Depends on
## Depends on
@@ -85,8 +85,9 @@ Its own PostgreSQL tables, created by [migrations/](migrations/) and written by
@@ -85,8 +85,9 @@ Its own PostgreSQL tables, created by [migrations/](migrations/) and written by
- **OpenVibe.Actor** — the person's agent: Work posts `POST /api/v1/tasks` with its own service token (capability
- **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
`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`).
(`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`).
+- **The job boards** — Arbeitnow, Remotive and RemoteOK are the only hosts Work itself fetches (`WORK_ARBEITNOW_URL`,
+ `WORK_REMOTIVE_URL`, `WORK_REMOTEOK_URL`, `WORK_USER_AGENT`). We Work Remotely and Hacker News "Who is hiring" arrive
+ through OpenVibe.Sources (`WORK_WWR_FROM=sources`, `WORK_HN_WHOISHIRING_FROM=sources`).
- **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.74.0 (`db`, `auth`, `account-data`, `limits`,
- **Packages**: `openvibe-contracts` v0.222.0, `openvibe-sdk` v0.74.0 (`db`, `auth`, `account-data`, `limits`,
@@ -7,7 +7,7 @@
@@ -7,7 +7,7 @@
module.exports = {
module.exports = {
id: 'work',
id: 'work',
name: 'OpenVibe.Work',
name: 'OpenVibe.Work',
- purpose: 'Find work on open job boards: listings from Arbeitnow, We Work Remotely, 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.',
+ purpose: 'Find work on open job boards: listings from Arbeitnow, We Work Remotely, Hacker News "Who is hiring", 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',
home: 'https://openvibe.work',
site: 'openvibe.work',
site: 'openvibe.work',
api: '/api/v1',
api: '/api/v1',
@@ -93,6 +93,9 @@ function load(env = process.env) {
@@ -93,6 +93,9 @@ function load(env = process.env) {
// We Work Remotely reaches Work the same way (WORK_WWR_FROM=sources, the weworkremotely key in Sources);
// We Work Remotely reaches Work the same way (WORK_WWR_FROM=sources, the weworkremotely key in Sources);
// it is off until the board is read through Sources, and Work never fetches the feed itself.
// it is off until the board is read through Sources, and Work never fetches the feed itself.
weworkremotelyFrom: String(env.WORK_WWR_FROM || '').trim().toLowerCase() === 'sources' ? 'sources' : 'board',
weworkremotelyFrom: String(env.WORK_WWR_FROM || '').trim().toLowerCase() === 'sources' ? 'sources' : 'board',
+ // Hacker News "Who is hiring" reaches Work the same way (WORK_HN_WHOISHIRING_FROM=sources, the
+ // hn-whoishiring key in Sources); its resolver reads the thread, never Work itself.
+ hnWhoishiringFrom: String(env.WORK_HN_WHOISHIRING_FROM || '').trim().toLowerCase() === 'sources' ? 'sources' : 'board',
},
},
// The background ingest (server/ingest/): off unless WORK_INGEST=on, so a test and a development run
// The background ingest (server/ingest/): off unless WORK_INGEST=on, so a test and a development run
// never call a job board. Once on, each source is fetched at most every intervalMs (Remotive's terms
// never call a job board. Once on, each source is fetched at most every intervalMs (Remotive's terms
@@ -25,7 +25,7 @@ const SITEMAP_JOBS = 1000;
@@ -25,7 +25,7 @@ const SITEMAP_JOBS = 1000;
const PAGE_TEXT = {
const PAGE_TEXT = {
'/': ['OpenVibe.Work home', 'OpenVibe.Work gathers job listings from public job boards, names the source of every one and links back to the original listing. Search is free and needs no account.'],
'/': ['OpenVibe.Work home', 'OpenVibe.Work gathers job listings from public job boards, names the source of every one and links back to the original listing. Search is free and needs no account.'],
- '/jobs': ['Jobs', 'Search job listings gathered from Arbeitnow, We Work Remotely, Remotive and RemoteOK, newest first, filterable by words, workplace, job type and location.'],
+ '/jobs': ['Jobs', 'Search job listings gathered from Arbeitnow, We Work Remotely, Hacker News "Who is hiring", Remotive and RemoteOK, newest first, filterable by words, workplace, job type and location.'],
'/companies': ['Companies hiring', 'Every company with open listings on the boards OpenVibe.Work reads, most openings first, each with a page of its jobs, where they are and which boards list them.'],
'/companies': ['Companies hiring', 'Every company with open listings on the boards OpenVibe.Work reads, most openings first, each with a page of its jobs, where they are and which boards list them.'],
'/sources': ['Sources', 'The public job boards OpenVibe.Work gathers from, the terms each is used under as applied here, and when each was last read.'],
'/sources': ['Sources', 'The public job boards OpenVibe.Work gathers from, the terms each is used under as applied here, and when each was last read.'],
'/docs': ['API', 'The OpenVibe.Work API: search the listings, read one with its provenance, list the sources and their terms, keep saved searches.'],
'/docs': ['API', 'The OpenVibe.Work API: search the listings, read one with its provenance, list the sources and their terms, keep saved searches.'],
@@ -101,6 +101,44 @@ function rawOfWwr(item) {
@@ -101,6 +101,44 @@ function rawOfWwr(item) {
};
};
}
}
+/**
+ * A Sources item → the row a Hacker News "Who is hiring" comment makes. The resolver's title is the comment's first
+ * line, which posters usually write as "Company | Role | Place" or "Company is hiring a role": the company comes from
+ * that, and a comment that does not name one is held by the same thin-listing gate as any other board. The excerpt is
+ * Sources' plain-text excerpt, already stripped of email addresses and phone numbers; the full comment is its link.
+ */
+function rawOfHn(item) {
+ const f = (item && item.fields) || {};
+ const first = String((item && item.title) || '').replace(/\s+/g, ' ').trim();
+ const parts = first.split(/\s*\|\s*/);
+ let company = '';
+ let title = first;
+ if (parts.length >= 2 && parts[0].trim()) {
+ company = parts[0].trim();
+ title = parts.slice(1).join(' | ').trim() || first;
+ } else {
+ const m = /^(.+?)\s+(?:is|are)\s+hiring\b/i.exec(first);
+ if (m && m[1].trim()) {
+ company = m[1].trim();
+ title = first.slice(m[0].length).replace(/^[\s:,-]+/, '').trim() || first;
+ }
+ }
+ const summary = String((item && item.summary) || '');
+ return {
+ source_id: item.identity,
+ url: item.canonical_url,
+ title,
+ company,
+ location: null, // the comment rarely states one; the title may, in its own words
+ remote: /\bremote\b/i.test(`${first} ${summary}`),
+ tags: list(f.tags),
+ job_type: null,
+ salary: null,
+ posted_at: item.published_at || null,
+ description_html: summary,
+ };
+}
+
/**
/**
* The boards Work reads through OpenVibe.Sources: the Sources key an item arrives under, the id the listing is
* The boards Work reads through OpenVibe.Sources: the Sources key an item arrives under, the id the listing is
* stored with, the kind of item that source keeps, and how the item becomes a listing.
* stored with, the kind of item that source keeps, and how the item becomes a listing.
@@ -108,12 +146,14 @@ function rawOfWwr(item) {
@@ -108,12 +146,14 @@ function rawOfWwr(item) {
const SOURCES = {
const SOURCES = {
[SOURCE_KEY]: { source_id: SOURCE_ID, kind: 'job', rawOf },
[SOURCE_KEY]: { source_id: SOURCE_ID, kind: 'job', rawOf },
'weworkremotely': { source_id: 'weworkremotely', kind: 'article', rawOf: rawOfWwr },
'weworkremotely': { source_id: 'weworkremotely', kind: 'article', rawOf: rawOfWwr },
+ 'hn-whoishiring': { source_id: 'hn-whoishiring', kind: 'job', rawOf: rawOfHn },
};
};
/** The switch that says a board comes from Sources instead of from its own fetch. */
/** The switch that says a board comes from Sources instead of from its own fetch. */
const SWITCH = {
const SWITCH = {
arbeitnow: (config) => (config.sources && config.sources.arbeitnowFrom) === 'sources',
arbeitnow: (config) => (config.sources && config.sources.arbeitnowFrom) === 'sources',
weworkremotely: (config) => (config.sources && config.sources.weworkremotelyFrom) === 'sources',
weworkremotely: (config) => (config.sources && config.sources.weworkremotelyFrom) === 'sources',
+ 'hn-whoishiring': (config) => (config.sources && config.sources.hnWhoishiringFrom) === 'sources',
};
};
function createFromSources({ config, s, fetchImpl = globalThis.fetch, log = console, source = null, afterRun = null, indexnow = null }) {
function createFromSources({ config, s, fetchImpl = globalThis.fetch, log = console, source = null, afterRun = null, indexnow = null }) {
@@ -199,4 +239,4 @@ function createFromSources({ config, s, fetchImpl = globalThis.fetch, log = cons
@@ -199,4 +239,4 @@ function createFromSources({ config, s, fetchImpl = globalThis.fetch, log = cons
return { enabled: Boolean(client), pull, start, stop, apply, SOURCE_ID, sourceIds: active.map(([, e]) => e.source_id) };
return { enabled: Boolean(client), pull, start, stop, apply, SOURCE_ID, sourceIds: active.map(([, e]) => e.source_id) };
}
}
-module.exports = { createFromSources, rawOf, rawOfWwr, SOURCES, SOURCE_ID, SOURCE_KEY };
+module.exports = { createFromSources, rawOf, rawOfWwr, rawOfHn, SOURCES, SOURCE_ID, SOURCE_KEY };
@@ -99,6 +99,17 @@ const SOURCES = [
@@ -99,6 +99,17 @@ const SOURCES = [
requests() { return ['https://weworkremotely.com/remote-jobs.rss']; },
requests() { return ['https://weworkremotely.com/remote-jobs.rss']; },
parse() { return []; }, // Work never reads this feed itself: the items arrive through Sources
parse() { return []; }, // Work never reads this feed itself: the items arrive through Sources
},
},
+ {
+ id: 'hn-whoishiring',
+ name: 'Hacker News: Who is hiring',
+ homepage: 'https://news.ycombinator.com/',
+ terms: 'Hacker News makes its public data available through its API. OpenVibe.Sources reads the monthly "Ask HN: Who is hiring?" thread\'s top-level comments, one job per comment with emails and phones removed, and links the comment; Work follows that change feed. Attribute Hacker News and the poster.',
+ attribution: 'Listing from Hacker News "Who is hiring"',
+ remoteDefault: false,
+ viaSources: true, // the resolver in OpenVibe.Sources is the only reader
+ requests() { return []; },
+ parse() { return []; }, // Work never calls the Hacker News API itself
+ },
{
{
id: 'remotive',
id: 'remotive',
name: 'Remotive',
name: 'Remotive',
new file mode 100644
new file mode 100644
@@ -0,0 +1,85 @@
@@ -0,0 +1,85 @@
+'use strict';
+/**
+ * Hacker News "Who is hiring" through OpenVibe.Sources (server/ingest/from-sources.js), against a Sources stand-in:
+ * - a comment becomes a listing with its company and role read from the first line ("Company | Role | Place" or
+ * "Company is hiring …");
+ * - the thin-listing gate still applies: a comment with no company, or a one-letter title/company, is held;
+ * - the listing links the HN comment and is attributed to Hacker News "Who is hiring";
+ * - Work never calls the Hacker News API itself.
+ */
+const assert = require('assert');
+const { boot, check, done } = require('./helpers/boot');
+const store = require('../server/jobs/store');
+
+const feed = [];
+let seq = 0;
+const push = (item) => feed.push({ ...item, change_seq: ++seq });
+const sourcesClient = {
+ enabled: true,
+ async listItems({ after = 0, limit = 100 } = {}) {
+ const page = feed.filter((i) => i.change_seq > after).slice(0, limit);
+ return { items: page, next_after: page.length ? page[page.length - 1].change_seq : after, more: false };
+ },
+};
+
+// A recorded item of the resolver's output (a fixture: no live call here).
+const hn = (identity, title, summary, over = {}) => ({
+ id: `itm_${identity}`, source_key: 'hn-whoishiring', category: 'jobs', kind: 'job', identity,
+ canonical_url: `https://news.ycombinator.com/item?id=${identity.replace('hn-', '')}`,
+ title, summary, authors: ['acme_founder'], published_at: '2026-10-01T15:00:00.000Z', source_updated_at: null,
+ fields: { story_id: '101', story_title: 'Ask HN: Who is hiring? (October 2026)', story_url: 'https://news.ycombinator.com/item?id=101' },
+ revision: 1, provenance: { retrieved_at: '2026-10-30T06:00:00.000Z' }, removed: null, ...over,
+});
+
+(async () => {
+ const clock = { t: Date.parse('2026-10-30T08:00:00Z') };
+ const t = await boot({ now: () => clock.t, env: { WORK_HN_WHOISHIRING_FROM: 'sources' }, sourcesClient });
+ const s = t.ctx.s;
+ const from = t.ctx.fromSources;
+
+ try {
+ await check('a comment becomes a listing with the company and role from its first line', async () => {
+ push(hn('hn-201', 'Acme Corp | Senior Backend Engineer | Remote (US)', 'Acme Corp is hiring. Email [email] or call [phone].'));
+ push(hn('hn-203', 'Beta & Co is hiring a product designer', 'Beta & Co is hiring a product designer. Contact [email].'));
+ push(hn('hn-205', 'No company here', 'A comment with no company in its first line.'));
+ push(hn('hn-206', 'Z | Q', 'A board test post with one-letter fields.'));
+ const out = await from.pull();
+ assert.deepStrictEqual([out.applied, out.hold, out.removed], [2, 2, 0]);
+
+ const acme = await s.db.maybe('SELECT * FROM work_listings WHERE id = $1', [store.idOf('hn-whoishiring', 'hn-201')]);
+ assert.deepStrictEqual([acme.source, acme.title, acme.company, acme.remote, acme.url],
+ ['hn-whoishiring', 'Senior Backend Engineer | Remote (US)', 'Acme Corp', true, 'https://news.ycombinator.com/item?id=201']);
+ assert.ok(acme.excerpt.includes('[email]') && acme.excerpt.includes('[phone]'), 'the excerpt is Sources\' privacy-stripped text');
+
+ const beta = await s.db.maybe('SELECT * FROM work_listings WHERE id = $1', [store.idOf('hn-whoishiring', 'hn-203')]);
+ assert.deepStrictEqual([beta.title, beta.company, beta.remote], ['a product designer', 'Beta & Co', false]);
+ assert.strictEqual(await s.db.maybe('SELECT id FROM work_listings WHERE id = $1', [store.idOf('hn-whoishiring', 'hn-205')]), null);
+ assert.strictEqual(await s.db.maybe('SELECT id FROM work_listings WHERE id = $1', [store.idOf('hn-whoishiring', 'hn-206')]), null);
+ });
+
+ await check('the listing links the HN comment and is attributed to the thread', async () => {
+ const id = store.idOf('hn-whoishiring', 'hn-201');
+ const list = await t.get('/jobs');
+ assert.strictEqual(list.status, 200);
+ assert.ok(list.text.includes('Hacker News: Who is hiring'));
+ assert.ok(list.text.includes('https://news.ycombinator.com/item?id=201'));
+ const page = await t.get(`/jobs/${id}`);
+ assert.strictEqual(page.status, 200);
+ assert.ok(page.text.includes('Hacker News: Who is hiring'));
+ assert.ok(page.text.includes('https://news.ycombinator.com/item?id=201'), 'the original comment is linked');
+ });
+
+ await check('a removed item expires the listing, and Work never reads HN itself', async () => {
+ push({ ...hn('hn-201', 'Acme Corp | Senior Backend Engineer | Remote (US)', 'x'), id: 'itm_hn-201', removed: { at: '2026-10-30T07:00:00.000Z', reason: 'gone_from_source' }, revision: 2 });
+ const out = await from.pull();
+ assert.strictEqual(out.removed, 1);
+ const row = await s.db.maybe('SELECT expired FROM work_listings WHERE id = $1', [store.idOf('hn-whoishiring', 'hn-201')]);
+ assert.ok(row.expired, 'the listing is expired, not deleted');
+ const runs = await t.ctx.ingest.runAll();
+ assert.deepStrictEqual(runs.find((o) => o.source === 'hn-whoishiring'), { source: 'hn-whoishiring', skipped: 'from_sources' });
+ });
+ } finally {
+ await t.close();
+ }
+ done();
+})().catch((err) => { console.error(err); process.exit(1); });
@@ -23,9 +23,10 @@ const DAY = 86_400_000;
@@ -23,9 +23,10 @@ const DAY = 86_400_000;
await check('reading the three boards keeps every listing with its source, its id and the original URL', async () => {
await check('reading the three boards keeps every listing with its source, its id and the original URL', async () => {
const out = await ingest.runAll();
const out = await ingest.runAll();
// We Work Remotely is skipped here: it reaches Work through OpenVibe.Sources, never from its own feed.
// We Work Remotely is skipped here: it reaches Work through OpenVibe.Sources, never from its own feed.
- assert.deepStrictEqual(out.map((o) => o.source), ['arbeitnow', 'weworkremotely', 'remotive', 'remoteok']);
- assert.ok(out.filter((o) => o.source !== 'weworkremotely').every((o) => o.ok), JSON.stringify(out.map((o) => o.error)));
+ assert.deepStrictEqual(out.map((o) => o.source), ['arbeitnow', 'weworkremotely', 'hn-whoishiring', 'remotive', 'remoteok']);
+ assert.ok(out.filter((o) => !['weworkremotely', 'hn-whoishiring'].includes(o.source)).every((o) => o.ok), JSON.stringify(out.map((o) => o.error)));
assert.strictEqual(out.find((o) => o.source === 'weworkremotely').skipped, 'from_sources');
assert.strictEqual(out.find((o) => o.source === 'weworkremotely').skipped, 'from_sources');
+ assert.strictEqual(out.find((o) => o.source === 'hn-whoishiring').skipped, 'from_sources');
// Arbeitnow's two pages (2 + 1), Remotive's two jobs, RemoteOK's two listings.
// Arbeitnow's two pages (2 + 1), Remotive's two jobs, RemoteOK's two listings.
const counts = await store.countsBySource(s);
const counts = await store.countsBySource(s);
assert.strictEqual(counts.get('arbeitnow').count, 3);
assert.strictEqual(counts.get('arbeitnow').count, 3);
@@ -185,9 +185,9 @@ const first = async (t, path) => (await t.get(path)).json();
@@ -185,9 +185,9 @@ const first = async (t, path) => (await t.get(path)).json();
await check('every source is listed with its terms as applied and when it was last read', async () => {
await check('every source is listed with its terms as applied and when it was last read', async () => {
const api = await first(t, '/api/v1/sources');
const api = await first(t, '/api/v1/sources');
- assert.deepStrictEqual(api.sources.map((s) => s.id).sort(), ['arbeitnow', 'remoteok', 'remotive', 'weworkremotely']);
+ assert.deepStrictEqual(api.sources.map((s) => s.id).sort(), ['arbeitnow', 'hn-whoishiring', 'remoteok', 'remotive', 'weworkremotely']);
// A board OpenVibe.Sources feeds has no read of its own here, so it is not asked for one.
// A board OpenVibe.Sources feeds has no read of its own here, so it is not asked for one.
- for (const src of api.sources.filter((x) => x.id !== 'weworkremotely')) {
+ for (const src of api.sources.filter((x) => !['weworkremotely', 'hn-whoishiring'].includes(x.id))) {
assert.ok(src.terms.length > 40, `${src.id} has no terms`);
assert.ok(src.terms.length > 40, `${src.id} has no terms`);
assert.match(src.terms, /link|attribute|mention|name/i, `${src.id}'s terms do not state the attribution rule`);
assert.match(src.terms, /link|attribute|mention|name/i, `${src.id}'s terms do not state the attribution rule`);
assert.strictEqual(src.last_fetch.ok, true);
assert.strictEqual(src.last_fetch.ok, true);
@@ -214,7 +214,7 @@ const first = async (t, path) => (await t.get(path)).json();
@@ -214,7 +214,7 @@ const first = async (t, path) => (await t.get(path)).json();
const llms = (await t.get('/llms.txt')).text;
const llms = (await t.get('/llms.txt')).text;
assert.ok(llms.includes('(https://openvibe.work/jobs)'));
assert.ok(llms.includes('(https://openvibe.work/jobs)'));
assert.ok(llms.includes('(https://openvibe.work/sources)'));
assert.ok(llms.includes('(https://openvibe.work/sources)'));
- assert.ok(llms.includes('Arbeitnow, We Work Remotely, Remotive, RemoteOK'), 'the sources are not named for a crawler');
+ assert.ok(llms.includes('Arbeitnow, We Work Remotely, Hacker News: Who is hiring, Remotive, RemoteOK'), 'the sources are not named for a crawler');
assert.ok(!llms.includes('/saved'), 'llms.txt names a private page');
assert.ok(!llms.includes('/saved'), 'llms.txt names a private page');
const robots = (await t.get('/robots.txt')).text;
const robots = (await t.get('/robots.txt')).text;
assert.ok(robots.includes('Disallow: /saved'));
assert.ok(robots.includes('Disallow: /saved'));
@@ -17,8 +17,8 @@ const { measure, check, format } = require('openvibe-shared/perf-budget');
@@ -17,8 +17,8 @@ const { measure, check, format } = require('openvibe-shared/perf-budget');
// 18.2 KB raw / 4.3 KB brotli, still two stylesheets and no external file. The home page itself stayed inside its own
// 18.2 KB raw / 4.3 KB brotli, still two stylesheets and no external file. The home page itself stayed inside its own
// budget — the listings replace the skeleton's placeholder sections rather than adding to them.
// budget — the listings replace the skeleton's placeholder sections rather than adding to them.
const BUDGETS = {
const BUDGETS = {
- htmlRawKB: 39, // 38.1 with the home's pick block and folds (the sections below the search are picked one at a time on a phone, 2026-10-30); 36.5 with openvibe-shared 4.53.0: the footer's columns fold on phones (2026-10-30); 31.2 with the Part of OpenVibe section (Shared 4.13, 2026-10-26); measured 24.9 (the home page: hero, features, steps, cta, JSON-LD)
- htmlBrotliKB: 9.5, // 9.1 with the home's pick block and folds (the sections below the search are picked one at a time on a phone, 2026-10-30); 7.8 with the Part of OpenVibe section (Shared 4.13, 2026-10-26); 6.2
+ htmlRawKB: 39, // 38.1 with the home's pick block and folds (the sections below the search are picked one at a time on a phone, 2026-10-30), plus the Hacker News "Who is hiring" source row (2026-10-31); 36.5 with openvibe-shared 4.53.0: the footer's columns fold on phones (2026-10-30); 31.2 with the Part of OpenVibe section (Shared 4.13, 2026-10-26); measured 24.9 (the home page: hero, features, steps, cta, JSON-LD)
+ htmlBrotliKB: 9.5, // 9.1 with the home's pick block and folds (the sections below the search are picked one at a time on a phone, 2026-10-30), plus the Hacker News source row; 7.8 with the Part of OpenVibe section (Shared 4.13, 2026-10-26); 6.2
jsFiles: 5, // 5 (theme-loader, web-runtime, navbar, footer, boost: openvibe-shared/shell)
jsFiles: 5, // 5 (theme-loader, web-runtime, navbar, footer, boost: openvibe-shared/shell)
jsRawKB: 322, // 321.1 with openvibe-shared 4.53.0: the footer's columns fold on phones (2026-10-30); 318.1 with openvibe-shared 4.52.0: the Dock's loader and its Chats tab (2026-10-30); 315.6 with openvibe-shared 4.48.0 (the site switcher on the mark, the menu's Start tab and quick tools; Kit's touch hit areas and the phone navbar fix, 4.44-4.47.3); 307.8 with openvibe-shared 4.43.0 (room.css carries the kit tokens, 4.39.1; the kit's 4.39.0 rules); 300.5 openvibe-shared 4.25.0: the menu's orb beside the brand, both balances on phones, messages, the account sheet on phones and the bar stepping away on phones; 293 openvibe-shared 4.22.0: the navbar's magnifier search button and More dropdown (search.js and loading.js load only when used); 287.2 Shared 4.17.0: OpenCoins and Vibes chips (wallet: true) in the navbar; 273.2 openvibe-shared 4.15 footer: the compact footer opens into the whole one, and the pulse line; 264.3 with Shared 4.13.0 (2026-10-26: account links); 239.8
jsRawKB: 322, // 321.1 with openvibe-shared 4.53.0: the footer's columns fold on phones (2026-10-30); 318.1 with openvibe-shared 4.52.0: the Dock's loader and its Chats tab (2026-10-30); 315.6 with openvibe-shared 4.48.0 (the site switcher on the mark, the menu's Start tab and quick tools; Kit's touch hit areas and the phone navbar fix, 4.44-4.47.3); 307.8 with openvibe-shared 4.43.0 (room.css carries the kit tokens, 4.39.1; the kit's 4.39.0 rules); 300.5 openvibe-shared 4.25.0: the menu's orb beside the brand, both balances on phones, messages, the account sheet on phones and the bar stepping away on phones; 293 openvibe-shared 4.22.0: the navbar's magnifier search button and More dropdown (search.js and loading.js load only when used); 287.2 Shared 4.17.0: OpenCoins and Vibes chips (wallet: true) in the navbar; 273.2 openvibe-shared 4.15 footer: the compact footer opens into the whole one, and the pulse line; 264.3 with Shared 4.13.0 (2026-10-26: account links); 239.8
jsBrotliKB: 79, // 78.2 with openvibe-shared 4.52.0: the Dock's loader and its Chats tab (2026-10-30); 77.6 with openvibe-shared 4.48.0 (the site switcher on the mark, the menu's Start tab and quick tools; Kit's touch hit areas and the phone navbar fix, 4.44-4.47.3); 76.1 with openvibe-shared 4.43.0 (room.css carries the kit tokens, 4.39.1; the kit's 4.39.0 rules); 74.2 openvibe-shared 4.25.0: the menu's orb beside the brand, both balances on phones, messages, the account sheet on phones and the bar stepping away on phones; 70.5 Shared 4.17.0: OpenCoins and Vibes chips (wallet: true) in the navbar; 67.9 openvibe-shared 4.15 footer: the compact footer opens into the whole one, and the pulse line; 65.3 Shared 4.12.0: the light/dark switch, the seasonal mark and the theme pairs; 56.5
jsBrotliKB: 79, // 78.2 with openvibe-shared 4.52.0: the Dock's loader and its Chats tab (2026-10-30); 77.6 with openvibe-shared 4.48.0 (the site switcher on the mark, the menu's Start tab and quick tools; Kit's touch hit areas and the phone navbar fix, 4.44-4.47.3); 76.1 with openvibe-shared 4.43.0 (room.css carries the kit tokens, 4.39.1; the kit's 4.39.0 rules); 74.2 openvibe-shared 4.25.0: the menu's orb beside the brand, both balances on phones, messages, the account sheet on phones and the bar stepping away on phones; 70.5 Shared 4.17.0: OpenCoins and Vibes chips (wallet: true) in the navbar; 67.9 openvibe-shared 4.15 footer: the compact footer opens into the whole one, and the pulse line; 65.3 Shared 4.12.0: the light/dark switch, the seasonal mark and the theme pairs; 56.5