openvibers/openvibe.work Commits
Work: the USAJOBS key and account email are owner settings (work.data_sources) (#205)
Changes
@@@ -71,6 +71,11 @@ WORK_EVENTS_URL=#OV_SOURCES_INTERNAL_URL=http://127.0.0.1:4720#WORK_REMOTIVE_URL=https://remotive.com/api/remote-jobs#WORK_REMOTEOK_URL=https://remoteok.com/api++# USAJOBS (D11 owner settings): the admin panel's work.data_sources namespace wins; these are the++# fallback when no value was entered there. The email must match the key's account.++#USAJOBS_API_KEY=++#USAJOBS_ACCOUNT_EMAIL=++# The User-Agent the boards see. Defaults to OpenVibeWork/0.1 (+<BASE_URL>).#WORK_USER_AGENT=@@@ -272,7 +272,7 @@ Reporting a vulnerability: [SECURITY.md](SECURITY.md).Part of the [OpenVibe network](https://openvibe.network). Built in the open by [OpenVibers](https://github.com/OpenVibers).<!-- versions:start -->--- openvibe-contracts: v0.289.1++- openvibe-contracts: v0.311.0- openvibe-sdk: v0.93.0- openvibe-shared: v4.124.0- openvibe-publishing: v1.4.0@@@ -7,11 +7,11 @@"runtime": "node 22, express 4, PostgreSQL 18 (openvibe-sdk/db, async) + Valkey 9","port": "4960","domain": "openvibe.work",-- "contracts": "openvibe-contracts v0.289.1",++ "contracts": "openvibe-contracts v0.311.0","sdk": "openvibe-sdk v0.93.0","shared": "openvibe-shared v4.124.0","packages": [-- "openvibe-contracts v0.289.1",++ "openvibe-contracts v0.311.0","openvibe-sdk v0.93.0","openvibe-shared v4.124.0","openvibe-publishing v1.4.0"nnew file mode 100644@@@ -0,0 +1,30 @@++-- phase: expand++-- D11 owner settings (openvibe-shared/config): the configuration snapshots and the per-namespace++-- fingerprint key. Work's data-source keys live in work.data_sources; the store seals secret-class++-- values under OV_CONFIG_KEKS and keeps only markers in snapshots. Every table here is new, and an++-- older release ignores all of it.++CREATE TABLE IF NOT EXISTS config_snapshots (++ namespace text COLLATE "C" NOT NULL,++ revision bigint NOT NULL,++ service text COLLATE "C" NOT NULL,++ previous_revision bigint,++ state text COLLATE "C" NOT NULL CHECK (state IN ('proposed', 'active', 'superseded', 'rejected', 'rolled_back')),++ values_json text COLLATE "C" NOT NULL,++ classification_json text COLLATE "C" NOT NULL,++ values_checksum text COLLATE "C" NOT NULL,++ created_at text COLLATE "C" NOT NULL,++ created_by text COLLATE "C" NOT NULL,++ activated_at text COLLATE "C",++ activated_by text COLLATE "C",++ reason text COLLATE "C",++ error text COLLATE "C",++ copied_from bigint,++ good bigint NOT NULL DEFAULT 0,++ PRIMARY KEY (namespace, revision)++);++CREATE UNIQUE INDEX IF NOT EXISTS config_snapshots_one_active ON config_snapshots (namespace) WHERE state = 'active';++CREATE TABLE IF NOT EXISTS config_keys (++ namespace text COLLATE "C" PRIMARY KEY,++ hmac_key bytea NOT NULL,++ created_at text COLLATE "C" NOT NULL++);@@@ -15,7 +15,7 @@"express-rate-limit": "^7.5.0","helmet": "^8.1.0","iovalkey": "^0.4.0",-- "openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.289.1",++ "openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.311.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.93.0","openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v4.124.0",@@@ -1147,9 +1147,9 @@}},"node_modules/openvibe-contracts": {-- "version": "0.289.1",-- "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.289.1",-- "integrity": "sha512-LrKTwe8VPCWn+b0MTNmfjAYEJ0rtgLxeM6EHONBDl0ynt/Igndz+ruDEpwuz5SZ9HYCIMkCqSV5ujhedh6zmzQ==",++ "version": "0.311.0",++ "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.311.0",++ "integrity": "sha512-s0CubY2CA281mADLTwfnHSlpcC+fXbdmTEHFd0gExXrE6P2DGZlf+l+p6GyyOTF0k4CJZeCutDzwSjfSU/ZJRg==","license": "AGPL-3.0-only","dependencies": {"ajv": "^8.17.1",@@@ -23,7 +23,7 @@"express-rate-limit": "^7.5.0","helmet": "^8.1.0","iovalkey": "^0.4.0",-- "openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.289.1",++ "openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.311.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.93.0","openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v4.124.0",@@@ -19,6 +19,7 @@ const helmet = require('helmet');const cookieParser = require('cookie-parser');const rateLimit = require('express-rate-limit');const contracts = require('openvibe-contracts');++const CONFIG_CAPABILITY = 'work.config.manage';const cache = require('openvibe-shared/cache-policy');const configLib = require('./config');@@@ -26,6 +27,8 @@ const { openStore } = require('./db');const { createKeyStore } = require('./auth/keys');const { createSso } = require('./auth/sso');const { createPrincipal } = require('./http/principal');++const settingsLib = require('./settings');++const { verifyServiceToken } = require('openvibe-sdk/auth');const { createApi } = require('./http/api');const { createPageRoutes } = require('./http/pages');const { createServiceReadiness } = require('./observability');@@@ -60,7 +63,20 @@ async function createApp(opts = {}) {const keys = createKeyStore({ config, fetchImpl, log });const sso = createSso({ config, keys, fetchImpl, now: s.now, log });const principal = createPrincipal({ config, keys });-- const ctx = { config, s, keys, sso, principal, log };++ // D11 owner settings (work.data_sources): created before the app mounts /internal/config. A failure leaves++ // Work serving (the USAJOBS pair falls back to the environment) and says why in the log.++ let settings = null;++ if (opts.settings) settings = opts.settings;++ else {++ try {++ const made = settingsLib.createSettings({ db: s.db, env: opts.env || process.env, log });++ await made.ready;++ settings = settingsLib.activate(made);++ } catch (err) {++ log.warn('[OpenVibe.Work] owner settings unavailable, the data-source keys fall back to the environment:', err && err.message);++ }++ }++ const ctx = { config, s, keys, sso, principal, settings, log };const app = express();// GET /openapi.json (SEO-3): this service's public API as OpenAPI 3.1, generated from the contracts@@@ -184,6 +200,32 @@ async function createApp(opts = {}) {// 404 for /internal/ so this is reachable only on 127.0.0.1.app.use('/internal/events', accountData.consumer({ secrets: config.events.secrets, send: accountSend, log }));++ // ── D11 owner settings (openvibe-shared/config): Network's admin proxy, acting for the owner, reads and++ // writes work.data_sources (the USAJOBS API key and account email). Loopback only, guarded by++ // work.config.manage; a secret change needs X-OV-Fresh: 1, and the test route makes one search read.++ if (settings) {++ const configGuard = async (req, res, next) => {++ if (req.headers['x-forwarded-for'] || req.headers['x-real-ip'] || req.headers['cf-connecting-ip']) {++ return res.status(404).json({ code: 'work.not_found', error: 'no such route' });++ }++ const header = String(req.headers.authorization || '');++ if (!header.startsWith('Bearer ')) return res.status(401).json({ code: 'token.missing', error: 'a service token is required' });++ const r = await verifyServiceToken(header.slice(7).trim(), {++ jwks: `${config.networkInternalUrl}/api/.well-known/jwks`, issuer: config.networkIssuer, audience: config.audience,++ contracts: require('openvibe-contracts'),++ });++ if (!r.ok) return res.status(r.code === 'token.unavailable' ? 503 : 401).json({ code: r.code, error: r.reason || 'the token does not verify' });++ const contracts = require('openvibe-contracts');++ const decision = contracts.capabilities.check(r.claims, CONFIG_CAPABILITY);++ const granted = decision.allowed || contracts.capabilities.grants(r.claims.cap, CONFIG_CAPABILITY);++ if (!granted) return res.status(403).json({ code: 'capability.denied', error: 'work.config.manage is not granted' });++ req.principal = { sub: r.claims.sub, cap: r.claims.cap, jti: r.claims.jti };++ return next();++ };++ app.use('/internal/config', express.json({ limit: '64kb' }));++ settings.mount(app, { guard: configGuard, log });++ }++// ── Static assets (content-hashed ?v= → immutable) ──────app.use('/shared', require('openvibe-shared/serve').handler());// GET /<key>.txt — the IndexNow key file, only when a key is configured.nnew file mode 100644@@@ -0,0 +1,145 @@++'use strict';++++/**++ * Owner settings (D11) for OpenVibe.Work: the data-source accounts in `work.data_sources`.++ *++ * USAJOBS takes two owner values: the API key and the account email that goes in the User-Agent++ * header. usajobs() reads them when a USAJOBS read is made: the admin panel's value wins, the++ * environment (USAJOBS_API_KEY, USAJOBS_ACCOUNT_EMAIL) is the fallback. The key is sealed by the++ * shared store under OV_CONFIG_KEKS and is write-only through++ * /internal/config/work.data_sources/secrets/usajobs_api_key; summaries, history and logs carry++ * markers. Network's admin proxy reaches this namespace with `work.config.manage`, acting for the++ * owner (X-OV-Subject); a secret change needs X-OV-Fresh: 1. The test route makes one cheap++ * authenticated search read and answers ok or failed in one line, never echoing the key.++ */++const { createConfigStore, adminRoutes } = require('openvibe-shared/config');++++const SERVICE = 'work';++const NAMESPACE = 'work.data_sources';++const BASE_PATH = '/internal/config';++const GROUP = 'Data sources';++++const SCHEMA = Object.freeze({++ $schema: 'https://json-schema.org/draft/2020-12/schema',++ title: 'Work data sources',++ description: 'The accounts Work reads job listings from. A source with no key is skipped; the other sources keep working.',++ type: 'object',++ additionalProperties: false,++ properties: {++ usajobs_api_key: {++ type: 'string', title: 'USAJOBS API key', 'x-group': GROUP,++ description: 'The API key from your USAJOBS developer account. It lets Work read federal jobs from USAJOBS directly; without it those jobs are missing from search and alerts.',++ 'x-owner-input': {++ group: 'data-sources',++ unlocks: 'Federal job listings from USAJOBS appear in Work\'s search, company pages and job alerts.',++ get_it_at: 'https://developer.usajobs.gov/apirequest/',++ get_it_how: 'Sign in with your USAJOBS account, request an API key for an app, and paste the Authorization Key it shows.',++ test: true,++ },++ 'x-required-for': 'USAJOBS federal jobs',++ },++ usajobs_account_email: {++ type: 'string', format: 'email', title: 'USAJOBS account email', 'x-group': GROUP,++ description: 'The email address on the USAJOBS developer account. USAJOBS requires it in the User-Agent header of every request, not just to sign in.',++ 'x-owner-input': {++ group: 'data-sources',++ unlocks: 'USAJOBS accepts Work\'s requests: the email must match the account the API key belongs to.',++ get_it_at: 'https://developer.usajobs.gov/apirequest/',++ get_it_how: 'Use the email you signed in to developer.usajobs.gov with.',++ test: true,++ },++ 'x-required-for': 'USAJOBS federal jobs',++ },++ },++});++++const SECRETS = new Set(['usajobs_api_key']);++const ACTIVE = { settings: null };++++function createSettings({ db, env = process.env, log = console, fetchImpl = globalThis.fetch, probes = null } = {}) {++ let store = null;++ const ready = createConfigStore({++ db, service: SERVICE, namespace: NAMESPACE, schema: SCHEMA,++ classify: (key) => (SECRETS.has(key) ? 'secret' : 'internal'),++ env, log,++ }).then((s) => { store = s; return s; });++++ const values = () => (store ? store.get() : {});++++ function valueOf(key, envName, fallback = '') {++ const v = values()[key];++ if (v !== undefined && v !== null && v !== '') return v;++ const e = envName ? env[envName] : undefined;++ return e !== undefined && e !== null && e !== '' ? e : fallback;++ }++++ /** The USAJOBS pair, read when a USAJOBS read is made. */++ function usajobs() {++ return {++ apiKey: valueOf('usajobs_api_key', 'USAJOBS_API_KEY', ''),++ email: valueOf('usajobs_account_email', 'USAJOBS_ACCOUNT_EMAIL', ''),++ };++ }++++ async function test(key) {++ if (key !== 'usajobs_api_key' && key !== 'usajobs_account_email') return { ok: false, detail: 'That setting has no test.' };++ const { apiKey, email } = usajobs();++ if (!apiKey || !email) return { ok: false, detail: 'USAJOBS is not configured: both the API key and the account email are needed.' };++ const probe = probes && typeof probes.usajobs === 'function'++ ? probes.usajobs++ : async ({ apiKey: keyValue, email: mail }) => {++ const r = await fetchImpl('https://data.usajobs.gov/api/search?Keyword=software&ResultsPerPage=1', {++ headers: { host: 'data.usajobs.gov', 'user-agent': mail, 'authorization-key': keyValue, accept: 'application/json' },++ });++ let body = {};++ try { body = await r.json(); } catch { /* not JSON */ }++ if (!r.ok) throw new Error(`HTTP ${r.status}${body && body.Message ? `: ${body.Message}` : ''}`);++ };++ try {++ await probe({ apiKey, email });++ return { ok: true, detail: 'ok — USAJOBS answered a one-result search with this key and email.' };++ } catch (err) {++ const words = String((err && err.message) || 'the request failed').split(/\s+/).join(' ').slice(0, 160);++ return { ok: false, detail: `failed — USAJOBS said: ${words.split(apiKey).join('[redacted]')}` };++ }++ }++++ function mount(app, { guard, log: appLog = log } = {}) {++ if (!store) throw new Error('createSettings: await ready before mount()');++ const routes = adminRoutes([store], {++ basePath: BASE_PATH,++ requireAdmin: guard,++ actor: (req) => {++ const id = String(req.get('x-ov-subject') || '').trim();++ return /^usr_[0-9A-HJKMNP-TV-Z]{26}$/.test(id) ? { type: 'user', id } : null;++ },++ requireFresh: (req) => String(req.get('x-ov-fresh') || '') === '1',++ log: appLog,++ });++ app.use(routes.handle);++ app.post(`${BASE_PATH}/:namespace/test/:key`, guard, async (req, res) => {++ if (req.params.namespace !== NAMESPACE) return res.status(404).json({ error: 'no such configuration namespace' });++ let key = String(req.params.key || '');++ try { key = decodeURIComponent(key); } catch { /* keep it as it is */ }++ if (!SCHEMA.properties[key]) return res.status(404).json({ error: 'no such setting' });++ try {++ const out = await test(key);++ return res.status(200).json({ ok: Boolean(out.ok), detail: String(out.detail).replace(/\s+/g, ' ').slice(0, 200) });++ } catch (err) {++ appLog.warn(`[config] ${NAMESPACE}: the test for ${key} failed: ${String((err && err.message) || err).slice(0, 160)}`);++ return res.status(200).json({ ok: false, detail: 'failed — the test did not finish. The service log has the reason.' });++ }++ });++ return routes;++ }++++ return { namespace: NAMESPACE, schema: SCHEMA, ready, value: valueOf, get: values, usajobs, test, mount };++}++++function activate(settings) { ACTIVE.settings = settings; return settings; }++function usajobs() {++ if (ACTIVE.settings) return ACTIVE.settings.usajobs();++ return { apiKey: process.env.USAJOBS_API_KEY || '', email: process.env.USAJOBS_ACCOUNT_EMAIL || '' };++}++++module.exports = { SERVICE, NAMESPACE, SCHEMA, SECRETS, createSettings, activate, usajobs };@@@ -71,7 +71,7 @@ async function makeApplication(t, subject) {try {const missing = await uncovered(d.db, {tables: TABLES,-- exempt: {"account_data_events":"the ledger of requests itself: which export and deletion work applied","work_index_revisions":"owner is the search-index document's owner (type/id), not a person; the row holds a revision and hash"},++ exempt: {"config_snapshots":"D11 owner settings: created_by/activated_by is the acting administrator (usr_...) for the config audit trail; the values are network configuration, not the person's account data, and are kept for rollback/history","account_data_events":"the ledger of requests itself: which export and deletion work applied","work_index_revisions":"owner is the search-index document's owner (type/id), not a person; the row holds a revision and hash"},});assert.deepStrictEqual(missing, [], `a table with a person column is neither covered nor exempted: ${missing.join(', ')}`);} finally { await d.close(); }@@@ -45,7 +45,7 @@ async function boot(opts = {}) {const { createStore } = require('../../server/db');const testdb = await require('./db').testDb();const store = createStore(testdb.db, { now: opts.now });-- const built = await createApp({ config, store, log, telemetrySink: opts.telemetrySink, limitsNow: opts.limitsNow, callerLimits: opts.callerLimits === true, accountSend: opts.accountSend, actor: opts.actor, assistLimitsNow: opts.assistLimitsNow, indexnow: opts.indexnow, sourcesClient: opts.sourcesClient });++ const built = await createApp({ config, env, store, log, telemetrySink: opts.telemetrySink, limitsNow: opts.limitsNow, callerLimits: opts.callerLimits === true, accountSend: opts.accountSend, actor: opts.actor, assistLimitsNow: opts.assistLimitsNow, indexnow: opts.indexnow, sourcesClient: opts.sourcesClient });await built.ctx.keys.ensure();const server = await new Promise((resolve) => { const s = http.createServer(built.app); s.listen(0, '127.0.0.1', () => resolve(s)); });const base = `http://127.0.0.1:${server.address().port}`;nnew file mode 100644@@@ -0,0 +1,99 @@++'use strict';++/**++ * Owner settings (D11) for Work's data sources (server/settings.js, work.data_sources):++ * - the USAJOBS API key and account email have plain titles, 30+ character descriptions,++ * x-owner-input words and a test;++ * - /internal/config is loopback only and needs work.config.manage;++ * - the key is write-only and marked in summaries, usajobs() reads the owner's pair and falls back++ * to USAJOBS_API_KEY / USAJOBS_ACCOUNT_EMAIL, and the test hook never echoes or logs the key.++ * node test/owner-settings.test.js++ */++const assert = require('assert');++const http = require('http');++const express = require('express');++const { serviceAuth } = require('openvibe-contracts');++const { boot } = require('./helpers/boot');++const { createSettings } = require('../server/settings');++++const CAP = 'work.config.manage';++const NS = 'work.data_sources';++const USR = 'usr_01J8Z3Q4R5S6T7V8W9X0Y1Z2A3';++const KEY = 'usajobs-owner-key-sentinel';++const EMAIL = '[email protected]';++++(async () => {++ const t = await boot({ env: { USAJOBS_API_KEY: 'usajobs-env-key', USAJOBS_ACCOUNT_EMAIL: '[email protected]' } });++ const now = Math.floor(Date.now() / 1000);++ const TOKEN = serviceAuth.signServiceToken({ iss: t.network.url, sub: 'svc:network', actor_type: 'service', aud: ['openvibe.work'], cap: [CAP], iat: now, exp: now + 300, jti: 'tok_owner_settings' }, t.network.privatePem);++ const checks = [];++ const check = async (name, fn) => {++ try { await fn(); checks.push(name); console.log(` ✓ ${name}`); } catch (e) { console.log(` ✗ ${name}\n${e.stack}`); await t.close(); process.exit(1); }++ };++++ await check('both USAJOBS fields have owner words and a test', async () => {++ const r = await t.get(`/internal/config/${NS}`, { bearer: TOKEN });++ assert.strictEqual(r.status, 200, r.text);++ const props = r.json().schema.properties;++ for (const key of ['usajobs_api_key', 'usajobs_account_email']) {++ const f = props[key];++ assert.ok(f, `${key} exists`);++ assert.ok(f.title && f.title.length >= 3);++ assert.ok(f.description && f.description.length >= 30);++ assert.strictEqual(f['x-owner-input'].group, 'data-sources');++ assert.ok(f['x-owner-input'].unlocks.length > 20);++ assert.ok(/^https:\/\//.test(f['x-owner-input'].get_it_at));++ assert.strictEqual(f['x-owner-input'].test, true);++ assert.strictEqual(f['x-required-for'], 'USAJOBS federal jobs');++ }++ });++++ await check('the routes are loopback only and need the capability', async () => {++ assert.strictEqual((await t.get(`/internal/config/${NS}`)).status, 401);++ const wrong = serviceAuth.signServiceToken({ iss: t.network.url, sub: 'svc:network', actor_type: 'service', aud: ['openvibe.work'], cap: ['work.job.read'], iat: now, exp: now + 300, jti: 'tok_wrong' }, t.network.privatePem);++ assert.strictEqual((await t.get(`/internal/config/${NS}`, { bearer: wrong })).status, 403);++ assert.strictEqual((await t.get(`/internal/config/${NS}`, { bearer: TOKEN, headers: { 'x-forwarded-for': '203.0.113.9' } })).status, 404);++ });++++ await check('the key is write-only and the environment is the fallback', async () => {++ assert.deepStrictEqual(t.ctx.settings.usajobs(), { apiKey: 'usajobs-env-key', email: '[email protected]' });++ const put = await t.get(`/internal/config/${NS}/secrets/usajobs_api_key`, {++ bearer: TOKEN, method: 'PUT', json: { value: KEY, reason: 'owner test' }, headers: { 'x-ov-subject': USR, 'x-ov-fresh': '1' },++ });++ assert.strictEqual(put.status, 200, put.text);++ assert.ok(put.json().fingerprint && put.json().hint);++ assert.ok(!put.text.includes(KEY));++ const email = await t.get(`/internal/config/${NS}`, {++ bearer: TOKEN, method: 'POST', json: { values: { usajobs_account_email: EMAIL }, merge: true, reason: 'owner test' }, headers: { 'x-ov-subject': USR },++ });++ assert.strictEqual(email.status, 200, email.text);++ const summary = await t.get(`/internal/config/${NS}`, { bearer: TOKEN });++ assert.ok(summary.json().values.usajobs_api_key.redacted);++ assert.ok(!summary.text.includes(KEY));++ assert.deepStrictEqual(t.ctx.settings.usajobs(), { apiKey: KEY, email: EMAIL });++ });++++ await check('the test hook says ok/failed in one line and never echoes a key', async () => {++ const fake = createSettings({++ db: t.ctx.s.db, env: {},++ probes: { usajobs: async ({ apiKey }) => { throw new Error(`refused ${apiKey}`); } },++ });++ await fake.ready;++ const app = express();++ app.use(express.json());++ fake.mount(app, { guard: (_req, _res, next) => next(), log: t.log });++ const server = await new Promise((resolve) => { const s = http.createServer(app); s.listen(0, '127.0.0.1', () => resolve(s)); });++ try {++ const r = await fetch(`http://127.0.0.1:${server.address().port}/internal/config/${NS}/test/usajobs_api_key`, { method: 'POST' });++ const body = await r.json();++ assert.strictEqual(r.status, 200);++ assert.strictEqual(body.ok, false);++ assert.ok(/^failed — USAJOBS said: /.test(body.detail), body.detail);++ assert.ok(!body.detail.includes(KEY));++ assert.ok(!body.detail.includes('\n'));++ } finally { server.close(); }++ assert.ok(!t.logs().includes(KEY), 'no key in a captured log line');++ });++++ await t.close();++ console.log(`\n${checks.length} checks passed`);++})().catch((e) => { console.error(e); process.exit(1); });
@@ -71,6 +71,11 @@ WORK_EVENTS_URL=
@@ -71,6 +71,11 @@ WORK_EVENTS_URL=
#OV_SOURCES_INTERNAL_URL=http://127.0.0.1:4720
#OV_SOURCES_INTERNAL_URL=http://127.0.0.1:4720
#WORK_REMOTIVE_URL=https://remotive.com/api/remote-jobs
#WORK_REMOTIVE_URL=https://remotive.com/api/remote-jobs
#WORK_REMOTEOK_URL=https://remoteok.com/api
#WORK_REMOTEOK_URL=https://remoteok.com/api
+# USAJOBS (D11 owner settings): the admin panel's work.data_sources namespace wins; these are the
+# fallback when no value was entered there. The email must match the key's account.
+#USAJOBS_API_KEY=
+#USAJOBS_ACCOUNT_EMAIL=
+
# The User-Agent the boards see. Defaults to OpenVibeWork/0.1 (+<BASE_URL>).
# The User-Agent the boards see. Defaults to OpenVibeWork/0.1 (+<BASE_URL>).
#WORK_USER_AGENT=
#WORK_USER_AGENT=
@@ -272,7 +272,7 @@ Reporting a vulnerability: [SECURITY.md](SECURITY.md).
@@ -272,7 +272,7 @@ Reporting a vulnerability: [SECURITY.md](SECURITY.md).
Part of the [OpenVibe network](https://openvibe.network). Built in the open by [OpenVibers](https://github.com/OpenVibers).
Part of the [OpenVibe network](https://openvibe.network). Built in the open by [OpenVibers](https://github.com/OpenVibers).
<!-- versions:start -->
<!-- versions:start -->
-- openvibe-contracts: v0.289.1
+- openvibe-contracts: v0.311.0
- openvibe-sdk: v0.93.0
- openvibe-sdk: v0.93.0
- openvibe-shared: v4.124.0
- openvibe-shared: v4.124.0
- openvibe-publishing: v1.4.0
- openvibe-publishing: v1.4.0
@@ -7,11 +7,11 @@
@@ -7,11 +7,11 @@
"runtime": "node 22, express 4, PostgreSQL 18 (openvibe-sdk/db, async) + Valkey 9",
"runtime": "node 22, express 4, PostgreSQL 18 (openvibe-sdk/db, async) + Valkey 9",
"port": "4960",
"port": "4960",
"domain": "openvibe.work",
"domain": "openvibe.work",
- "contracts": "openvibe-contracts v0.289.1",
+ "contracts": "openvibe-contracts v0.311.0",
"sdk": "openvibe-sdk v0.93.0",
"sdk": "openvibe-sdk v0.93.0",
"shared": "openvibe-shared v4.124.0",
"shared": "openvibe-shared v4.124.0",
"packages": [
"packages": [
- "openvibe-contracts v0.289.1",
+ "openvibe-contracts v0.311.0",
"openvibe-sdk v0.93.0",
"openvibe-sdk v0.93.0",
"openvibe-shared v4.124.0",
"openvibe-shared v4.124.0",
"openvibe-publishing v1.4.0"
"openvibe-publishing v1.4.0"
new file mode 100644
new file mode 100644
@@ -0,0 +1,30 @@
@@ -0,0 +1,30 @@
+-- phase: expand
+-- D11 owner settings (openvibe-shared/config): the configuration snapshots and the per-namespace
+-- fingerprint key. Work's data-source keys live in work.data_sources; the store seals secret-class
+-- values under OV_CONFIG_KEKS and keeps only markers in snapshots. Every table here is new, and an
+-- older release ignores all of it.
+CREATE TABLE IF NOT EXISTS config_snapshots (
+ namespace text COLLATE "C" NOT NULL,
+ revision bigint NOT NULL,
+ service text COLLATE "C" NOT NULL,
+ previous_revision bigint,
+ state text COLLATE "C" NOT NULL CHECK (state IN ('proposed', 'active', 'superseded', 'rejected', 'rolled_back')),
+ values_json text COLLATE "C" NOT NULL,
+ classification_json text COLLATE "C" NOT NULL,
+ values_checksum text COLLATE "C" NOT NULL,
+ created_at text COLLATE "C" NOT NULL,
+ created_by text COLLATE "C" NOT NULL,
+ activated_at text COLLATE "C",
+ activated_by text COLLATE "C",
+ reason text COLLATE "C",
+ error text COLLATE "C",
+ copied_from bigint,
+ good bigint NOT NULL DEFAULT 0,
+ PRIMARY KEY (namespace, revision)
+);
+CREATE UNIQUE INDEX IF NOT EXISTS config_snapshots_one_active ON config_snapshots (namespace) WHERE state = 'active';
+CREATE TABLE IF NOT EXISTS config_keys (
+ namespace text COLLATE "C" PRIMARY KEY,
+ hmac_key bytea NOT NULL,
+ created_at text COLLATE "C" NOT NULL
+);
@@ -15,7 +15,7 @@
@@ -15,7 +15,7 @@
"express-rate-limit": "^7.5.0",
"express-rate-limit": "^7.5.0",
"helmet": "^8.1.0",
"helmet": "^8.1.0",
"iovalkey": "^0.4.0",
"iovalkey": "^0.4.0",
- "openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.289.1",
+ "openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.311.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.93.0",
"openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.93.0",
"openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v4.124.0",
"openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v4.124.0",
@@ -1147,9 +1147,9 @@
@@ -1147,9 +1147,9 @@
}
}
},
},
"node_modules/openvibe-contracts": {
"node_modules/openvibe-contracts": {
- "version": "0.289.1",
- "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.289.1",
- "integrity": "sha512-LrKTwe8VPCWn+b0MTNmfjAYEJ0rtgLxeM6EHONBDl0ynt/Igndz+ruDEpwuz5SZ9HYCIMkCqSV5ujhedh6zmzQ==",
+ "version": "0.311.0",
+ "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.311.0",
+ "integrity": "sha512-s0CubY2CA281mADLTwfnHSlpcC+fXbdmTEHFd0gExXrE6P2DGZlf+l+p6GyyOTF0k4CJZeCutDzwSjfSU/ZJRg==",
"license": "AGPL-3.0-only",
"license": "AGPL-3.0-only",
"dependencies": {
"dependencies": {
"ajv": "^8.17.1",
"ajv": "^8.17.1",
@@ -23,7 +23,7 @@
@@ -23,7 +23,7 @@
"express-rate-limit": "^7.5.0",
"express-rate-limit": "^7.5.0",
"helmet": "^8.1.0",
"helmet": "^8.1.0",
"iovalkey": "^0.4.0",
"iovalkey": "^0.4.0",
- "openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.289.1",
+ "openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.311.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.93.0",
"openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.93.0",
"openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v4.124.0",
"openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v4.124.0",
@@ -19,6 +19,7 @@ const helmet = require('helmet');
@@ -19,6 +19,7 @@ const helmet = require('helmet');
const cookieParser = require('cookie-parser');
const cookieParser = require('cookie-parser');
const rateLimit = require('express-rate-limit');
const rateLimit = require('express-rate-limit');
const contracts = require('openvibe-contracts');
const contracts = require('openvibe-contracts');
+const CONFIG_CAPABILITY = 'work.config.manage';
const cache = require('openvibe-shared/cache-policy');
const cache = require('openvibe-shared/cache-policy');
const configLib = require('./config');
const configLib = require('./config');
@@ -26,6 +27,8 @@ const { openStore } = require('./db');
@@ -26,6 +27,8 @@ const { openStore } = require('./db');
const { createKeyStore } = require('./auth/keys');
const { createKeyStore } = require('./auth/keys');
const { createSso } = require('./auth/sso');
const { createSso } = require('./auth/sso');
const { createPrincipal } = require('./http/principal');
const { createPrincipal } = require('./http/principal');
+const settingsLib = require('./settings');
+const { verifyServiceToken } = require('openvibe-sdk/auth');
const { createApi } = require('./http/api');
const { createApi } = require('./http/api');
const { createPageRoutes } = require('./http/pages');
const { createPageRoutes } = require('./http/pages');
const { createServiceReadiness } = require('./observability');
const { createServiceReadiness } = require('./observability');
@@ -60,7 +63,20 @@ async function createApp(opts = {}) {
@@ -60,7 +63,20 @@ async function createApp(opts = {}) {
const keys = createKeyStore({ config, fetchImpl, log });
const keys = createKeyStore({ config, fetchImpl, log });
const sso = createSso({ config, keys, fetchImpl, now: s.now, log });
const sso = createSso({ config, keys, fetchImpl, now: s.now, log });
const principal = createPrincipal({ config, keys });
const principal = createPrincipal({ config, keys });
- const ctx = { config, s, keys, sso, principal, log };
+ // D11 owner settings (work.data_sources): created before the app mounts /internal/config. A failure leaves
+ // Work serving (the USAJOBS pair falls back to the environment) and says why in the log.
+ let settings = null;
+ if (opts.settings) settings = opts.settings;
+ else {
+ try {
+ const made = settingsLib.createSettings({ db: s.db, env: opts.env || process.env, log });
+ await made.ready;
+ settings = settingsLib.activate(made);
+ } catch (err) {
+ log.warn('[OpenVibe.Work] owner settings unavailable, the data-source keys fall back to the environment:', err && err.message);
+ }
+ }
+ const ctx = { config, s, keys, sso, principal, settings, log };
const app = express();
const app = express();
// GET /openapi.json (SEO-3): this service's public API as OpenAPI 3.1, generated from the contracts
// GET /openapi.json (SEO-3): this service's public API as OpenAPI 3.1, generated from the contracts
@@ -184,6 +200,32 @@ async function createApp(opts = {}) {
@@ -184,6 +200,32 @@ async function createApp(opts = {}) {
// 404 for /internal/ so this is reachable only on 127.0.0.1.
// 404 for /internal/ so this is reachable only on 127.0.0.1.
app.use('/internal/events', accountData.consumer({ secrets: config.events.secrets, send: accountSend, log }));
app.use('/internal/events', accountData.consumer({ secrets: config.events.secrets, send: accountSend, log }));
+ // ── D11 owner settings (openvibe-shared/config): Network's admin proxy, acting for the owner, reads and
+ // writes work.data_sources (the USAJOBS API key and account email). Loopback only, guarded by
+ // work.config.manage; a secret change needs X-OV-Fresh: 1, and the test route makes one search read.
+ if (settings) {
+ const configGuard = async (req, res, next) => {
+ if (req.headers['x-forwarded-for'] || req.headers['x-real-ip'] || req.headers['cf-connecting-ip']) {
+ return res.status(404).json({ code: 'work.not_found', error: 'no such route' });
+ }
+ const header = String(req.headers.authorization || '');
+ if (!header.startsWith('Bearer ')) return res.status(401).json({ code: 'token.missing', error: 'a service token is required' });
+ const r = await verifyServiceToken(header.slice(7).trim(), {
+ jwks: `${config.networkInternalUrl}/api/.well-known/jwks`, issuer: config.networkIssuer, audience: config.audience,
+ contracts: require('openvibe-contracts'),
+ });
+ if (!r.ok) return res.status(r.code === 'token.unavailable' ? 503 : 401).json({ code: r.code, error: r.reason || 'the token does not verify' });
+ const contracts = require('openvibe-contracts');
+ const decision = contracts.capabilities.check(r.claims, CONFIG_CAPABILITY);
+ const granted = decision.allowed || contracts.capabilities.grants(r.claims.cap, CONFIG_CAPABILITY);
+ if (!granted) return res.status(403).json({ code: 'capability.denied', error: 'work.config.manage is not granted' });
+ req.principal = { sub: r.claims.sub, cap: r.claims.cap, jti: r.claims.jti };
+ return next();
+ };
+ app.use('/internal/config', express.json({ limit: '64kb' }));
+ settings.mount(app, { guard: configGuard, log });
+ }
+
// ── Static assets (content-hashed ?v= → immutable) ──────
// ── Static assets (content-hashed ?v= → immutable) ──────
app.use('/shared', require('openvibe-shared/serve').handler());
app.use('/shared', require('openvibe-shared/serve').handler());
// GET /<key>.txt — the IndexNow key file, only when a key is configured.
// GET /<key>.txt — the IndexNow key file, only when a key is configured.
new file mode 100644
new file mode 100644
@@ -0,0 +1,145 @@
@@ -0,0 +1,145 @@
+'use strict';
+
+/**
+ * Owner settings (D11) for OpenVibe.Work: the data-source accounts in `work.data_sources`.
+ *
+ * USAJOBS takes two owner values: the API key and the account email that goes in the User-Agent
+ * header. usajobs() reads them when a USAJOBS read is made: the admin panel's value wins, the
+ * environment (USAJOBS_API_KEY, USAJOBS_ACCOUNT_EMAIL) is the fallback. The key is sealed by the
+ * shared store under OV_CONFIG_KEKS and is write-only through
+ * /internal/config/work.data_sources/secrets/usajobs_api_key; summaries, history and logs carry
+ * markers. Network's admin proxy reaches this namespace with `work.config.manage`, acting for the
+ * owner (X-OV-Subject); a secret change needs X-OV-Fresh: 1. The test route makes one cheap
+ * authenticated search read and answers ok or failed in one line, never echoing the key.
+ */
+const { createConfigStore, adminRoutes } = require('openvibe-shared/config');
+
+const SERVICE = 'work';
+const NAMESPACE = 'work.data_sources';
+const BASE_PATH = '/internal/config';
+const GROUP = 'Data sources';
+
+const SCHEMA = Object.freeze({
+ $schema: 'https://json-schema.org/draft/2020-12/schema',
+ title: 'Work data sources',
+ description: 'The accounts Work reads job listings from. A source with no key is skipped; the other sources keep working.',
+ type: 'object',
+ additionalProperties: false,
+ properties: {
+ usajobs_api_key: {
+ type: 'string', title: 'USAJOBS API key', 'x-group': GROUP,
+ description: 'The API key from your USAJOBS developer account. It lets Work read federal jobs from USAJOBS directly; without it those jobs are missing from search and alerts.',
+ 'x-owner-input': {
+ group: 'data-sources',
+ unlocks: 'Federal job listings from USAJOBS appear in Work\'s search, company pages and job alerts.',
+ get_it_at: 'https://developer.usajobs.gov/apirequest/',
+ get_it_how: 'Sign in with your USAJOBS account, request an API key for an app, and paste the Authorization Key it shows.',
+ test: true,
+ },
+ 'x-required-for': 'USAJOBS federal jobs',
+ },
+ usajobs_account_email: {
+ type: 'string', format: 'email', title: 'USAJOBS account email', 'x-group': GROUP,
+ description: 'The email address on the USAJOBS developer account. USAJOBS requires it in the User-Agent header of every request, not just to sign in.',
+ 'x-owner-input': {
+ group: 'data-sources',
+ unlocks: 'USAJOBS accepts Work\'s requests: the email must match the account the API key belongs to.',
+ get_it_at: 'https://developer.usajobs.gov/apirequest/',
+ get_it_how: 'Use the email you signed in to developer.usajobs.gov with.',
+ test: true,
+ },
+ 'x-required-for': 'USAJOBS federal jobs',
+ },
+ },
+});
+
+const SECRETS = new Set(['usajobs_api_key']);
+const ACTIVE = { settings: null };
+
+function createSettings({ db, env = process.env, log = console, fetchImpl = globalThis.fetch, probes = null } = {}) {
+ let store = null;
+ const ready = createConfigStore({
+ db, service: SERVICE, namespace: NAMESPACE, schema: SCHEMA,
+ classify: (key) => (SECRETS.has(key) ? 'secret' : 'internal'),
+ env, log,
+ }).then((s) => { store = s; return s; });
+
+ const values = () => (store ? store.get() : {});
+
+ function valueOf(key, envName, fallback = '') {
+ const v = values()[key];
+ if (v !== undefined && v !== null && v !== '') return v;
+ const e = envName ? env[envName] : undefined;
+ return e !== undefined && e !== null && e !== '' ? e : fallback;
+ }
+
+ /** The USAJOBS pair, read when a USAJOBS read is made. */
+ function usajobs() {
+ return {
+ apiKey: valueOf('usajobs_api_key', 'USAJOBS_API_KEY', ''),
+ email: valueOf('usajobs_account_email', 'USAJOBS_ACCOUNT_EMAIL', ''),
+ };
+ }
+
+ async function test(key) {
+ if (key !== 'usajobs_api_key' && key !== 'usajobs_account_email') return { ok: false, detail: 'That setting has no test.' };
+ const { apiKey, email } = usajobs();
+ if (!apiKey || !email) return { ok: false, detail: 'USAJOBS is not configured: both the API key and the account email are needed.' };
+ const probe = probes && typeof probes.usajobs === 'function'
+ ? probes.usajobs
+ : async ({ apiKey: keyValue, email: mail }) => {
+ const r = await fetchImpl('https://data.usajobs.gov/api/search?Keyword=software&ResultsPerPage=1', {
+ headers: { host: 'data.usajobs.gov', 'user-agent': mail, 'authorization-key': keyValue, accept: 'application/json' },
+ });
+ let body = {};
+ try { body = await r.json(); } catch { /* not JSON */ }
+ if (!r.ok) throw new Error(`HTTP ${r.status}${body && body.Message ? `: ${body.Message}` : ''}`);
+ };
+ try {
+ await probe({ apiKey, email });
+ return { ok: true, detail: 'ok — USAJOBS answered a one-result search with this key and email.' };
+ } catch (err) {
+ const words = String((err && err.message) || 'the request failed').split(/\s+/).join(' ').slice(0, 160);
+ return { ok: false, detail: `failed — USAJOBS said: ${words.split(apiKey).join('[redacted]')}` };
+ }
+ }
+
+ function mount(app, { guard, log: appLog = log } = {}) {
+ if (!store) throw new Error('createSettings: await ready before mount()');
+ const routes = adminRoutes([store], {
+ basePath: BASE_PATH,
+ requireAdmin: guard,
+ actor: (req) => {
+ const id = String(req.get('x-ov-subject') || '').trim();
+ return /^usr_[0-9A-HJKMNP-TV-Z]{26}$/.test(id) ? { type: 'user', id } : null;
+ },
+ requireFresh: (req) => String(req.get('x-ov-fresh') || '') === '1',
+ log: appLog,
+ });
+ app.use(routes.handle);
+ app.post(`${BASE_PATH}/:namespace/test/:key`, guard, async (req, res) => {
+ if (req.params.namespace !== NAMESPACE) return res.status(404).json({ error: 'no such configuration namespace' });
+ let key = String(req.params.key || '');
+ try { key = decodeURIComponent(key); } catch { /* keep it as it is */ }
+ if (!SCHEMA.properties[key]) return res.status(404).json({ error: 'no such setting' });
+ try {
+ const out = await test(key);
+ return res.status(200).json({ ok: Boolean(out.ok), detail: String(out.detail).replace(/\s+/g, ' ').slice(0, 200) });
+ } catch (err) {
+ appLog.warn(`[config] ${NAMESPACE}: the test for ${key} failed: ${String((err && err.message) || err).slice(0, 160)}`);
+ return res.status(200).json({ ok: false, detail: 'failed — the test did not finish. The service log has the reason.' });
+ }
+ });
+ return routes;
+ }
+
+ return { namespace: NAMESPACE, schema: SCHEMA, ready, value: valueOf, get: values, usajobs, test, mount };
+}
+
+function activate(settings) { ACTIVE.settings = settings; return settings; }
+function usajobs() {
+ if (ACTIVE.settings) return ACTIVE.settings.usajobs();
+ return { apiKey: process.env.USAJOBS_API_KEY || '', email: process.env.USAJOBS_ACCOUNT_EMAIL || '' };
+}
+
+module.exports = { SERVICE, NAMESPACE, SCHEMA, SECRETS, createSettings, activate, usajobs };
@@ -71,7 +71,7 @@ async function makeApplication(t, subject) {
@@ -71,7 +71,7 @@ async function makeApplication(t, subject) {
try {
try {
const missing = await uncovered(d.db, {
const missing = await uncovered(d.db, {
tables: TABLES,
tables: TABLES,
- exempt: {"account_data_events":"the ledger of requests itself: which export and deletion work applied","work_index_revisions":"owner is the search-index document's owner (type/id), not a person; the row holds a revision and hash"},
+ exempt: {"config_snapshots":"D11 owner settings: created_by/activated_by is the acting administrator (usr_...) for the config audit trail; the values are network configuration, not the person's account data, and are kept for rollback/history","account_data_events":"the ledger of requests itself: which export and deletion work applied","work_index_revisions":"owner is the search-index document's owner (type/id), not a person; the row holds a revision and hash"},
});
});
assert.deepStrictEqual(missing, [], `a table with a person column is neither covered nor exempted: ${missing.join(', ')}`);
assert.deepStrictEqual(missing, [], `a table with a person column is neither covered nor exempted: ${missing.join(', ')}`);
} finally { await d.close(); }
} finally { await d.close(); }
@@ -45,7 +45,7 @@ async function boot(opts = {}) {
@@ -45,7 +45,7 @@ async function boot(opts = {}) {
const { createStore } = require('../../server/db');
const { createStore } = require('../../server/db');
const testdb = await require('./db').testDb();
const testdb = await require('./db').testDb();
const store = createStore(testdb.db, { now: opts.now });
const store = createStore(testdb.db, { now: opts.now });
- const built = await createApp({ config, store, log, telemetrySink: opts.telemetrySink, limitsNow: opts.limitsNow, callerLimits: opts.callerLimits === true, accountSend: opts.accountSend, actor: opts.actor, assistLimitsNow: opts.assistLimitsNow, indexnow: opts.indexnow, sourcesClient: opts.sourcesClient });
+ const built = await createApp({ config, env, store, log, telemetrySink: opts.telemetrySink, limitsNow: opts.limitsNow, callerLimits: opts.callerLimits === true, accountSend: opts.accountSend, actor: opts.actor, assistLimitsNow: opts.assistLimitsNow, indexnow: opts.indexnow, sourcesClient: opts.sourcesClient });
await built.ctx.keys.ensure();
await built.ctx.keys.ensure();
const server = await new Promise((resolve) => { const s = http.createServer(built.app); s.listen(0, '127.0.0.1', () => resolve(s)); });
const server = await new Promise((resolve) => { const s = http.createServer(built.app); s.listen(0, '127.0.0.1', () => resolve(s)); });
const base = `http://127.0.0.1:${server.address().port}`;
const base = `http://127.0.0.1:${server.address().port}`;
new file mode 100644
new file mode 100644
@@ -0,0 +1,99 @@
@@ -0,0 +1,99 @@
+'use strict';
+/**
+ * Owner settings (D11) for Work's data sources (server/settings.js, work.data_sources):
+ * - the USAJOBS API key and account email have plain titles, 30+ character descriptions,
+ * x-owner-input words and a test;
+ * - /internal/config is loopback only and needs work.config.manage;
+ * - the key is write-only and marked in summaries, usajobs() reads the owner's pair and falls back
+ * to USAJOBS_API_KEY / USAJOBS_ACCOUNT_EMAIL, and the test hook never echoes or logs the key.
+ * node test/owner-settings.test.js
+ */
+const assert = require('assert');
+const http = require('http');
+const express = require('express');
+const { serviceAuth } = require('openvibe-contracts');
+const { boot } = require('./helpers/boot');
+const { createSettings } = require('../server/settings');
+
+const CAP = 'work.config.manage';
+const NS = 'work.data_sources';
+const USR = 'usr_01J8Z3Q4R5S6T7V8W9X0Y1Z2A3';
+const KEY = 'usajobs-owner-key-sentinel';
+const EMAIL = '[email protected]';
+
+(async () => {
+ const t = await boot({ env: { USAJOBS_API_KEY: 'usajobs-env-key', USAJOBS_ACCOUNT_EMAIL: '[email protected]' } });
+ const now = Math.floor(Date.now() / 1000);
+ const TOKEN = serviceAuth.signServiceToken({ iss: t.network.url, sub: 'svc:network', actor_type: 'service', aud: ['openvibe.work'], cap: [CAP], iat: now, exp: now + 300, jti: 'tok_owner_settings' }, t.network.privatePem);
+ const checks = [];
+ const check = async (name, fn) => {
+ try { await fn(); checks.push(name); console.log(` ✓ ${name}`); } catch (e) { console.log(` ✗ ${name}\n${e.stack}`); await t.close(); process.exit(1); }
+ };
+
+ await check('both USAJOBS fields have owner words and a test', async () => {
+ const r = await t.get(`/internal/config/${NS}`, { bearer: TOKEN });
+ assert.strictEqual(r.status, 200, r.text);
+ const props = r.json().schema.properties;
+ for (const key of ['usajobs_api_key', 'usajobs_account_email']) {
+ const f = props[key];
+ assert.ok(f, `${key} exists`);
+ assert.ok(f.title && f.title.length >= 3);
+ assert.ok(f.description && f.description.length >= 30);
+ assert.strictEqual(f['x-owner-input'].group, 'data-sources');
+ assert.ok(f['x-owner-input'].unlocks.length > 20);
+ assert.ok(/^https:\/\//.test(f['x-owner-input'].get_it_at));
+ assert.strictEqual(f['x-owner-input'].test, true);
+ assert.strictEqual(f['x-required-for'], 'USAJOBS federal jobs');
+ }
+ });
+
+ await check('the routes are loopback only and need the capability', async () => {
+ assert.strictEqual((await t.get(`/internal/config/${NS}`)).status, 401);
+ const wrong = serviceAuth.signServiceToken({ iss: t.network.url, sub: 'svc:network', actor_type: 'service', aud: ['openvibe.work'], cap: ['work.job.read'], iat: now, exp: now + 300, jti: 'tok_wrong' }, t.network.privatePem);
+ assert.strictEqual((await t.get(`/internal/config/${NS}`, { bearer: wrong })).status, 403);
+ assert.strictEqual((await t.get(`/internal/config/${NS}`, { bearer: TOKEN, headers: { 'x-forwarded-for': '203.0.113.9' } })).status, 404);
+ });
+
+ await check('the key is write-only and the environment is the fallback', async () => {
+ assert.deepStrictEqual(t.ctx.settings.usajobs(), { apiKey: 'usajobs-env-key', email: '[email protected]' });
+ const put = await t.get(`/internal/config/${NS}/secrets/usajobs_api_key`, {
+ bearer: TOKEN, method: 'PUT', json: { value: KEY, reason: 'owner test' }, headers: { 'x-ov-subject': USR, 'x-ov-fresh': '1' },
+ });
+ assert.strictEqual(put.status, 200, put.text);
+ assert.ok(put.json().fingerprint && put.json().hint);
+ assert.ok(!put.text.includes(KEY));
+ const email = await t.get(`/internal/config/${NS}`, {
+ bearer: TOKEN, method: 'POST', json: { values: { usajobs_account_email: EMAIL }, merge: true, reason: 'owner test' }, headers: { 'x-ov-subject': USR },
+ });
+ assert.strictEqual(email.status, 200, email.text);
+ const summary = await t.get(`/internal/config/${NS}`, { bearer: TOKEN });
+ assert.ok(summary.json().values.usajobs_api_key.redacted);
+ assert.ok(!summary.text.includes(KEY));
+ assert.deepStrictEqual(t.ctx.settings.usajobs(), { apiKey: KEY, email: EMAIL });
+ });
+
+ await check('the test hook says ok/failed in one line and never echoes a key', async () => {
+ const fake = createSettings({
+ db: t.ctx.s.db, env: {},
+ probes: { usajobs: async ({ apiKey }) => { throw new Error(`refused ${apiKey}`); } },
+ });
+ await fake.ready;
+ const app = express();
+ app.use(express.json());
+ fake.mount(app, { guard: (_req, _res, next) => next(), log: t.log });
+ const server = await new Promise((resolve) => { const s = http.createServer(app); s.listen(0, '127.0.0.1', () => resolve(s)); });
+ try {
+ const r = await fetch(`http://127.0.0.1:${server.address().port}/internal/config/${NS}/test/usajobs_api_key`, { method: 'POST' });
+ const body = await r.json();
+ assert.strictEqual(r.status, 200);
+ assert.strictEqual(body.ok, false);
+ assert.ok(/^failed — USAJOBS said: /.test(body.detail), body.detail);
+ assert.ok(!body.detail.includes(KEY));
+ assert.ok(!body.detail.includes('\n'));
+ } finally { server.close(); }
+ assert.ok(!t.logs().includes(KEY), 'no key in a captured log line');
+ });
+
+ await t.close();
+ console.log(`\n${checks.length} checks passed`);
+})().catch((e) => { console.error(e); process.exit(1); });