openvibers/openvibe.watch Commits

Watch: a watch can use its project's own secrets (credential revealed for its origin, webhook signed by Network) (#63)

OP OpenVibers committed 29 days ago · 3564de3556 · 16 files, +291 −35

**A watch can use its project's own secrets (ADR-056, Contracts 0.179.0).**

Until now a watch's credential and webhook key were operator environment variables with hand-written grants (`WATCH_CRED_*`, `WATCH_HOOK_*`, `WATCH_SECRET_GRANTS`). A watch in a project can now name the project's own secrets, kept by OpenVibe.Network and granted per secret on the project's Secrets page.

- **Source credential (`source.auth.secret_ref`):** before a fetch, Network reveals the value for the source URL's origin only (`server/project-secrets.js`, `network.secret.use`). It is sent as the mode says (bearer, header or query) for that one request and never stored. A secret Network refuses leaves the credential missing, so the source is not asked with a guess and the check says "project secret … is not available". Network unreachable throws, and the check retries.
- **Webhook action (`secret_ref`):** at delivery Network signs `"<t>.<raw body>"`, and the signature goes out as `X-OpenVibe-Signature-V2: t=<t>,v2=<hex>` with `X-OpenVibe-Timestamp`. The receiver verifies with `verifyDeliveryV2` and its own secret, and Watch never holds the key. A refusal makes the action `unavailable` (`watch.action_secret_not_granted`), and Network unreachable is retried on the worker's backoff.
- **`project_env`:** a new column (migration `0010`, expand). It is production by default when a watch names a project, follows the project in a patch, and is shown in `watch.watch@1`. Naming a project secret without a project is refused (`watch.project_required`).
- **Unchanged:** operator credentials and keys keep working exactly as before. The grants check still covers them.

**Tests:** `project-secrets.test.js` (new, with a stand-in Network):
- the project requirement, `project_env` defaulting and a valid view;
- a bearer credential revealed for the source's origin and sent, and never stored;
- a refused credential, so nothing is sent and the check says why;
- a webhook verified under the project secret, where the signed message is exactly what was sent; a refused one is unavailable; Network down sends nothing unsigned and retries.

`registry.test.js` lists `project_env` in the view. `outbox.test.js` brings its staged schema up to date before starting the service. Contracts pinned to v0.179.0; 21 reached test files pass.

Network grants watch `network.secret.use` (Network#198).

Changes

ddiff --git a/README.md b/README.md
iindex e9c4911..ff913ff 100644
---- a/README.md
++++ b/README.md
@@@ -398,6 +398,15 @@ Reporting: [SECURITY.md](SECURITY.md). The rules the code keeps:
that names one is refused with 422 `watch.secret_not_granted` (the form shows why), and the carrier
and the action worker check again at use. `OV_OAUTH_CLIENT_SECRET`, the credentials and the grants
live in `/etc/openvibe/watch.env` (0600).
++- **Project secrets (ADR-056).** A watch that names a project may use that project's own secrets instead:
++ `source.auth.secret_ref` for its credential, and `secret_ref` on a webhook action for its signing key,
++ looked up in the watch's `project_env` (production by default). OpenVibe.Network keeps them and the project
++ grants each to `watch` (on openvibe.services, the project's Secrets page). A credential is **revealed** by
++ Network for the source URL's origin only, at fetch time, and used for that one request. A webhook is
++ **signed** by Network over `"<t>.<raw body>"` in the usual headers, so Watch never holds that key. A secret
++ Network refuses leaves the credential missing (the check says so), or makes the action unavailable.
++ Network unreachable is retried. Watch holds `network.secret.use` (Network's default grant), and naming a
++ project secret needs `project_id` (422 `watch.project_required`).
- **Data.** An observation records what the source stated, never a guess; snapshots are capped and
pruned by retention; a failed check never records an observation.
ddiff --git a/migrations/0010_project_env.sql b/migrations/0010_project_env.sql
nnew file mode 100644
iindex 0000000..a7f001b
---- /dev/null
++++ b/migrations/0010_project_env.sql
@@@ -0,0 +1,5 @@
++-- phase: expand
++-- ADR-056: a watch that names a project looks its project secrets (secret_ref: a source credential, a webhook action's
++-- signing key) up in this environment of that project. production by default when a project is named, NULL without one.
++-- Additive: an older release never reads the column.
++ALTER TABLE watches ADD COLUMN IF NOT EXISTS project_env text COLLATE "C";
ddiff --git a/package-lock.json b/package-lock.json
iindex bbdb8b8..a5eab72 100644
---- a/package-lock.json
++++ b/package-lock.json
@@@ -12,7 +12,7 @@
"dotenv": "^16.4.7",
"express": "^4.21.2",
"fast-xml-parser": "^4.5.3",
-- "openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.167.0",
++ "openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.179.0",
"openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.44.0",
"openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v4.1.7",
"pg": "^8.23.0"
@@@ -769,9 +769,9 @@
}
},
"node_modules/openvibe-contracts": {
-- "version": "0.167.0",
-- "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.167.0",
-- "integrity": "sha512-CVV3IX+jS6WJM5+7lYS3KK8ZJUNkTyBY55Pal3CG0hZlSDpIqGa/wFw1uSVgoo4vE4vkljE6hIjz6cGvopLrnQ==",
++ "version": "0.179.0",
++ "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.179.0",
++ "integrity": "sha512-36eR/FYklPbEOOqzGPN1QM8zvDLLnxTemFPQVVzUNCG/jKBflRExv53AFl2p9nb1s+KrCgSDZJUHhniw9wqfxw==",
"license": "AGPL-3.0-only",
"dependencies": {
"ajv": "^8.17.1",
ddiff --git a/package.json b/package.json
iindex 102316a..180e007 100644
---- a/package.json
++++ b/package.json
@@@ -24,7 +24,7 @@
"dotenv": "^16.4.7",
"express": "^4.21.2",
"fast-xml-parser": "^4.5.3",
-- "openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.167.0",
++ "openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.179.0",
"openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.44.0",
"openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v4.1.7",
"pg": "^8.23.0"
ddiff --git a/server/actions.js b/server/actions.js
iindex a5158c4..cf97e89 100644
---- a/server/actions.js
++++ b/server/actions.js
@@@ -62,6 +62,7 @@
* status() says delivery is off.
*/
++const { SecretRefused } = require('./project-secrets');
const { signDeliveryHeaders } = require('openvibe-sdk/events');
const USER_SUBJECT = /^usr_[0-9A-HJKMNP-TV-Z]{26}$/;
@@@ -177,7 +178,7 @@ function actorAgentInputFor(args) {
return prefix + actorContextFor(args, 8000 - prefix.length);
}
--function createActions({ db, config, notifications = null, post = null, actor = null, run: runJobs = null, codes = null, now = () => Date.now(), log = console }) {
++function createActions({ db, config, notifications = null, post = null, actor = null, run: runJobs = null, codes = null, projectSecrets = null, now = () => Date.now(), log = console }) {
// Read at each use, not once: what a process was started with is what it enforces, for rows queued before it too.
const grants = () => config.secretGrants;
const enabled = Boolean(notifications) && config.actions.enabled;
@@@ -213,6 +214,10 @@ function createActions({ db, config, notifications = null, post = null, actor =
if (action.event_type !== TRIGGERED) { state = 'unavailable'; error = `watch.action_unavailable: ${action.event_type || 'an event'} is not an event Watch declares (only ${TRIGGERED})`; }
else if (!USER_SUBJECT.test(String(owner || ''))) { state = 'skipped'; error = `watch.action_no_person: ${TRIGGERED} names a person, and this watch belongs to an agent or a service`; }
else { state = 'sent'; error = 'emitted with the check run'; }
++ } else if (kind === 'webhook' && action.secret_ref) {
++ // A project secret (ADR-056): Network signs at delivery; the project's grant to watch decides then.
++ if (!watch.project_id || !watch.project_env) { state = 'unavailable'; error = 'watch.action_secret_unavailable: a project secret needs the watch to name its project'; }
++ else request = { url: action.url, secret_ref: action.secret_ref, project_id: watch.project_id, project_env: watch.project_env, body: webhookFor({ config, watch, run, value, previousValue, observedAt }) };
} else if (kind === 'webhook') {
const why = grants() ? grants().refusal(action.secret_env, owner) : `${action.secret_env} is not granted to any owner`;
if (why) { state = 'unavailable'; error = `watch.action_secret_not_granted: ${why}`; }
@@@ -241,17 +246,35 @@ function createActions({ db, config, notifications = null, post = null, actor =
}
}
-- async function deliverWebhook(row, request, attempt) {
++ /** The signature headers: the operator-granted key secret_env names, or Network's signature with the project secret. */
++ async function webhookSignature(row, request, body) {
++ if (request.secret_ref) {
++ if (!projectSecrets) return { refused: 'watch.action_secret_unavailable: project secrets are not configured in this Watch' };
++ const t = Math.floor(now() / 1000);
++ try {
++ const { signature } = await projectSecrets.sign({ project_id: request.project_id, environment: request.project_env, name: request.secret_ref, message: `${t}.${body}` });
++ return { headers: { 'X-OpenVibe-Timestamp': String(t), 'X-OpenVibe-Signature-V2': `t=${t},v2=${signature}` } };
++ } catch (err) {
++ if (err instanceof SecretRefused) return { refused: `watch.action_secret_not_granted: ${err.message}` };
++ throw err; // Network unreachable: the worker's backoff tries again
++ }
++ }
const why = grants() ? grants().refusal(request.secret_env, row.owner_sub) : 'no grants';
-- if (why) return { state: 'unavailable', error: `watch.action_secret_not_granted: ${why}` };
++ if (why) return { refused: `watch.action_secret_not_granted: ${why}` };
const secret = config.secrets ? config.secrets[request.secret_env] : null;
if (!secret || String(secret).length < MIN_HOOK_SECRET) {
-- return { state: 'unavailable', error: `watch.action_secret_missing: ${request.secret_env} is not set (${MIN_HOOK_SECRET} characters or more)` };
++ return { refused: `watch.action_secret_missing: ${request.secret_env} is not set (${MIN_HOOK_SECRET} characters or more)` };
}
-- if (!post) throw Object.assign(new Error('webhook delivery is not configured in this process'), { code: 'webhook_unconfigured' });
++ return { headers: signDeliveryHeaders(body, String(secret), { now: now() }) };
++ }
++
++ async function deliverWebhook(row, request, attempt) {
const body = JSON.stringify(request.body);
++ const signed = await webhookSignature(row, request, body);
++ if (signed.refused) return { state: 'unavailable', error: signed.refused };
++ if (!post) throw Object.assign(new Error('webhook delivery is not configured in this process'), { code: 'webhook_unconfigured' });
const headers = {
-- ...signDeliveryHeaders(body, String(secret), { now: now() }),
++ ...signed.headers,
'X-OpenVibe-Event-Type': TRIGGERED,
'X-OpenVibe-Delivery-Id': `${row.check_run_id}.${row.action_index}`,
'X-OpenVibe-Delivery-Attempt': String(attempt),
ddiff --git a/server/carriers/api.js b/server/carriers/api.js
iindex 2da93bb..7193a77 100644
---- a/server/carriers/api.js
++++ b/server/carriers/api.js
@@@ -15,7 +15,7 @@
*/
const { parseXml, decodeBody, ParseError } = require('../util');
const { at } = require('../extract');
--const { conditionalGet, onlyChange, ACCEPT } = require('./http');
++const { conditionalGet, onlyChange, secretsFor, ACCEPT } = require('./http');
const { mapFields } = require('./feed');
const PATH_RE = /^[A-Za-z0-9_@:$#-]+(\.[A-Za-z0-9_@:$#-]+)*$/;
@@@ -30,13 +30,13 @@ function mapped(document, itemsPath) {
return at(document, itemsPath);
}
--function createApiCarrier({ fetcher, config }) {
++function createApiCarrier({ fetcher, config, projectSecrets = null }) {
return {
name: 'api',
run: async ({ watch, state }) => {
const source = watch.source;
const out = await conditionalGet({
-- source, state, fetcher, secrets: config.secretGrants.viewFor(watch, config.secrets), accept: ACCEPT.api,
++ source, state, fetcher, secrets: await secretsFor(watch, config, projectSecrets), accept: ACCEPT.api,
onlyChangeNeeded: onlyChange(watch),
});
if (out.state !== 'ok' || !out.body) return out;
ddiff --git a/server/carriers/feed.js b/server/carriers/feed.js
iindex f64b8a3..6184de7 100644
---- a/server/carriers/feed.js
++++ b/server/carriers/feed.js
@@@ -13,7 +13,7 @@
*/
const { parseXml, toText, toIsoDate, canonicalUrl, toId, asArray, pick, decodeBody, ParseError } = require('../util');
const { at } = require('../extract');
--const { conditionalGet, onlyChange, ACCEPT } = require('./http');
++const { conditionalGet, onlyChange, secretsFor, ACCEPT } = require('./http');
function atomLink(entry, base) {
const links = asArray(entry.link);
@@@ -120,12 +120,12 @@ function parseFeed(text, url, { summaryMax = 1000, maxItems = 500 } = {}) {
return { ...meta, items, skipped };
}
--function createFeedCarrier({ fetcher, config }) {
++function createFeedCarrier({ fetcher, config, projectSecrets = null }) {
return {
name: 'feed',
run: async ({ watch, state }) => {
const out = await conditionalGet({
-- source: watch.source, state, fetcher, secrets: config.secretGrants.viewFor(watch, config.secrets), accept: ACCEPT.feed,
++ source: watch.source, state, fetcher, secrets: await secretsFor(watch, config, projectSecrets), accept: ACCEPT.feed,
onlyChangeNeeded: onlyChange(watch),
});
if (out.state !== 'ok' || !out.body) return out;
ddiff --git a/server/carriers/http.js b/server/carriers/http.js
iindex 771814f..017b3ba 100644
---- a/server/carriers/http.js
++++ b/server/carriers/http.js
@@@ -18,6 +18,7 @@
* read from the environment by NAME at fetch time (config.secrets[auth.env]) and never stored,
* logged, or sent to another origin than the one it was configured for.
*/
++const { SecretRefused } = require('../project-secrets');
const { sha256, decodeBody } = require('../util');
const ACCEPT = {
@@@ -46,8 +47,10 @@ function requestFor(source, secrets, accept) {
if (accept) headers.Accept = accept;
const auth = source.auth;
if (auth && auth.mode && auth.mode !== 'none') {
-- const secret = secrets ? secrets[auth.env] : null;
-- if (!secret) return { missing: auth.env };
++ // A project secret (secret_ref, ADR-056) arrives under its own key, revealed for this source's origin (secretsFor).
++ const key = auth.secret_ref ? `ref:${auth.secret_ref}` : auth.env;
++ const secret = secrets ? secrets[key] : null;
++ if (!secret) return { missing: auth.secret_ref ? `project secret ${auth.secret_ref}` : auth.env };
if (auth.mode === 'bearer') { headers.Authorization = `Bearer ${secret}`; sensitive.push('Authorization'); }
else if (auth.mode === 'header' && auth.header) { headers[auth.header] = secret; sensitive.push(auth.header); }
else if (auth.mode === 'query' && auth.param) { const u = new URL(url); u.searchParams.set(auth.param, secret); url = u.toString(); }
@@@ -71,7 +74,7 @@ async function conditionalGet({ source, state = {}, fetcher, secrets, accept, no
if (req.missing) {
return {
state: 'disabled', error_code: 'credential_missing',
-- detail: `environment variable ${req.missing} is not set`, http_status: null, bytes: null, raw_body_hash: null,
++ detail: /^project secret /.test(req.missing) ? `${req.missing} is not available: not set, or not granted to watch for this source's origin` : `environment variable ${req.missing} is not set`, http_status: null, bytes: null, raw_body_hash: null,
etag: null, last_modified: null, body: null, content_type: null, notBefore: null,
};
}
@@@ -147,14 +150,34 @@ async function conditionalGet({ source, state = {}, fetcher, secrets, accept, no
return { ...out, state: 'ok', body: res.body };
}
--function createHttpCarrier({ fetcher, config }) {
++/**
++ * The credentials a carrier may send for this watch: its granted environment credential (secret-grants viewFor), or the
++ * project secret its source names (secret_ref), revealed by Network for the source URL's origin only. A secret Network
++ * refuses is simply absent (the check reports the credential missing); Network unreachable throws, and the check retries.
++ */
++async function secretsFor(watch, config, projectSecrets) {
++ const base = config.secretGrants ? config.secretGrants.viewFor(watch, config.secrets) : {};
++ const auth = watch && watch.source && watch.source.auth;
++ if (!auth || !auth.secret_ref || !auth.mode || auth.mode === 'none' || !projectSecrets || !watch.project_id || !watch.project_env) return base;
++ let origin;
++ try { origin = new URL(watch.source.url).origin; } catch { return base; }
++ try {
++ const { value } = await projectSecrets.reveal({ project_id: watch.project_id, environment: watch.project_env, name: auth.secret_ref, origin });
++ return { ...base, [`ref:${auth.secret_ref}`]: value };
++ } catch (err) {
++ if (err instanceof SecretRefused) return base;
++ throw err;
++ }
++}
++
++function createHttpCarrier({ fetcher, config, projectSecrets = null }) {
return {
name: 'http',
run: async ({ watch, state }) => conditionalGet({
-- source: watch.source, state, fetcher, secrets: config.secretGrants.viewFor(watch, config.secrets), accept: ACCEPT.http,
++ source: watch.source, state, fetcher, secrets: await secretsFor(watch, config, projectSecrets), accept: ACCEPT.http,
onlyChangeNeeded: onlyChange(watch),
}),
};
}
--module.exports = { createHttpCarrier, conditionalGet, retryAfterMs, onlyChange, requestFor, ACCEPT, decodeBody };
++module.exports = { createHttpCarrier, conditionalGet, retryAfterMs, onlyChange, requestFor, secretsFor, ACCEPT, decodeBody };
ddiff --git a/server/carriers/index.js b/server/carriers/index.js
iindex 7b38578..f54a6be 100644
---- a/server/carriers/index.js
++++ b/server/carriers/index.js
@@@ -48,11 +48,11 @@ function unavailable(kind) {
};
}
--function createCarriers({ fetcher, config, log = console, machines = null }) {
++function createCarriers({ fetcher, config, log = console, machines = null, projectSecrets = null }) {
const carriers = {
-- http: createHttpCarrier({ fetcher, config, log }),
-- feed: createFeedCarrier({ fetcher, config, log }),
-- api: createApiCarrier({ fetcher, config, log }),
++ http: createHttpCarrier({ fetcher, config, log, projectSecrets }),
++ feed: createFeedCarrier({ fetcher, config, log, projectSecrets }),
++ api: createApiCarrier({ fetcher, config, log, projectSecrets }),
event: createEventCarrier(),
webhook: createWebhookCarrier(),
node: createNodeCarrier({ machines }),
ddiff --git a/server/index.js b/server/index.js
iindex 0519435..428b7d5 100644
---- a/server/index.js
++++ b/server/index.js
@@@ -44,7 +44,7 @@ const { createApp } = require('./app');
const accountDataLib = require('./account-data');
const { createNetworkSender, startSubscriptions } = require('openvibe-sdk/account-data');
--async function start({ config, db: givenDb = null, now = () => Date.now(), fetchImpl = globalThis.fetch, lookupImpl, log = console, listen = true, limitsNow = null, accountSend: givenSend = null, notifications: givenNotifications = null, post: givenPost = null, actor: givenActor = null, run: givenRun = null, codes: givenCodes = null, projects: givenProjects = null, machines: givenMachines = null } = {}) {
++async function start({ config, db: givenDb = null, now = () => Date.now(), fetchImpl = globalThis.fetch, lookupImpl, log = console, listen = true, limitsNow = null, accountSend: givenSend = null, notifications: givenNotifications = null, post: givenPost = null, actor: givenActor = null, run: givenRun = null, codes: givenCodes = null, projects: givenProjects = null, machines: givenMachines = null, projectSecrets: givenProjectSecrets = null } = {}) {
config = config || load();
// PostgreSQL (ADR-035): opened and migrated here unless the caller (a test) hands in a migrated handle.
const db = givenDb || await openDb(config, { log });
@@@ -78,14 +78,16 @@ async function start({ config, db: givenDb = null, now = () => Date.now(), fetch
// Every carrier goes out through the spacer, so two watches over one host never hammer it.
// A node source probes from its owner's own machine through OpenVibe.Bot (carriers/node.js).
const machines = givenMachines || (client ? createMachines(client, { baseUrl: config.actions.botUrl }) : null);
-- const carriers = createCarriers({ fetcher: spacedFetcher(fetcher, spacer), config, log, machines });
++ // Project secrets (ADR-056): a source credential or a webhook signature by secret_ref, through Network.
++ const projectSecrets = givenProjectSecrets || (client ? require('./project-secrets').createProjectSecrets(client, { networkUrl: config.networkInternalUrl }) : null);
++ const carriers = createCarriers({ fetcher: spacedFetcher(fetcher, spacer), config, log, machines, projectSecrets });
const notifications = client ? createNotificationsClient(client) : null;
const actorTasks = client ? createActorTasks(client, { baseUrl: config.actions.actorUrl }) : null;
// Webhook actions leave through the same guard as every fetch (server/net/post.js).
const post = givenPost || createPoster({ guard, userAgent: config.fetch.userAgent, timeoutMs: config.actions.webhookTimeoutMs });
const runJobs = client ? createRunJobs(client, { baseUrl: config.actions.runUrl }) : null;
const codesTasks = client ? createCodesTasks(client, { baseUrl: config.actions.codesUrl }) : null;
-- const actions = createActions({ db, config, notifications: givenNotifications || notifications, post, actor: givenActor || actorTasks, run: givenRun || runJobs, codes: givenCodes || codesTasks, now, log });
++ const actions = createActions({ db, config, notifications: givenNotifications || notifications, post, actor: givenActor || actorTasks, run: givenRun || runJobs, codes: givenCodes || codesTasks, projectSecrets, now, log });
// Usage readings (plan T5 step 14): a completed check queues one check-operation reading for Billing in its own
// commit; off unless WATCH_BILLING_INTERVAL_MS is set (server/billing.js).
const billing = createWatchBilling({ db, config: config.billing, networkUrl: config.networkInternalUrl, clock: { now }, fetchImpl, log });
ddiff --git a/server/project-secrets.js b/server/project-secrets.js
nnew file mode 100644
iindex 0000000..47822e4
---- /dev/null
++++ b/server/project-secrets.js
@@@ -0,0 +1,41 @@
++'use strict';
++/**
++ * Project secrets (ADR-056) through OpenVibe.Network's POST /internal/secrets/use, with Watch's own service token
++ * (network.secret.use, audience openvibe.network). The project's grant of each secret to watch decides:
++ *
++ * reveal({ project_id, environment, name, origin }) → { value, version } a source credential, for the source's origin
++ * sign({ project_id, environment, name, message }) → { signature, version } a webhook action's delivery signature
++ *
++ * Network answers 404 for a secret that does not exist, or is not granted to watch for that purpose (and origin): that
++ * is SecretRefused, final for this attempt. Anything else (Network unreachable) is thrown as it is and retried.
++ * A revealed value is used for the one request it is for and never kept.
++ */
++class SecretRefused extends Error {}
++
++function createProjectSecrets(client, { networkUrl }) {
++ async function use(body) {
++ try {
++ return await client.json({
++ service: 'network', baseUrl: networkUrl, audience: 'openvibe.network', method: 'POST', path: '/internal/secrets/use',
++ json: body, retries: 0, idempotencyKey: false,
++ });
++ } catch (err) {
++ if (err && err.status === 404) throw new SecretRefused(`project secret ${body.name} (${body.environment}) does not exist or is not granted to watch for ${body.purpose}${body.origin ? ` to ${body.origin}` : ''}`);
++ throw err;
++ }
++ }
++ return {
++ async reveal({ project_id, environment, name, origin }) {
++ const out = await use({ project_id, environment, name, purpose: 'reveal', origin });
++ if (!out || typeof out.value !== 'string' || !out.value) throw new Error('Network answered no value');
++ return { value: out.value, version: out.version };
++ },
++ async sign({ project_id, environment, name, message }) {
++ const out = await use({ project_id, environment, name, purpose: 'sign', message });
++ if (!out || !/^[0-9a-f]{64}$/.test(String(out.signature || ''))) throw new Error('Network answered no signature');
++ return { signature: out.signature, version: out.version };
++ },
++ };
++}
++
++module.exports = { createProjectSecrets, SecretRefused };
ddiff --git a/server/registry.js b/server/registry.js
iindex efea5a1..d48e348 100644
---- a/server/registry.js
++++ b/server/registry.js
@@@ -170,6 +170,13 @@ function createRegistry({ db, now = () => Date.now(), ids = contractIds, outbox
const bad = secretGrants.check(owner, rec);
if (bad.length) throw new RegistryError(`${bad[0].path}: ${bad[0].reason}`, 'watch.secret_not_granted');
}
++ /** A project secret (ADR-056, secret_ref) lives in a project: a watch naming one must name its project. */
++ function checkProjectSecrets(rec) {
++ const auth = rec.source && rec.source.auth;
++ const refs = (auth && auth.mode && auth.mode !== 'none' && auth.secret_ref ? 1 : 0)
++ + (Array.isArray(rec.action) ? rec.action.filter((a) => a && a.kind === 'webhook' && a.secret_ref).length : 0);
++ if (refs && !rec.project_id) throw new RegistryError('a project secret (secret_ref) lives in a project: set project_id to a project you are an admin or owner of', 'watch.project_required');
++ }
function checkActorAgents(owner, rec) {
if (!PERSON.test(String(owner || '')) && Array.isArray(rec.action) && rec.action.some((a) => a && a.kind === 'actor-agent')) {
throw new RegistryError('watch.action_no_person: an Actor agent runs for a person, and this watch belongs to an agent or a service');
@@@ -197,9 +204,9 @@ function createRegistry({ db, now = () => Date.now(), ids = contractIds, outbox
const st = {
get: db.prepare('SELECT * FROM watches WHERE id = ? AND deleted_at IS NULL'),
countOwned: db.prepare('SELECT COUNT(*) AS n FROM watches WHERE owner_sub = ? AND deleted_at IS NULL'),
-- insert: db.prepare(`INSERT INTO watches (id, project_id, owner_sub, name, status, source, cadence, extraction, comparison, condition,
++ insert: db.prepare(`INSERT INTO watches (id, project_id, project_env, owner_sub, name, status, source, cadence, extraction, comparison, condition,
action, budget, retention, labels, next_due_at, not_before, created_at, updated_at, updated_by)
-- VALUES (@id, @project_id, @owner_sub, @name, @status, @source, @cadence, @extraction, @comparison, @condition,
++ VALUES (@id, @project_id, @project_env, @owner_sub, @name, @status, @source, @cadence, @extraction, @comparison, @condition,
@action, @budget, @retention, @labels, @next_due_at, 0, @created_at, @updated_at, @updated_by)`),
};
@@@ -217,6 +224,7 @@ function createRegistry({ db, now = () => Date.now(), ids = contractIds, outbox
budget: (v) => JSON.stringify(v || {}),
retention: (v) => JSON.stringify(v || {}),
project_id: (v) => v,
++ project_env: (v) => v,
};
/** The stored record, request-shaped (what a PATCH merges into). */
@@@ -225,6 +233,7 @@ function createRegistry({ db, now = () => Date.now(), ids = contractIds, outbox
return {
id: row.id,
project_id: row.project_id,
++ ...(row.project_env ? { project_env: row.project_env } : {}),
owner: row.owner_sub,
name: row.name,
labels: parse(row.labels) || {},
@@@ -269,6 +278,7 @@ function createRegistry({ db, now = () => Date.now(), ids = contractIds, outbox
return validateView({
id: rec.id,
project_id: rec.project_id,
++ project_env: rec.project_env || null,
owner: rec.owner,
name: rec.name,
labels: rec.labels,
@@@ -313,6 +323,7 @@ function createRegistry({ db, now = () => Date.now(), ids = contractIds, outbox
const owner = ownerOf(principal);
if (!owner) throw new RegistryError('no acting subject', 'watch.invalid');
checkSecrets(owner, req);
++ checkProjectSecrets(req);
checkActorAgents(owner, req);
await checkProject(owner, req, true);
// A per-owner cap, so one caller cannot fill the registry (the API and the form both come
@@@ -339,6 +350,7 @@ function createRegistry({ db, now = () => Date.now(), ids = contractIds, outbox
const row = {
id,
project_id: req.project_id || null,
++ project_env: req.project_id ? (req.project_env || 'production') : null,
owner_sub: owner,
name: rec.name,
status: rec.status,
@@@ -383,8 +395,12 @@ function createRegistry({ db, now = () => Date.now(), ids = contractIds, outbox
if (merged.project_id === null) delete merged.project_id;
validateRequest(merged);
checkSecrets(row.owner_sub, merged);
++ checkProjectSecrets(merged);
checkActorAgents(row.owner_sub, merged);
await checkProject(row.owner_sub, merged, changes.project_id !== undefined && changes.project_id !== row.project_id);
++ // The project environment follows the project: production when a project is first named, gone with it.
++ if (changes.project_id === null) changes = { ...changes, project_env: null };
++ else if (changes.project_id && changes.project_env === undefined && !row.project_env) changes = { ...changes, project_env: 'production' };
const sets = [];
const args = { id: row.id, updated_at: now(), updated_by: ownerOf(principal) };
for (const [k, v] of Object.entries(changes)) {
ddiff --git a/test/helpers.js b/test/helpers.js
iindex bb577f4..194f7ec 100644
---- a/test/helpers.js
++++ b/test/helpers.js
@@@ -49,7 +49,7 @@ const newAgent = () => require('openvibe-contracts').ids.newId('agent');
* the caller says otherwise (a test of the SSRF guard boots with allowPrivate: false).
* log: Watch's logger (default silent); limitsNow: the per-actor limiter's clock (default the wall clock).
*/
--async function boot({ env = {}, worker = 'off', lookupImpl, now, log = silent, limitsNow = null, allowPrivate = true, accountSend = null, notifications = null, post = null, actor = null, run = null, codes = null, projects = null, machines = null } = {}) {
++async function boot({ env = {}, worker = 'off', lookupImpl, now, log = silent, limitsNow = null, allowPrivate = true, accountSend = null, notifications = null, post = null, actor = null, run = null, codes = null, projects = null, machines = null, projectSecrets = null } = {}) {
// A stub Network JWKS serving the generated signing key, so the SDK's JWKS client has something real
// to fetch and verify against (no test touches the internet). Its URL is overridable via env.
const jwksSite = await site({ '/api/.well-known/jwks': () => ({ body: JSON.stringify({ keys: [publicJwk] }) }) });
@@@ -79,7 +79,7 @@ async function boot({ env = {}, worker = 'off', lookupImpl, now, log = silent, l
});
// One database per boot (PGlite, or WATCH_TEST_STORE=pg: the containers), dropped when the boot stops.
const testdb = await require('./db').testDb();
-- const h = await start({ config, db: testdb.db, log, lookupImpl, limitsNow, accountSend, ...(notifications ? { notifications } : {}), ...(post ? { post } : {}), ...(actor ? { actor } : {}), ...(run ? { run } : {}), ...(codes ? { codes } : {}), ...(projects ? { projects } : {}), ...(machines ? { machines } : {}), ...(now ? { now } : {}) });
++ const h = await start({ config, db: testdb.db, log, lookupImpl, limitsNow, accountSend, ...(notifications ? { notifications } : {}), ...(post ? { post } : {}), ...(actor ? { actor } : {}), ...(run ? { run } : {}), ...(codes ? { codes } : {}), ...(projects ? { projects } : {}), ...(machines ? { machines } : {}), ...(projectSecrets ? { projectSecrets } : {}), ...(now ? { now } : {}) });
const base = `http://127.0.0.1:${h.server.address().port}`;
/** A session cookie for a person, as /auth/callback would have set it. */
ddiff --git a/test/outbox.test.js b/test/outbox.test.js
iindex 7f5037c..2bfa990 100644
---- a/test/outbox.test.js
++++ b/test/outbox.test.js
@@@ -66,6 +66,8 @@ t('expand copies only pending legacy rows into the exact SDK table', async () =>
});
t('rows wait with the relay off and an aborted change has no event', async () => {
++ // The rest of the schema (0005 on): the service writes today's columns (watches.project_env since 0010).
++ await db.migrate({ dir: migrations, log: silent });
svc = await start({ config: config(''), db, fetchImpl: fakeFetch, log: silent, listen: false });
assert.deepStrictEqual(await svc.outbox.status(), { enabled: false, pending: 1, rejected: 0, last_error: null });
await assert.rejects(db.tx(async () => {
ddiff --git a/test/project-secrets.test.js b/test/project-secrets.test.js
nnew file mode 100644
iindex 0000000..8e0d5d8
---- /dev/null
++++ b/test/project-secrets.test.js
@@@ -0,0 +1,135 @@
++'use strict';
++/**
++ * Project secrets in Watch (ADR-056, Contracts 0.179.0): a watch in a project names a secret instead of an operator's
++ * environment variable.
++ * - a source credential (auth.secret_ref) is revealed by Network for the source URL's origin at fetch time and sent as
++ * the mode says; a secret Network refuses leaves the credential missing (the check says so), never a guess;
++ * - a webhook action (secret_ref) is signed by Network over "<t>.<raw body>" in the usual headers, so the receiver
++ * verifies with verifyDeliveryV2 and its own secret; Watch never holds the value; a refused secret makes the action
++ * unavailable, Network unreachable is retried;
++ * - a watch naming a project secret must name its project; project_env is production by default and shown.
++ */
++const assert = require('assert');
++const crypto = require('crypto');
++const { verifyDeliveryV2 } = require('openvibe-sdk/events');
++const { validate } = require('openvibe-contracts');
++const { boot, site, suite, newUser } = require('./helpers');
++const { SecretRefused } = require('../server/project-secrets');
++
++const t = suite('project-secrets');
++const PROJECT = 'prj_01JAB2C3D4E5F6G7H8J9K0MNPR';
++const SIGNING = `ovs_${crypto.randomBytes(24).toString('hex')}`;
++const TOKEN = 't'.repeat(40);
++const person = { sub: newUser() };
++let svc;
++let web;
++let price = '19.99';
++const seen = []; // what the source saw: its Authorization header
++const posts = [];
++const uses = [];
++let networkDown = false;
++// OpenVibe.Network's secrets/use, as the project granted them: api_token revealed to the source's origin only,
++// webhook-signing for sign. Production only.
++const projectSecrets = {
++ async reveal({ project_id, environment, name, origin }) {
++ uses.push({ purpose: 'reveal', project_id, environment, name, origin });
++ if (networkDown) throw Object.assign(new Error('connect ECONNREFUSED'), { code: 'ECONNREFUSED' });
++ if (project_id !== PROJECT || environment !== 'production' || name !== 'api_token' || origin !== web.origin) throw new SecretRefused(`project secret ${name} is not granted to watch for reveal to ${origin}`);
++ return { value: TOKEN, version: 1 };
++ },
++ async sign({ project_id, environment, name, message }) {
++ uses.push({ purpose: 'sign', project_id, environment, name, message });
++ if (networkDown) throw Object.assign(new Error('connect ECONNREFUSED'), { code: 'ECONNREFUSED' });
++ if (project_id !== PROJECT || environment !== 'production' || name !== 'webhook-signing') throw new SecretRefused(`project secret ${name} is not granted to watch for sign`);
++ return { signature: crypto.createHmac('sha256', SIGNING).update(message).digest('hex'), version: 2 };
++ },
++};
++async function post(url, { headers, body }) { posts.push({ url, headers, body }); return { status: 204 }; }
++const projects = { async check() { return { ok: true }; } };
++
++const def = (overrides = {}) => ({
++ name: 'Private price',
++ project_id: PROJECT,
++ source: { kind: 'http', url: `${web.origin}/price`, format: null, auth: { mode: 'bearer', secret_ref: 'api_token' } },
++ cadence: { every_sec: 900 },
++ extraction: { kind: 'text' },
++ condition: { op: 'lt', value: 10 },
++ action: [{ kind: 'webhook', url: 'https://hooks.example.org/w', secret_ref: 'webhook-signing' }],
++ ...overrides,
++});
++const check = async (id) => await svc.check.run(id, { trigger: 'schedule' });
++const acts = async (id) => await svc.db.prepare('SELECT * FROM action_runs WHERE watch_id = ? ORDER BY rid').all(id);
++
++t('boot', async () => {
++ web = await site({
++ '/price': (req) => { seen.push(req.headers.authorization || null); return { headers: { 'content-type': 'text/plain' }, body: price }; },
++ });
++ svc = await boot({ post, projects, projectSecrets });
++});
++
++t('a watch naming a project secret must name its project; project_env is production by default and shown', async () => {
++ await assert.rejects(svc.registry.create(def({ project_id: undefined }), person), (e) => e.code === 'watch.project_required');
++ const row = await svc.registry.create(def(), person);
++ const view = svc.registry.view(row);
++ assert.strictEqual(view.project_env, 'production');
++ assert.ok(validate('watch.watch@1', view).valid, JSON.stringify(validate('watch.watch@1', view).errors));
++ assert.strictEqual(view.source.auth.secret_ref, 'api_token');
++ assert.ok(!JSON.stringify(view).includes(TOKEN), 'no value is stored or shown');
++});
++
++t('the source credential is revealed for the source\'s origin and sent as a bearer token', async () => {
++ const row = await svc.registry.create(def({ name: 'Bearer check', action: [{ kind: 'notification', priority: 'low' }] }), person);
++ seen.length = 0;
++ const out = await check(row.id);
++ assert.strictEqual(out.run.state === 'failed', false, JSON.stringify(out.run).slice(0, 300));
++ assert.deepStrictEqual(seen, [`Bearer ${TOKEN}`]);
++ const asked = uses.filter((u) => u.purpose === 'reveal').pop();
++ assert.deepStrictEqual([asked.project_id, asked.environment, asked.name, asked.origin], [PROJECT, 'production', 'api_token', web.origin]);
++ const stored = JSON.stringify(await svc.db.prepare('SELECT * FROM watches WHERE id = ?').get(row.id));
++ assert.ok(!stored.includes(TOKEN));
++});
++
++t('a credential Network refuses is missing: nothing is sent with a guessed value, and the check says why', async () => {
++ const row = await svc.registry.create(def({ name: 'Wrong env', project_env: 'sandbox', action: [{ kind: 'notification', priority: 'low' }] }), person);
++ seen.length = 0;
++ const out = await check(row.id);
++ assert.deepStrictEqual(seen, [], 'the source was not asked without its credential');
++ assert.match(JSON.stringify(out.run), /project secret api_token is not available/);
++});
++
++t('the webhook is signed by Network and verifies with the project secret; refused and unreachable behave', async () => {
++ const row = await svc.registry.create(def({ name: 'Signed hook' }), person);
++ price = '19.99'; await check(row.id);
++ price = '7.50'; const out = await check(row.id);
++ assert.strictEqual(out.run.state, 'condition_met');
++ posts.length = 0;
++ await svc.actions.tick();
++ assert.strictEqual(posts.length, 1);
++ const d = posts[0];
++ assert.ok(verifyDeliveryV2(d.body, d.headers, SIGNING), 'verifies with the project secret');
++ const signed = uses.filter((u) => u.purpose === 'sign').pop();
++ assert.strictEqual(signed.message, `${d.headers['X-OpenVibe-Timestamp']}.${d.body}`, 'Network signed exactly what was sent');
++ assert.strictEqual((await acts(row.id))[0].state, 'sent');
++
++ const refused = await svc.registry.create(def({ name: 'Ungranted hook', action: [{ kind: 'webhook', url: 'https://hooks.example.org/w', secret_ref: 'not-granted' }] }), person);
++ price = '19.99'; await check(refused.id);
++ price = '7.50'; await check(refused.id);
++ await svc.actions.tick();
++ const r = (await acts(refused.id))[0];
++ assert.strictEqual(r.state, 'unavailable');
++ assert.match(r.last_error, /watch\.action_secret_not_granted/);
++
++ const later = await svc.registry.create(def({ name: 'Network down hook' }), person);
++ price = '19.99'; await check(later.id);
++ price = '7.50'; await check(later.id);
++ networkDown = true;
++ posts.length = 0;
++ await svc.actions.tick();
++ networkDown = false;
++ const l = (await acts(later.id))[0];
++ assert.strictEqual(posts.length, 0, 'nothing unsigned was sent');
++ assert.ok(['queued', 'retrying'].includes(l.state), `retried later (${l.state}: ${l.last_error})`);
++});
++
++t('done', async () => { await web.close(); await svc.stop(); });
++t.run();
ddiff --git a/test/registry.test.js b/test/registry.test.js
iindex aedf949..3c2b412 100644
---- a/test/registry.test.js
++++ b/test/registry.test.js
@@@ -111,7 +111,7 @@ t('the view is validated against watch.watch@1, and the health tells the runtime
assert.ok(validate('watch.watch@1', view).valid);
assert.deepStrictEqual(Object.keys(view).sort(), [
'action', 'budget', 'cadence', 'comparison', 'condition', 'created_at', 'extraction', 'health', 'id',
-- 'labels', 'name', 'owner', 'project_id', 'retention', 'source', 'status', 'updated_at',
++ 'labels', 'name', 'owner', 'project_env', 'project_id', 'retention', 'source', 'status', 'updated_at',
]);
assert.deepStrictEqual(view.health.status, 'never_checked');
assert.strictEqual(view.health.next_due_at != null, true);