openvibers/openvibe.actor Commits
Actor: a task's webhook can be signed with the project's own secret (secret_ref; Network signs, Actor never holds it) (#71)
Changes
@@@ -69,7 +69,13 @@ The resource index is for OpenVibe.Services on loopback and is blocked at the pu- **Signing:** the answer that creates the task carries `webhook_secret`, minted for that task. It is in no read or list, never exported, and erased once the task has ended and its deliveries are done. Each delivery is `actor.task-webhook@1` (`{ type: "actor.task.state", delivery_id, state, task }`, the task as it was then), signed like an Events delivery (`X-OpenVibe-Timestamp` + `X-OpenVibe-Signature-V2`), so a receiver checks it with openvibe-sdk/events `verifyDeliveryV2(rawBody, headers, secret)`. The delivery id stays the same across retries; dedupe on it.- **Outcomes:** 2xx is delivered; 410, or a URL refused at send time, ends the delivery; anything else is retried after 10 s, 1 min, 5 min, 30 min, 2 h, 6 h and 12 h, then fails.- **Where Actor may connect:** a webhook URL is the one address a caller chooses (`server/net/webhook-post.js`): https on port 443 or 8443, no credentials, public addresses only. It is checked at creation (`actor.webhook.refused`) and again at send time through openvibe-shared/egress, so a name that later resolves to a private address is refused. Redirects are never followed.--- **Not yet:** a project secret named by reference (`secret_ref`) is refused until project secrets exist.++- **A project secret instead (ADR-056):** a webhook may name one (`secret_ref`, a lower-case name) when the task is made++ by a project's app or agent token. The secret is looked up in the task's project and environment, and the project must++ have granted it to `actor` for `sign` (on openvibe.services, the project's Secrets page). OpenVibe.Network then signs++ each delivery over `"<t>.<raw body>"` in the same two headers, so the receiver's check is the same and Actor never++ holds the value. No `webhook_secret` is minted when every webhook names one. A secret Network refuses (missing, or not++ granted) fails that delivery at once; Network unreachable is retried on the usual backoff. A person's own task cannot++ name one (422 `actor.webhook.secret_unavailable`).**Errors:** RFC 9457 problems. The codes are on [openvibe.actor/docs](https://openvibe.actor/docs#errors).nnew file mode 100644@@@ -0,0 +1,7 @@++-- phase: expand++-- ADR-056: a task webhook may name a project secret (secret_ref) that signs its deliveries instead of the task's own++-- secret; OpenVibe.Network computes each signature, so Actor never holds the value. A delivery records the secret's++-- name and the project environment to look it up in, as they were when it was queued. Additive: an older release++-- ignores the columns and keeps signing with the task's secret.++ALTER TABLE webhook_deliveries ADD COLUMN IF NOT EXISTS secret_ref text COLLATE "C";++ALTER TABLE webhook_deliveries ADD COLUMN IF NOT EXISTS secret_env text COLLATE "C";@@@ -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.169.0",++ "openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.178.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"@@@ -871,9 +871,9 @@}},"node_modules/openvibe-contracts": {-- "version": "0.169.0",-- "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.169.0",-- "integrity": "sha512-Py2z8UaKGvLzG3atuIAnJKnRe74Te9cJRTaxqFq6m4fov6gaMXeS6rn7lH0tSIo0qBGD5trrT618ARXnklkp+A==",++ "version": "0.178.0",++ "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.178.0",++ "integrity": "sha512-86Q1Mmo+BvMnF2GG2IZrbHq8iZ2FUfED28rnDcFlgZTtIzHxDQdi2dY+Xe8WbXc4EebjGUYhEl5J3LReA2wiqg==","license": "AGPL-3.0-only","dependencies": {"ajv": "^8.17.1",@@@ -27,7 +27,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.169.0",++ "openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.178.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"@@@ -60,8 +60,10 @@ async function createApp(opts = {}) {const stream = createTaskStream({ log });const adapters = opts.adapters || createAdapters({ config, rates: catalog.RATES, fetchImpl: opts.providerFetch || globalThis.fetch, log });// Task webhooks (plan T17): the only connections to an address a caller chose, through server/net/webhook-post.js.++ // A webhook naming a project secret (ADR-056) is signed by OpenVibe.Network (server/net/secret-signer.js).++ const signer = opts.secretSigner || require('./net/secret-signer').createSecretSigner({ config, fetchImpl });const webhooks = createWebhooks({-- s, config, now: s.now, log,++ s, config, now: s.now, log, signer,post: opts.webhookPost || createWebhookPoster({ userAgent: `OpenVibe.Actor/${VERSION} (+https://openvibe.actor/docs#webhooks)`, timeoutMs: config.webhooks.timeoutMs }),});const engine = createEngine({ config, s, adapters, stream, webhooks, now: s.now, log });@@@ -410,7 +410,7 @@ data: {"task_id":"tsk_…","seq":3,"kind":"output","agent":"openvibe-runtime","s<li><strong>An app or agent</strong>: a Network token for audience <code>openvibe.actor</code> with the <code>actor.task.*</code> capability the route needs, granted on <a href="https://openvibe.services">OpenVibe.Services</a>. Tasks belong to the app's project.</li><li><strong>Anyone</strong>: <code>GET /api/v1/agents</code> and <code>POST /api/v1/route</code> need no token.</li></ul>${table(['Route', 'Capability', 'What it does'], [-- [html`<code>POST /api/v1/tasks</code>`, html`<code>actor.task.create</code>`, html`Create a task: <code>{ task, mode?, budget?: { per_task_usd, per_day_usd }, agent?, idempotency_key?, webhooks?: [{ url, events }] }</code>. Answers 201 with the task (queued); with webhooks, also its <code>webhook_secret</code>, this once (<a href="#webhooks">webhooks</a>).`],++ [html`<code>POST /api/v1/tasks</code>`, html`<code>actor.task.create</code>`, html`Create a task: <code>{ task, mode?, budget?: { per_task_usd, per_day_usd }, agent?, idempotency_key?, webhooks?: [{ url, events, secret_ref? }] }</code>. Answers 201 with the task (queued); with webhooks, also its <code>webhook_secret</code>, this once, unless every webhook names a project secret (<a href="#webhooks">webhooks</a>).`],[html`<code>GET /api/v1/tasks/:id</code>`, html`<code>actor.task.read</code>`, 'The task: state, result, cost, explanation, error.'],[html`<code>GET /api/v1/tasks/:id/events</code>`, html`<code>actor.task.read</code>`, html`Server-sent events (<code>actor.task-event@1</code>): <code>state</code>, <code>output</code> (each step), <code>end</code>. Resumes with <code>Last-Event-ID</code>.`],[html`<code>POST /api/v1/tasks/:id/cancel</code>`, html`<code>actor.task.create</code>`, 'Stop it. What was spent stays spent.'],@@@ -422,6 +422,7 @@ ${raw(showcase.code({ title: 'Create a task, then watch it', samples: [{ label:<h2 id="webhooks">Webhooks</h2><p>Give a task up to 8 HTTPS endpoints and the state changes each wants (<code>queued</code>, <code>running</code>, <code>verifying</code>, <code>succeeded</code>, <code>failed</code>, <code>cancelled</code>). Actor POSTs <a href="https://openvibe.services/docs/contracts/actor.task-webhook@1"><code>actor.task-webhook@1</code></a> to each: <code>{ type: "actor.task.state", delivery_id, state, task }</code>, with the task as it was at that moment.</p><ul><li><strong>Signed per task.</strong> The answer that creates the task carries <code>webhook_secret</code>, and nothing else ever shows it. Each delivery has <code>X-OpenVibe-Timestamp</code> and <code>X-OpenVibe-Signature-V2</code>, exactly like an OpenVibe.Events delivery, so <code>verifyDeliveryV2(rawBody, headers, secret)</code> from <code>openvibe-sdk/events</code> checks it (and the five-minute window).</li>++<li><strong>Or with your project's secret.</strong> A project's app or agent may name a project secret instead (<code>secret_ref</code>): grant it to <code>actor</code> for signing on <a href="https://openvibe.services/projects">openvibe.services</a>, and OpenVibe.Network signs each delivery with it in the same headers. Actor never sees the value, and you verify with your own secret.</li><li><strong>Retried.</strong> A 2xx answer is delivered. 410 Gone ends it. Anything else is retried after 10 s, 1 min, 5 min, 30 min, 2 h, 6 h and 12 h. <code>delivery_id</code> (also <code>X-OpenVibe-Delivery-Id</code>) is the same on every attempt: dedupe on it.</li><li><strong>Public endpoints only.</strong> HTTPS on port 443 or 8443, no credentials in the URL, and only public addresses, checked when the task is created and again when each delivery is sent. Redirects are not followed.</li></ul><h2 id="limits">Limits and money</h2>nnew file mode 100644@@@ -0,0 +1,38 @@++'use strict';++/**++ * Signing with a project secret (ADR-056): OpenVibe.Network's POST /internal/secrets/use, purpose sign, with Actor's own++ * service token (network.secret.use, audience openvibe.network). Network answers HMAC-SHA256 of the message under the++ * secret the project granted to actor; the value never reaches Actor.++ *++ * sign({ project_id, environment, name, message }) → { signature, version }++ * throws SecretRefused (404 secret.not_found: no such secret, or not granted to actor for sign; final)++ * throws any other error for a transient failure (retried by the caller)++ */++const { serviceAuth } = require('openvibe-contracts');++++class SecretRefused extends Error {}++++function createSecretSigner({ config, fetchImpl = globalThis.fetch }) {++ const enabled = Boolean(config.networkInternalUrl && config.oauth && config.oauth.clientSecret);++ const tokens = enabled ? serviceAuth.createTokenClient({++ tokenUrl: `${config.networkInternalUrl}/oauth/token`, clientId: config.oauth.clientId, clientSecret: config.oauth.clientSecret,++ audience: 'openvibe.network', scope: 'network.secret.use', fetchImpl,++ }) : null;++ async function sign({ project_id, environment, name, message }) {++ if (!tokens) throw new Error('project secrets are unavailable: Actor has no Network client secret');++ const res = await fetchImpl(`${config.networkInternalUrl}/internal/secrets/use`, {++ method: 'POST',++ headers: { ...(await tokens.authHeaders()), 'Content-Type': 'application/json', Accept: 'application/json' },++ body: JSON.stringify({ project_id, environment, name, purpose: 'sign', message }),++ signal: AbortSignal.timeout(5000),++ });++ const data = await res.json().catch(() => null);++ if (res.status === 401 && tokens.invalidate) tokens.invalidate();++ if (res.status === 404) throw new SecretRefused(`project secret ${name} (${environment}) does not exist or is not granted to actor for sign`);++ if (!res.ok || !data || !/^[0-9a-f]{64}$/.test(String(data.signature || ''))) throw new Error(`Network answered ${res.status}${data && data.code ? ` ${data.code}` : ''}`);++ return { signature: data.signature, version: data.version };++ }++ return { enabled, sign };++}++++module.exports = { createSecretSigner, SecretRefused };@@@ -30,10 +30,17 @@ async function createTask({ s, config, engine, principal, body, webhooks: webhooif (body.agent && !catalog.byId(body.agent)) return { status: 422, code: 'actor.agent.unknown', detail: `no agent "${body.agent}"; GET /api/v1/agents lists them` };// Webhooks (plan T17): each URL must be one Actor may deliver to (https, port 443/8443, a public address); the// send-time check repeats it against what the name resolves to then.-- const webhooks = (body.webhooks || []).map((h) => ({ url: h.url, events: h.events }));++ // A webhook may name a project secret (ADR-056, secret_ref): Network signs its deliveries with it. The secret lives in++ // the task's project and environment, so the task must come from a project's app or agent token.++ const env = principal.claims && ['sandbox', 'production'].includes(principal.claims.env) ? principal.claims.env : null;++ const webhooks = (body.webhooks || []).map((h) => ({ url: h.url, events: h.events, ...(h.secret_ref ? { secret_ref: h.secret_ref, environment: env } : {}) }));for (const h of webhooks) {try { checkWebhookUrl(h.url); } catch (err) { return { status: 422, code: 'actor.webhook.refused', detail: `${h.url.slice(0, 200)}: ${err.message}` }; }++ if (h.secret_ref && (!principal.project || !env)) {++ return { status: 422, code: 'actor.webhook.secret_unavailable', detail: `secret_ref names a project secret: make the task with a project's app or agent token (it carries project_id and env); this one has ${principal.project ? 'no env' : 'no project'}.` };++ }}++ const ownSecret = webhooks.some((h) => !h.secret_ref);const hash = crypto.createHash('sha256').update(JSON.stringify({ task: body.task, mode: body.mode || 'balanced', budget: [perTask, perDay], agent: body.agent || null, ...(webhooks.length ? { webhooks } : {}) })).digest('hex');if (body.idempotency_key) {@@@ -62,7 +69,7 @@ async function createTask({ s, config, engine, principal, body, webhooks: webhooconst row = await store.insertTask(s, {id: s.newId('tsk'), requester: principal.requester, via: principal.via || null, project_id: principal.project || null, task: body.task, mode: body.mode || 'balanced', agent: body.agent || null,budget_task: perTask, budget_day: perDay, idem_key: body.idempotency_key || null, idem_hash: body.idempotency_key ? hash : null, created_at: s.iso(),-- webhooks, webhook_secret: webhooks.length ? mintSecret() : null,++ webhooks, webhook_secret: ownSecret ? mintSecret() : null,});return { status: 201, task: row };});@@@ -156,7 +156,7 @@ function toWire(row, { baseUrl }) {if (t.explanation && !t.explanation.length) t.explanation = null;// What the task registered; the signing secret is never part of the task as read (only the creating answer has it).const hooks = parse(row.webhooks);-- if (hooks && hooks.length) t.webhooks = hooks.map((h) => ({ url: h.url, events: h.events }));++ if (hooks && hooks.length) t.webhooks = hooks.map((h) => ({ url: h.url, events: h.events, ...(h.secret_ref ? { secret_ref: h.secret_ref } : {}) }));return t;}@@@ -9,6 +9,11 @@* 410, or a refused URL failed at once (the receiver is gone, or the URL is not a public address any more)* anything else retried: 10 s, 1 min, 5 min, 30 min, 2 h, 6 h, 12 h, then failed (about a day)*++ * A webhook that names a project secret (secret_ref, ADR-056) is signed by OpenVibe.Network instead, in the same headers:++ * Actor sends Network "<t>.<raw body>" and puts the signature it answers in X-OpenVibe-Signature-V2, so the receiver's++ * check is the same and Actor never holds the value. A secret that does not exist or is not granted to actor fails that++ * delivery at once; Network being unreachable is retried like any other failure.++ ** A delivery never delays or changes the task. Once a task has ended and none of its deliveries is pending, its secret* is erased. The delivery id is stable across attempts (dedupe on it). One Actor process sends: rows are claimed with* FOR UPDATE SKIP LOCKED all the same, and a row a crash left 'sending' is pending again at the next start.@@@ -17,6 +22,7 @@ const crypto = require('crypto');const { signDeliveryHeaders } = require('openvibe-sdk/events');const store = require('./store');const { WebhookRefused } = require('../net/webhook-post');++const { SecretRefused } = require('../net/secret-signer');const BACKOFF_MS = [10_000, 60_000, 300_000, 1_800_000, 7_200_000, 21_600_000, 43_200_000];const BATCH = 20;@@@ -24,7 +30,7 @@ const BATCH = 20;/** A fresh per-task secret: whsec_ + 32 random bytes, base64url (43 characters). */const mintSecret = () => `whsec_${crypto.randomBytes(32).toString('base64url')}`;--function createWebhooks({ s, config, post, now = () => Date.now(), log = console }) {++function createWebhooks({ s, config, post, signer = null, now = () => Date.now(), log = console }) {let timer = null;let running = null;@@@ -38,8 +44,8 @@ function createWebhooks({ s, config, post, now = () => Date.now(), log = consolefor (const h of wanted) {const id = s.newId('whd');const body = { type: 'actor.task.state', delivery_id: id, state, task };-- await s.db.query(`INSERT INTO webhook_deliveries (id, task_id, url, state, body, status, attempts, next_at, created_at)-- VALUES ($1, $2, $3, $4, $5::jsonb, 'pending', 0, $6, $7)`, [id, taskId, h.url, state, JSON.stringify(body), now(), s.iso()]);++ await s.db.query(`INSERT INTO webhook_deliveries (id, task_id, url, state, body, status, attempts, next_at, created_at, secret_ref, secret_env)++ VALUES ($1, $2, $3, $4, $5::jsonb, 'pending', 0, $6, $7, $8, $9)`, [id, taskId, h.url, state, JSON.stringify(body), now(), s.iso(), h.secret_ref || null, h.secret_ref ? h.environment : null]);}return wanted.length;}@@@ -62,13 +68,39 @@ function createWebhooks({ s, config, post, now = () => Date.now(), log = consoleAND NOT EXISTS (SELECT 1 FROM webhook_deliveries WHERE task_id = $1 AND status IN ('pending', 'sending'))`, [taskId]);}++ /** The signature headers: the task's own secret, or Network's signature with the project secret the webhook names. */++ async function signatureHeaders(d, body) {++ if (!d.secret_ref) {++ const secret = await s.db.value('SELECT webhook_secret FROM tasks WHERE id = $1', [d.task_id]);++ return secret ? signDeliveryHeaders(body, secret, { now: now() }) : null;++ }++ const project = await s.db.value('SELECT project_id FROM tasks WHERE id = $1', [d.task_id]);++ if (!project || !signer) throw new SecretRefused('the task has no project to look the secret up in');++ const t = Math.floor(now() / 1000);++ const { signature } = await signer.sign({ project_id: project, environment: d.secret_env, name: d.secret_ref, message: `${t}.${body}` });++ return { 'X-OpenVibe-Timestamp': String(t), 'X-OpenVibe-Signature-V2': `t=${t},v2=${signature}` };++ }++++ /** Try again on the backoff, or give up once it is spent. */++ async function retryLater(d, attempts, lastStatus, why) {++ if (attempts > BACKOFF_MS.length) return finish(d, 'failed', { lastStatus, error: why, attempts });++ await s.db.query(`UPDATE webhook_deliveries SET status = 'pending', attempts = $2, next_at = $3, last_status = $4, last_error = $5 WHERE id = $1`,++ [d.id, attempts, now() + BACKOFF_MS[attempts - 1], lastStatus, why]);++ return undefined;++ }++async function sendOne(d) {const attempts = Number(d.attempts) + 1;-- const secret = await s.db.value('SELECT webhook_secret FROM tasks WHERE id = $1', [d.task_id]);-- if (!secret) return finish(d, 'failed', { error: 'the task has no signing secret any more', attempts });const body = JSON.stringify(store.parseJson(d.body));++ let signed;++ try { signed = await signatureHeaders(d, body); } catch (err) {++ if (err instanceof SecretRefused) return finish(d, 'failed', { error: err.message.slice(0, 300), attempts });++ // Network could not sign just now: the same backoff as a receiver that did not answer.++ return retryLater(d, attempts, null, `signing with the project secret: ${String(err.message || err).slice(0, 250)}`);++ }++ if (!signed) return finish(d, 'failed', { error: 'the task has no signing secret any more', attempts });const headers = {-- ...signDeliveryHeaders(body, secret, { now: now() }),++ ...signed,'X-OpenVibe-Delivery-Id': d.id,'X-OpenVibe-Task-Id': d.task_id,'X-OpenVibe-Task-State': d.state,@@@ -82,10 +114,7 @@ function createWebhooks({ s, config, post, now = () => Date.now(), log = consolereturn finish(d, 'failed', { lastStatus: res ? res.status : null, error: error ? error.message : 'the receiver answered 410 Gone', attempts });}const why = error ? String(error.message || error).slice(0, 300) : `HTTP ${res.status}`;-- if (attempts > BACKOFF_MS.length) return finish(d, 'failed', { lastStatus: res ? res.status : null, error: why, attempts });-- await s.db.query(`UPDATE webhook_deliveries SET status = 'pending', attempts = $2, next_at = $3, last_status = $4, last_error = $5 WHERE id = $1`,-- [d.id, attempts, now() + BACKOFF_MS[attempts - 1], res ? res.status : null, why]);-- return undefined;++ return retryLater(d, attempts, res ? res.status : null, why);}/** One pass over what is due. One pass at a time. */@@@ -42,7 +42,7 @@ async function boot(opts = {}) {const { createStore } = require('../../server/db');const testdb = await require('./db').testDb();const store = createStore(testdb.db, { now: opts.now });-- const built = await createApp({ config, store, log, limitsNow: opts.limitsNow, callerLimits: opts.callerLimits === true, adapters: opts.adapters, accountSend: opts.accountSend, webhookPost: opts.webhookPost, bot: opts.bot, projects: opts.projects });++ const built = await createApp({ config, store, log, limitsNow: opts.limitsNow, callerLimits: opts.callerLimits === true, adapters: opts.adapters, accountSend: opts.accountSend, webhookPost: opts.webhookPost, secretSigner: opts.secretSigner, bot: opts.bot, projects: opts.projects });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}`;@@@ -112,6 +112,7 @@ const SAME = { 'sec-fetch-site': 'same-origin' };'server/identity/projects.js': 'Network\'s internal membership read (configured networkInternalUrl) with Actor\'s own service token (network.project.member.read); the project and subject ids are path-encoded and come from a parsed OVRN and a checked usr_ id (test/resource-control.test.js)','server/agents/tools.js': 'the configured OpenVibe.Tools and OpenVibe.Search bases; ids come from Tools\' catalog, a page URL goes to Tools as input','server/events-consumer.js': 'OpenVibe.Events: the configured events.url (ACTOR_EVENTS_URL), to create the two ADR-033 subscriptions at boot',++ 'server/net/secret-signer.js': 'OpenVibe.Network: the configured networkInternalUrl (OV_NETWORK_INTERNAL_URL), POST /internal/secrets/use to sign a webhook delivery with a project secret (ADR-056); the body is the timestamp and the delivery, never a URL','server/net/webhook-post.js': 'a task\'s webhook URL (plan T17), the one caller-chosen address: https on 443/8443 to public addresses only, checked at creation and at send time through openvibe-shared/egress safeLookup, never a redirect (test/webhooks.test.js)',};const root = path.join(__dirname, '..');nnew file mode 100644@@@ -0,0 +1,111 @@++'use strict';++/**++ * Task webhooks signed with a project secret (ADR-056, secret_ref): a project's app names the secret; Actor mints no++ * secret of its own for that webhook and never sees the value; each delivery is signed by OpenVibe.Network (here a++ * stand-in signer holding the secret) over "<t>.<raw body>" and verifies with openvibe-sdk/events verifyDeliveryV2++ * under the project secret; a secret Network refuses fails the delivery at once; Network being unreachable is retried++ * on the backoff; a person's task (no project) cannot name one. (An app token always carries env: Network refuses one without.)++ */++const assert = require('assert');++const crypto = require('crypto');++const contracts = require('openvibe-contracts');++const { verifyDeliveryV2 } = require('openvibe-sdk/events');++const { boot, check, done } = require('./helpers/boot');++const { checkWebhookUrl } = require('../server/net/webhook-post');++const { SecretRefused } = require('../server/net/secret-signer');++++const HOOK = 'https://hooks.example.com/openvibe/actor';++const PROJECT = 'prj_01JAB2C3D4E5F6G7H8J9K0MNPR';++const SECRET = `ovs_${crypto.randomBytes(24).toString('hex')}`;++++(async () => {++ let clock = Date.now();++ const sent = [];++ const webhookPost = async (url, { headers, body }) => { checkWebhookUrl(url); sent.push({ url, headers, body }); return { status: 200 }; };++ // OpenVibe.Network's sign, as the project granted it: one secret, in production, to actor.++ const signs = [];++ let networkDown = false;++ const secretSigner = {++ enabled: true,++ async sign({ project_id, environment, name, message }) {++ signs.push({ project_id, environment, name, message });++ if (networkDown) throw new Error('connect ECONNREFUSED 127.0.0.1:4000');++ if (project_id !== PROJECT || environment !== 'production' || name !== 'webhook-signing') throw new SecretRefused(`project secret ${name} (${environment}) does not exist or is not granted to actor for sign`);++ return { signature: crypto.createHmac('sha256', SECRET).update(message).digest('hex'), version: 3 };++ },++ };++ const t = await boot({ webhookPost, secretSigner, now: () => clock });++ const kim = t.network.addUser('kim');++ const now = Math.floor(Date.now() / 1000);++ const appToken = (extra = {}) => contracts.serviceAuth.signServiceToken({ iss: t.network.url, sub: 'app:app_01JAB2C3D4E5F6G7H8J9K0MNPT', actor_type: 'app', aud: ['openvibe.actor'], cap: ['actor.task.create', 'actor.task.read'], ns: [PROJECT], project_id: PROJECT, env: 'production', iat: now, exp: now + 300, jti: `tok_${crypto.randomBytes(6).toString('hex')}`, ...extra }, t.network.privatePem);++ const deliveries = (taskId) => t.ctx.s.db.many('SELECT * FROM webhook_deliveries WHERE task_id = $1 ORDER BY created_at, id', [taskId]);++ async function queued(taskId, n) {++ for (let i = 0; i < 200; i++) { if ((await deliveries(taskId)).length >= n) return; await new Promise((res) => setTimeout(res, 20)); }++ throw new Error(`${taskId}: ${n} deliveries never queued`);++ }++++ let task;++ await check('a project\'s app names a project secret: no webhook_secret is minted, and the task shows the name only', async () => {++ t.providers.reset();++ const r = await t.get('/api/v1/tasks', { bearer: appToken(), json: { task: 'Say hello from an app', webhooks: [{ url: HOOK, events: ['succeeded'], secret_ref: 'webhook-signing' }] } });++ assert.strictEqual(r.status, 201, r.text);++ task = r.json();++ assert.strictEqual('webhook_secret' in task, false, 'nothing minted');++ assert.deepStrictEqual(task.webhooks, [{ url: HOOK, events: ['succeeded'], secret_ref: 'webhook-signing' }]);++ assert.ok(contracts.validate('platform.task@1', task).valid, JSON.stringify(contracts.validate('platform.task@1', task).errors));++ assert.strictEqual(await t.ctx.s.db.value('SELECT webhook_secret FROM tasks WHERE id = $1', [task.id]), null);++ });++++ await check('the delivery is signed by Network over "<t>.<raw body>" and verifies under the project secret', async () => {++ await t.waitFor(task.id);++ await queued(task.id, 1);++ await t.ctx.webhooks.tick();++ assert.strictEqual(sent.length, 1);++ const d = sent[0];++ assert.ok(verifyDeliveryV2(d.body, d.headers, SECRET, { now: clock }), 'verifyDeliveryV2 accepts it with the project secret');++ assert.ok(!verifyDeliveryV2(d.body, d.headers, 'whsec_someone-elses', { now: clock }));++ assert.deepStrictEqual([signs[0].project_id, signs[0].environment, signs[0].name], [PROJECT, 'production', 'webhook-signing']);++ assert.strictEqual(signs[0].message, `${d.headers['X-OpenVibe-Timestamp']}.${d.body}`, 'Network signed exactly the timestamp and the body sent');++ assert.strictEqual((await deliveries(task.id))[0].status, 'delivered');++ });++++ await check('a secret Network refuses fails that delivery at once; Network unreachable is retried on the backoff', async () => {++ t.providers.reset();++ const r = await t.get('/api/v1/tasks', { bearer: appToken(), json: { task: 'Say hello again', webhooks: [{ url: HOOK, events: ['succeeded'], secret_ref: 'not-granted' }] } });++ assert.strictEqual(r.status, 201, r.text);++ await t.waitFor(r.json().id);++ await queued(r.json().id, 1);++ await t.ctx.webhooks.tick();++ const refused = (await deliveries(r.json().id))[0];++ assert.deepStrictEqual([refused.status, refused.attempts], ['failed', 1]);++ assert.match(refused.last_error, /not granted to actor/);++++ t.providers.reset();++ const r2 = await t.get('/api/v1/tasks', { bearer: appToken(), json: { task: 'And once more', webhooks: [{ url: HOOK, events: ['succeeded'], secret_ref: 'webhook-signing' }] } });++ await t.waitFor(r2.json().id);++ await queued(r2.json().id, 1);++ networkDown = true;++ const before = sent.length;++ await t.ctx.webhooks.tick();++ let row = (await deliveries(r2.json().id))[0];++ assert.deepStrictEqual([row.status, row.attempts], ['pending', 1], 'retried later, with the attempt counted');++ assert.match(row.last_error, /signing with the project secret/);++ assert.strictEqual(sent.length, before, 'nothing unsigned was sent');++ networkDown = false;++ clock += 11_000;++ await t.ctx.webhooks.tick();++ row = (await deliveries(r2.json().id))[0];++ assert.strictEqual(row.status, 'delivered');++ });++++ await check('a person\'s own task cannot name a project secret; a webhook without one still gets the task\'s own secret', async () => {++ const person = await t.get('/api/v1/tasks', { as: kim, json: { task: 'x', webhooks: [{ url: HOOK, events: ['succeeded'], secret_ref: 'webhook-signing' }] }, headers: { 'sec-fetch-site': 'same-origin' } });++ assert.deepStrictEqual([person.status, person.json().code], [422, 'actor.webhook.secret_unavailable']);++ const mixed = await t.get('/api/v1/tasks', { bearer: appToken(), json: { task: 'Mixed hooks', webhooks: [{ url: HOOK, events: ['succeeded'], secret_ref: 'webhook-signing' }, { url: `${HOOK}/2`, events: ['succeeded'] }] } });++ assert.strictEqual(mixed.status, 201, mixed.text);++ assert.match(mixed.json().webhook_secret, /^whsec_/, 'a webhook without secret_ref still gets the task\'s own secret');++ });++++ await t.close();++ done();++})();
@@ -69,7 +69,13 @@ The resource index is for OpenVibe.Services on loopback and is blocked at the pu
@@ -69,7 +69,13 @@ The resource index is for OpenVibe.Services on loopback and is blocked at the pu
- **Signing:** the answer that creates the task carries `webhook_secret`, minted for that task. It is in no read or list, never exported, and erased once the task has ended and its deliveries are done. Each delivery is `actor.task-webhook@1` (`{ type: "actor.task.state", delivery_id, state, task }`, the task as it was then), signed like an Events delivery (`X-OpenVibe-Timestamp` + `X-OpenVibe-Signature-V2`), so a receiver checks it with openvibe-sdk/events `verifyDeliveryV2(rawBody, headers, secret)`. The delivery id stays the same across retries; dedupe on it.
- **Signing:** the answer that creates the task carries `webhook_secret`, minted for that task. It is in no read or list, never exported, and erased once the task has ended and its deliveries are done. Each delivery is `actor.task-webhook@1` (`{ type: "actor.task.state", delivery_id, state, task }`, the task as it was then), signed like an Events delivery (`X-OpenVibe-Timestamp` + `X-OpenVibe-Signature-V2`), so a receiver checks it with openvibe-sdk/events `verifyDeliveryV2(rawBody, headers, secret)`. The delivery id stays the same across retries; dedupe on it.
- **Outcomes:** 2xx is delivered; 410, or a URL refused at send time, ends the delivery; anything else is retried after 10 s, 1 min, 5 min, 30 min, 2 h, 6 h and 12 h, then fails.
- **Outcomes:** 2xx is delivered; 410, or a URL refused at send time, ends the delivery; anything else is retried after 10 s, 1 min, 5 min, 30 min, 2 h, 6 h and 12 h, then fails.
- **Where Actor may connect:** a webhook URL is the one address a caller chooses (`server/net/webhook-post.js`): https on port 443 or 8443, no credentials, public addresses only. It is checked at creation (`actor.webhook.refused`) and again at send time through openvibe-shared/egress, so a name that later resolves to a private address is refused. Redirects are never followed.
- **Where Actor may connect:** a webhook URL is the one address a caller chooses (`server/net/webhook-post.js`): https on port 443 or 8443, no credentials, public addresses only. It is checked at creation (`actor.webhook.refused`) and again at send time through openvibe-shared/egress, so a name that later resolves to a private address is refused. Redirects are never followed.
-- **Not yet:** a project secret named by reference (`secret_ref`) is refused until project secrets exist.
+- **A project secret instead (ADR-056):** a webhook may name one (`secret_ref`, a lower-case name) when the task is made
+ by a project's app or agent token. The secret is looked up in the task's project and environment, and the project must
+ have granted it to `actor` for `sign` (on openvibe.services, the project's Secrets page). OpenVibe.Network then signs
+ each delivery over `"<t>.<raw body>"` in the same two headers, so the receiver's check is the same and Actor never
+ holds the value. No `webhook_secret` is minted when every webhook names one. A secret Network refuses (missing, or not
+ granted) fails that delivery at once; Network unreachable is retried on the usual backoff. A person's own task cannot
+ name one (422 `actor.webhook.secret_unavailable`).
**Errors:** RFC 9457 problems. The codes are on [openvibe.actor/docs](https://openvibe.actor/docs#errors).
**Errors:** RFC 9457 problems. The codes are on [openvibe.actor/docs](https://openvibe.actor/docs#errors).
new file mode 100644
new file mode 100644
@@ -0,0 +1,7 @@
@@ -0,0 +1,7 @@
+-- phase: expand
+-- ADR-056: a task webhook may name a project secret (secret_ref) that signs its deliveries instead of the task's own
+-- secret; OpenVibe.Network computes each signature, so Actor never holds the value. A delivery records the secret's
+-- name and the project environment to look it up in, as they were when it was queued. Additive: an older release
+-- ignores the columns and keeps signing with the task's secret.
+ALTER TABLE webhook_deliveries ADD COLUMN IF NOT EXISTS secret_ref text COLLATE "C";
+ALTER TABLE webhook_deliveries ADD COLUMN IF NOT EXISTS secret_env text COLLATE "C";
@@ -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.169.0",
+ "openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.178.0",
"openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.44.0",
"openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.44.0",
"openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v4.1.7",
"openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v4.1.7",
"pg": "^8.23.0"
"pg": "^8.23.0"
@@ -871,9 +871,9 @@
@@ -871,9 +871,9 @@
}
}
},
},
"node_modules/openvibe-contracts": {
"node_modules/openvibe-contracts": {
- "version": "0.169.0",
- "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.169.0",
- "integrity": "sha512-Py2z8UaKGvLzG3atuIAnJKnRe74Te9cJRTaxqFq6m4fov6gaMXeS6rn7lH0tSIo0qBGD5trrT618ARXnklkp+A==",
+ "version": "0.178.0",
+ "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.178.0",
+ "integrity": "sha512-86Q1Mmo+BvMnF2GG2IZrbHq8iZ2FUfED28rnDcFlgZTtIzHxDQdi2dY+Xe8WbXc4EebjGUYhEl5J3LReA2wiqg==",
"license": "AGPL-3.0-only",
"license": "AGPL-3.0-only",
"dependencies": {
"dependencies": {
"ajv": "^8.17.1",
"ajv": "^8.17.1",
@@ -27,7 +27,7 @@
@@ -27,7 +27,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.169.0",
+ "openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.178.0",
"openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.44.0",
"openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.44.0",
"openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v4.1.7",
"openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v4.1.7",
"pg": "^8.23.0"
"pg": "^8.23.0"
@@ -60,8 +60,10 @@ async function createApp(opts = {}) {
@@ -60,8 +60,10 @@ async function createApp(opts = {}) {
const stream = createTaskStream({ log });
const stream = createTaskStream({ log });
const adapters = opts.adapters || createAdapters({ config, rates: catalog.RATES, fetchImpl: opts.providerFetch || globalThis.fetch, log });
const adapters = opts.adapters || createAdapters({ config, rates: catalog.RATES, fetchImpl: opts.providerFetch || globalThis.fetch, log });
// Task webhooks (plan T17): the only connections to an address a caller chose, through server/net/webhook-post.js.
// Task webhooks (plan T17): the only connections to an address a caller chose, through server/net/webhook-post.js.
+ // A webhook naming a project secret (ADR-056) is signed by OpenVibe.Network (server/net/secret-signer.js).
+ const signer = opts.secretSigner || require('./net/secret-signer').createSecretSigner({ config, fetchImpl });
const webhooks = createWebhooks({
const webhooks = createWebhooks({
- s, config, now: s.now, log,
+ s, config, now: s.now, log, signer,
post: opts.webhookPost || createWebhookPoster({ userAgent: `OpenVibe.Actor/${VERSION} (+https://openvibe.actor/docs#webhooks)`, timeoutMs: config.webhooks.timeoutMs }),
post: opts.webhookPost || createWebhookPoster({ userAgent: `OpenVibe.Actor/${VERSION} (+https://openvibe.actor/docs#webhooks)`, timeoutMs: config.webhooks.timeoutMs }),
});
});
const engine = createEngine({ config, s, adapters, stream, webhooks, now: s.now, log });
const engine = createEngine({ config, s, adapters, stream, webhooks, now: s.now, log });
@@ -410,7 +410,7 @@ data: {"task_id":"tsk_…","seq":3,"kind":"output","agent":"openvibe-runtime","s
@@ -410,7 +410,7 @@ data: {"task_id":"tsk_…","seq":3,"kind":"output","agent":"openvibe-runtime","s
<li><strong>An app or agent</strong>: a Network token for audience <code>openvibe.actor</code> with the <code>actor.task.*</code> capability the route needs, granted on <a href="https://openvibe.services">OpenVibe.Services</a>. Tasks belong to the app's project.</li>
<li><strong>An app or agent</strong>: a Network token for audience <code>openvibe.actor</code> with the <code>actor.task.*</code> capability the route needs, granted on <a href="https://openvibe.services">OpenVibe.Services</a>. Tasks belong to the app's project.</li>
<li><strong>Anyone</strong>: <code>GET /api/v1/agents</code> and <code>POST /api/v1/route</code> need no token.</li></ul>
<li><strong>Anyone</strong>: <code>GET /api/v1/agents</code> and <code>POST /api/v1/route</code> need no token.</li></ul>
${table(['Route', 'Capability', 'What it does'], [
${table(['Route', 'Capability', 'What it does'], [
- [html`<code>POST /api/v1/tasks</code>`, html`<code>actor.task.create</code>`, html`Create a task: <code>{ task, mode?, budget?: { per_task_usd, per_day_usd }, agent?, idempotency_key?, webhooks?: [{ url, events }] }</code>. Answers 201 with the task (queued); with webhooks, also its <code>webhook_secret</code>, this once (<a href="#webhooks">webhooks</a>).`],
+ [html`<code>POST /api/v1/tasks</code>`, html`<code>actor.task.create</code>`, html`Create a task: <code>{ task, mode?, budget?: { per_task_usd, per_day_usd }, agent?, idempotency_key?, webhooks?: [{ url, events, secret_ref? }] }</code>. Answers 201 with the task (queued); with webhooks, also its <code>webhook_secret</code>, this once, unless every webhook names a project secret (<a href="#webhooks">webhooks</a>).`],
[html`<code>GET /api/v1/tasks/:id</code>`, html`<code>actor.task.read</code>`, 'The task: state, result, cost, explanation, error.'],
[html`<code>GET /api/v1/tasks/:id</code>`, html`<code>actor.task.read</code>`, 'The task: state, result, cost, explanation, error.'],
[html`<code>GET /api/v1/tasks/:id/events</code>`, html`<code>actor.task.read</code>`, html`Server-sent events (<code>actor.task-event@1</code>): <code>state</code>, <code>output</code> (each step), <code>end</code>. Resumes with <code>Last-Event-ID</code>.`],
[html`<code>GET /api/v1/tasks/:id/events</code>`, html`<code>actor.task.read</code>`, html`Server-sent events (<code>actor.task-event@1</code>): <code>state</code>, <code>output</code> (each step), <code>end</code>. Resumes with <code>Last-Event-ID</code>.`],
[html`<code>POST /api/v1/tasks/:id/cancel</code>`, html`<code>actor.task.create</code>`, 'Stop it. What was spent stays spent.'],
[html`<code>POST /api/v1/tasks/:id/cancel</code>`, html`<code>actor.task.create</code>`, 'Stop it. What was spent stays spent.'],
@@ -422,6 +422,7 @@ ${raw(showcase.code({ title: 'Create a task, then watch it', samples: [{ label:
@@ -422,6 +422,7 @@ ${raw(showcase.code({ title: 'Create a task, then watch it', samples: [{ label:
<h2 id="webhooks">Webhooks</h2>
<h2 id="webhooks">Webhooks</h2>
<p>Give a task up to 8 HTTPS endpoints and the state changes each wants (<code>queued</code>, <code>running</code>, <code>verifying</code>, <code>succeeded</code>, <code>failed</code>, <code>cancelled</code>). Actor POSTs <a href="https://openvibe.services/docs/contracts/actor.task-webhook@1"><code>actor.task-webhook@1</code></a> to each: <code>{ type: "actor.task.state", delivery_id, state, task }</code>, with the task as it was at that moment.</p>
<p>Give a task up to 8 HTTPS endpoints and the state changes each wants (<code>queued</code>, <code>running</code>, <code>verifying</code>, <code>succeeded</code>, <code>failed</code>, <code>cancelled</code>). Actor POSTs <a href="https://openvibe.services/docs/contracts/actor.task-webhook@1"><code>actor.task-webhook@1</code></a> to each: <code>{ type: "actor.task.state", delivery_id, state, task }</code>, with the task as it was at that moment.</p>
<ul><li><strong>Signed per task.</strong> The answer that creates the task carries <code>webhook_secret</code>, and nothing else ever shows it. Each delivery has <code>X-OpenVibe-Timestamp</code> and <code>X-OpenVibe-Signature-V2</code>, exactly like an OpenVibe.Events delivery, so <code>verifyDeliveryV2(rawBody, headers, secret)</code> from <code>openvibe-sdk/events</code> checks it (and the five-minute window).</li>
<ul><li><strong>Signed per task.</strong> The answer that creates the task carries <code>webhook_secret</code>, and nothing else ever shows it. Each delivery has <code>X-OpenVibe-Timestamp</code> and <code>X-OpenVibe-Signature-V2</code>, exactly like an OpenVibe.Events delivery, so <code>verifyDeliveryV2(rawBody, headers, secret)</code> from <code>openvibe-sdk/events</code> checks it (and the five-minute window).</li>
+<li><strong>Or with your project's secret.</strong> A project's app or agent may name a project secret instead (<code>secret_ref</code>): grant it to <code>actor</code> for signing on <a href="https://openvibe.services/projects">openvibe.services</a>, and OpenVibe.Network signs each delivery with it in the same headers. Actor never sees the value, and you verify with your own secret.</li>
<li><strong>Retried.</strong> A 2xx answer is delivered. 410 Gone ends it. Anything else is retried after 10 s, 1 min, 5 min, 30 min, 2 h, 6 h and 12 h. <code>delivery_id</code> (also <code>X-OpenVibe-Delivery-Id</code>) is the same on every attempt: dedupe on it.</li>
<li><strong>Retried.</strong> A 2xx answer is delivered. 410 Gone ends it. Anything else is retried after 10 s, 1 min, 5 min, 30 min, 2 h, 6 h and 12 h. <code>delivery_id</code> (also <code>X-OpenVibe-Delivery-Id</code>) is the same on every attempt: dedupe on it.</li>
<li><strong>Public endpoints only.</strong> HTTPS on port 443 or 8443, no credentials in the URL, and only public addresses, checked when the task is created and again when each delivery is sent. Redirects are not followed.</li></ul>
<li><strong>Public endpoints only.</strong> HTTPS on port 443 or 8443, no credentials in the URL, and only public addresses, checked when the task is created and again when each delivery is sent. Redirects are not followed.</li></ul>
<h2 id="limits">Limits and money</h2>
<h2 id="limits">Limits and money</h2>
new file mode 100644
new file mode 100644
@@ -0,0 +1,38 @@
@@ -0,0 +1,38 @@
+'use strict';
+/**
+ * Signing with a project secret (ADR-056): OpenVibe.Network's POST /internal/secrets/use, purpose sign, with Actor's own
+ * service token (network.secret.use, audience openvibe.network). Network answers HMAC-SHA256 of the message under the
+ * secret the project granted to actor; the value never reaches Actor.
+ *
+ * sign({ project_id, environment, name, message }) → { signature, version }
+ * throws SecretRefused (404 secret.not_found: no such secret, or not granted to actor for sign; final)
+ * throws any other error for a transient failure (retried by the caller)
+ */
+const { serviceAuth } = require('openvibe-contracts');
+
+class SecretRefused extends Error {}
+
+function createSecretSigner({ config, fetchImpl = globalThis.fetch }) {
+ const enabled = Boolean(config.networkInternalUrl && config.oauth && config.oauth.clientSecret);
+ const tokens = enabled ? serviceAuth.createTokenClient({
+ tokenUrl: `${config.networkInternalUrl}/oauth/token`, clientId: config.oauth.clientId, clientSecret: config.oauth.clientSecret,
+ audience: 'openvibe.network', scope: 'network.secret.use', fetchImpl,
+ }) : null;
+ async function sign({ project_id, environment, name, message }) {
+ if (!tokens) throw new Error('project secrets are unavailable: Actor has no Network client secret');
+ const res = await fetchImpl(`${config.networkInternalUrl}/internal/secrets/use`, {
+ method: 'POST',
+ headers: { ...(await tokens.authHeaders()), 'Content-Type': 'application/json', Accept: 'application/json' },
+ body: JSON.stringify({ project_id, environment, name, purpose: 'sign', message }),
+ signal: AbortSignal.timeout(5000),
+ });
+ const data = await res.json().catch(() => null);
+ if (res.status === 401 && tokens.invalidate) tokens.invalidate();
+ if (res.status === 404) throw new SecretRefused(`project secret ${name} (${environment}) does not exist or is not granted to actor for sign`);
+ if (!res.ok || !data || !/^[0-9a-f]{64}$/.test(String(data.signature || ''))) throw new Error(`Network answered ${res.status}${data && data.code ? ` ${data.code}` : ''}`);
+ return { signature: data.signature, version: data.version };
+ }
+ return { enabled, sign };
+}
+
+module.exports = { createSecretSigner, SecretRefused };
@@ -30,10 +30,17 @@ async function createTask({ s, config, engine, principal, body, webhooks: webhoo
@@ -30,10 +30,17 @@ async function createTask({ s, config, engine, principal, body, webhooks: webhoo
if (body.agent && !catalog.byId(body.agent)) return { status: 422, code: 'actor.agent.unknown', detail: `no agent "${body.agent}"; GET /api/v1/agents lists them` };
if (body.agent && !catalog.byId(body.agent)) return { status: 422, code: 'actor.agent.unknown', detail: `no agent "${body.agent}"; GET /api/v1/agents lists them` };
// Webhooks (plan T17): each URL must be one Actor may deliver to (https, port 443/8443, a public address); the
// Webhooks (plan T17): each URL must be one Actor may deliver to (https, port 443/8443, a public address); the
// send-time check repeats it against what the name resolves to then.
// send-time check repeats it against what the name resolves to then.
- const webhooks = (body.webhooks || []).map((h) => ({ url: h.url, events: h.events }));
+ // A webhook may name a project secret (ADR-056, secret_ref): Network signs its deliveries with it. The secret lives in
+ // the task's project and environment, so the task must come from a project's app or agent token.
+ const env = principal.claims && ['sandbox', 'production'].includes(principal.claims.env) ? principal.claims.env : null;
+ const webhooks = (body.webhooks || []).map((h) => ({ url: h.url, events: h.events, ...(h.secret_ref ? { secret_ref: h.secret_ref, environment: env } : {}) }));
for (const h of webhooks) {
for (const h of webhooks) {
try { checkWebhookUrl(h.url); } catch (err) { return { status: 422, code: 'actor.webhook.refused', detail: `${h.url.slice(0, 200)}: ${err.message}` }; }
try { checkWebhookUrl(h.url); } catch (err) { return { status: 422, code: 'actor.webhook.refused', detail: `${h.url.slice(0, 200)}: ${err.message}` }; }
+ if (h.secret_ref && (!principal.project || !env)) {
+ return { status: 422, code: 'actor.webhook.secret_unavailable', detail: `secret_ref names a project secret: make the task with a project's app or agent token (it carries project_id and env); this one has ${principal.project ? 'no env' : 'no project'}.` };
+ }
}
}
+ const ownSecret = webhooks.some((h) => !h.secret_ref);
const hash = crypto.createHash('sha256').update(JSON.stringify({ task: body.task, mode: body.mode || 'balanced', budget: [perTask, perDay], agent: body.agent || null, ...(webhooks.length ? { webhooks } : {}) })).digest('hex');
const hash = crypto.createHash('sha256').update(JSON.stringify({ task: body.task, mode: body.mode || 'balanced', budget: [perTask, perDay], agent: body.agent || null, ...(webhooks.length ? { webhooks } : {}) })).digest('hex');
if (body.idempotency_key) {
if (body.idempotency_key) {
@@ -62,7 +69,7 @@ async function createTask({ s, config, engine, principal, body, webhooks: webhoo
@@ -62,7 +69,7 @@ async function createTask({ s, config, engine, principal, body, webhooks: webhoo
const row = await store.insertTask(s, {
const row = await store.insertTask(s, {
id: s.newId('tsk'), requester: principal.requester, via: principal.via || null, project_id: principal.project || null, task: body.task, mode: body.mode || 'balanced', agent: body.agent || null,
id: s.newId('tsk'), requester: principal.requester, via: principal.via || null, project_id: principal.project || null, task: body.task, mode: body.mode || 'balanced', agent: body.agent || null,
budget_task: perTask, budget_day: perDay, idem_key: body.idempotency_key || null, idem_hash: body.idempotency_key ? hash : null, created_at: s.iso(),
budget_task: perTask, budget_day: perDay, idem_key: body.idempotency_key || null, idem_hash: body.idempotency_key ? hash : null, created_at: s.iso(),
- webhooks, webhook_secret: webhooks.length ? mintSecret() : null,
+ webhooks, webhook_secret: ownSecret ? mintSecret() : null,
});
});
return { status: 201, task: row };
return { status: 201, task: row };
});
});
@@ -156,7 +156,7 @@ function toWire(row, { baseUrl }) {
@@ -156,7 +156,7 @@ function toWire(row, { baseUrl }) {
if (t.explanation && !t.explanation.length) t.explanation = null;
if (t.explanation && !t.explanation.length) t.explanation = null;
// What the task registered; the signing secret is never part of the task as read (only the creating answer has it).
// What the task registered; the signing secret is never part of the task as read (only the creating answer has it).
const hooks = parse(row.webhooks);
const hooks = parse(row.webhooks);
- if (hooks && hooks.length) t.webhooks = hooks.map((h) => ({ url: h.url, events: h.events }));
+ if (hooks && hooks.length) t.webhooks = hooks.map((h) => ({ url: h.url, events: h.events, ...(h.secret_ref ? { secret_ref: h.secret_ref } : {}) }));
return t;
return t;
}
}
@@ -9,6 +9,11 @@
@@ -9,6 +9,11 @@
* 410, or a refused URL failed at once (the receiver is gone, or the URL is not a public address any more)
* 410, or a refused URL failed at once (the receiver is gone, or the URL is not a public address any more)
* anything else retried: 10 s, 1 min, 5 min, 30 min, 2 h, 6 h, 12 h, then failed (about a day)
* anything else retried: 10 s, 1 min, 5 min, 30 min, 2 h, 6 h, 12 h, then failed (about a day)
*
*
+ * A webhook that names a project secret (secret_ref, ADR-056) is signed by OpenVibe.Network instead, in the same headers:
+ * Actor sends Network "<t>.<raw body>" and puts the signature it answers in X-OpenVibe-Signature-V2, so the receiver's
+ * check is the same and Actor never holds the value. A secret that does not exist or is not granted to actor fails that
+ * delivery at once; Network being unreachable is retried like any other failure.
+ *
* A delivery never delays or changes the task. Once a task has ended and none of its deliveries is pending, its secret
* A delivery never delays or changes the task. Once a task has ended and none of its deliveries is pending, its secret
* is erased. The delivery id is stable across attempts (dedupe on it). One Actor process sends: rows are claimed with
* is erased. The delivery id is stable across attempts (dedupe on it). One Actor process sends: rows are claimed with
* FOR UPDATE SKIP LOCKED all the same, and a row a crash left 'sending' is pending again at the next start.
* FOR UPDATE SKIP LOCKED all the same, and a row a crash left 'sending' is pending again at the next start.
@@ -17,6 +22,7 @@ const crypto = require('crypto');
@@ -17,6 +22,7 @@ const crypto = require('crypto');
const { signDeliveryHeaders } = require('openvibe-sdk/events');
const { signDeliveryHeaders } = require('openvibe-sdk/events');
const store = require('./store');
const store = require('./store');
const { WebhookRefused } = require('../net/webhook-post');
const { WebhookRefused } = require('../net/webhook-post');
+const { SecretRefused } = require('../net/secret-signer');
const BACKOFF_MS = [10_000, 60_000, 300_000, 1_800_000, 7_200_000, 21_600_000, 43_200_000];
const BACKOFF_MS = [10_000, 60_000, 300_000, 1_800_000, 7_200_000, 21_600_000, 43_200_000];
const BATCH = 20;
const BATCH = 20;
@@ -24,7 +30,7 @@ const BATCH = 20;
@@ -24,7 +30,7 @@ const BATCH = 20;
/** A fresh per-task secret: whsec_ + 32 random bytes, base64url (43 characters). */
/** A fresh per-task secret: whsec_ + 32 random bytes, base64url (43 characters). */
const mintSecret = () => `whsec_${crypto.randomBytes(32).toString('base64url')}`;
const mintSecret = () => `whsec_${crypto.randomBytes(32).toString('base64url')}`;
-function createWebhooks({ s, config, post, now = () => Date.now(), log = console }) {
+function createWebhooks({ s, config, post, signer = null, now = () => Date.now(), log = console }) {
let timer = null;
let timer = null;
let running = null;
let running = null;
@@ -38,8 +44,8 @@ function createWebhooks({ s, config, post, now = () => Date.now(), log = console
@@ -38,8 +44,8 @@ function createWebhooks({ s, config, post, now = () => Date.now(), log = console
for (const h of wanted) {
for (const h of wanted) {
const id = s.newId('whd');
const id = s.newId('whd');
const body = { type: 'actor.task.state', delivery_id: id, state, task };
const body = { type: 'actor.task.state', delivery_id: id, state, task };
- await s.db.query(`INSERT INTO webhook_deliveries (id, task_id, url, state, body, status, attempts, next_at, created_at)
- VALUES ($1, $2, $3, $4, $5::jsonb, 'pending', 0, $6, $7)`, [id, taskId, h.url, state, JSON.stringify(body), now(), s.iso()]);
+ await s.db.query(`INSERT INTO webhook_deliveries (id, task_id, url, state, body, status, attempts, next_at, created_at, secret_ref, secret_env)
+ VALUES ($1, $2, $3, $4, $5::jsonb, 'pending', 0, $6, $7, $8, $9)`, [id, taskId, h.url, state, JSON.stringify(body), now(), s.iso(), h.secret_ref || null, h.secret_ref ? h.environment : null]);
}
}
return wanted.length;
return wanted.length;
}
}
@@ -62,13 +68,39 @@ function createWebhooks({ s, config, post, now = () => Date.now(), log = console
@@ -62,13 +68,39 @@ function createWebhooks({ s, config, post, now = () => Date.now(), log = console
AND NOT EXISTS (SELECT 1 FROM webhook_deliveries WHERE task_id = $1 AND status IN ('pending', 'sending'))`, [taskId]);
AND NOT EXISTS (SELECT 1 FROM webhook_deliveries WHERE task_id = $1 AND status IN ('pending', 'sending'))`, [taskId]);
}
}
+ /** The signature headers: the task's own secret, or Network's signature with the project secret the webhook names. */
+ async function signatureHeaders(d, body) {
+ if (!d.secret_ref) {
+ const secret = await s.db.value('SELECT webhook_secret FROM tasks WHERE id = $1', [d.task_id]);
+ return secret ? signDeliveryHeaders(body, secret, { now: now() }) : null;
+ }
+ const project = await s.db.value('SELECT project_id FROM tasks WHERE id = $1', [d.task_id]);
+ if (!project || !signer) throw new SecretRefused('the task has no project to look the secret up in');
+ const t = Math.floor(now() / 1000);
+ const { signature } = await signer.sign({ project_id: project, environment: d.secret_env, name: d.secret_ref, message: `${t}.${body}` });
+ return { 'X-OpenVibe-Timestamp': String(t), 'X-OpenVibe-Signature-V2': `t=${t},v2=${signature}` };
+ }
+
+ /** Try again on the backoff, or give up once it is spent. */
+ async function retryLater(d, attempts, lastStatus, why) {
+ if (attempts > BACKOFF_MS.length) return finish(d, 'failed', { lastStatus, error: why, attempts });
+ await s.db.query(`UPDATE webhook_deliveries SET status = 'pending', attempts = $2, next_at = $3, last_status = $4, last_error = $5 WHERE id = $1`,
+ [d.id, attempts, now() + BACKOFF_MS[attempts - 1], lastStatus, why]);
+ return undefined;
+ }
+
async function sendOne(d) {
async function sendOne(d) {
const attempts = Number(d.attempts) + 1;
const attempts = Number(d.attempts) + 1;
- const secret = await s.db.value('SELECT webhook_secret FROM tasks WHERE id = $1', [d.task_id]);
- if (!secret) return finish(d, 'failed', { error: 'the task has no signing secret any more', attempts });
const body = JSON.stringify(store.parseJson(d.body));
const body = JSON.stringify(store.parseJson(d.body));
+ let signed;
+ try { signed = await signatureHeaders(d, body); } catch (err) {
+ if (err instanceof SecretRefused) return finish(d, 'failed', { error: err.message.slice(0, 300), attempts });
+ // Network could not sign just now: the same backoff as a receiver that did not answer.
+ return retryLater(d, attempts, null, `signing with the project secret: ${String(err.message || err).slice(0, 250)}`);
+ }
+ if (!signed) return finish(d, 'failed', { error: 'the task has no signing secret any more', attempts });
const headers = {
const headers = {
- ...signDeliveryHeaders(body, secret, { now: now() }),
+ ...signed,
'X-OpenVibe-Delivery-Id': d.id,
'X-OpenVibe-Delivery-Id': d.id,
'X-OpenVibe-Task-Id': d.task_id,
'X-OpenVibe-Task-Id': d.task_id,
'X-OpenVibe-Task-State': d.state,
'X-OpenVibe-Task-State': d.state,
@@ -82,10 +114,7 @@ function createWebhooks({ s, config, post, now = () => Date.now(), log = console
@@ -82,10 +114,7 @@ function createWebhooks({ s, config, post, now = () => Date.now(), log = console
return finish(d, 'failed', { lastStatus: res ? res.status : null, error: error ? error.message : 'the receiver answered 410 Gone', attempts });
return finish(d, 'failed', { lastStatus: res ? res.status : null, error: error ? error.message : 'the receiver answered 410 Gone', attempts });
}
}
const why = error ? String(error.message || error).slice(0, 300) : `HTTP ${res.status}`;
const why = error ? String(error.message || error).slice(0, 300) : `HTTP ${res.status}`;
- if (attempts > BACKOFF_MS.length) return finish(d, 'failed', { lastStatus: res ? res.status : null, error: why, attempts });
- await s.db.query(`UPDATE webhook_deliveries SET status = 'pending', attempts = $2, next_at = $3, last_status = $4, last_error = $5 WHERE id = $1`,
- [d.id, attempts, now() + BACKOFF_MS[attempts - 1], res ? res.status : null, why]);
- return undefined;
+ return retryLater(d, attempts, res ? res.status : null, why);
}
}
/** One pass over what is due. One pass at a time. */
/** One pass over what is due. One pass at a time. */
@@ -42,7 +42,7 @@ async function boot(opts = {}) {
@@ -42,7 +42,7 @@ async function boot(opts = {}) {
const { createStore } = require('../../server/db');
const { createStore } = require('../../server/db');
const testdb = await require('./db').testDb();
const testdb = await require('./db').testDb();
const store = createStore(testdb.db, { now: opts.now });
const store = createStore(testdb.db, { now: opts.now });
- const built = await createApp({ config, store, log, limitsNow: opts.limitsNow, callerLimits: opts.callerLimits === true, adapters: opts.adapters, accountSend: opts.accountSend, webhookPost: opts.webhookPost, bot: opts.bot, projects: opts.projects });
+ const built = await createApp({ config, store, log, limitsNow: opts.limitsNow, callerLimits: opts.callerLimits === true, adapters: opts.adapters, accountSend: opts.accountSend, webhookPost: opts.webhookPost, secretSigner: opts.secretSigner, bot: opts.bot, projects: opts.projects });
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}`;
@@ -112,6 +112,7 @@ const SAME = { 'sec-fetch-site': 'same-origin' };
@@ -112,6 +112,7 @@ const SAME = { 'sec-fetch-site': 'same-origin' };
'server/identity/projects.js': 'Network\'s internal membership read (configured networkInternalUrl) with Actor\'s own service token (network.project.member.read); the project and subject ids are path-encoded and come from a parsed OVRN and a checked usr_ id (test/resource-control.test.js)',
'server/identity/projects.js': 'Network\'s internal membership read (configured networkInternalUrl) with Actor\'s own service token (network.project.member.read); the project and subject ids are path-encoded and come from a parsed OVRN and a checked usr_ id (test/resource-control.test.js)',
'server/agents/tools.js': 'the configured OpenVibe.Tools and OpenVibe.Search bases; ids come from Tools\' catalog, a page URL goes to Tools as input',
'server/agents/tools.js': 'the configured OpenVibe.Tools and OpenVibe.Search bases; ids come from Tools\' catalog, a page URL goes to Tools as input',
'server/events-consumer.js': 'OpenVibe.Events: the configured events.url (ACTOR_EVENTS_URL), to create the two ADR-033 subscriptions at boot',
'server/events-consumer.js': 'OpenVibe.Events: the configured events.url (ACTOR_EVENTS_URL), to create the two ADR-033 subscriptions at boot',
+ 'server/net/secret-signer.js': 'OpenVibe.Network: the configured networkInternalUrl (OV_NETWORK_INTERNAL_URL), POST /internal/secrets/use to sign a webhook delivery with a project secret (ADR-056); the body is the timestamp and the delivery, never a URL',
'server/net/webhook-post.js': 'a task\'s webhook URL (plan T17), the one caller-chosen address: https on 443/8443 to public addresses only, checked at creation and at send time through openvibe-shared/egress safeLookup, never a redirect (test/webhooks.test.js)',
'server/net/webhook-post.js': 'a task\'s webhook URL (plan T17), the one caller-chosen address: https on 443/8443 to public addresses only, checked at creation and at send time through openvibe-shared/egress safeLookup, never a redirect (test/webhooks.test.js)',
};
};
const root = path.join(__dirname, '..');
const root = path.join(__dirname, '..');
new file mode 100644
new file mode 100644
@@ -0,0 +1,111 @@
@@ -0,0 +1,111 @@
+'use strict';
+/**
+ * Task webhooks signed with a project secret (ADR-056, secret_ref): a project's app names the secret; Actor mints no
+ * secret of its own for that webhook and never sees the value; each delivery is signed by OpenVibe.Network (here a
+ * stand-in signer holding the secret) over "<t>.<raw body>" and verifies with openvibe-sdk/events verifyDeliveryV2
+ * under the project secret; a secret Network refuses fails the delivery at once; Network being unreachable is retried
+ * on the backoff; a person's task (no project) cannot name one. (An app token always carries env: Network refuses one without.)
+ */
+const assert = require('assert');
+const crypto = require('crypto');
+const contracts = require('openvibe-contracts');
+const { verifyDeliveryV2 } = require('openvibe-sdk/events');
+const { boot, check, done } = require('./helpers/boot');
+const { checkWebhookUrl } = require('../server/net/webhook-post');
+const { SecretRefused } = require('../server/net/secret-signer');
+
+const HOOK = 'https://hooks.example.com/openvibe/actor';
+const PROJECT = 'prj_01JAB2C3D4E5F6G7H8J9K0MNPR';
+const SECRET = `ovs_${crypto.randomBytes(24).toString('hex')}`;
+
+(async () => {
+ let clock = Date.now();
+ const sent = [];
+ const webhookPost = async (url, { headers, body }) => { checkWebhookUrl(url); sent.push({ url, headers, body }); return { status: 200 }; };
+ // OpenVibe.Network's sign, as the project granted it: one secret, in production, to actor.
+ const signs = [];
+ let networkDown = false;
+ const secretSigner = {
+ enabled: true,
+ async sign({ project_id, environment, name, message }) {
+ signs.push({ project_id, environment, name, message });
+ if (networkDown) throw new Error('connect ECONNREFUSED 127.0.0.1:4000');
+ if (project_id !== PROJECT || environment !== 'production' || name !== 'webhook-signing') throw new SecretRefused(`project secret ${name} (${environment}) does not exist or is not granted to actor for sign`);
+ return { signature: crypto.createHmac('sha256', SECRET).update(message).digest('hex'), version: 3 };
+ },
+ };
+ const t = await boot({ webhookPost, secretSigner, now: () => clock });
+ const kim = t.network.addUser('kim');
+ const now = Math.floor(Date.now() / 1000);
+ const appToken = (extra = {}) => contracts.serviceAuth.signServiceToken({ iss: t.network.url, sub: 'app:app_01JAB2C3D4E5F6G7H8J9K0MNPT', actor_type: 'app', aud: ['openvibe.actor'], cap: ['actor.task.create', 'actor.task.read'], ns: [PROJECT], project_id: PROJECT, env: 'production', iat: now, exp: now + 300, jti: `tok_${crypto.randomBytes(6).toString('hex')}`, ...extra }, t.network.privatePem);
+ const deliveries = (taskId) => t.ctx.s.db.many('SELECT * FROM webhook_deliveries WHERE task_id = $1 ORDER BY created_at, id', [taskId]);
+ async function queued(taskId, n) {
+ for (let i = 0; i < 200; i++) { if ((await deliveries(taskId)).length >= n) return; await new Promise((res) => setTimeout(res, 20)); }
+ throw new Error(`${taskId}: ${n} deliveries never queued`);
+ }
+
+ let task;
+ await check('a project\'s app names a project secret: no webhook_secret is minted, and the task shows the name only', async () => {
+ t.providers.reset();
+ const r = await t.get('/api/v1/tasks', { bearer: appToken(), json: { task: 'Say hello from an app', webhooks: [{ url: HOOK, events: ['succeeded'], secret_ref: 'webhook-signing' }] } });
+ assert.strictEqual(r.status, 201, r.text);
+ task = r.json();
+ assert.strictEqual('webhook_secret' in task, false, 'nothing minted');
+ assert.deepStrictEqual(task.webhooks, [{ url: HOOK, events: ['succeeded'], secret_ref: 'webhook-signing' }]);
+ assert.ok(contracts.validate('platform.task@1', task).valid, JSON.stringify(contracts.validate('platform.task@1', task).errors));
+ assert.strictEqual(await t.ctx.s.db.value('SELECT webhook_secret FROM tasks WHERE id = $1', [task.id]), null);
+ });
+
+ await check('the delivery is signed by Network over "<t>.<raw body>" and verifies under the project secret', async () => {
+ await t.waitFor(task.id);
+ await queued(task.id, 1);
+ await t.ctx.webhooks.tick();
+ assert.strictEqual(sent.length, 1);
+ const d = sent[0];
+ assert.ok(verifyDeliveryV2(d.body, d.headers, SECRET, { now: clock }), 'verifyDeliveryV2 accepts it with the project secret');
+ assert.ok(!verifyDeliveryV2(d.body, d.headers, 'whsec_someone-elses', { now: clock }));
+ assert.deepStrictEqual([signs[0].project_id, signs[0].environment, signs[0].name], [PROJECT, 'production', 'webhook-signing']);
+ assert.strictEqual(signs[0].message, `${d.headers['X-OpenVibe-Timestamp']}.${d.body}`, 'Network signed exactly the timestamp and the body sent');
+ assert.strictEqual((await deliveries(task.id))[0].status, 'delivered');
+ });
+
+ await check('a secret Network refuses fails that delivery at once; Network unreachable is retried on the backoff', async () => {
+ t.providers.reset();
+ const r = await t.get('/api/v1/tasks', { bearer: appToken(), json: { task: 'Say hello again', webhooks: [{ url: HOOK, events: ['succeeded'], secret_ref: 'not-granted' }] } });
+ assert.strictEqual(r.status, 201, r.text);
+ await t.waitFor(r.json().id);
+ await queued(r.json().id, 1);
+ await t.ctx.webhooks.tick();
+ const refused = (await deliveries(r.json().id))[0];
+ assert.deepStrictEqual([refused.status, refused.attempts], ['failed', 1]);
+ assert.match(refused.last_error, /not granted to actor/);
+
+ t.providers.reset();
+ const r2 = await t.get('/api/v1/tasks', { bearer: appToken(), json: { task: 'And once more', webhooks: [{ url: HOOK, events: ['succeeded'], secret_ref: 'webhook-signing' }] } });
+ await t.waitFor(r2.json().id);
+ await queued(r2.json().id, 1);
+ networkDown = true;
+ const before = sent.length;
+ await t.ctx.webhooks.tick();
+ let row = (await deliveries(r2.json().id))[0];
+ assert.deepStrictEqual([row.status, row.attempts], ['pending', 1], 'retried later, with the attempt counted');
+ assert.match(row.last_error, /signing with the project secret/);
+ assert.strictEqual(sent.length, before, 'nothing unsigned was sent');
+ networkDown = false;
+ clock += 11_000;
+ await t.ctx.webhooks.tick();
+ row = (await deliveries(r2.json().id))[0];
+ assert.strictEqual(row.status, 'delivered');
+ });
+
+ await check('a person\'s own task cannot name a project secret; a webhook without one still gets the task\'s own secret', async () => {
+ const person = await t.get('/api/v1/tasks', { as: kim, json: { task: 'x', webhooks: [{ url: HOOK, events: ['succeeded'], secret_ref: 'webhook-signing' }] }, headers: { 'sec-fetch-site': 'same-origin' } });
+ assert.deepStrictEqual([person.status, person.json().code], [422, 'actor.webhook.secret_unavailable']);
+ const mixed = await t.get('/api/v1/tasks', { bearer: appToken(), json: { task: 'Mixed hooks', webhooks: [{ url: HOOK, events: ['succeeded'], secret_ref: 'webhook-signing' }, { url: `${HOOK}/2`, events: ['succeeded'] }] } });
+ assert.strictEqual(mixed.status, 201, mixed.text);
+ assert.match(mixed.json().webhook_secret, /^whsec_/, 'a webhook without secret_ref still gets the task\'s own secret');
+ });
+
+ await t.close();
+ done();
+})();