diff --git a/.agentworkforce/trajectories/completed/2026-06/traj_gaeqizrg7xrp/summary.md b/.agentworkforce/trajectories/completed/2026-06/traj_gaeqizrg7xrp/summary.md new file mode 100644 index 00000000..586f301a --- /dev/null +++ b/.agentworkforce/trajectories/completed/2026-06/traj_gaeqizrg7xrp/summary.md @@ -0,0 +1,31 @@ +# Trajectory: Review PR #283 in AgentWorkforce/relayfile + +> **Status:** ✅ Completed +> **Confidence:** 72% +> **Started:** June 15, 2026 at 01:21 PM +> **Completed:** June 15, 2026 at 01:24 PM + +--- + +## Summary + +Reviewed PR #283, applied mechanical conformance -h parser fix, validated remaining semantic webhook findings, and recorded CI blockers from missing Go plus lockfile/npm-ci mismatch. + +**Approach:** Standard approach + +--- + +## Key Decisions + +### Only applied mechanical -h parser fix +- **Chose:** Only applied mechanical -h parser fix +- **Reasoning:** Other validated issues change API contract assertions, auth scopes, delivery topology, or server lifecycle behavior and need human-authored review changes. + +--- + +## Chapters + +### 1. Work +*Agent: default* + +- Only applied mechanical -h parser fix: Only applied mechanical -h parser fix diff --git a/.agentworkforce/trajectories/completed/2026-06/traj_gaeqizrg7xrp/trajectory.json b/.agentworkforce/trajectories/completed/2026-06/traj_gaeqizrg7xrp/trajectory.json new file mode 100644 index 00000000..6839b5db --- /dev/null +++ b/.agentworkforce/trajectories/completed/2026-06/traj_gaeqizrg7xrp/trajectory.json @@ -0,0 +1,53 @@ +{ + "id": "traj_gaeqizrg7xrp", + "version": 1, + "task": { + "title": "Review PR #283 in AgentWorkforce/relayfile" + }, + "status": "completed", + "startedAt": "2026-06-15T13:21:37.270Z", + "completedAt": "2026-06-15T13:24:15.714Z", + "agents": [ + { + "name": "default", + "role": "lead", + "joinedAt": "2026-06-15T13:24:14.620Z" + } + ], + "chapters": [ + { + "id": "chap_pan5whw9gob0", + "title": "Work", + "agentName": "default", + "startedAt": "2026-06-15T13:24:14.620Z", + "endedAt": "2026-06-15T13:24:15.714Z", + "events": [ + { + "ts": 1781529854621, + "type": "decision", + "content": "Only applied mechanical -h parser fix: Only applied mechanical -h parser fix", + "raw": { + "question": "Only applied mechanical -h parser fix", + "chosen": "Only applied mechanical -h parser fix", + "alternatives": [], + "reasoning": "Other validated issues change API contract assertions, auth scopes, delivery topology, or server lifecycle behavior and need human-authored review changes." + }, + "significance": "high" + } + ] + } + ], + "retrospective": { + "summary": "Reviewed PR #283, applied mechanical conformance -h parser fix, validated remaining semantic webhook findings, and recorded CI blockers from missing Go plus lockfile/npm-ci mismatch.", + "approach": "Standard approach", + "confidence": 0.72 + }, + "commits": [], + "filesChanged": [], + "projectId": "AgentWorkforce/relayfile", + "tags": [], + "_trace": { + "startRef": "46ff6cefef152d5f4ad6f87cdaf4e5a868e01f0a", + "endRef": "46ff6cefef152d5f4ad6f87cdaf4e5a868e01f0a" + } +} \ No newline at end of file diff --git a/.agentworkforce/trajectories/completed/2026-06/traj_n21cpwtav76z/summary.md b/.agentworkforce/trajectories/completed/2026-06/traj_n21cpwtav76z/summary.md new file mode 100644 index 00000000..496bc23a --- /dev/null +++ b/.agentworkforce/trajectories/completed/2026-06/traj_n21cpwtav76z/summary.md @@ -0,0 +1,31 @@ +# Trajectory: Review PR #283 in AgentWorkforce/relayfile + +> **Status:** ✅ Completed +> **Confidence:** 78% +> **Started:** June 15, 2026 at 01:15 PM +> **Completed:** June 15, 2026 at 01:17 PM + +--- + +## Summary + +Reviewed PR #283 webhook conformance/e2e changes; validated two blocking semantic findings and CI environment/lockfile blockers; no mechanical edits applied. + +**Approach:** Standard approach + +--- + +## Key Decisions + +### Did not auto-edit webhook conformance failures +- **Chose:** Did not auto-edit webhook conformance failures +- **Reasoning:** The demonstrated issues are semantic contract/auth and receiver-topology questions, so they need human-authored PR changes rather than mechanical cleanup. + +--- + +## Chapters + +### 1. Work +*Agent: default* + +- Did not auto-edit webhook conformance failures: Did not auto-edit webhook conformance failures diff --git a/.agentworkforce/trajectories/completed/2026-06/traj_n21cpwtav76z/trajectory.json b/.agentworkforce/trajectories/completed/2026-06/traj_n21cpwtav76z/trajectory.json new file mode 100644 index 00000000..cf969f73 --- /dev/null +++ b/.agentworkforce/trajectories/completed/2026-06/traj_n21cpwtav76z/trajectory.json @@ -0,0 +1,53 @@ +{ + "id": "traj_n21cpwtav76z", + "version": 1, + "task": { + "title": "Review PR #283 in AgentWorkforce/relayfile" + }, + "status": "completed", + "startedAt": "2026-06-15T13:15:12.557Z", + "completedAt": "2026-06-15T13:17:08.538Z", + "agents": [ + { + "name": "default", + "role": "lead", + "joinedAt": "2026-06-15T13:17:08.449Z" + } + ], + "chapters": [ + { + "id": "chap_4but9zldwcvy", + "title": "Work", + "agentName": "default", + "startedAt": "2026-06-15T13:17:08.449Z", + "endedAt": "2026-06-15T13:17:08.538Z", + "events": [ + { + "ts": 1781529428450, + "type": "decision", + "content": "Did not auto-edit webhook conformance failures: Did not auto-edit webhook conformance failures", + "raw": { + "question": "Did not auto-edit webhook conformance failures", + "chosen": "Did not auto-edit webhook conformance failures", + "alternatives": [], + "reasoning": "The demonstrated issues are semantic contract/auth and receiver-topology questions, so they need human-authored PR changes rather than mechanical cleanup." + }, + "significance": "high" + } + ] + } + ], + "retrospective": { + "summary": "Reviewed PR #283 webhook conformance/e2e changes; validated two blocking semantic findings and CI environment/lockfile blockers; no mechanical edits applied.", + "approach": "Standard approach", + "confidence": 0.78 + }, + "commits": [], + "filesChanged": [], + "projectId": "AgentWorkforce/relayfile", + "tags": [], + "_trace": { + "startRef": "7b335b3424847d1ae1feae8706693f4ab0f4e1ab", + "endRef": "7b335b3424847d1ae1feae8706693f4ab0f4e1ab" + } +} \ No newline at end of file diff --git a/package-lock.json b/package-lock.json index 4198dd1a..bd4fb157 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "relayfile", - "version": "0.8.29", + "version": "0.8.30", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "relayfile", - "version": "0.8.29", + "version": "0.8.30", "license": "Apache-2.0", "workspaces": [ "packages/core", @@ -830,7 +830,7 @@ }, "node_modules/@clack/prompts/node_modules/is-unicode-supported": { "version": "1.3.0", - "dev": true, + "extraneous": true, "inBundle": true, "license": "MIT", "engines": { @@ -1999,9 +1999,9 @@ "link": true }, "node_modules/@relayfile/mount-darwin-arm64": { - "version": "0.8.29", - "resolved": "https://registry.npmjs.org/@relayfile/mount-darwin-arm64/-/mount-darwin-arm64-0.8.29.tgz", - "integrity": "sha512-BuxmPfMz/kTGPrb5dblwHf7Q3w08t2edbtNpfKjd7DvNOLiltRz8Dek5hw0ogeWU3GeQUQYmgGFE38XvpsAKCA==", + "version": "0.8.30", + "resolved": "https://registry.npmjs.org/@relayfile/mount-darwin-arm64/-/mount-darwin-arm64-0.8.30.tgz", + "integrity": "sha512-XYIJ1aiay6u9QdpGeDoWNA+Q0jboHbsOjCi37kBHpEapbuUXpqSW9HH5vAmpIPxzaFvsmYvTkTkjHPS9RLlnUA==", "cpu": [ "arm64" ], @@ -2012,9 +2012,9 @@ ] }, "node_modules/@relayfile/mount-darwin-x64": { - "version": "0.8.29", - "resolved": "https://registry.npmjs.org/@relayfile/mount-darwin-x64/-/mount-darwin-x64-0.8.29.tgz", - "integrity": "sha512-hC8Ym8Izwur1Et0of2J+AxiVxEHzoXvL01oMhLwCQC/EVZwCjy5AcyrU4d0jc7pOck7rRQ4ga5W/gXE9DM9sYQ==", + "version": "0.8.30", + "resolved": "https://registry.npmjs.org/@relayfile/mount-darwin-x64/-/mount-darwin-x64-0.8.30.tgz", + "integrity": "sha512-i8SJtwzUeZGSSghmHD8CFe3AK+TIPez7DnpMm7XvYJi7qFqdlPb3Xz8YMo7ANBKD0PrANexvxW71NeYzpZ4IEg==", "cpu": [ "x64" ], @@ -2025,9 +2025,9 @@ ] }, "node_modules/@relayfile/mount-linux-arm64": { - "version": "0.8.29", - "resolved": "https://registry.npmjs.org/@relayfile/mount-linux-arm64/-/mount-linux-arm64-0.8.29.tgz", - "integrity": "sha512-Rm+r1LCcwNR7o8Hj12xXIjDjw2lLsFBk6XNpfSG/fu0kgihQibpEvMp6ZCkli4xn6XM6T9uZT/e7s6XKv2e4mQ==", + "version": "0.8.30", + "resolved": "https://registry.npmjs.org/@relayfile/mount-linux-arm64/-/mount-linux-arm64-0.8.30.tgz", + "integrity": "sha512-PkBSEqKSak1dBLnZE9iBpsefNlaBVI83DjhXP9VAjnRBDN2jqEy8QrcztbmimgbRp3h960PONVtobLGAqQ90tg==", "cpu": [ "arm64" ], @@ -2038,9 +2038,9 @@ ] }, "node_modules/@relayfile/mount-linux-x64": { - "version": "0.8.29", - "resolved": "https://registry.npmjs.org/@relayfile/mount-linux-x64/-/mount-linux-x64-0.8.29.tgz", - "integrity": "sha512-tiWdfM2/jIRsL/mMmfsjJLKZl1SSLSANglQJUXMa8eANTwB/ItxJ5mwUz7ryuWoBSB+43beWK8Euj2zHDDlicQ==", + "version": "0.8.30", + "resolved": "https://registry.npmjs.org/@relayfile/mount-linux-x64/-/mount-linux-x64-0.8.30.tgz", + "integrity": "sha512-rjcETwjE80U29CueveHsW/VXvEW68+W8ePqq9IG3OTQZomgYSbXaYb/JS/SWGn0ywUeZaZi0wPlcNEnVNtflfQ==", "cpu": [ "x64" ], @@ -5287,7 +5287,7 @@ }, "packages/cli": { "name": "relayfile", - "version": "0.8.29", + "version": "0.8.30", "hasInstallScript": true, "license": "MIT", "bin": { @@ -5299,7 +5299,7 @@ }, "packages/core": { "name": "@relayfile/core", - "version": "0.8.29", + "version": "0.8.30", "license": "MIT", "devDependencies": { "@types/node": "^22.0.0", @@ -5312,7 +5312,7 @@ }, "packages/file-observer": { "name": "@relayfile/file-observer", - "version": "0.8.29", + "version": "0.8.30", "license": "MIT", "dependencies": { "class-variance-authority": "^0.7.0", @@ -6120,7 +6120,7 @@ }, "packages/local-mount": { "name": "@relayfile/local-mount", - "version": "0.8.29", + "version": "0.8.30", "license": "MIT", "dependencies": { "@parcel/watcher": "^2.5.6", @@ -6149,10 +6149,10 @@ }, "packages/sdk/typescript": { "name": "@relayfile/sdk", - "version": "0.8.29", + "version": "0.8.30", "license": "MIT", "dependencies": { - "@relayfile/core": "0.8.29", + "@relayfile/core": "0.8.30", "ignore": "^7.0.5", "tar": "^7.5.10" }, @@ -6164,10 +6164,10 @@ "node": ">=18" }, "optionalDependencies": { - "@relayfile/mount-darwin-arm64": "0.8.29", - "@relayfile/mount-darwin-x64": "0.8.29", - "@relayfile/mount-linux-arm64": "0.8.29", - "@relayfile/mount-linux-x64": "0.8.29" + "@relayfile/mount-darwin-arm64": "0.8.30", + "@relayfile/mount-darwin-x64": "0.8.30", + "@relayfile/mount-linux-arm64": "0.8.30", + "@relayfile/mount-linux-x64": "0.8.30" } } } diff --git a/scripts/conformance.ts b/scripts/conformance.ts index 0c950ba2..ede59fe1 100644 --- a/scripts/conformance.ts +++ b/scripts/conformance.ts @@ -23,9 +23,11 @@ import { createLocalRs256Auth, type LocalRs256Auth } from './test-utils/rsa-sign // --------------------------------------------------------------------------- // Config // --------------------------------------------------------------------------- -const flags = new Set(process.argv.slice(2).filter((a) => a.startsWith('--'))); +const argv = process.argv.slice(2); +const flags = new Set(argv.filter((a) => a.startsWith('--'))); const CI = flags.has('--ci') || !!process.env.CI; const REMOTE = flags.has('--remote'); +const HELP = flags.has('--help') || argv.includes('-h'); const PORT = Number(process.env.RELAYFILE_PORT || 19090); const BASE_URL = process.env.RELAYFILE_BASE_URL || `http://127.0.0.1:${PORT}`; @@ -61,7 +63,19 @@ function generateToken( return rs256Auth.generateToken(workspaceId, agentName, scopes, 3600); } -const ALL_SCOPES = ['fs:read', 'fs:write', 'sync:read', 'sync:trigger', 'ops:read', 'ops:replay', 'admin:read', 'admin:replay']; +const ALL_SCOPES = [ + 'fs:read', + 'fs:write', + 'sync:read', + 'sync:trigger', + 'ops:read', + 'ops:replay', + 'admin:read', + 'admin:replay', + 'webhooks:read', + 'webhooks:write', + 'webhooks:replay', +]; let TOKEN_ALPHA = ''; let TOKEN_BETA = ''; let TOKEN_LIMITED = ''; @@ -141,6 +155,30 @@ function assert(condition: boolean, msg: string): void { if (!condition) throw new Error(`Assertion failed: ${msg}`); } +function subscriptionId(data: any): string | undefined { + const raw = data?.subscriptionId ?? data?.id; + return typeof raw === 'string' && raw.length > 0 ? raw : undefined; +} + +function subscriptionItems(data: any): any[] { + if (Array.isArray(data)) return data; + if (Array.isArray(data?.items)) return data.items; + if (Array.isArray(data?.subscriptions)) return data.subscriptions; + if (Array.isArray(data?.webhooks)) return data.webhooks; + return []; +} + +function errorCode(data: any): string | undefined { + const raw = data?.code ?? data?.error; + return typeof raw === 'string' ? raw : undefined; +} + +function skipCloudOnlyWebhookRoute(testName: string, response: { status: number }): boolean { + if (response.status !== 404) return false; + log('⏭️ ', `${testName} skipped: webhook subscription routes are not implemented by this server`); + return true; +} + // --------------------------------------------------------------------------- // Process management // --------------------------------------------------------------------------- @@ -410,7 +448,11 @@ async function runSuite() { assert(types.has('file.created'), 'Missing file.created events'); }); - // ── 8. Export ─────────────────────────────────────────────────────── + // ── 8. Outbound webhooks ─────────────────────────────────────────── + + await webhooksSection(); + + // ── 9. Export ─────────────────────────────────────────────────────── step('Export'); @@ -424,7 +466,7 @@ async function runSuite() { assert(readme.semantics?.properties?.author === 'agent-alpha', 'Export missing semantics'); }); - // ── 9. Writeback lifecycle ───────────────────────────────────────── + // ── 10. Writeback lifecycle ──────────────────────────────────────── step('Writeback Lifecycle'); @@ -460,7 +502,7 @@ async function runSuite() { ok(' Acked item correctly filtered from pending'); }); - // ── 10. ACL / Permission enforcement ─────────────────────────────── + // ── 11. ACL / Permission enforcement ─────────────────────────────── step('ACL Permission Enforcement'); @@ -506,7 +548,7 @@ async function runSuite() { } }); - // ── 11. WebSocket catch-up ───────────────────────────────────────── + // ── 12. WebSocket catch-up ───────────────────────────────────────── step('WebSocket'); @@ -554,7 +596,7 @@ async function runSuite() { log('🔌', `WebSocket delivered ${events.length} catch-up events`); }); - // ── 12. Concurrent writes from multiple agents ───────────────────── + // ── 13. Concurrent writes from multiple agents ───────────────────── step('Concurrent Multi-Agent Writes'); @@ -611,10 +653,104 @@ async function runSuite() { }); } +async function webhooksSection() { + step('Outbound Webhooks'); + + await test('register + list + delete subscription lifecycle', async () => { + const create = await api('POST', `${ws()}/webhooks`, TOKEN_ALPHA, { + url: `https://example.com/relayfile/conformance/${Date.now()}`, + pathGlobs: ['/webhooks/lifecycle/**'], + secret: 'conformance-webhook-secret', + }); + if (skipCloudOnlyWebhookRoute('register + list + delete subscription lifecycle', create)) return; + assert(create.status === 201, `Expected 201, got ${create.status}: ${JSON.stringify(create.data)}`); + const id = subscriptionId(create.data); + assert(id !== undefined, `Missing subscriptionId in response: ${JSON.stringify(create.data)}`); + + const listed = await api('GET', `${ws()}/webhooks`, TOKEN_ALPHA); + assert(listed.status === 200, `Expected 200, got ${listed.status}: ${JSON.stringify(listed.data)}`); + const items = subscriptionItems(listed.data); + assert(items.some((item) => subscriptionId(item) === id), `Created subscription ${id} missing from list`); + + const deleted = await api('DELETE', `${ws()}/webhooks/${encodeURIComponent(id)}`, TOKEN_ALPHA); + assert(deleted.status === 204, `Expected 204, got ${deleted.status}: ${JSON.stringify(deleted.data)}`); + + const listedAfter = await api('GET', `${ws()}/webhooks`, TOKEN_ALPHA); + assert(listedAfter.status === 200, `Expected 200 after delete, got ${listedAfter.status}`); + const afterItems = subscriptionItems(listedAfter.data); + assert(!afterItems.some((item) => subscriptionId(item) === id), `Deleted subscription ${id} still listed`); + }); + + await test('SSRF rejection', async () => { + const loopback = await api('POST', `${ws()}/webhooks`, TOKEN_ALPHA, { + url: 'https://127.0.0.1/hook', + pathGlobs: ['/webhooks/ssrf/**'], + secret: 'conformance-webhook-secret', + }); + if (skipCloudOnlyWebhookRoute('SSRF rejection', loopback)) return; + assert(loopback.status === 400, `Expected 400 for loopback URL, got ${loopback.status}: ${JSON.stringify(loopback.data)}`); + assert(errorCode(loopback.data) === 'invalid_webhook_url', + `Expected invalid_webhook_url for loopback URL, got ${JSON.stringify(loopback.data)}`); + + const plaintext = await api('POST', `${ws()}/webhooks`, TOKEN_ALPHA, { + url: 'http://example.com/hook', + pathGlobs: ['/webhooks/ssrf/**'], + secret: 'conformance-webhook-secret', + }); + assert(plaintext.status === 400, `Expected 400 for non-https URL, got ${plaintext.status}: ${JSON.stringify(plaintext.data)}`); + assert(errorCode(plaintext.data) === 'invalid_webhook_url', + `Expected invalid_webhook_url for non-https URL, got ${JSON.stringify(plaintext.data)}`); + }); + + await test('Scope enforcement', async () => { + const tokenPathScoped = generateToken('agent-webhook-path-scoped', [ + 'relayfile:fs:write:/webhooks/scoped/**', + 'relayfile:webhooks:write:/webhooks/scoped/**', + ]); + const create = await api('POST', `${ws()}/webhooks`, tokenPathScoped, { + url: `https://example.com/relayfile/scoped/${Date.now()}`, + pathGlobs: ['/webhooks/scoped/**'], + secret: 'conformance-webhook-secret', + }); + if (skipCloudOnlyWebhookRoute('Scope enforcement', create)) return; + assert(create.status === 201, `Expected 201 for matching path-scoped token, got ${create.status}: ${JSON.stringify(create.data)}`); + const id = subscriptionId(create.data); + assert(id !== undefined, `Missing subscriptionId in scoped create response: ${JSON.stringify(create.data)}`); + + const listed = await api('GET', `${ws()}/webhooks`, tokenPathScoped); + assert(listed.status === 403, `Expected 403 for path-scoped list, got ${listed.status}: ${JSON.stringify(listed.data)}`); + + const deleted = await api('DELETE', `${ws()}/webhooks/${encodeURIComponent(id)}`, tokenPathScoped); + assert(deleted.status === 403, `Expected 403 for path-scoped delete, got ${deleted.status}: ${JSON.stringify(deleted.data)}`); + + const cleanup = await api('DELETE', `${ws()}/webhooks/${encodeURIComponent(id)}`, TOKEN_ALPHA); + assert(cleanup.status === 204, `Expected 204 for cleanup delete, got ${cleanup.status}: ${JSON.stringify(cleanup.data)}`); + }); + + await test('DLQ endpoint', async () => { + const r = await api('GET', `${ws()}/sync/dead-letter`, TOKEN_ALPHA); + assert(r.status === 200, `Expected 200, got ${r.status}: ${JSON.stringify(r.data)}`); + assert(Array.isArray(r.data?.items), `Expected items array, got ${JSON.stringify(r.data)}`); + }); +} + // --------------------------------------------------------------------------- // Main // --------------------------------------------------------------------------- async function main() { + if (HELP) { + console.log(`Relayfile API Conformance Suite + +Usage: + npx tsx scripts/conformance.ts [--ci] [--remote] [--help] + +Environment: + RELAYFILE_BASE_URL Base URL when using --remote + RELAYFILE_PORT Local server port when not using --remote +`); + return; + } + rs256Auth = await createLocalRs256Auth(); TOKEN_ALPHA = generateToken('agent-alpha', ALL_SCOPES); TOKEN_BETA = generateToken('agent-beta', ALL_SCOPES); diff --git a/scripts/e2e.ts b/scripts/e2e.ts index b2a0fc73..e68595da 100644 --- a/scripts/e2e.ts +++ b/scripts/e2e.ts @@ -9,6 +9,7 @@ * npx tsx scripts/e2e.ts # default (interactive) * npx tsx scripts/e2e.ts --ci # CI mode (shorter pauses) * npx tsx scripts/e2e.ts --continue-on-failure # keep running after failures + * npx tsx scripts/e2e.ts --webhook-receiver-url https://public-tunnel.example/hook */ import { execSync, spawn, ChildProcess } from 'node:child_process'; @@ -20,15 +21,26 @@ import { createLocalRs256Auth, type LocalRs256Auth } from './test-utils/rsa-sign // --------------------------------------------------------------------------- // Config // --------------------------------------------------------------------------- -const flags = new Set(process.argv.slice(2).filter((a) => a.startsWith('--'))); +const argv = process.argv.slice(2); +const flags = new Set(argv.filter((a) => a.startsWith('--')).map((a) => a.split('=', 1)[0])); const CI = flags.has('--ci') || !!process.env.CI; const CONTINUE_ON_FAILURE = flags.has('--continue-on-failure'); +const WEBHOOK_RECEIVER_URL = readArg('--webhook-receiver-url') || process.env.RELAYFILE_WEBHOOK_RECEIVER_URL || ''; const PORT = 9090; const BASE_URL = `http://127.0.0.1:${PORT}`; const WORKSPACE = 'e2e-test'; const DISABLE_SHARED_SECRET_JWT_ENV = `RELAYFILE_VERIFIER_ACCEPT_HS${256}`; +function readArg(name: string): string | undefined { + const prefix = `${name}=`; + const inline = argv.find((arg) => arg.startsWith(prefix)); + if (inline) return inline.slice(prefix.length); + const index = argv.indexOf(name); + if (index >= 0 && index + 1 < argv.length) return argv[index + 1]; + return undefined; +} + // --------------------------------------------------------------------------- // Terminal colors // --------------------------------------------------------------------------- @@ -129,6 +141,31 @@ async function api(method: string, path: string, body?: unknown, headers?: Recor return { status: resp.status, data }; } +async function waitForEventId( + filePath: string, + revision: string | undefined, + timeoutMs: number, + excludedEventIds: string[] = [], +): Promise { + const excluded = new Set(excludedEventIds); + const deadline = Date.now() + timeoutMs; + while (Date.now() < deadline) { + const resp = await api('GET', `/v1/workspaces/${WORKSPACE}/fs/events?limit=100`); + assert(resp.status === 200, `Events feed failed: ${resp.status}: ${JSON.stringify(resp.data)}`); + const events = Array.isArray(resp.data.events) ? resp.data.events : []; + const match = events.find((event: any) => + event?.path === filePath && + (!revision || event?.revision === revision) && + typeof event?.eventId === 'string' && + event.eventId.length > 0 && + !excluded.has(event.eventId) + ); + if (match) return match.eventId; + await sleep(250); + } + throw new Error(`Timed out waiting for event id for ${filePath}${revision ? ` at ${revision}` : ''}`); +} + function apiFileContent(data: { content?: string; encoding?: string }): string { const content = data.content ?? ''; if (data.encoding === 'base64') { @@ -567,6 +604,81 @@ ${B}${CYAN}╔══════════════════════ log('🔌', `WebSocket received ${events.length} event(s)`); }); + // ------------------------------------------------------------------ + // Webhook delivery smoke + // ------------------------------------------------------------------ + await run('Webhook delivery smoke', async () => { + step('Testing outbound webhook delivery'); + + // Deployed CF workers cannot reach localhost. CI/CD should pass + // --webhook-receiver-url with a public receiver/tunnel URL. The receiver + // process owns delivery capture and HMAC verification for that URL. + if (!WEBHOOK_RECEIVER_URL) { + log('⏭️ ', 'webhook delivery smoke skipped: no public receiver URL'); + return; + } + + let subscriptionId = ''; + try { + const secret = `e2e-webhook-secret-${Date.now()}`; + const pathGlob = `/webhooks/smoke-${Date.now()}-*`; + const hookUrl = WEBHOOK_RECEIVER_URL; + log('🪝', `Registering external webhook receiver ${hookUrl}`); + log('🔐', `Webhook receiver must verify deliveries with secret ${secret}`); + + const created = await api('POST', `/v1/workspaces/${WORKSPACE}/webhooks`, { + url: hookUrl, + pathGlobs: [pathGlob], + secret, + }); + assert(created.status === 201, `Register webhook failed: ${created.status}: ${JSON.stringify(created.data)}`); + subscriptionId = created.data?.subscriptionId || created.data?.id || ''; + assert(subscriptionId.length > 0, `Missing subscriptionId: ${JSON.stringify(created.data)}`); + + const filePath = `/webhooks/smoke-${Date.now()}-delivery.txt`; + const firstWrite = await api('PUT', `/v1/workspaces/${WORKSPACE}/fs/file?path=${encodeURIComponent(filePath)}`, { + contentType: 'text/plain', + content: 'webhook smoke', + }, { 'If-Match': '0' }); + assert(firstWrite.status === 200 || firstWrite.status === 201 || firstWrite.status === 202, + `First webhook write failed: ${firstWrite.status}: ${JSON.stringify(firstWrite.data)}`); + const firstEventId = firstWrite.data?.eventId || + await waitForEventId(filePath, firstWrite.data?.targetRevision, 10_000); + log('📨', `Expect external receiver delivery for ${firstEventId}`); + + const secondWrite = await api('PUT', `/v1/workspaces/${WORKSPACE}/fs/file?path=${encodeURIComponent(filePath)}`, { + contentType: 'text/plain', + content: 'webhook smoke', + }, { 'If-Match': '*' }); + assert(secondWrite.status === 200 || secondWrite.status === 201 || secondWrite.status === 202, + `Second webhook write failed: ${secondWrite.status}: ${JSON.stringify(secondWrite.data)}`); + const secondEventId = secondWrite.data?.eventId || + await waitForEventId(filePath, secondWrite.data?.targetRevision, 10_000, [firstEventId]); + assert(secondEventId !== firstEventId, `Expected distinct event IDs, both writes used ${firstEventId}`); + log('📨', `Expect external receiver delivery for ${secondEventId} after ${firstEventId}`); + + const deleted = await api('DELETE', `/v1/workspaces/${WORKSPACE}/webhooks/${encodeURIComponent(subscriptionId)}`); + assert(deleted.status === 204, `Delete webhook failed: ${deleted.status}: ${JSON.stringify(deleted.data)}`); + subscriptionId = ''; + + const thirdWrite = await api('PUT', `/v1/workspaces/${WORKSPACE}/fs/file?path=${encodeURIComponent(filePath)}`, { + contentType: 'text/plain', + content: 'webhook smoke after delete', + }, { 'If-Match': '*' }); + assert(thirdWrite.status === 200 || thirdWrite.status === 201 || thirdWrite.status === 202, + `Third webhook write failed: ${thirdWrite.status}: ${JSON.stringify(thirdWrite.data)}`); + const thirdEventId = thirdWrite.data?.eventId || + await waitForEventId(filePath, thirdWrite.data?.targetRevision, 10_000, [firstEventId, secondEventId]); + + await sleep(2_000); + log('🚫', `External receiver should not receive delivery for ${thirdEventId} after subscription deletion`); + } finally { + if (subscriptionId) { + await api('DELETE', `/v1/workspaces/${WORKSPACE}/webhooks/${encodeURIComponent(subscriptionId)}`).catch(() => {}); + } + } + }); + } finally { // ------------------------------------------------------------------ // Teardown