diff --git a/config.yaml.example b/config.yaml.example index 4b1243c..df8a2a3 100644 --- a/config.yaml.example +++ b/config.yaml.example @@ -500,6 +500,21 @@ tools: # 起動時に vapid_current_path に鍵が無ければ自動生成、mode 0600 で保存。 # 鍵をローテーションする場合: npm run vapid-rotate +# ── ワークスペース Webhook 通知 (Discord / Slack / Teams) ──────────────────── +# Spec: issue #797. PR1 では Discord のみ実装(Slack/Teams は後続 PR)。 +# 通知先 URL はワークスペースごとに設定 UI から登録し、DB に AES-256-GCM で +# 暗号化して保存する(config.yaml には書かない)。 +# webhooks: +# enabled: false # true で有効化。false のときは送信を no-op する +# timeout_ms: 10000 # 1 送信あたりの HTTP タイムアウト +# payload_max_bytes: 8192 # 送信 payload の上限(バイト) +# max_redirects: 5 # ssrfSafeFetch が辿るリダイレクト上限 +# queue_concurrency: 8 # 送信キューの同時実行数 +# retry: +# max_attempts: 2 # 429 / 5xx / timeout 時の再試行回数(初回 + 2 = 最大 3 回送信) +# backoff_ms: 500 # 再試行のベース backoff(指数バックオフ + jitter) +# public_base_url: "https://maestro.example.com" # 通知本文のタスクリンクに使う絶対 URL(未設定は相対パス) + # ── A2A OAuth2 Authorization Server ────────────────────────────────────────── # Requires auth to be active (consent screen uses req.user). # diff --git a/src/bridge/__snapshots__/server-route-order.test.ts.snap b/src/bridge/__snapshots__/server-route-order.test.ts.snap index 3000562..edf4be8 100644 --- a/src/bridge/__snapshots__/server-route-order.test.ts.snap +++ b/src/bridge/__snapshots__/server-route-order.test.ts.snap @@ -60,6 +60,7 @@ exports[`createCoreServer route registration order > pins the auth-active + Conf "ROUTE get /", "ROUTE get /api/version", "ROUTE get /api/repos", + "USE name=router path=^\\/api\\/?(?=\\/|$)", "ROUTE get /api/local/tasks", "ROUTE post /api/local/tasks", "ROUTE get /api/local/tasks/:taskId", @@ -178,6 +179,7 @@ exports[`createCoreServer route registration order > pins the no-auth minimal bo "ROUTE get /", "ROUTE get /api/version", "ROUTE get /api/repos", + "USE name=router path=^\\/api\\/?(?=\\/|$)", "ROUTE get /api/local/tasks", "ROUTE post /api/local/tasks", "ROUTE get /api/local/tasks/:taskId", diff --git a/src/bridge/a2a-subsystem.ts b/src/bridge/a2a-subsystem.ts index f9df483..4113bc3 100644 --- a/src/bridge/a2a-subsystem.ts +++ b/src/bridge/a2a-subsystem.ts @@ -10,6 +10,7 @@ import { createA2aOidcProvider, mountA2aOidc } from './a2a/oidc-provider.js'; import { createA2aClientsAdminRouter } from './a2a/a2a-clients-admin-api.js'; import { createA2aRouter, createSpaceA2aAdminRouter } from './a2a/a2a-api.js'; import { mountA2aRequestHandler } from './a2a/request-handler.js'; +import { A2aLimiter, resolveA2aLimits } from './a2a/limiter.js'; import { A2aTaskReconciler } from './a2a/reconciler.js'; import { createUserDelegationsRouter, createAdminDelegationsRouter } from './a2a/delegations-api.js'; @@ -50,10 +51,13 @@ export function setupA2aSubsystem( const a2aBaseUrl = (() => { try { return new URL(String(a2aCfg.resourceAudience)).origin; } catch { return String(a2aCfg.issuer); } })(); - app.use(createA2aRouter({ provider, repo, baseUrl: a2aBaseUrl, issuer: String(a2aCfg.issuer), version: '1.0.0' })); + // JSON-RPC と拡張カード(REST)の両経路で 1 grant のレート予算を共有するため、 + // limiter は 1 インスタンスをここで生成して両者に渡す。 + const a2aLimiter = new A2aLimiter(resolveA2aLimits(a2aCfg.limits)); + app.use(createA2aRouter({ provider, repo, baseUrl: a2aBaseUrl, issuer: String(a2aCfg.issuer), version: '1.0.0', limiter: a2aLimiter })); // JSON-RPC エンドポイント /a2a(SDK DefaultRequestHandler 経由) // /a2a は /api 配下でないため requireAuth に掛からない。認証は UserBuilder が担う。 - mountA2aRequestHandler(app, { provider, repo, baseUrl: a2aBaseUrl, issuer: String(a2aCfg.issuer), version: '1.0.0', limits: a2aCfg.limits }); + mountA2aRequestHandler(app, { provider, repo, baseUrl: a2aBaseUrl, issuer: String(a2aCfg.issuer), version: '1.0.0', limiter: a2aLimiter }); // bridge は単一プロセス前提。複数プロセスが同一 DB を共有する構成では // lease が必要になる(将来課題)。 const a2aReconciler = new A2aTaskReconciler({ repo }); diff --git a/src/bridge/a2a/a2a-api.ts b/src/bridge/a2a/a2a-api.ts index ea572cb..4bc39e6 100644 --- a/src/bridge/a2a/a2a-api.ts +++ b/src/bridge/a2a/a2a-api.ts @@ -16,6 +16,7 @@ import { computeEffectiveScope } from './delegation.js'; import { canManageSpace } from '../visibility.js'; import { viewerOf } from '../space-viewer.js'; import { logger } from '../../logger.js'; +import type { A2aLimiter } from './limiter.js'; export interface A2aRouterDeps { provider: Provider; @@ -23,6 +24,12 @@ export interface A2aRouterDeps { baseUrl: string; issuer: string; version: string; + /** + * 共有レート limiter(任意)。指定時、extended card ルートは認証直後・scope 計算前に + * grant 単位のレートを消費し、超過なら 429 を返す(scope 計算も監査書き込みもしない)。 + * JSON-RPC 側の getAuthenticatedExtendedAgentCard と同一 limiter を共有する想定。 + */ + limiter?: A2aLimiter; } /** @@ -61,6 +68,15 @@ export function createA2aRouter(deps: A2aRouterDeps): Router { return; } + // レート制限(limiter 注入時のみ): 認証直後・scope 計算前に消費する。超過時は + // 429 を返し、以降の DB 読み込みも監査書き込みも行わない(書き込み増幅を防ぐ)。 + if (deps.limiter && !deps.limiter.tryConsumeRate(principal.grantId)) { + // 行儀の良いクライアントのバックオフ用に概算の待機秒数を返す。 + const retryAfter = Math.max(1, Math.ceil(60 / deps.limiter.limits.ratePerMinute)); + res.status(429).set('Retry-After', String(retryAfter)).json({ error: 'rate limit exceeded' }); + return; + } + // acting user の可視スペース・スキルを delegation の AND 交差で算出(IDOR 防止) const scope = await computeEffectiveScope(deps.repo, principal); if (!scope) { diff --git a/src/bridge/a2a/a2a-card-e2e.test.ts b/src/bridge/a2a/a2a-card-e2e.test.ts index a172eb6..2eb8859 100644 --- a/src/bridge/a2a/a2a-card-e2e.test.ts +++ b/src/bridge/a2a/a2a-card-e2e.test.ts @@ -10,6 +10,8 @@ import { join } from 'path'; import { Repository } from '../../db/repository.js'; import { createA2aOidcProvider, mountA2aOidc } from './oidc-provider.js'; import { createA2aRouter } from './a2a-api.js'; +import { mountA2aRequestHandler } from './request-handler.js'; +import { A2aLimiter, resolveA2aLimits } from './limiter.js'; import { b64url, Client } from './__e2e-helpers.js'; /** @@ -86,14 +88,26 @@ describe('A2A card e2e (real token → scoped extended card + negative paths)', // テスト専用: http/127.0.0.1 で cookie を non-secure にして cookie jar が機能するようにする。 (provider as any).proxy = false; - // 実 provider + consent を /oidc に、A2A card ルータを同じ app にマウント。 + // 実 provider + consent を /oidc に、A2A card ルータ + JSON-RPC ハンドラを同じ app に + // マウント。両者に「同一」limiter を渡すことで a2a-subsystem の共有配線を再現する。 + // ratePerMinute:2 は各テストが 1 grant あたり最大 2 回しか叩かないため既存ケースに + // 影響しない(obtainToken ごとに新しい grantId=新しいバケット)。 + const sharedLimiter = new A2aLimiter(resolveA2aLimits({ ratePerMinute: 2 })); mountA2aOidc(app, provider, { repo, resourceAudience: audience }); app.use(createA2aRouter({ provider, repo, baseUrl: base, issuer: `${base}/oidc`, version: '1.0.0-test', + limiter: sharedLimiter, })); + mountA2aRequestHandler(app, { + provider, repo, + baseUrl: base, + issuer: `${base}/oidc`, + version: '1.0.0-test', + limiter: sharedLimiter, + }); }); afterAll(() => { server?.close(); }); @@ -216,6 +230,53 @@ describe('A2A card e2e (real token → scoped extended card + negative paths)', expect(ids).not.toContain('summarize'); }); + it('rate limit: extended card throttles past ratePerMinute and writes no audit row on 429', async () => { + // Fresh grant → fresh token-bucket seeded to ratePerMinute:2. Third call on the same + // token exhausts it. The 429 must be enforced BEFORE the scope computation + audit write, + // so the audit_log count for a2a.card.extended must not grow across the throttled call. + const { accessToken } = await obtainToken(spaceId, 'research'); + const hdr = { authorization: `Bearer ${accessToken}` }; + + const r1 = await fetch(`${base}/a2a/agent-card/extended`, { headers: hdr }); + const r2 = await fetch(`${base}/a2a/agent-card/extended`, { headers: hdr }); + expect(r1.status).toBe(200); + expect(r2.status).toBe(200); + + const auditBefore = (repo as any).db + .prepare("SELECT COUNT(*) AS n FROM audit_log WHERE action = 'a2a.card.extended'") + .get().n as number; + + const r3 = await fetch(`${base}/a2a/agent-card/extended`, { headers: hdr }); + expect(r3.status).toBe(429); + + const auditAfter = (repo as any).db + .prepare("SELECT COUNT(*) AS n FROM audit_log WHERE action = 'a2a.card.extended'") + .get().n as number; + expect(auditAfter).toBe(auditBefore); // throttled request did not touch the DB + }); + + it('shared limiter: REST card drain throttles the JSON-RPC extended-card RPC on the same grant', async () => { + // Core invariant of this change: the REST route and the JSON-RPC handler share ONE limiter + // (mirrors a2a-subsystem wiring). Draining the grant's bucket via 2 REST calls must throttle + // the RPC surface too. If each surface held its own bucket, the RPC would still have 2 tokens + // and return a result — so a `rate limit` error here proves the buckets are shared. + const { accessToken } = await obtainToken(spaceId, 'research'); + const hdr = { authorization: `Bearer ${accessToken}` }; + + expect((await fetch(`${base}/a2a/agent-card/extended`, { headers: hdr })).status).toBe(200); + expect((await fetch(`${base}/a2a/agent-card/extended`, { headers: hdr })).status).toBe(200); + // bucket for this grant is now empty — the RPC surface must observe it + + const rpcRes = await fetch(`${base}/a2a`, { + method: 'POST', + headers: { ...hdr, 'content-type': 'application/json' }, + body: JSON.stringify({ jsonrpc: '2.0', id: 1, method: 'agent/getAuthenticatedExtendedCard' }), + }); + const body = await rpcRes.json() as { result?: unknown; error?: { code: number; message: string } }; + expect(body.error).toBeDefined(); // NOT a successful card result + expect(body.error!.message).toMatch(/rate limit/); // shared bucket already drained by REST + }); + it('cross-space AND-intersection: non-granted space skills are absent from extended card', async () => { // space-a(research)と space-b(analyze)が両方ユーザーから見えるが、 // space-a のみに同意 → resolveEffectiveSpaces が space-b を AND 交差で排除し、 diff --git a/src/bridge/a2a/request-handler-auth-gate.test.ts b/src/bridge/a2a/request-handler-auth-gate.test.ts index 8d93054..3d515e8 100644 --- a/src/bridge/a2a/request-handler-auth-gate.test.ts +++ b/src/bridge/a2a/request-handler-auth-gate.test.ts @@ -423,6 +423,63 @@ describe('AuthGatedRequestHandler — fail-closed on missing grantId', () => { }); }); +// ─── getAuthenticatedExtendedAgentCard: auth + rate (extended-card metering) ── +// The RPC surface for the authenticated extended card. Like getTask/cancelTask it must +// require auth, fail-closed on a missing grantId, and consume a rate token per call so an +// authenticated grant cannot poll the scope-computing card endpoint unmetered. + +describe('AuthGatedRequestHandler.getAuthenticatedExtendedAgentCard', () => { + const NO_GRANT_CTX = new ServerCallContext( + undefined, + new A2aAuthenticatedUser({ ...FAKE_PRINCIPAL, grantId: undefined } as any), + ); + // Provider is a function → SDK returns its result. base card carries + // supportsAuthenticatedExtendedCard:true so the SDK reaches the provider. + const extendedCardProvider = async () => buildBaseCard(BASE_DEPS); + + function makeExtHandler(limiter?: A2aLimiter) { + const store = new InMemoryTaskStore(); + const fakeExecutor = { execute: async () => {} } as any; + const baseCard = buildBaseCard(BASE_DEPS); + return new AuthGatedRequestHandler(baseCard, store, fakeExecutor, extendedCardProvider, limiter); + } + + it('rejects UnauthenticatedUser with INVALID_REQUEST (-32600)', async () => { + const h = makeExtHandler(); + await expect(h.getAuthenticatedExtendedAgentCard(UNAUTH_CTX)) + .rejects.toMatchObject({ code: -32600 }); + }); + + it('authenticated within rate returns the extended card', async () => { + const h = makeExtHandler(new A2aLimiter(resolveA2aLimits({ ratePerMinute: 5 }))); + const card = await h.getAuthenticatedExtendedAgentCard(AUTH_CTX); + expect(card).toBeDefined(); + }); + + it('second call past rate=1 throws A2AError (rate limit exceeded)', async () => { + // Frozen clock → no token refill between the two calls (deterministic, no wall-clock flake). + const h = makeExtHandler(new A2aLimiter(resolveA2aLimits({ ratePerMinute: 1 }), () => 1000)); + // First call must SUCCEED (drains the single token) — assert it, don't swallow it. + await expect(h.getAuthenticatedExtendedAgentCard(AUTH_CTX)).resolves.toBeDefined(); + const err = await h.getAuthenticatedExtendedAgentCard(AUTH_CTX).catch(e => e); + expect(err).toBeInstanceOf(A2AError); + expect(err.code).toBe(-32600); + expect(err.message).toMatch(/rate limit/); + }); + + it('fail-closed: authenticated caller with no grantId denied when limiter active', async () => { + const h = makeExtHandler(new A2aLimiter(resolveA2aLimits({ ratePerMinute: 100 }))); + await expect(h.getAuthenticatedExtendedAgentCard(NO_GRANT_CTX)) + .rejects.toMatchObject({ code: -32600 }); + }); + + it('without a limiter the card is unmetered (auth passes, no rate error)', async () => { + const h = makeExtHandler(); + const card = await h.getAuthenticatedExtendedAgentCard(AUTH_CTX); + expect(card).toBeDefined(); + }); +}); + // ─── E2E: tasks/get via HTTP → JSON-RPC error when unauthenticated ─────────── describe('mountA2aRequestHandler – tasks/get auth gate (E2E)', () => { diff --git a/src/bridge/a2a/request-handler.ts b/src/bridge/a2a/request-handler.ts index 48d30f9..02804de 100644 --- a/src/bridge/a2a/request-handler.ts +++ b/src/bridge/a2a/request-handler.ts @@ -29,6 +29,12 @@ export interface MountA2aRequestHandlerDeps { issuer: string; version: string; limits?: Partial; + /** + * 共有 limiter。サブシステムが REST カードルータと同一インスタンスを渡すことで、 + * 1 grant のレート予算が JSON-RPC と拡張カード両経路で共有される。未指定時は + * `limits` から生成(後方互換: limiter を渡さない既存呼び出し・テスト向け)。 + */ + limiter?: A2aLimiter; } type CardDeps = { baseUrl: string; issuer: string; version: string }; @@ -124,6 +130,20 @@ export class AuthGatedRequestHandler extends DefaultRequestHandler { return super.cancelTask(params, context); } + /** + * 認証済み拡張カード RPC。scope 計算(DB 読み)を伴うため getTask 同様に + * auth → grantId(fail-closed) → rate の順でメータリングする。REST 経路 + * (`GET /a2a/agent-card/extended`) の同等ゲートと対になる(両者は同一 limiter を共有)。 + */ + override async getAuthenticatedExtendedAgentCard(context?: ServerCallContext) { + this.requireAuthenticated(context); + const grantId = this.meteredGrantId(context); + if (this.limiter && !this.limiter.tryConsumeRate(grantId!)) { + throw A2AError.invalidRequest('rate limit exceeded'); + } + return super.getAuthenticatedExtendedAgentCard(context); + } + /** * 認証ゲートを同期的に実行してから super.resubscribe() を返す。 * 同期 throw にすることで、JsonRpcTransportHandler がジェネレータを取得する前に @@ -271,8 +291,8 @@ export function makeExtendedCardProvider( * 認証は makeA2aUserBuilder(UserBuilder)が担う。 */ export function mountA2aRequestHandler(app: Express, deps: MountA2aRequestHandlerDeps): void { - const limits = resolveA2aLimits(deps.limits); - const limiter = new A2aLimiter(limits); + const limiter = deps.limiter ?? new A2aLimiter(resolveA2aLimits(deps.limits)); + const limits = limiter.limits; const taskStore = new SqliteA2aTaskStore(deps.repo); const executor = new MaestroA2aExecutor(deps.repo, { limiter }); const cardDeps: CardDeps = { diff --git a/src/bridge/server.ts b/src/bridge/server.ts index d18b96f..8175f5f 100644 --- a/src/bridge/server.ts +++ b/src/bridge/server.ts @@ -97,6 +97,9 @@ export interface CoreServerOptions { pushService?: import('../push-service.js').PushService | null; /** Notifications V2 — VAPID key store. Required when pushService is set. */ vapidStore?: import('../vapid-store.js').VapidKeyStore | null; + /** Workspace webhook notifications (issue #797). null disables test-send + * (route returns 503) but the CRUD routes remain mounted. */ + webhookService?: import('../webhook-service.js').WebhookDeliveryService | null; /** * TCP port the bridge will listen on. Forwarded to admin endpoints * (e.g. /api/admin/gateway/status) so the UI can label the gateway @@ -465,6 +468,7 @@ export function createCoreServer(opts: CoreServerOptions): { worktreeDir: loadConfig().worktreeDir ?? './data/worktrees', authActive, getPythonPackagesConfig: () => loadConfig().pythonPackages, + webhookService: opts.webhookService ?? null, })); // BackendStatusRegistry singleton — probes every configured worker diff --git a/src/bridge/setup-api.test.ts b/src/bridge/setup-api.test.ts index 1149a9c..b3c40e7 100644 --- a/src/bridge/setup-api.test.ts +++ b/src/bridge/setup-api.test.ts @@ -118,6 +118,19 @@ describe('setup-api: GET /api/setup/status', () => { expect(res.body.authActive).toBe(true); expect(res.body.tokenRequired).toBe(false); }); + + it('a2aEnabled=false by default (no a2a section)', async () => { + const { app } = makeApp(UNCONFIGURED); + const res = await request(app).get('/api/setup/status'); + // The UI hides the per-user A2A Delegations settings section unless this is true. + expect(res.body.a2aEnabled).toBe(false); + }); + + it('a2aEnabled=true when a2a.enabled is set in config', async () => { + const { app } = makeApp('config_version: 2\na2a:\n enabled: true\n'); + const res = await request(app).get('/api/setup/status'); + expect(res.body.a2aEnabled).toBe(true); + }); }); describe('setup-api: token gate on /api/setup/apply', () => { diff --git a/src/bridge/setup-api.ts b/src/bridge/setup-api.ts index 25f18d3..30e361d 100644 --- a/src/bridge/setup-api.ts +++ b/src/bridge/setup-api.ts @@ -302,10 +302,15 @@ export function mountSetupApi( try { const needsSetup = await computeNeedsSetup(configManager); const tokenRequired = !authActive && readSetupToken(dataDir) !== null; - res.json({ needsSetup, authActive, port: listenPort, deployHint, tokenRequired }); + // a2a is off by default and mounts its /api/local/a2a/delegations route only + // when enabled. Expose the flag so the UI can hide the per-user A2A Delegations + // settings section instead of showing a load error against the missing route. + const a2aEnabled = ((configManager.getConfig() as { a2a?: { enabled?: boolean } })?.a2a?.enabled) === true; + res.json({ needsSetup, authActive, port: listenPort, deployHint, tokenRequired, a2aEnabled }); } catch (e) { logger.warn(`[setup-api] status failed: ${String(e)}`); - res.status(500).json({ needsSetup: false, authActive, error: 'status unavailable' }); + // Fail-closed: on error the UI treats a2a as disabled and hides the section. + res.status(500).json({ needsSetup: false, authActive, a2aEnabled: false, error: 'status unavailable' }); } }); diff --git a/src/bridge/space-api.ts b/src/bridge/space-api.ts index 806a53b..bf37598 100644 --- a/src/bridge/space-api.ts +++ b/src/bridge/space-api.ts @@ -11,6 +11,7 @@ import { registerSpaceInviteRoutes } from './space-invite-api.js'; import { registerSpaceAppShareRoutes } from './space-app-share-api.js'; import { registerSpaceToolPolicyRoutes } from './space-tool-policy-api.js'; import { registerSpacePythonPackagesRoutes } from './space-python-packages-api.js'; +import { registerSpaceWebhooksRoutes } from './space-webhooks-api.js'; import { viewerOf } from './space-viewer.js'; import { logger } from '../logger.js'; import { sendOfficePreview, handleOfficePreviewError } from './office-preview.js'; @@ -39,6 +40,8 @@ export interface SpaceApiDeps { uploadLimitMb?: number; /** Per-space Python packages のライブ config(loadConfig() を都度読む getter)。未配線 = 無効扱い。 */ getPythonPackagesConfig?: () => import('../config.js').PythonPackagesConfigShape | undefined; + /** ワークスペース Webhook 通知(issue #797)— test-send 実行用。null/未配線 = 501/503 応答。 */ + webhookService?: import('../webhook-service.js').WebhookDeliveryService | null; } /** @@ -186,6 +189,9 @@ export function createSpaceApi(deps: SpaceApiDeps): Router { // Python パッケージ管理 GET/POST/DELETE は space-python-packages-api.ts に分離。 registerSpacePythonPackagesRoutes(router, deps); + // Webhook 通知(Discord/Slack/Teams)CRUD + test-send は space-webhooks-api.ts に分離。 + registerSpaceWebhooksRoutes(router, deps); + // ─── スペース間資格情報コピー ────────────────────────────────────── // 両スペースの管理権(canManageSpace)を持つユーザーのみ利用可能。 // 成功時に audit_log へ記録する。 diff --git a/src/bridge/space-webhooks-api.test.ts b/src/bridge/space-webhooks-api.test.ts new file mode 100644 index 0000000..aa7bc0b --- /dev/null +++ b/src/bridge/space-webhooks-api.test.ts @@ -0,0 +1,337 @@ +/** + * Multi-user integration tests for /api/local/spaces/:id/webhooks. + * + * Auth pattern mirrors space-api.tool-policy.test.ts: authActive=true + + * middleware that sets req.user = . Roles: manager (space + * owner), member (viewer-role member), stranger (no relation). + * + * SECURITY: several tests specifically assert the webhook URL never appears + * in a response body (no `url`, no `url_enc`, no plaintext-URL-looking + * substring) — the entire point of issue #797's non-disclosure requirement. + */ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { mkdtempSync, rmSync } from 'node:fs'; +import { join } from 'node:path'; +import { tmpdir } from 'node:os'; +import { randomUUID, randomBytes } from 'node:crypto'; +import express from 'express'; +import request from 'supertest'; +import { Repository } from '../db/repository.js'; +import { createSpaceApi } from './space-api.js'; +import type { SendResult } from '../webhook-service.js'; + +const DISCORD_URL = 'https://discord.com/api/webhooks/1234567890/super-secret-token-abcXYZ'; + +function freshSetup() { + const dir = mkdtempSync(join(tmpdir(), 'webhooks-api-test-')); + const repo = new Repository(join(dir, `${randomUUID()}.db`)); + return { repo, dir }; +} + +interface FakeWebhookService { + sendTest: (id: string) => Promise; +} + +function makeApp(repo: Repository, currentUser: Express.User, webhookService?: FakeWebhookService | null) { + const app = express(); + app.use((req: any, _res: any, next: any) => { + req.user = currentUser; + next(); + }); + app.use( + '/', + createSpaceApi({ + repo, + dataRoot: tmpdir(), + worktreeDir: tmpdir(), + authActive: true, + webhookService: webhookService as never, + }), + ); + return app; +} + +async function seed(repo: Repository) { + const manager = repo.createUser({ email: 'manager@example.com', name: 'Manager', role: 'user', status: 'active' }); + const member = repo.createUser({ email: 'member@example.com', name: 'Member', role: 'user', status: 'active' }); + const stranger = repo.createUser({ email: 'stranger@example.com', name: 'Stranger', role: 'user', status: 'active' }); + + const space = await repo.createSpace({ kind: 'case', title: 'Test Space', ownerId: manager.id, visibility: 'public' }); + await repo.addSpaceMember({ spaceId: space.id, userId: member.id, role: 'viewer', invitedBy: manager.id }); + + return { manager, member, stranger, space }; +} + +function asExpressUser(user: { id: string; role: 'admin' | 'user' }): Express.User { + return { id: user.id, role: user.role, orgIds: [] } as unknown as Express.User; +} + +describe('space-webhooks-api', () => { + let repo: Repository; + let dir: string; + let fixtures: Awaited>; + + beforeEach(async () => { + process.env.MCP_ENCRYPTION_KEY = randomBytes(32).toString('hex'); + ({ repo, dir } = freshSetup()); + fixtures = await seed(repo); + }); + + afterEach(() => { + repo.close(); + rmSync(dir, { recursive: true, force: true }); + }); + + async function createWebhook(app: express.Express, overrides: Record = {}) { + return request(app) + .post(`/${fixtures.space.id}/webhooks`) + .send({ provider: 'discord', label: 'Dev', url: DISCORD_URL, events: ['succeeded', 'failed'], ...overrides }); + } + + // ── GET (list) — any viewer ───────────────────────────────────────── + + describe('GET /:id/webhooks', () => { + it('manager sees an empty list initially', async () => { + const app = makeApp(repo, asExpressUser(fixtures.manager)); + const res = await request(app).get(`/${fixtures.space.id}/webhooks`); + expect(res.status).toBe(200); + expect(res.body.webhooks).toEqual([]); + }); + + it('member (viewer) can GET the list', async () => { + const managerApp = makeApp(repo, asExpressUser(fixtures.manager)); + await createWebhook(managerApp); + const memberApp = makeApp(repo, asExpressUser(fixtures.member)); + const res = await request(memberApp).get(`/${fixtures.space.id}/webhooks`); + expect(res.status).toBe(200); + expect(res.body.webhooks).toHaveLength(1); + }); + + it('stranger on a private space gets 404', async () => { + const priv = await repo.createSpace({ kind: 'case', title: 'Priv', ownerId: fixtures.manager.id, visibility: 'private' }); + const app = makeApp(repo, asExpressUser(fixtures.stranger)); + const res = await request(app).get(`/${priv.id}/webhooks`); + expect(res.status).toBe(404); + }); + + it('response never contains url, url_enc, or a plaintext-URL-looking string', async () => { + const managerApp = makeApp(repo, asExpressUser(fixtures.manager)); + await createWebhook(managerApp); + const res = await request(managerApp).get(`/${fixtures.space.id}/webhooks`); + expect(res.status).toBe(200); + const raw = JSON.stringify(res.body); + expect(res.body.webhooks[0]).not.toHaveProperty('url'); + expect(res.body.webhooks[0]).not.toHaveProperty('urlEnc'); + expect(res.body.webhooks[0]).not.toHaveProperty('url_enc'); + expect(raw).not.toContain('discord.com/api/webhooks'); + expect(raw).not.toContain('super-secret-token'); + }); + }); + + // ── POST — canManageSpace only ────────────────────────────────────── + + describe('POST /:id/webhooks', () => { + it('manager (owner) can create a webhook; response has no url field', async () => { + const app = makeApp(repo, asExpressUser(fixtures.manager)); + const res = await createWebhook(app); + expect(res.status).toBe(201); + expect(res.body).not.toHaveProperty('url'); + expect(res.body).not.toHaveProperty('urlEnc'); + expect(res.body.label).toBe('Dev'); + expect(res.body.events).toEqual(['succeeded', 'failed']); + expect(res.body.enabled).toBe(true); + expect(res.body.failureCount).toBe(0); + expect(JSON.stringify(res.body)).not.toContain('discord.com/api/webhooks'); + }); + + it('member (viewer-role) receives 403', async () => { + const app = makeApp(repo, asExpressUser(fixtures.member)); + const res = await createWebhook(app); + expect(res.status).toBe(403); + }); + + it('stranger on a public space receives 403', async () => { + const app = makeApp(repo, asExpressUser(fixtures.stranger)); + const res = await createWebhook(app); + expect(res.status).toBe(403); + }); + + it('rejects a non-https url with 400', async () => { + const app = makeApp(repo, asExpressUser(fixtures.manager)); + const res = await createWebhook(app, { url: 'http://discord.com/api/webhooks/1/2' }); + expect(res.status).toBe(400); + }); + + it('rejects a provider name outside the known enum with 400', async () => { + // As of PR3 (Teams), all three WebhookProvider values (discord/slack/ + // teams) have working adapters, so there's no more "known but not yet + // supported" provider to exercise IMPLEMENTED_PROVIDERS with. This now + // covers the KNOWN_PROVIDERS enum check itself via a made-up name. + const app = makeApp(repo, asExpressUser(fixtures.manager)); + const res = await createWebhook(app, { provider: 'webex' }); + expect(res.status).toBe(400); + expect(res.body.error).toMatch(/must be one of/); + }); + + it('accepts provider=slack and creates a webhook; response has no url field', async () => { + const app = makeApp(repo, asExpressUser(fixtures.manager)); + const res = await createWebhook(app, { + provider: 'slack', + url: 'https://hooks.slack.com/services/T00000000/B00000000/XXXXXXXXXXXXXXXXXXXXXXXX', + }); + expect(res.status).toBe(201); + expect(res.body.provider).toBe('slack'); + expect(res.body).not.toHaveProperty('url'); + expect(res.body).not.toHaveProperty('urlEnc'); + expect(JSON.stringify(res.body)).not.toContain('hooks.slack.com'); + }); + + it('accepts provider=teams and creates a webhook; response has no url field', async () => { + const app = makeApp(repo, asExpressUser(fixtures.manager)); + const res = await createWebhook(app, { + provider: 'teams', + url: 'https://example.webhook.office.com/webhookb2/00000000-0000-0000-0000-000000000000@tenant/IncomingWebhook/abcdef/00000000', + }); + expect(res.status).toBe(201); + expect(res.body.provider).toBe('teams'); + expect(res.body).not.toHaveProperty('url'); + expect(res.body).not.toHaveProperty('urlEnc'); + expect(JSON.stringify(res.body)).not.toContain('webhook.office.com'); + }); + + it('rejects an empty events array with 400', async () => { + const app = makeApp(repo, asExpressUser(fixtures.manager)); + const res = await createWebhook(app, { events: [] }); + expect(res.status).toBe(400); + }); + + it('rejects an empty label with 400', async () => { + const app = makeApp(repo, asExpressUser(fixtures.manager)); + const res = await createWebhook(app, { label: ' ' }); + expect(res.status).toBe(400); + }); + }); + + // ── PUT — canManageSpace only ─────────────────────────────────────── + + describe('PUT /:id/webhooks/:webhookId', () => { + it('manager can update label/events; GET reflects the change', async () => { + const app = makeApp(repo, asExpressUser(fixtures.manager)); + const created = await createWebhook(app); + const put = await request(app) + .put(`/${fixtures.space.id}/webhooks/${created.body.id}`) + .send({ label: 'Renamed', events: ['waiting_human'] }); + expect(put.status).toBe(200); + expect(put.body.label).toBe('Renamed'); + expect(put.body.events).toEqual(['waiting_human']); + expect(put.body).not.toHaveProperty('url'); + }); + + it('member receives 403 on PUT', async () => { + const managerApp = makeApp(repo, asExpressUser(fixtures.manager)); + const created = await createWebhook(managerApp); + const memberApp = makeApp(repo, asExpressUser(fixtures.member)); + const res = await request(memberApp) + .put(`/${fixtures.space.id}/webhooks/${created.body.id}`) + .send({ label: 'Hacked' }); + expect(res.status).toBe(403); + }); + + it('URL re-entry via PUT does not echo the new URL back', async () => { + const app = makeApp(repo, asExpressUser(fixtures.manager)); + const created = await createWebhook(app); + const newUrl = 'https://discord.com/api/webhooks/999/new-secret-token'; + const put = await request(app) + .put(`/${fixtures.space.id}/webhooks/${created.body.id}`) + .send({ url: newUrl }); + expect(put.status).toBe(200); + expect(JSON.stringify(put.body)).not.toContain('new-secret-token'); + }); + }); + + // ── PATCH — enable/disable ────────────────────────────────────────── + + describe('PATCH /:id/webhooks/:webhookId', () => { + it('manager can disable then re-enable', async () => { + const app = makeApp(repo, asExpressUser(fixtures.manager)); + const created = await createWebhook(app); + const disable = await request(app).patch(`/${fixtures.space.id}/webhooks/${created.body.id}`).send({ enabled: false }); + expect(disable.status).toBe(200); + expect(disable.body.enabled).toBe(false); + + const enable = await request(app).patch(`/${fixtures.space.id}/webhooks/${created.body.id}`).send({ enabled: true }); + expect(enable.status).toBe(200); + expect(enable.body.enabled).toBe(true); + }); + + it('member receives 403', async () => { + const managerApp = makeApp(repo, asExpressUser(fixtures.manager)); + const created = await createWebhook(managerApp); + const memberApp = makeApp(repo, asExpressUser(fixtures.member)); + const res = await request(memberApp).patch(`/${fixtures.space.id}/webhooks/${created.body.id}`).send({ enabled: false }); + expect(res.status).toBe(403); + }); + }); + + // ── DELETE ─────────────────────────────────────────────────────────── + + describe('DELETE /:id/webhooks/:webhookId', () => { + it('manager can delete', async () => { + const app = makeApp(repo, asExpressUser(fixtures.manager)); + const created = await createWebhook(app); + const del = await request(app).delete(`/${fixtures.space.id}/webhooks/${created.body.id}`); + expect(del.status).toBe(200); + const list = await request(app).get(`/${fixtures.space.id}/webhooks`); + expect(list.body.webhooks).toHaveLength(0); + }); + + it('member receives 403', async () => { + const managerApp = makeApp(repo, asExpressUser(fixtures.manager)); + const created = await createWebhook(managerApp); + const memberApp = makeApp(repo, asExpressUser(fixtures.member)); + const res = await request(memberApp).delete(`/${fixtures.space.id}/webhooks/${created.body.id}`); + expect(res.status).toBe(403); + }); + }); + + // ── POST /:id/webhooks/:webhookId/test ────────────────────────────── + + describe('POST /:id/webhooks/:webhookId/test', () => { + it('manager can trigger a test send (service reports success)', async () => { + const sendTest = vi.fn(async (): Promise => ({ ok: true })); + const app = makeApp(repo, asExpressUser(fixtures.manager), { sendTest }); + const created = await createWebhook(app); + const res = await request(app).post(`/${fixtures.space.id}/webhooks/${created.body.id}/test`); + expect(res.status).toBe(200); + expect(res.body.ok).toBe(true); + expect(sendTest).toHaveBeenCalledWith(created.body.id); + }); + + it('reports failure from the service without leaking anything sensitive', async () => { + const sendTest = vi.fn(async (): Promise => ({ ok: false, error: 'webhook rejected (404)' })); + const app = makeApp(repo, asExpressUser(fixtures.manager), { sendTest }); + const created = await createWebhook(app); + const res = await request(app).post(`/${fixtures.space.id}/webhooks/${created.body.id}/test`); + expect(res.status).toBe(502); + expect(res.body.ok).toBe(false); + expect(JSON.stringify(res.body)).not.toContain('discord.com/api/webhooks'); + }); + + it('returns 503 when webhook delivery is disabled (no service configured)', async () => { + const app = makeApp(repo, asExpressUser(fixtures.manager), null); + const created = await createWebhook(app); + const res = await request(app).post(`/${fixtures.space.id}/webhooks/${created.body.id}/test`); + expect(res.status).toBe(503); + }); + + it('member receives 403 (test-send requires canManageSpace)', async () => { + const sendTest = vi.fn(async (): Promise => ({ ok: true })); + const managerApp = makeApp(repo, asExpressUser(fixtures.manager), { sendTest }); + const created = await createWebhook(managerApp); + const memberApp = makeApp(repo, asExpressUser(fixtures.member), { sendTest }); + const res = await request(memberApp).post(`/${fixtures.space.id}/webhooks/${created.body.id}/test`); + expect(res.status).toBe(403); + expect(sendTest).not.toHaveBeenCalled(); + }); + }); +}); diff --git a/src/bridge/space-webhooks-api.ts b/src/bridge/space-webhooks-api.ts new file mode 100644 index 0000000..40e5fd6 --- /dev/null +++ b/src/bridge/space-webhooks-api.ts @@ -0,0 +1,272 @@ +import express, { Router } from 'express'; +import { canManageSpace } from './visibility.js'; +import { viewerOf } from './space-viewer.js'; +import { logger } from '../logger.js'; +import { encrypt, loadKeyFromEnv } from '../mcp/crypto.js'; +import type { SpaceApiDeps } from './space-api.js'; +import type { SpaceWebhookRecord, WebhookEventType, WebhookProvider } from '../db/repository.js'; + +/** + * Per-space webhook notification CRUD + test-send (issue #797, PR1). + * + * GET /:id/webhooks — viewer: list (masked DTO) + * POST /:id/webhooks — canManageSpace: create + * PUT /:id/webhooks/:webhookId — canManageSpace: update (label/url/events/includeDetails) + * PATCH /:id/webhooks/:webhookId — canManageSpace: enable/disable + * DELETE /:id/webhooks/:webhookId — canManageSpace: delete + * POST /:id/webhooks/:webhookId/test — canManageSpace: send a test notification + * + * SECURITY: the response DTO (toPublicWebhook) NEVER includes `url` or + * `url_enc` — the webhook URL is write-only. Once saved it cannot be + * re-displayed; changing it means re-entering the full URL (PUT). + */ + +const KNOWN_PROVIDERS: WebhookProvider[] = ['discord', 'slack', 'teams']; +// PR1 implemented Discord delivery, PR2 added Slack, PR3 adds Teams — every +// WebhookProvider value now has a working adapter (see webhook-service.ts's +// ADAPTERS map). IMPLEMENTED_PROVIDERS is kept as a separate list (rather +// than collapsed into KNOWN_PROVIDERS) so a future provider can be added to +// the schema/type first and rejected here until its adapter ships. +const IMPLEMENTED_PROVIDERS: WebhookProvider[] = ['discord', 'slack', 'teams']; +const KNOWN_EVENTS: WebhookEventType[] = ['succeeded', 'failed', 'waiting_human']; +const MAX_URL_LENGTH = 2048; +const MAX_LABEL_LENGTH = 200; + +function toPublicWebhook(w: SpaceWebhookRecord) { + return { + id: w.id, + provider: w.provider, + label: w.label, + events: w.events, + includeDetails: w.includeDetails, + enabled: w.enabled, + disabledReason: w.disabledReason, + lastSuccessAt: w.lastSuccessAt, + lastFailureAt: w.lastFailureAt, + failureCount: w.failureCount, + createdAt: w.createdAt, + updatedAt: w.updatedAt, + }; +} + +function validateEvents(input: unknown): WebhookEventType[] | { error: string } { + if (!Array.isArray(input) || input.length === 0) { + return { error: 'events must be a non-empty array' }; + } + for (const e of input) { + if (typeof e !== 'string' || !KNOWN_EVENTS.includes(e as WebhookEventType)) { + return { error: `unknown event: ${String(e)}` }; + } + } + return input as WebhookEventType[]; +} + +function validateUrl(input: unknown): string | { error: string } { + if (typeof input !== 'string' || !input.startsWith('https://')) { + return { error: 'url must be an https:// URL' }; + } + if (input.length > MAX_URL_LENGTH) { + return { error: 'url too long' }; + } + return input; +} + +export function registerSpaceWebhooksRoutes(router: Router, deps: SpaceApiDeps): void { + const { repo } = deps; + const jsonParser = express.json({ limit: '64kb' }); + + async function loadSpaceAndCheckManage( + req: express.Request, + res: express.Response, + ): Promise<{ viewer: Express.User; space: NonNullable>> } | null> { + const viewer = viewerOf(req, deps.authActive); + const space = await repo.getSpace(req.params.id, { viewer }); + if (!space) { + res.status(404).json({ error: 'not found' }); + return null; + } + const memberRole = repo.getSpaceMemberRole(space.id, viewer.id); + if (!canManageSpace(viewer, { ownerId: space.ownerId }, memberRole)) { + res.status(403).json({ error: 'forbidden' }); + return null; + } + return { viewer, space }; + } + + // GET — any space viewer. + router.get('/:id/webhooks', async (req, res) => { + const viewer = viewerOf(req, deps.authActive); + const space = await repo.getSpace(req.params.id, { viewer }); + if (!space) return res.status(404).json({ error: 'not found' }); + try { + const webhooks = repo.listSpaceWebhooks(space.id); + res.json({ webhooks: webhooks.map(toPublicWebhook) }); + } catch (err) { + logger.error(`[space-webhooks-api] GET failed space=${space.id} err=${(err as Error).message}`); + res.status(500).json({ error: 'internal error' }); + } + }); + + // POST — canManageSpace. + router.post('/:id/webhooks', jsonParser, async (req, res) => { + const ctx = await loadSpaceAndCheckManage(req, res); + if (!ctx) return; + const { viewer, space } = ctx; + + const provider = req.body?.provider; + if (typeof provider !== 'string' || !KNOWN_PROVIDERS.includes(provider as WebhookProvider)) { + return res.status(400).json({ error: `provider must be one of: ${KNOWN_PROVIDERS.join(', ')}` }); + } + if (!IMPLEMENTED_PROVIDERS.includes(provider as WebhookProvider)) { + return res.status(400).json({ error: `provider not yet supported: ${provider}` }); + } + const label = String(req.body?.label ?? '').trim(); + if (!label || label.length > MAX_LABEL_LENGTH) { + return res.status(400).json({ error: `label must be 1..${MAX_LABEL_LENGTH} chars` }); + } + const urlResult = validateUrl(req.body?.url); + if (typeof urlResult !== 'string') return res.status(400).json(urlResult); + const eventsResult = validateEvents(req.body?.events); + if (!Array.isArray(eventsResult)) return res.status(400).json(eventsResult); + const includeDetails = req.body?.includeDetails !== false; + + let urlEnc: Buffer; + try { + urlEnc = encrypt(urlResult, loadKeyFromEnv()); + } catch (err) { + logger.error(`[space-webhooks-api] encryption unavailable: ${(err as Error).message}`); + return res.status(503).json({ error: 'webhook encryption is not configured on this server' }); + } + + try { + const created = repo.createSpaceWebhook({ + spaceId: space.id, + provider: provider as WebhookProvider, + label, + urlEnc, + events: eventsResult, + includeDetails, + createdBy: viewer.id, + }); + logger.info(`[space-webhooks-api] created webhook=${created.id} space=${space.id} provider=${provider}`); + res.status(201).json(toPublicWebhook(created)); + } catch (err) { + logger.error(`[space-webhooks-api] POST failed space=${space.id} err=${(err as Error).message}`); + res.status(500).json({ error: 'internal error' }); + } + }); + + // Shared lookup for :webhookId routes — 404s if the webhook doesn't belong to this space. + function findWebhookInSpace(spaceId: string, webhookId: string): SpaceWebhookRecord | null { + const webhook = repo.getSpaceWebhookById(webhookId); + if (!webhook || webhook.spaceId !== spaceId) return null; + return webhook; + } + + // PUT — canManageSpace. url is optional (write-only re-entry); omit to keep the existing URL. + router.put('/:id/webhooks/:webhookId', jsonParser, async (req, res) => { + const ctx = await loadSpaceAndCheckManage(req, res); + if (!ctx) return; + const { space } = ctx; + const webhook = findWebhookInSpace(space.id, req.params.webhookId); + if (!webhook) return res.status(404).json({ error: 'not found' }); + + const patch: Parameters[1] = {}; + if (req.body?.label !== undefined) { + const label = String(req.body.label).trim(); + if (!label || label.length > MAX_LABEL_LENGTH) { + return res.status(400).json({ error: `label must be 1..${MAX_LABEL_LENGTH} chars` }); + } + patch.label = label; + } + if (req.body?.url !== undefined) { + const urlResult = validateUrl(req.body.url); + if (typeof urlResult !== 'string') return res.status(400).json(urlResult); + try { + patch.urlEnc = encrypt(urlResult, loadKeyFromEnv()); + } catch (err) { + logger.error(`[space-webhooks-api] encryption unavailable: ${(err as Error).message}`); + return res.status(503).json({ error: 'webhook encryption is not configured on this server' }); + } + } + if (req.body?.events !== undefined) { + const eventsResult = validateEvents(req.body.events); + if (!Array.isArray(eventsResult)) return res.status(400).json(eventsResult); + patch.events = eventsResult; + } + if (req.body?.includeDetails !== undefined) { + if (typeof req.body.includeDetails !== 'boolean') { + return res.status(400).json({ error: 'includeDetails must be boolean' }); + } + patch.includeDetails = req.body.includeDetails; + } + + try { + const updated = repo.updateSpaceWebhook(webhook.id, patch); + if (!updated) return res.status(404).json({ error: 'not found' }); + res.json(toPublicWebhook(updated)); + } catch (err) { + logger.error(`[space-webhooks-api] PUT failed webhook=${webhook.id} err=${(err as Error).message}`); + res.status(500).json({ error: 'internal error' }); + } + }); + + // PATCH — canManageSpace. Enable/disable (also used to clear an auto-disable). + router.patch('/:id/webhooks/:webhookId', jsonParser, async (req, res) => { + const ctx = await loadSpaceAndCheckManage(req, res); + if (!ctx) return; + const { space } = ctx; + const webhook = findWebhookInSpace(space.id, req.params.webhookId); + if (!webhook) return res.status(404).json({ error: 'not found' }); + + if (typeof req.body?.enabled !== 'boolean') { + return res.status(400).json({ error: 'enabled must be boolean' }); + } + try { + const updated = repo.setSpaceWebhookEnabled(webhook.id, req.body.enabled); + if (!updated) return res.status(404).json({ error: 'not found' }); + logger.info(`[space-webhooks-api] webhook=${webhook.id} enabled=${req.body.enabled}`); + res.json(toPublicWebhook(updated)); + } catch (err) { + logger.error(`[space-webhooks-api] PATCH failed webhook=${webhook.id} err=${(err as Error).message}`); + res.status(500).json({ error: 'internal error' }); + } + }); + + // DELETE — canManageSpace. + router.delete('/:id/webhooks/:webhookId', async (req, res) => { + const ctx = await loadSpaceAndCheckManage(req, res); + if (!ctx) return; + const { space } = ctx; + const webhook = findWebhookInSpace(space.id, req.params.webhookId); + if (!webhook) return res.status(404).json({ error: 'not found' }); + try { + repo.deleteSpaceWebhook(webhook.id); + logger.info(`[space-webhooks-api] deleted webhook=${webhook.id} space=${space.id}`); + res.json({ ok: true }); + } catch (err) { + logger.error(`[space-webhooks-api] DELETE failed webhook=${webhook.id} err=${(err as Error).message}`); + res.status(500).json({ error: 'internal error' }); + } + }); + + // POST /:webhookId/test — canManageSpace. Sends one real notification synchronously. + router.post('/:id/webhooks/:webhookId/test', async (req, res) => { + const ctx = await loadSpaceAndCheckManage(req, res); + if (!ctx) return; + const { space } = ctx; + const webhook = findWebhookInSpace(space.id, req.params.webhookId); + if (!webhook) return res.status(404).json({ error: 'not found' }); + if (!deps.webhookService) { + return res.status(503).json({ error: 'webhook delivery is disabled (config: webhooks.enabled)' }); + } + try { + const result = await deps.webhookService.sendTest(webhook.id); + if (result.ok) return res.json({ ok: true }); + return res.status(502).json({ ok: false, error: result.error }); + } catch (err) { + logger.error(`[space-webhooks-api] test-send failed webhook=${webhook.id} err=${(err as Error).message}`); + res.status(500).json({ error: 'internal error' }); + } + }); +} diff --git a/src/config.ts b/src/config.ts index bbae930..ae78236 100644 --- a/src/config.ts +++ b/src/config.ts @@ -517,6 +517,29 @@ export interface NotificationsConfig { push?: PushNotificationsConfig; } +export interface WebhooksConfig { + /** Master switch for workspace webhook notifications (Discord/Slack/Teams). Default false. */ + enabled?: boolean; + /** Per-send HTTP timeout (ms). */ + timeoutMs?: number; + /** Maximum outbound payload size in bytes. */ + payloadMaxBytes?: number; + /** Max redirect hops ssrfSafeFetch will follow per send. */ + maxRedirects?: number; + /** Max concurrent sends from the queue. */ + queueConcurrency?: number; + retry?: { + /** Number of retries after the initial attempt for 429/5xx/timeout. */ + maxAttempts?: number; + /** Base backoff (ms); doubled per attempt with jitter. */ + backoffMs?: number; + }; + /** Optional absolute base URL used to build task-detail links in payloads + * (e.g. "https://maestro.example.com"). Falls back to a relative path + * when unset. */ + publicBaseUrl?: string; +} + export interface AppConfig { /** * Schema version. `2` = v2 layout (`llm.*` / `storage.*`). Missing or `1` @@ -570,6 +593,15 @@ export interface AppConfig { mcp?: Partial; ssh?: Partial; notifications?: NotificationsConfig; + /** + * Workspace-level webhook notifications (Discord/Slack/Teams). Spec: issue + * #797. Disabled by default; when `webhooks.enabled` is false the service + * does not start / no-ops sends (mirrors notifications.push.enabled gating + * in worker-bootstrap.ts). snake_case in YAML: + * webhooks.{enabled,timeout_ms,payload_max_bytes,max_redirects,queue_concurrency, + * retry.{max_attempts,backoff_ms},public_base_url} + */ + webhooks?: WebhooksConfig; server?: Partial; a2a?: { enabled?: boolean; diff --git a/src/db/migrate.ts b/src/db/migrate.ts index 9d46996..4ef2d18 100644 --- a/src/db/migrate.ts +++ b/src/db/migrate.ts @@ -244,6 +244,7 @@ export function runMigrations(db: Database.Database): void { migrateTaskCommentIndex(db); migrateBackfillTaskCommentIndex(db); migrateA2aTaskDelegationColumns(db); + migrateSpaceWebhooksTables(db); } /** @@ -1373,6 +1374,37 @@ function migratePushNotificationsTables(db: Database.Database): void { `); } +/** + * ワークスペース単位の Webhook 通知(issue #797, PR1)。 + * dual-path: schema.sql の CREATE TABLE と同一定義(三重ミラー; memory: + * project_db_migration_dual_path)。冪等(CREATE TABLE IF NOT EXISTS)。 + * url_enc は AES-256-GCM で暗号化された Webhook URL(src/mcp/crypto.ts)。 + * 平文 URL は決して保存・ログ出力しない。 + */ +function migrateSpaceWebhooksTables(db: Database.Database): void { + db.exec(` + CREATE TABLE IF NOT EXISTS space_webhooks ( + id TEXT PRIMARY KEY, + space_id TEXT NOT NULL REFERENCES spaces(id) ON DELETE CASCADE, + provider TEXT NOT NULL DEFAULT 'discord', + label TEXT NOT NULL DEFAULT '', + url_enc BLOB NOT NULL, + key_version INTEGER NOT NULL DEFAULT 1, + events TEXT NOT NULL DEFAULT '["succeeded","failed","waiting_human"]', + include_details INTEGER NOT NULL DEFAULT 1, + enabled INTEGER NOT NULL DEFAULT 1, + disabled_reason TEXT, + last_success_at TEXT, + last_failure_at TEXT, + failure_count INTEGER NOT NULL DEFAULT 0, + created_by TEXT, + created_at TEXT NOT NULL DEFAULT (datetime('now')), + updated_at TEXT NOT NULL DEFAULT (datetime('now')) + ); + CREATE INDEX IF NOT EXISTS idx_space_webhooks_space_id ON space_webhooks(space_id); + `); +} + /** * a2a_tasks に委任列を追加(Plan 2C-1 Task 2)。 * dual-path: schema.sql の CREATE TABLE にも同じ列を追加済み。 diff --git a/src/db/repositories/webhooks.test.ts b/src/db/repositories/webhooks.test.ts new file mode 100644 index 0000000..af6eea3 --- /dev/null +++ b/src/db/repositories/webhooks.test.ts @@ -0,0 +1,157 @@ +import { afterEach, beforeEach, describe, expect, it } from 'vitest'; +import { mkdtempSync, rmSync } from 'fs'; +import { join } from 'path'; +import { tmpdir } from 'os'; +import { Repository } from '../repository.js'; +import { AUTO_DISABLE_FAILURE_THRESHOLD } from './webhooks.js'; + +describe('space_webhooks repository', () => { + let tempDir = ''; + let repo: Repository; + let spaceId = ''; + + beforeEach(async () => { + tempDir = mkdtempSync(join(tmpdir(), 'maestro-webhooks-')); + repo = new Repository(join(tempDir, 'db.sqlite')); + const owner = repo.createUser({ email: 'owner@example.com', name: 'Owner', role: 'user', status: 'active' }); + const space = await repo.createSpace({ kind: 'case', title: 'Case', ownerId: owner.id, visibility: 'private' }); + spaceId = space.id; + }); + + afterEach(() => { + repo.close(); + if (tempDir) { + rmSync(tempDir, { recursive: true, force: true }); + tempDir = ''; + } + }); + + const urlEnc = () => Buffer.from('fake-ciphertext-not-real-crypto'); + + it('creates a webhook and returns the full record (including url_enc as an opaque Buffer)', () => { + const created = repo.createSpaceWebhook({ + spaceId, + provider: 'discord', + label: 'Dev channel', + urlEnc: urlEnc(), + events: ['succeeded', 'failed'], + includeDetails: true, + }); + expect(created.id).toBeTruthy(); + expect(created.spaceId).toBe(spaceId); + expect(created.provider).toBe('discord'); + expect(created.label).toBe('Dev channel'); + expect(created.events).toEqual(['succeeded', 'failed']); + expect(created.includeDetails).toBe(true); + expect(created.enabled).toBe(true); + expect(created.failureCount).toBe(0); + expect(Buffer.isBuffer(created.urlEnc)).toBe(true); + }); + + it('defaults includeDetails to true when omitted, false when explicitly false', () => { + const a = repo.createSpaceWebhook({ spaceId, provider: 'discord', label: 'A', urlEnc: urlEnc(), events: ['succeeded'] }); + expect(a.includeDetails).toBe(true); + const b = repo.createSpaceWebhook({ spaceId, provider: 'discord', label: 'B', urlEnc: urlEnc(), events: ['succeeded'], includeDetails: false }); + expect(b.includeDetails).toBe(false); + }); + + it('lists webhooks scoped to a space, ordered by creation', () => { + repo.createSpaceWebhook({ spaceId, provider: 'discord', label: 'One', urlEnc: urlEnc(), events: ['succeeded'] }); + repo.createSpaceWebhook({ spaceId, provider: 'discord', label: 'Two', urlEnc: urlEnc(), events: ['failed'] }); + const list = repo.listSpaceWebhooks(spaceId); + expect(list.map(w => w.label)).toEqual(['One', 'Two']); + }); + + it('getSpaceWebhookById returns null for unknown id', () => { + expect(repo.getSpaceWebhookById('does-not-exist')).toBeNull(); + }); + + it('updateSpaceWebhook patches label/events/includeDetails without touching unspecified fields', () => { + const created = repo.createSpaceWebhook({ spaceId, provider: 'discord', label: 'Orig', urlEnc: urlEnc(), events: ['succeeded'] }); + const updated = repo.updateSpaceWebhook(created.id, { label: 'Renamed', events: ['failed', 'waiting_human'] }); + expect(updated?.label).toBe('Renamed'); + expect(updated?.events).toEqual(['failed', 'waiting_human']); + expect(updated?.includeDetails).toBe(true); // untouched + }); + + it('updateSpaceWebhook can replace url_enc (URL re-entry)', () => { + const created = repo.createSpaceWebhook({ spaceId, provider: 'discord', label: 'Orig', urlEnc: urlEnc(), events: ['succeeded'] }); + const newEnc = Buffer.from('new-ciphertext'); + const updated = repo.updateSpaceWebhook(created.id, { urlEnc: newEnc }); + expect(updated?.urlEnc.equals(newEnc)).toBe(true); + }); + + it('deleteSpaceWebhook removes the row', () => { + const created = repo.createSpaceWebhook({ spaceId, provider: 'discord', label: 'Gone', urlEnc: urlEnc(), events: ['succeeded'] }); + repo.deleteSpaceWebhook(created.id); + expect(repo.getSpaceWebhookById(created.id)).toBeNull(); + }); + + it('markSpaceWebhookSuccess resets failure_count and stamps last_success_at', () => { + const created = repo.createSpaceWebhook({ spaceId, provider: 'discord', label: 'X', urlEnc: urlEnc(), events: ['succeeded'] }); + repo.markSpaceWebhookFailure(created.id); + repo.markSpaceWebhookFailure(created.id); + repo.markSpaceWebhookSuccess(created.id); + const after = repo.getSpaceWebhookById(created.id)!; + expect(after.failureCount).toBe(0); + expect(after.lastSuccessAt).toBeTruthy(); + }); + + it('markSpaceWebhookFailure increments failure_count and stamps last_failure_at', () => { + const created = repo.createSpaceWebhook({ spaceId, provider: 'discord', label: 'X', urlEnc: urlEnc(), events: ['succeeded'] }); + const result = repo.markSpaceWebhookFailure(created.id); + expect(result.failureCount).toBe(1); + expect(result.autoDisabled).toBe(false); + const after = repo.getSpaceWebhookById(created.id)!; + expect(after.failureCount).toBe(1); + expect(after.lastFailureAt).toBeTruthy(); + expect(after.enabled).toBe(true); + }); + + it('auto-disables at the failure threshold (10 consecutive failures)', () => { + const created = repo.createSpaceWebhook({ spaceId, provider: 'discord', label: 'X', urlEnc: urlEnc(), events: ['succeeded'] }); + let result; + for (let i = 0; i < AUTO_DISABLE_FAILURE_THRESHOLD; i++) { + result = repo.markSpaceWebhookFailure(created.id); + } + expect(result!.failureCount).toBe(AUTO_DISABLE_FAILURE_THRESHOLD); + expect(result!.autoDisabled).toBe(true); + const after = repo.getSpaceWebhookById(created.id)!; + expect(after.enabled).toBe(false); + expect(after.disabledReason).toBeTruthy(); + }); + + it('does not re-trigger autoDisabled=true on further failures once already disabled', () => { + const created = repo.createSpaceWebhook({ spaceId, provider: 'discord', label: 'X', urlEnc: urlEnc(), events: ['succeeded'] }); + for (let i = 0; i < AUTO_DISABLE_FAILURE_THRESHOLD; i++) repo.markSpaceWebhookFailure(created.id); + const extra = repo.markSpaceWebhookFailure(created.id); + expect(extra.autoDisabled).toBe(false); // already disabled; not a fresh transition + expect(extra.failureCount).toBe(AUTO_DISABLE_FAILURE_THRESHOLD + 1); + }); + + it('one success before the threshold prevents auto-disable', () => { + const created = repo.createSpaceWebhook({ spaceId, provider: 'discord', label: 'X', urlEnc: urlEnc(), events: ['succeeded'] }); + for (let i = 0; i < AUTO_DISABLE_FAILURE_THRESHOLD - 1; i++) repo.markSpaceWebhookFailure(created.id); + repo.markSpaceWebhookSuccess(created.id); + const after = repo.getSpaceWebhookById(created.id)!; + expect(after.failureCount).toBe(0); + expect(after.enabled).toBe(true); + }); + + it('setSpaceWebhookEnabled(false) sets a manual disabled_reason', () => { + const created = repo.createSpaceWebhook({ spaceId, provider: 'discord', label: 'X', urlEnc: urlEnc(), events: ['succeeded'] }); + const updated = repo.setSpaceWebhookEnabled(created.id, false, 'manually paused'); + expect(updated?.enabled).toBe(false); + expect(updated?.disabledReason).toBe('manually paused'); + }); + + it('setSpaceWebhookEnabled(true) re-enables, clears disabled_reason, and resets failure_count', () => { + const created = repo.createSpaceWebhook({ spaceId, provider: 'discord', label: 'X', urlEnc: urlEnc(), events: ['succeeded'] }); + for (let i = 0; i < AUTO_DISABLE_FAILURE_THRESHOLD; i++) repo.markSpaceWebhookFailure(created.id); + expect(repo.getSpaceWebhookById(created.id)!.enabled).toBe(false); + const reenabled = repo.setSpaceWebhookEnabled(created.id, true); + expect(reenabled?.enabled).toBe(true); + expect(reenabled?.disabledReason).toBeNull(); + expect(reenabled?.failureCount).toBe(0); + }); +}); diff --git a/src/db/repositories/webhooks.ts b/src/db/repositories/webhooks.ts new file mode 100644 index 0000000..f492def --- /dev/null +++ b/src/db/repositories/webhooks.ts @@ -0,0 +1,253 @@ +// space_webhooks repository domain (issue #797, PR1). Follows the same +// sub-repository + thin-delegation-on-Repository pattern as notifications.ts. +// Facade: src/db/repository.ts (Repository class). +// +// SECURITY: url_enc is an AES-256-GCM envelope-encrypted BLOB (src/mcp/crypto.ts, +// same scheme as mcp_servers.static_token_enc). This module never decrypts — +// callers (webhook-service.ts) decrypt just-in-time for the outbound send and +// never log or return the plaintext. rowToSpaceWebhook() keeps url_enc as an +// opaque Buffer; the API layer (space-webhooks-api.ts) must never place it, +// or a decrypted URL, into a JSON response. +import Database from 'better-sqlite3'; +import { randomUUID } from 'crypto'; + +export type WebhookProvider = 'discord' | 'slack' | 'teams'; +export type WebhookEventType = 'succeeded' | 'failed' | 'waiting_human'; + +export const AUTO_DISABLE_FAILURE_THRESHOLD = 10; +export const WARNING_FAILURE_THRESHOLD = 3; + +export interface SpaceWebhookRecord { + id: string; + spaceId: string; + provider: WebhookProvider; + label: string; + urlEnc: Buffer; + keyVersion: number; + events: WebhookEventType[]; + includeDetails: boolean; + enabled: boolean; + disabledReason: string | null; + lastSuccessAt: string | null; + lastFailureAt: string | null; + failureCount: number; + createdBy: string | null; + createdAt: string; + updatedAt: string; +} + +export interface CreateSpaceWebhookInput { + spaceId: string; + provider: WebhookProvider; + label: string; + urlEnc: Buffer; + keyVersion?: number; + events: WebhookEventType[]; + includeDetails?: boolean; + createdBy?: string | null; +} + +export interface UpdateSpaceWebhookInput { + label?: string; + /** Present only when the caller re-entered a URL (URLs are write-only). */ + urlEnc?: Buffer; + keyVersion?: number; + events?: WebhookEventType[]; + includeDetails?: boolean; +} + +interface SpaceWebhookRow { + id: string; + space_id: string; + provider: string; + label: string; + url_enc: Buffer; + key_version: number; + events: string; + include_details: number; + enabled: number; + disabled_reason: string | null; + last_success_at: string | null; + last_failure_at: string | null; + failure_count: number; + created_by: string | null; + created_at: string; + updated_at: string; +} + +export function rowToSpaceWebhook(row: SpaceWebhookRow): SpaceWebhookRecord { + let events: WebhookEventType[]; + try { + const parsed = JSON.parse(row.events); + events = Array.isArray(parsed) ? (parsed as WebhookEventType[]) : []; + } catch { + events = []; + } + return { + id: row.id, + spaceId: row.space_id, + provider: row.provider as WebhookProvider, + label: row.label, + urlEnc: row.url_enc, + keyVersion: row.key_version, + events, + includeDetails: row.include_details !== 0, + enabled: row.enabled !== 0, + disabledReason: row.disabled_reason, + lastSuccessAt: row.last_success_at, + lastFailureAt: row.last_failure_at, + failureCount: row.failure_count, + createdBy: row.created_by, + createdAt: row.created_at, + updatedAt: row.updated_at, + }; +} + +const SELECT_COLUMNS = `id, space_id, provider, label, url_enc, key_version, events, + include_details, enabled, disabled_reason, last_success_at, last_failure_at, + failure_count, created_by, created_at, updated_at`; + +export function createSpaceWebhook( + db: Database.Database, + input: CreateSpaceWebhookInput, +): SpaceWebhookRecord { + const id = randomUUID(); + db.prepare( + `INSERT INTO space_webhooks + (id, space_id, provider, label, url_enc, key_version, events, include_details, created_by) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`, + ).run( + id, + input.spaceId, + input.provider, + input.label, + input.urlEnc, + input.keyVersion ?? 1, + JSON.stringify(input.events), + input.includeDetails === false ? 0 : 1, + input.createdBy ?? null, + ); + return getSpaceWebhookById(db, id)!; +} + +export function listSpaceWebhooks(db: Database.Database, spaceId: string): SpaceWebhookRecord[] { + const rows = db + .prepare(`SELECT ${SELECT_COLUMNS} FROM space_webhooks WHERE space_id = ? ORDER BY created_at ASC`) + .all(spaceId) as SpaceWebhookRow[]; + return rows.map(rowToSpaceWebhook); +} + +export function getSpaceWebhookById(db: Database.Database, id: string): SpaceWebhookRecord | null { + const row = db + .prepare(`SELECT ${SELECT_COLUMNS} FROM space_webhooks WHERE id = ?`) + .get(id) as SpaceWebhookRow | undefined; + return row ? rowToSpaceWebhook(row) : null; +} + +export function updateSpaceWebhook( + db: Database.Database, + id: string, + patch: UpdateSpaceWebhookInput, +): SpaceWebhookRecord | null { + const sets: string[] = []; + const params: Array = []; + if (patch.label !== undefined) { + sets.push('label = ?'); + params.push(patch.label); + } + if (patch.urlEnc !== undefined) { + sets.push('url_enc = ?'); + params.push(patch.urlEnc); + } + if (patch.keyVersion !== undefined) { + sets.push('key_version = ?'); + params.push(patch.keyVersion); + } + if (patch.events !== undefined) { + sets.push('events = ?'); + params.push(JSON.stringify(patch.events)); + } + if (patch.includeDetails !== undefined) { + sets.push('include_details = ?'); + params.push(patch.includeDetails ? 1 : 0); + } + if (sets.length === 0) return getSpaceWebhookById(db, id); + sets.push("updated_at = datetime('now')"); + params.push(id); + db.prepare(`UPDATE space_webhooks SET ${sets.join(', ')} WHERE id = ?`).run(...params); + return getSpaceWebhookById(db, id); +} + +export function deleteSpaceWebhook(db: Database.Database, id: string): void { + db.prepare('DELETE FROM space_webhooks WHERE id = ?').run(id); +} + +/** + * Manual enable/disable (settings-UI toggle and the post-auto-disable + * "re-enable" action). Re-enabling also resets failure_count so the webhook + * gets a fresh run at the failure thresholds, and clears disabled_reason. + */ +export function setSpaceWebhookEnabled( + db: Database.Database, + id: string, + enabled: boolean, + reason?: string | null, +): SpaceWebhookRecord | null { + if (enabled) { + db.prepare( + `UPDATE space_webhooks + SET enabled = 1, disabled_reason = NULL, failure_count = 0, updated_at = datetime('now') + WHERE id = ?`, + ).run(id); + } else { + db.prepare( + `UPDATE space_webhooks + SET enabled = 0, disabled_reason = ?, updated_at = datetime('now') + WHERE id = ?`, + ).run(reason ?? null, id); + } + return getSpaceWebhookById(db, id); +} + +/** Successful delivery: reset failure_count, stamp last_success_at. */ +export function markSpaceWebhookSuccess(db: Database.Database, id: string): void { + db.prepare( + `UPDATE space_webhooks + SET last_success_at = datetime('now'), failure_count = 0, updated_at = datetime('now') + WHERE id = ?`, + ).run(id); +} + +/** + * Failed delivery: increment failure_count, stamp last_failure_at, and + * auto-disable once failure_count reaches AUTO_DISABLE_FAILURE_THRESHOLD + * (10 consecutive failures). Returns the post-update failure_count and + * whether this call performed the auto-disable transition. + */ +export function markSpaceWebhookFailure( + db: Database.Database, + id: string, +): { failureCount: number; autoDisabled: boolean } { + db.prepare( + `UPDATE space_webhooks + SET last_failure_at = datetime('now'), + failure_count = failure_count + 1, + updated_at = datetime('now') + WHERE id = ?`, + ).run(id); + const row = db + .prepare('SELECT failure_count, enabled FROM space_webhooks WHERE id = ?') + .get(id) as { failure_count: number; enabled: number } | undefined; + if (!row) return { failureCount: 0, autoDisabled: false }; + if (row.failure_count >= AUTO_DISABLE_FAILURE_THRESHOLD && row.enabled !== 0) { + db.prepare( + `UPDATE space_webhooks + SET enabled = 0, + disabled_reason = ?, + updated_at = datetime('now') + WHERE id = ?`, + ).run(`${AUTO_DISABLE_FAILURE_THRESHOLD} consecutive delivery failures`, id); + return { failureCount: row.failure_count, autoDisabled: true }; + } + return { failureCount: row.failure_count, autoDisabled: false }; +} diff --git a/src/db/repository.ts b/src/db/repository.ts index 26f6918..ddb9a64 100644 --- a/src/db/repository.ts +++ b/src/db/repository.ts @@ -22,6 +22,8 @@ import * as llmUsageRepo from './repositories/llm-usage.js'; import { LlmUsageIncrement, LlmUsageDailyAgg, LlmUsageHourlyIncrement, LlmUsageHourlyRow } from './repositories/llm-usage.js'; import * as notificationsRepo from './repositories/notifications.js'; import { PushSubscriptionRecord, UpsertPushSubscriptionInput, NotificationPrefs, NotificationPrefsUpdate } from './repositories/notifications.js'; +import * as webhooksRepo from './repositories/webhooks.js'; +import { SpaceWebhookRecord, CreateSpaceWebhookInput, UpdateSpaceWebhookInput } from './repositories/webhooks.js'; import * as auditRepo from './repositories/audit.js'; import * as appSharesRepo from './repositories/app-shares.js'; import * as provenanceRepo from './repositories/provenance.js'; @@ -35,6 +37,7 @@ import { CalendarEvent, CalendarDayCounts, CalendarDayTask, CrossSpaceCalendarMo export { enumerateLocalDays } from './repositories/calendar.js'; export type { CalendarEvent, CalendarDayCounts, CalendarDayTask, CrossCalendarSpace, CrossSpaceCalendarMonth } from './repositories/calendar.js'; export type { NotifyEventType, PushSubscriptionRecord, UpsertPushSubscriptionInput, NotificationPrefs, NotificationPrefsUpdate } from './repositories/notifications.js'; +export type { WebhookProvider, WebhookEventType, SpaceWebhookRecord, CreateSpaceWebhookInput, UpdateSpaceWebhookInput } from './repositories/webhooks.js'; export type { LlmUsageIncrement, LlmUsageDailyAgg, LlmUsageHourlyIncrement, LlmUsageHourlyRow } from './repositories/llm-usage.js'; export type { GatewayVirtualKeySource, GatewayVirtualKey, GatewayKeyUsage } from './repositories/gateway.js'; export type { ToolRequestCategory, ToolRequestStatus, ToolRequest, ToolRequestAggregate, PackageRequest, PackageRequestStatus } from './repositories/tool-requests.js'; @@ -1094,6 +1097,39 @@ export class Repository { return notificationsRepo.markV1MigrationComplete(this.db, userId); } + // ── Workspace webhooks (issue #797, PR1) ────────────────────────────── + createSpaceWebhook(input: CreateSpaceWebhookInput): SpaceWebhookRecord { + return webhooksRepo.createSpaceWebhook(this.db, input); + } + + listSpaceWebhooks(spaceId: string): SpaceWebhookRecord[] { + return webhooksRepo.listSpaceWebhooks(this.db, spaceId); + } + + getSpaceWebhookById(id: string): SpaceWebhookRecord | null { + return webhooksRepo.getSpaceWebhookById(this.db, id); + } + + updateSpaceWebhook(id: string, patch: UpdateSpaceWebhookInput): SpaceWebhookRecord | null { + return webhooksRepo.updateSpaceWebhook(this.db, id, patch); + } + + deleteSpaceWebhook(id: string): void { + return webhooksRepo.deleteSpaceWebhook(this.db, id); + } + + setSpaceWebhookEnabled(id: string, enabled: boolean, reason?: string | null): SpaceWebhookRecord | null { + return webhooksRepo.setSpaceWebhookEnabled(this.db, id, enabled, reason); + } + + markSpaceWebhookSuccess(id: string): void { + return webhooksRepo.markSpaceWebhookSuccess(this.db, id); + } + + markSpaceWebhookFailure(id: string): { failureCount: number; autoDisabled: boolean } { + return webhooksRepo.markSpaceWebhookFailure(this.db, id); + } + close(): void { this.db.close(); } diff --git a/src/db/schema.sql b/src/db/schema.sql index be114d3..4590d12 100644 --- a/src/db/schema.sql +++ b/src/db/schema.sql @@ -906,6 +906,35 @@ CREATE TABLE IF NOT EXISTS user_notification_prefs ( updated_at TEXT NOT NULL DEFAULT (datetime('now')) ); +-- ── Workspace-level webhook notifications (issue #797, PR1) ────────────── +-- Discord/Slack/Teams incoming-webhook notifications for a space. url_enc is +-- the AES-256-GCM envelope-encrypted webhook URL (src/mcp/crypto.ts, same +-- scheme as mcp_servers.static_token_enc) — the plaintext URL is NEVER +-- stored, logged, or returned by the API. key_version supports future key +-- rotation. events is a JSON array of NotifyEventType strings, e.g. +-- '["succeeded","failed","waiting_human"]'. 10 consecutive failures +-- auto-disables the webhook (enabled=0, disabled_reason set); the UI offers +-- a re-enable action. +CREATE TABLE IF NOT EXISTS space_webhooks ( + id TEXT PRIMARY KEY, + space_id TEXT NOT NULL REFERENCES spaces(id) ON DELETE CASCADE, + provider TEXT NOT NULL DEFAULT 'discord', -- 'discord' | 'slack' | 'teams' (only discord in PR1) + label TEXT NOT NULL DEFAULT '', + url_enc BLOB NOT NULL, + key_version INTEGER NOT NULL DEFAULT 1, + events TEXT NOT NULL DEFAULT '["succeeded","failed","waiting_human"]', + include_details INTEGER NOT NULL DEFAULT 1, + enabled INTEGER NOT NULL DEFAULT 1, + disabled_reason TEXT, + last_success_at TEXT, + last_failure_at TEXT, + failure_count INTEGER NOT NULL DEFAULT 0, + created_by TEXT, + created_at TEXT NOT NULL DEFAULT (datetime('now')), + updated_at TEXT NOT NULL DEFAULT (datetime('now')) +); +CREATE INDEX IF NOT EXISTS idx_space_webhooks_space_id ON space_webhooks(space_id); + -- Audit 2026-06-08 fix: user_gitea_orgs (per-user Gitea org cache) was created -- only in Repository.initSchema(); mirrored here (fresh path) and in migrate.ts -- (upgrade path) so the task-list display SELECT never hits "no such table". diff --git a/src/webhook-adapters/discord.test.ts b/src/webhook-adapters/discord.test.ts new file mode 100644 index 0000000..523a883 --- /dev/null +++ b/src/webhook-adapters/discord.test.ts @@ -0,0 +1,56 @@ +import { describe, it, expect } from 'vitest'; +import { buildDiscordPayload } from './discord.js'; +import type { NotificationEvent } from './types.js'; + +const base: NotificationEvent = { + event: 'succeeded', + taskId: 42, + taskTitle: 'Ship the widget', + pieceName: 'chat', + spaceName: 'Acme Workspace', + taskUrl: 'https://maestro.example.com/?page=spaces&space=s1&chat=42', + includeDetails: true, +}; + +describe('buildDiscordPayload', () => { + it('includeDetails=true: embed carries title, piece, workspace, task id, and link', () => { + const payload = buildDiscordPayload(base); + expect(payload.embeds).toHaveLength(1); + const embed = payload.embeds[0]; + expect(embed.title).toContain('Ship the widget'); + expect(embed.title).toContain('タスク完了'); + expect(embed.url).toBe(base.taskUrl); + expect(embed.fields).toBeDefined(); + const fieldValues = embed.fields!.map(f => f.value); + expect(fieldValues).toContain('chat'); + expect(fieldValues).toContain('Acme Workspace'); + expect(fieldValues).toContain('42'); + }); + + it('includeDetails=false: omits task title, piece name, and workspace name', () => { + const payload = buildDiscordPayload({ ...base, includeDetails: false }); + const embed = payload.embeds[0]; + expect(embed.title).not.toContain('Ship the widget'); + expect(embed.title).toContain('#42'); + expect(embed.fields).toBeUndefined(); + // Serialized form must not leak the detailed fields either. + const json = JSON.stringify(payload); + expect(json).not.toContain('Ship the widget'); + expect(json).not.toContain('Acme Workspace'); + }); + + it('uses a distinct color/emoji per event kind', () => { + const succeeded = buildDiscordPayload({ ...base, event: 'succeeded' }); + const failed = buildDiscordPayload({ ...base, event: 'failed' }); + const waiting = buildDiscordPayload({ ...base, event: 'waiting_human' }); + expect(succeeded.embeds[0].color).not.toBe(failed.embeds[0].color); + expect(failed.embeds[0].color).not.toBe(waiting.embeds[0].color); + expect(failed.embeds[0].title).toContain('❌'); + expect(waiting.embeds[0].title).toContain('❓'); + }); + + it('handles an empty taskUrl gracefully (no url field)', () => { + const payload = buildDiscordPayload({ ...base, taskUrl: '' }); + expect(payload.embeds[0].url).toBeUndefined(); + }); +}); diff --git a/src/webhook-adapters/discord.ts b/src/webhook-adapters/discord.ts new file mode 100644 index 0000000..0a72464 --- /dev/null +++ b/src/webhook-adapters/discord.ts @@ -0,0 +1,75 @@ +/** + * Discord Incoming Webhook adapter (issue #797, PR1). + * + * Converts the provider-agnostic NotificationEvent into a Discord webhook + * JSON body using an embed so status/title/link render legibly in a channel. + * https://discord.com/developers/docs/resources/webhook#execute-webhook + */ +import type { NotificationEvent, WebhookEventKind } from './types.js'; + +const EVENT_LABEL: Record = { + succeeded: 'タスク完了', + failed: 'タスク失敗', + waiting_human: '回答待ち', +}; + +const EVENT_EMOJI: Record = { + succeeded: '✅', + failed: '❌', + waiting_human: '❓', +}; + +// Discord embed color (decimal RGB): green / red / amber. +const EVENT_COLOR: Record = { + succeeded: 0x22c55e, + failed: 0xef4444, + waiting_human: 0xf59e0b, +}; + +export interface DiscordWebhookPayload { + embeds: Array<{ + title: string; + url?: string; + color: number; + fields?: Array<{ name: string; value: string; inline?: boolean }>; + }>; +} + +/** + * Build the Discord webhook payload. When `event.includeDetails` is false, + * the embed omits task title / piece name / workspace name — only the + * generic event label, task id, and link — mirroring push-service.ts's + * includeDetails privacy default. + */ +export function buildDiscordPayload(event: NotificationEvent): DiscordWebhookPayload { + const label = EVENT_LABEL[event.event]; + const emoji = EVENT_EMOJI[event.event]; + const color = EVENT_COLOR[event.event]; + + if (!event.includeDetails) { + return { + embeds: [ + { + title: `${emoji} タスク #${event.taskId} ${label}`, + url: event.taskUrl || undefined, + color, + }, + ], + }; + } + + return { + embeds: [ + { + title: `${emoji} ${label}: ${event.taskTitle}`, + url: event.taskUrl || undefined, + color, + fields: [ + { name: 'Piece', value: event.pieceName || '(none)', inline: true }, + { name: 'Workspace', value: event.spaceName || '(none)', inline: true }, + { name: 'Task ID', value: String(event.taskId), inline: true }, + ], + }, + ], + }; +} diff --git a/src/webhook-adapters/slack.test.ts b/src/webhook-adapters/slack.test.ts new file mode 100644 index 0000000..5c857ff --- /dev/null +++ b/src/webhook-adapters/slack.test.ts @@ -0,0 +1,54 @@ +import { describe, it, expect } from 'vitest'; +import { buildSlackPayload } from './slack.js'; +import type { NotificationEvent } from './types.js'; + +const base: NotificationEvent = { + event: 'succeeded', + taskId: 42, + taskTitle: 'Ship the widget', + pieceName: 'chat', + spaceName: 'Acme Workspace', + taskUrl: 'https://maestro.example.com/?page=spaces&space=s1&chat=42', + includeDetails: true, +}; + +describe('buildSlackPayload', () => { + it('includeDetails=true: blocks carry title, piece, workspace, task id, and link', () => { + const payload = buildSlackPayload(base); + expect(payload.text).toContain('Ship the widget'); + expect(payload.text).toContain('タスク完了'); + const json = JSON.stringify(payload.blocks); + expect(json).toContain('Ship the widget'); + expect(json).toContain(base.taskUrl); + expect(json).toContain('chat'); + expect(json).toContain('Acme Workspace'); + expect(json).toContain('42'); + }); + + it('includeDetails=false: omits task title, piece name, and workspace name', () => { + const payload = buildSlackPayload({ ...base, includeDetails: false }); + expect(payload.text).not.toContain('Ship the widget'); + expect(payload.text).toContain('#42'); + // Serialized form must not leak the detailed fields either. + const json = JSON.stringify(payload); + expect(json).not.toContain('Ship the widget'); + expect(json).not.toContain('Acme Workspace'); + expect(json).not.toContain('*Piece:*'); + }); + + it('uses a distinct emoji per event kind', () => { + const succeeded = buildSlackPayload({ ...base, event: 'succeeded' }); + const failed = buildSlackPayload({ ...base, event: 'failed' }); + const waiting = buildSlackPayload({ ...base, event: 'waiting_human' }); + expect(succeeded.text).toContain('✅'); + expect(failed.text).toContain('❌'); + expect(waiting.text).toContain('❓'); + }); + + it('handles an empty taskUrl gracefully (no link markup)', () => { + const payload = buildSlackPayload({ ...base, taskUrl: '' }); + const json = JSON.stringify(payload); + expect(json).not.toContain('<|'); + expect(json).not.toContain('undefined'); + }); +}); diff --git a/src/webhook-adapters/slack.ts b/src/webhook-adapters/slack.ts new file mode 100644 index 0000000..527b38f --- /dev/null +++ b/src/webhook-adapters/slack.ts @@ -0,0 +1,74 @@ +/** + * Slack Incoming Webhook adapter (issue #797, PR2). + * + * Converts the provider-agnostic NotificationEvent into a Slack Incoming + * Webhook JSON body using Block Kit so status/title/link render legibly in + * a channel. `text` is always populated as the notification fallback (shown + * in previews / accessibility contexts); `blocks` carries the rich layout. + * https://api.slack.com/messaging/webhooks + */ +import type { NotificationEvent, WebhookEventKind } from './types.js'; + +const EVENT_LABEL: Record = { + succeeded: 'タスク完了', + failed: 'タスク失敗', + waiting_human: '回答待ち', +}; + +const EVENT_EMOJI: Record = { + succeeded: '✅', + failed: '❌', + waiting_human: '❓', +}; + +export interface SlackWebhookPayload { + text: string; + blocks: Array>; +} + +/** + * Build the Slack webhook payload. When `event.includeDetails` is false, + * the message omits task title / piece name / workspace name — only the + * generic event label, task id, and link — mirroring push-service.ts's + * includeDetails privacy default (and buildDiscordPayload's behavior). + */ +export function buildSlackPayload(event: NotificationEvent): SlackWebhookPayload { + const label = EVENT_LABEL[event.event]; + const emoji = EVENT_EMOJI[event.event]; + const link = event.taskUrl ? `<${event.taskUrl}|${'詳細を見る'}>` : undefined; + + if (!event.includeDetails) { + const headline = `${emoji} タスク #${event.taskId} ${label}`; + const text = link ? `${headline} ${link}` : headline; + return { + text: headline, + blocks: [ + { + type: 'section', + text: { type: 'mrkdwn', text }, + }, + ], + }; + } + + const headline = `${emoji} ${label}: ${event.taskTitle}`; + const sectionText = link ? `${headline}\n${link}` : headline; + + return { + text: headline, + blocks: [ + { + type: 'section', + text: { type: 'mrkdwn', text: sectionText }, + }, + { + type: 'context', + elements: [ + { type: 'mrkdwn', text: `*Piece:* ${event.pieceName || '(none)'}` }, + { type: 'mrkdwn', text: `*Workspace:* ${event.spaceName || '(none)'}` }, + { type: 'mrkdwn', text: `*Task ID:* ${event.taskId}` }, + ], + }, + ], + }; +} diff --git a/src/webhook-adapters/teams.test.ts b/src/webhook-adapters/teams.test.ts new file mode 100644 index 0000000..14beac5 --- /dev/null +++ b/src/webhook-adapters/teams.test.ts @@ -0,0 +1,60 @@ +import { describe, it, expect } from 'vitest'; +import { buildTeamsPayload } from './teams.js'; +import type { NotificationEvent } from './types.js'; + +const base: NotificationEvent = { + event: 'succeeded', + taskId: 42, + taskTitle: 'Ship the widget', + pieceName: 'chat', + spaceName: 'Acme Workspace', + taskUrl: 'https://maestro.example.com/?page=spaces&space=s1&chat=42', + includeDetails: true, +}; + +describe('buildTeamsPayload', () => { + it('includeDetails=true: MessageCard carries title, piece, workspace, task id, and link', () => { + const payload = buildTeamsPayload(base); + expect(payload['@type']).toBe('MessageCard'); + expect(payload['@context']).toBe('http://schema.org/extensions'); + expect(payload.summary).toContain('Ship the widget'); + expect(payload.summary).toContain('タスク完了'); + expect(payload.sections).toHaveLength(1); + const section = payload.sections[0]; + expect(section.activityTitle).toContain('Ship the widget'); + expect(section.facts).toBeDefined(); + const factValues = section.facts!.map(f => f.value); + expect(factValues).toContain('chat'); + expect(factValues).toContain('Acme Workspace'); + expect(factValues).toContain('42'); + expect(payload.potentialAction).toBeDefined(); + expect(payload.potentialAction![0].targets[0].uri).toBe(base.taskUrl); + }); + + it('includeDetails=false: omits task title, piece name, and workspace name', () => { + const payload = buildTeamsPayload({ ...base, includeDetails: false }); + expect(payload.summary).not.toContain('Ship the widget'); + expect(payload.summary).toContain('#42'); + expect(payload.sections[0].facts).toBeUndefined(); + // Serialized form must not leak the detailed fields either. + const json = JSON.stringify(payload); + expect(json).not.toContain('Ship the widget'); + expect(json).not.toContain('Acme Workspace'); + }); + + it('uses a distinct themeColor/emoji per event kind', () => { + const succeeded = buildTeamsPayload({ ...base, event: 'succeeded' }); + const failed = buildTeamsPayload({ ...base, event: 'failed' }); + const waiting = buildTeamsPayload({ ...base, event: 'waiting_human' }); + expect(succeeded.themeColor).not.toBe(failed.themeColor); + expect(failed.themeColor).not.toBe(waiting.themeColor); + expect(failed.summary).toContain('❌'); + expect(waiting.summary).toContain('❓'); + }); + + it('handles an empty taskUrl gracefully (no potentialAction)', () => { + const payload = buildTeamsPayload({ ...base, taskUrl: '' }); + expect(payload.potentialAction).toBeUndefined(); + expect(JSON.stringify(payload)).not.toContain('undefined'); + }); +}); diff --git a/src/webhook-adapters/teams.ts b/src/webhook-adapters/teams.ts new file mode 100644 index 0000000..9f3edc3 --- /dev/null +++ b/src/webhook-adapters/teams.ts @@ -0,0 +1,109 @@ +/** + * Microsoft Teams Incoming Webhook adapter (issue #797, PR3). + * + * Converts the provider-agnostic NotificationEvent into a MessageCard + * (Office 365 Connector Card) JSON body — the format understood by the + * classic Teams "Incoming Webhook" connector (URLs under + * *.webhook.office.com). This is the connector type most widely deployed + * today, so it's what we target here. + * + * NOTE: Microsoft has been deprecating Office 365 Connectors (including + * this MessageCard-based Incoming Webhook) in favor of "Workflows" (Power + * Automate), which expects an Adaptive Card in a different envelope. This + * adapter does NOT speak that newer format. If/when Workflows-based + * webhooks become the primary path, a second adapter (or a payload-shape + * switch here) will be needed — see + * https://learn.microsoft.com/microsoftteams/platform/webhooks-and-connectors/how-to/connectors-using + * and https://learn.microsoft.com/microsoftteams/platform/webhooks-and-connectors/what-are-webhooks-and-connectors + */ +import type { NotificationEvent, WebhookEventKind } from './types.js'; + +const EVENT_LABEL: Record = { + succeeded: 'タスク完了', + failed: 'タスク失敗', + waiting_human: '回答待ち', +}; + +const EVENT_EMOJI: Record = { + succeeded: '✅', + failed: '❌', + waiting_human: '❓', +}; + +// MessageCard themeColor is a bare hex triplet (no leading '#'). green / red / amber. +const EVENT_COLOR: Record = { + succeeded: '22c55e', + failed: 'ef4444', + waiting_human: 'f59e0b', +}; + +export interface TeamsWebhookPayload { + '@type': 'MessageCard'; + '@context': 'http://schema.org/extensions'; + themeColor: string; + summary: string; + sections: Array<{ + activityTitle: string; + facts?: Array<{ name: string; value: string }>; + markdown: true; + }>; + potentialAction?: Array<{ + '@type': 'OpenUri'; + name: string; + targets: Array<{ os: 'default'; uri: string }>; + }>; +} + +/** + * Build the Teams webhook payload. When `event.includeDetails` is false, + * the card omits task title / piece name / workspace name — only the + * generic event label, task id, and link — mirroring push-service.ts's + * includeDetails privacy default (and buildDiscordPayload / buildSlackPayload's + * behavior). + */ +export function buildTeamsPayload(event: NotificationEvent): TeamsWebhookPayload { + const label = EVENT_LABEL[event.event]; + const emoji = EVENT_EMOJI[event.event]; + const themeColor = EVENT_COLOR[event.event]; + const potentialAction: TeamsWebhookPayload['potentialAction'] = event.taskUrl + ? [ + { + '@type': 'OpenUri', + name: '詳細を見る', + targets: [{ os: 'default', uri: event.taskUrl }], + }, + ] + : undefined; + + if (!event.includeDetails) { + const activityTitle = `${emoji} タスク #${event.taskId} ${label}`; + return { + '@type': 'MessageCard', + '@context': 'http://schema.org/extensions', + themeColor, + summary: activityTitle, + sections: [{ activityTitle, markdown: true }], + ...(potentialAction ? { potentialAction } : {}), + }; + } + + const activityTitle = `${emoji} ${label}: ${event.taskTitle}`; + return { + '@type': 'MessageCard', + '@context': 'http://schema.org/extensions', + themeColor, + summary: activityTitle, + sections: [ + { + activityTitle, + facts: [ + { name: 'Piece', value: event.pieceName || '(none)' }, + { name: 'Workspace', value: event.spaceName || '(none)' }, + { name: 'Task ID', value: String(event.taskId) }, + ], + markdown: true, + }, + ], + ...(potentialAction ? { potentialAction } : {}), + }; +} diff --git a/src/webhook-adapters/types.ts b/src/webhook-adapters/types.ts new file mode 100644 index 0000000..4113690 --- /dev/null +++ b/src/webhook-adapters/types.ts @@ -0,0 +1,32 @@ +/** + * Shared adapter contract for workspace webhook notifications (issue #797, PR1). + * + * `NotificationEvent` is the provider-agnostic model that WebhookDeliveryService + * builds from a job transition. Each provider adapter (discord.ts today; + * slack.ts / teams.ts later) converts it into that chat app's payload shape. + * Adding a new provider should never require reshaping this type — if a field + * a new adapter needs isn't here yet, extend this interface instead of + * inventing a parallel one. + */ + +export type WebhookEventKind = 'succeeded' | 'failed' | 'waiting_human'; + +export interface NotificationEvent { + event: WebhookEventKind; + taskId: number; + taskTitle: string; + pieceName: string; + spaceName: string; + /** Absolute or relative URL to the task detail screen. */ + taskUrl: string; + /** + * Per-webhook "詳細を含める" toggle. When false, adapters must omit task + * title / piece name / space name from the payload and send only the + * generic event label + link (mirrors push-service.ts's includeDetails + * privacy default). + */ + includeDetails: boolean; +} + +/** A provider adapter turns a NotificationEvent into that provider's webhook JSON body. */ +export type WebhookAdapter = (event: NotificationEvent) => unknown; diff --git a/src/webhook-service.test.ts b/src/webhook-service.test.ts new file mode 100644 index 0000000..ace673b --- /dev/null +++ b/src/webhook-service.test.ts @@ -0,0 +1,353 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { mkdtempSync, rmSync } from 'fs'; +import { join } from 'path'; +import { tmpdir } from 'os'; +import { randomBytes } from 'crypto'; +import { Repository } from './db/repository.js'; +import type { SpaceWebhookRecord } from './db/repository.js'; +import { encrypt } from './mcp/crypto.js'; +import { WebhookDeliveryService, encodeWebhookPayload, buildTaskUrl, type WebhookNotifyInput } from './webhook-service.js'; +import { buildDiscordPayload } from './webhook-adapters/discord.js'; +import { buildSlackPayload } from './webhook-adapters/slack.js'; +import { buildTeamsPayload } from './webhook-adapters/teams.js'; +import type { NotificationEvent } from './webhook-adapters/types.js'; + +// Wrap the REAL ssrfSafeFetch by default (so the literal-private-IP / localhost +// tests exercise actual SSRF enforcement, not a stand-in), but let individual +// tests override it with mockResolvedValueOnce/mockRejectedValueOnce for +// scenarios that need a controlled Response (status codes, timeouts, a +// redirect-to-private-IP outcome — the redirect-hop revalidation itself is +// unit-tested in src/engine/tools/shared/ssrf.test.ts; here we simulate the +// error ssrfSafeFetch reports for that case and verify webhook-service reacts +// to it correctly). +vi.mock('./engine/tools/shared/ssrf.js', async (importOriginal) => { + const actual = await importOriginal(); + return { ...actual, ssrfSafeFetch: vi.fn(actual.ssrfSafeFetch) }; +}); + +import { ssrfSafeFetch } from './engine/tools/shared/ssrf.js'; +const ssrfMock = ssrfSafeFetch as unknown as ReturnType; + +function fakeResponse(status: number, ok = status >= 200 && status < 300): Response { + return { ok, status } as Response; +} + +describe('webhook-service', () => { + let tempDir = ''; + let repo: Repository; + let spaceId = ''; + + beforeEach(async () => { + process.env.MCP_ENCRYPTION_KEY = randomBytes(32).toString('hex'); + tempDir = mkdtempSync(join(tmpdir(), 'maestro-webhook-svc-')); + repo = new Repository(join(tempDir, 'db.sqlite')); + const owner = repo.createUser({ email: 'owner@example.com', name: 'Owner', role: 'user', status: 'active' }); + const space = await repo.createSpace({ kind: 'case', title: 'Case', ownerId: owner.id, visibility: 'private' }); + spaceId = space.id; + ssrfMock.mockClear(); + }); + + afterEach(() => { + repo.close(); + if (tempDir) { + rmSync(tempDir, { recursive: true, force: true }); + tempDir = ''; + } + }); + + function seedWebhook( + url: string, + opts: Partial<{ events: WebhookNotifyInput['event'][]; includeDetails: boolean; provider: SpaceWebhookRecord['provider'] }> = {}, + ): SpaceWebhookRecord { + const key = Buffer.from(process.env.MCP_ENCRYPTION_KEY!, 'hex'); + return repo.createSpaceWebhook({ + spaceId, + provider: opts.provider ?? 'discord', + label: 'Test webhook', + urlEnc: encrypt(url, key), + events: opts.events ?? ['succeeded', 'failed', 'waiting_human'], + includeDetails: opts.includeDetails ?? true, + }); + } + + const input = (overrides: Partial = {}): WebhookNotifyInput => ({ + event: 'succeeded', + spaceId, + taskId: 1, + taskTitle: 'T', + pieceName: 'chat', + spaceName: 'Case', + ...overrides, + }); + + // ── SSRF blocking ────────────────────────────────────────────────────── + + it('blocks a literal private IP destination as a permanent failure (no retry)', async () => { + const wh = seedWebhook('https://127.0.0.1:9000/hook'); + const service = new WebhookDeliveryService(repo, { perSendTimeoutMs: 1_000, maxRetryAttempts: 2, backoffMs: 5 }); + service.enqueue(input()); + await service.waitIdle(); + expect(ssrfMock).toHaveBeenCalledTimes(1); // permanent — no retry + const after = repo.getSpaceWebhookById(wh.id)!; + expect(after.failureCount).toBe(1); + expect(after.enabled).toBe(true); // below auto-disable threshold + }); + + it('blocks localhost as a permanent failure (no retry)', async () => { + const wh = seedWebhook('https://localhost/hook'); + const service = new WebhookDeliveryService(repo, { perSendTimeoutMs: 1_000, maxRetryAttempts: 2, backoffMs: 5 }); + service.enqueue(input()); + await service.waitIdle(); + expect(ssrfMock).toHaveBeenCalledTimes(1); + expect(repo.getSpaceWebhookById(wh.id)!.failureCount).toBe(1); + }); + + it('treats a redirect that lands on a private IP as a permanent failure (no retry)', async () => { + const wh = seedWebhook('https://discord.example/hook'); + ssrfMock.mockRejectedValueOnce( + new Error('SSRF blocked: "discord.example" resolves to forbidden IP "10.0.0.5"'), + ); + const service = new WebhookDeliveryService(repo, { perSendTimeoutMs: 1_000, maxRetryAttempts: 2, backoffMs: 5 }); + service.enqueue(input()); + await service.waitIdle(); + expect(ssrfMock).toHaveBeenCalledTimes(1); // SSRF-blocked → permanent, no retry + expect(repo.getSpaceWebhookById(wh.id)!.failureCount).toBe(1); + }); + + it('rejects a non-https URL before ever calling ssrfSafeFetch', async () => { + // Reachable only by going through the repo directly — the API layer + // (space-webhooks-api.ts) already rejects non-https input. + const wh = seedWebhook('http://example.com/hook'); + const service = new WebhookDeliveryService(repo, { perSendTimeoutMs: 1_000 }); + service.enqueue(input()); + await service.waitIdle(); + expect(ssrfMock).not.toHaveBeenCalled(); + expect(repo.getSpaceWebhookById(wh.id)!.failureCount).toBe(1); + }); + + // ── Timeout ────────────────────────────────────────────────────────── + + it('marks a failure on timeout without hanging', async () => { + const wh = seedWebhook('https://discord.example/hook'); + ssrfMock.mockImplementationOnce(() => new Promise(() => { /* never resolves */ })); + const service = new WebhookDeliveryService(repo, { perSendTimeoutMs: 30, maxRetryAttempts: 0 }); + service.enqueue(input()); + await service.waitIdle(); + expect(repo.getSpaceWebhookById(wh.id)!.failureCount).toBe(1); + }); + + // ── Payload size cap ───────────────────────────────────────────────── + + describe('encodeWebhookPayload', () => { + const notification: NotificationEvent = { + event: 'succeeded', + taskId: 1, + taskTitle: 'x'.repeat(2_000), + pieceName: 'y'.repeat(2_000), + spaceName: 'z'.repeat(2_000), + taskUrl: 'https://maestro.example.com/?page=spaces&space=s1&chat=1', + includeDetails: true, + }; + + it('returns the full payload when under the byte cap', () => { + const json = encodeWebhookPayload(buildDiscordPayload, notification, 1_000_000); + expect(json).toContain('x'.repeat(2_000)); + }); + + it('falls back to a generic (includeDetails=false) payload when over the byte cap', () => { + const json = encodeWebhookPayload(buildDiscordPayload, notification, 200); + expect(json).not.toContain('x'.repeat(50)); + expect(Buffer.byteLength(json, 'utf8')).toBeLessThan(Buffer.byteLength(JSON.stringify(buildDiscordPayload(notification)), 'utf8')); + }); + }); + + it('sends a trimmed body when the detailed payload exceeds payloadMaxBytes', async () => { + seedWebhook('https://discord.example/hook'); + ssrfMock.mockResolvedValueOnce(fakeResponse(204)); + const service = new WebhookDeliveryService(repo, { perSendTimeoutMs: 1_000, payloadMaxBytes: 100 }); + service.enqueue(input({ taskTitle: 'T'.repeat(1000), pieceName: 'P'.repeat(1000) })); + await service.waitIdle(); + const call = ssrfMock.mock.calls[0]; + const body = call[2]?.body as string; + expect(body).not.toContain('T'.repeat(50)); + }); + + // ── Provider dispatch ──────────────────────────────────────────────── + + it('dispatches provider=slack through the Slack adapter (same ssrfSafeFetch/retry path as Discord)', async () => { + const wh = seedWebhook('https://hooks.slack.com/services/T000/B000/XXXX', { provider: 'slack' }); + ssrfMock.mockResolvedValueOnce(fakeResponse(200)); + const service = new WebhookDeliveryService(repo, { perSendTimeoutMs: 1_000 }); + service.enqueue(input()); + await service.waitIdle(); + expect(ssrfMock).toHaveBeenCalledTimes(1); + const call = ssrfMock.mock.calls[0]; + const sentBody = call[2]?.body as string; + const expectedBody = JSON.stringify( + buildSlackPayload({ + event: 'succeeded', + taskId: 1, + taskTitle: 'T', + pieceName: 'chat', + spaceName: 'Case', + taskUrl: buildTaskUrl(spaceId, 1, ''), + includeDetails: true, + }), + ); + expect(sentBody).toBe(expectedBody); + expect(repo.getSpaceWebhookById(wh.id)!.lastSuccessAt).toBeTruthy(); + }); + + it('marks a Slack webhook failure through the same retry/failure-count path as Discord', async () => { + const wh = seedWebhook('https://hooks.slack.com/services/T000/B000/XXXX', { provider: 'slack' }); + ssrfMock.mockResolvedValueOnce(fakeResponse(404)); + const service = new WebhookDeliveryService(repo, { perSendTimeoutMs: 1_000, maxRetryAttempts: 2, backoffMs: 5 }); + service.enqueue(input()); + await service.waitIdle(); + expect(ssrfMock).toHaveBeenCalledTimes(1); // 404 is permanent — no retry + expect(repo.getSpaceWebhookById(wh.id)!.failureCount).toBe(1); + }); + + it('dispatches provider=teams through the Teams adapter (same ssrfSafeFetch/retry path as Discord)', async () => { + const wh = seedWebhook('https://example.webhook.office.com/webhookb2/xxx/IncomingWebhook/yyy/zzz', { provider: 'teams' }); + ssrfMock.mockResolvedValueOnce(fakeResponse(200)); + const service = new WebhookDeliveryService(repo, { perSendTimeoutMs: 1_000 }); + service.enqueue(input()); + await service.waitIdle(); + expect(ssrfMock).toHaveBeenCalledTimes(1); + const call = ssrfMock.mock.calls[0]; + const sentBody = call[2]?.body as string; + const expectedBody = JSON.stringify( + buildTeamsPayload({ + event: 'succeeded', + taskId: 1, + taskTitle: 'T', + pieceName: 'chat', + spaceName: 'Case', + taskUrl: buildTaskUrl(spaceId, 1, ''), + includeDetails: true, + }), + ); + expect(sentBody).toBe(expectedBody); + expect(repo.getSpaceWebhookById(wh.id)!.lastSuccessAt).toBeTruthy(); + }); + + it('marks a Teams webhook failure through the same retry/failure-count path as Discord', async () => { + const wh = seedWebhook('https://example.webhook.office.com/webhookb2/xxx/IncomingWebhook/yyy/zzz', { provider: 'teams' }); + ssrfMock.mockResolvedValueOnce(fakeResponse(404)); + const service = new WebhookDeliveryService(repo, { perSendTimeoutMs: 1_000, maxRetryAttempts: 2, backoffMs: 5 }); + service.enqueue(input()); + await service.waitIdle(); + expect(ssrfMock).toHaveBeenCalledTimes(1); // 404 is permanent — no retry + expect(repo.getSpaceWebhookById(wh.id)!.failureCount).toBe(1); + }); + + // ── Retry vs no-retry branching by status code ───────────────────────── + + for (const code of [401, 403, 404, 410]) { + it(`does NOT retry on ${code} (permanent)`, async () => { + const wh = seedWebhook('https://discord.example/hook'); + ssrfMock.mockResolvedValueOnce(fakeResponse(code)); + const service = new WebhookDeliveryService(repo, { perSendTimeoutMs: 1_000, maxRetryAttempts: 2, backoffMs: 5 }); + service.enqueue(input()); + await service.waitIdle(); + expect(ssrfMock).toHaveBeenCalledTimes(1); + expect(repo.getSpaceWebhookById(wh.id)!.failureCount).toBe(1); + }); + } + + it('retries on 429 and eventually succeeds', async () => { + const wh = seedWebhook('https://discord.example/hook'); + ssrfMock.mockResolvedValueOnce(fakeResponse(429)).mockResolvedValueOnce(fakeResponse(204)); + const service = new WebhookDeliveryService(repo, { perSendTimeoutMs: 1_000, maxRetryAttempts: 2, backoffMs: 5 }); + service.enqueue(input()); + await service.waitIdle(); + expect(ssrfMock).toHaveBeenCalledTimes(2); + const after = repo.getSpaceWebhookById(wh.id)!; + expect(after.failureCount).toBe(0); + expect(after.lastSuccessAt).toBeTruthy(); + }); + + it('retries on 503 up to maxRetryAttempts then gives up', async () => { + const wh = seedWebhook('https://discord.example/hook'); + ssrfMock.mockResolvedValue(fakeResponse(503)); + const service = new WebhookDeliveryService(repo, { perSendTimeoutMs: 1_000, maxRetryAttempts: 2, backoffMs: 5 }); + service.enqueue(input()); + await service.waitIdle(); + expect(ssrfMock).toHaveBeenCalledTimes(3); // initial + 2 retries + expect(repo.getSpaceWebhookById(wh.id)!.failureCount).toBe(1); + }); + + it('retries on a network error (no status code) then succeeds', async () => { + const wh = seedWebhook('https://discord.example/hook'); + ssrfMock.mockRejectedValueOnce(new Error('ECONNRESET')).mockResolvedValueOnce(fakeResponse(200)); + const service = new WebhookDeliveryService(repo, { perSendTimeoutMs: 1_000, maxRetryAttempts: 2, backoffMs: 5 }); + service.enqueue(input()); + await service.waitIdle(); + expect(ssrfMock).toHaveBeenCalledTimes(2); + expect(repo.getSpaceWebhookById(wh.id)!.lastSuccessAt).toBeTruthy(); + }); + + // ── Event filtering (silent drop, no error) ───────────────────────────── + + it('does not send when the webhook is not subscribed to the event', async () => { + seedWebhook('https://discord.example/hook', { events: ['succeeded'] }); + const service = new WebhookDeliveryService(repo, { perSendTimeoutMs: 1_000 }); + service.enqueue(input({ event: 'failed' })); + await service.waitIdle(); + expect(ssrfMock).not.toHaveBeenCalled(); + }); + + it('does not send when the webhook is disabled', async () => { + const wh = seedWebhook('https://discord.example/hook'); + repo.setSpaceWebhookEnabled(wh.id, false, 'paused'); + const service = new WebhookDeliveryService(repo, { perSendTimeoutMs: 1_000 }); + service.enqueue(input()); + await service.waitIdle(); + expect(ssrfMock).not.toHaveBeenCalled(); + }); + + it('enqueue() is fire-and-forget and never throws', () => { + seedWebhook('https://discord.example/hook'); + ssrfMock.mockResolvedValue(fakeResponse(200)); + const service = new WebhookDeliveryService(repo, { perSendTimeoutMs: 1_000 }); + expect(() => service.enqueue(input())).not.toThrow(); + }); + + // ── Task URL construction ──────────────────────────────────────────── + + it('buildTaskUrl uses a relative path when no publicBaseUrl is configured', () => { + expect(buildTaskUrl('space-1', 42, '')).toBe('/?page=spaces&space=space-1&chat=42'); + }); + + it('buildTaskUrl prefixes an absolute publicBaseUrl', () => { + expect(buildTaskUrl('space-1', 42, 'https://maestro.example.com/')).toBe( + 'https://maestro.example.com/?page=spaces&space=space-1&chat=42', + ); + }); + + // ── sendTest() ─────────────────────────────────────────────────────── + + it('sendTest() sends immediately and reports success', async () => { + const wh = seedWebhook('https://discord.example/hook'); + ssrfMock.mockResolvedValueOnce(fakeResponse(204)); + const service = new WebhookDeliveryService(repo, { perSendTimeoutMs: 1_000 }); + const result = await service.sendTest(wh.id); + expect(result).toEqual({ ok: true }); + expect(repo.getSpaceWebhookById(wh.id)!.lastSuccessAt).toBeTruthy(); + }); + + it('sendTest() reports failure without throwing', async () => { + const wh = seedWebhook('https://discord.example/hook'); + ssrfMock.mockResolvedValueOnce(fakeResponse(404)); + const service = new WebhookDeliveryService(repo, { perSendTimeoutMs: 1_000 }); + const result = await service.sendTest(wh.id); + expect(result.ok).toBe(false); + }); + + it('sendTest() returns an error for an unknown webhook id', async () => { + const service = new WebhookDeliveryService(repo, { perSendTimeoutMs: 1_000 }); + const result = await service.sendTest('does-not-exist'); + expect(result).toEqual({ ok: false, error: 'webhook not found' }); + }); +}); diff --git a/src/webhook-service.ts b/src/webhook-service.ts new file mode 100644 index 0000000..bee7414 --- /dev/null +++ b/src/webhook-service.ts @@ -0,0 +1,273 @@ +import PQueue from 'p-queue'; +import { logger } from './logger.js'; +import type { Repository, SpaceWebhookRecord, WebhookEventType } from './db/repository.js'; +import { decrypt, loadKeyFromEnv } from './mcp/crypto.js'; +import { ssrfSafeFetch } from './engine/tools/shared/ssrf.js'; +import type { NotificationEvent, WebhookAdapter } from './webhook-adapters/types.js'; +import { buildDiscordPayload } from './webhook-adapters/discord.js'; +import { buildSlackPayload } from './webhook-adapters/slack.js'; +import { buildTeamsPayload } from './webhook-adapters/teams.js'; + +/** + * Workspace webhook delivery service (issue #797, PR1 — Discord; PR2 — Slack; + * PR3 — Teams. All three known providers now have adapters registered below.) + * + * Modeled closely on src/push-service.ts: worker hooks call `enqueue()` + * fire-and-forget, a bounded PQueue absorbs concurrency, and delivery + * failures never throw back into the worker's job-completion path. + * + * SECURITY (do not regress): + * - The webhook URL is decrypted here (crypto.ts, same AES-256-GCM scheme + * as mcp_servers.static_token_enc) ONLY for the outbound fetch. It is + * never logged, never included in a thrown Error message, and never + * returned to a caller. + * - All outbound sends go through `ssrfSafeFetch` — never raw `fetch`. + * - https:// is enforced here in addition to the API-layer validation, + * since ssrfSafeFetch itself does not gate on scheme. + */ + +export interface WebhookNotifyInput { + event: WebhookEventType; + spaceId: string; + taskId: number; + taskTitle: string; + pieceName: string; + spaceName: string; +} + +export interface WebhookServiceOptions { + queueConcurrency?: number; + perSendTimeoutMs?: number; + payloadMaxBytes?: number; + maxRedirects?: number; + maxRetryAttempts?: number; + backoffMs?: number; + /** Absolute base URL for task-detail links; relative path when unset. */ + publicBaseUrl?: string; +} + +const DEFAULT_OPTIONS: Required = { + queueConcurrency: 8, + perSendTimeoutMs: 10_000, + payloadMaxBytes: 8_192, + maxRedirects: 5, + maxRetryAttempts: 2, // initial + 2 retries = up to 3 sends + backoffMs: 500, + publicBaseUrl: '', +}; + +const ADAPTERS: Partial> = { + discord: buildDiscordPayload as WebhookAdapter, + slack: buildSlackPayload as WebhookAdapter, + teams: buildTeamsPayload as WebhookAdapter, +}; + +export type SendResult = { ok: true } | { ok: false; error: string }; + +function sleep(ms: number): Promise { + return new Promise(resolve => setTimeout(resolve, ms)); +} + +function withTimeout(promise: Promise, ms: number): Promise { + return new Promise((resolve, reject) => { + const timer = setTimeout(() => reject(new Error(`webhook send timed out after ${ms}ms`)), ms); + promise.then( + value => { clearTimeout(timer); resolve(value); }, + err => { clearTimeout(timer); reject(err); }, + ); + }); +} + +/** Build the task-detail deep link used in the payload. Relative when no publicBaseUrl configured. */ +export function buildTaskUrl(spaceId: string, taskId: number, publicBaseUrl: string): string { + const path = `/?page=spaces&space=${encodeURIComponent(spaceId)}&chat=${taskId}`; + return publicBaseUrl ? `${publicBaseUrl.replace(/\/+$/, '')}${path}` : path; +} + +/** + * Serialize a provider payload with a byte-size cap. When the detailed + * payload exceeds `maxBytes`, falls back to a generic (includeDetails=false) + * payload built from the same adapter — mirrors push-service.ts's + * buildPushPayload trim-then-fallback strategy. + */ +export function encodeWebhookPayload( + adapter: WebhookAdapter, + event: NotificationEvent, + maxBytes: number, +): string { + const full = JSON.stringify(adapter(event)); + if (Buffer.byteLength(full, 'utf8') <= maxBytes) return full; + if (!event.includeDetails) return full; // already generic; nothing left to trim + const generic = JSON.stringify(adapter({ ...event, includeDetails: false })); + return generic; +} + +export class WebhookDeliveryService { + private readonly queue: PQueue; + private readonly options: Required; + + constructor( + private readonly repo: Repository, + options: WebhookServiceOptions = {}, + ) { + this.options = { ...DEFAULT_OPTIONS, ...options }; + this.queue = new PQueue({ concurrency: this.options.queueConcurrency }); + } + + /** + * Fire-and-forget — worker hooks call this and never await the promise. + * Silently drops webhooks whose `events` list does not include this event + * (no error, no log spam — this is expected filtering, not a failure). + */ + enqueue(input: WebhookNotifyInput): void { + let webhooks: SpaceWebhookRecord[]; + try { + webhooks = this.repo.listSpaceWebhooks(input.spaceId); + } catch (err) { + logger.error(`[webhook] listSpaceWebhooks failed space=${input.spaceId} err=${String(err)}`); + return; + } + for (const webhook of webhooks) { + if (!webhook.enabled) continue; + if (!webhook.events.includes(input.event)) continue; // silent drop by design + const notification: NotificationEvent = { + event: input.event, + taskId: input.taskId, + taskTitle: input.taskTitle, + pieceName: input.pieceName, + spaceName: input.spaceName, + taskUrl: buildTaskUrl(input.spaceId, input.taskId, this.options.publicBaseUrl), + includeDetails: webhook.includeDetails, + }; + void this.queue + .add(() => this.sendWithRetry(webhook, notification)) + .catch(err => { + logger.error(`[webhook] queue error webhook=${webhook.id} event=${input.event} err=${String(err)}`); + }); + } + } + + /** Returns when the queue is empty. Mainly useful in tests / shutdown. */ + async waitIdle(): Promise { + await this.queue.onIdle(); + } + + /** + * Manual "send test" action from the settings UI. Awaited directly (not + * fire-and-forget) so the API route can report success/failure inline. + * Runs through the same queue so a burst of test-sends still respects the + * concurrency cap. + */ + async sendTest(webhookId: string): Promise { + const webhook = this.repo.getSpaceWebhookById(webhookId); + if (!webhook) return { ok: false, error: 'webhook not found' }; + let spaceName = ''; + try { + const space = await this.repo.getSpace(webhook.spaceId); + spaceName = space?.title ?? ''; + } catch { + // best-effort; test payload still sends with an empty workspace name + } + const notification: NotificationEvent = { + event: 'succeeded', + taskId: 0, + taskTitle: 'テスト通知', + pieceName: 'Webhook 動作確認', + spaceName, + taskUrl: buildTaskUrl(webhook.spaceId, 0, this.options.publicBaseUrl), + includeDetails: webhook.includeDetails, + }; + return this.queue.add(() => this.sendWithRetry(webhook, notification)) as Promise; + } + + private async sendWithRetry(webhook: SpaceWebhookRecord, event: NotificationEvent): Promise { + const adapter = ADAPTERS[webhook.provider]; + if (!adapter) { + // Defensive: every known WebhookProvider has an adapter registered + // above as of PR3. This only fires for a future provider value that + // slipped past the API layer's IMPLEMENTED_PROVIDERS gate. Not a + // delivery failure — skip silently rather than incrementing failure_count. + logger.warn(`[webhook] no adapter for provider=${webhook.provider} webhook=${webhook.id} — skipping`); + return { ok: false, error: `provider not implemented: ${webhook.provider}` }; + } + + let url: string; + try { + url = decrypt(webhook.urlEnc, loadKeyFromEnv()); + } catch (err) { + // Never include the decrypt error's context (could echo ciphertext bytes) + // and never log the URL itself. + logger.error(`[webhook] decrypt failed webhook=${webhook.id} err=${(err as Error).message}`); + this.repo.markSpaceWebhookFailure(webhook.id); + return { ok: false, error: 'internal error' }; + } + if (!url.startsWith('https://')) { + logger.error(`[webhook] non-https URL rejected webhook=${webhook.id}`); + this.repo.markSpaceWebhookFailure(webhook.id); + return { ok: false, error: 'webhook URL must be https' }; + } + + const body = encodeWebhookPayload(adapter, event, this.options.payloadMaxBytes); + + let attempt = 0; + while (true) { + let res: Response; + try { + res = await withTimeout( + ssrfSafeFetch( + url, + [], + { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body, + }, + this.options.maxRedirects, + ), + this.options.perSendTimeoutMs, + ); + } catch (err) { + const msg = (err as Error).message ?? String(err); + // SSRF-blocked (or otherwise fundamentally disallowed) destinations + // will never succeed on retry — treat as permanent. + if (msg.includes('SSRF blocked')) { + logger.warn(`[webhook] blocked destination webhook=${webhook.id}: ${msg}`); + this.repo.markSpaceWebhookFailure(webhook.id); + return { ok: false, error: 'destination not allowed' }; + } + // Network error / timeout / DNS failure: transient, retry. + if (attempt < this.options.maxRetryAttempts) { + attempt += 1; + await sleep(this.options.backoffMs * Math.pow(2, attempt) + Math.random() * 300); + continue; + } + logger.warn(`[webhook] send failed (network) webhook=${webhook.id} attempt=${attempt} msg=${msg}`); + this.repo.markSpaceWebhookFailure(webhook.id); + return { ok: false, error: 'network error' }; + } + + if (res.ok) { + this.repo.markSpaceWebhookSuccess(webhook.id); + return { ok: true }; + } + + const code = res.status; + // 401 / 403 / 404 / 410: permanent — the destination is gone or rejects us. + if (code === 401 || code === 403 || code === 404 || code === 410) { + this.repo.markSpaceWebhookFailure(webhook.id); + logger.info(`[webhook] permanent failure (${code}) webhook=${webhook.id}`); + return { ok: false, error: `webhook rejected (${code})` }; + } + // 429 / 5xx: transient — backoff + jitter, retry up to maxRetryAttempts. + const isTransient = code === 429 || (code >= 500 && code < 600); + if (isTransient && attempt < this.options.maxRetryAttempts) { + attempt += 1; + await sleep(this.options.backoffMs * Math.pow(2, attempt) + Math.random() * 300); + continue; + } + this.repo.markSpaceWebhookFailure(webhook.id); + logger.warn(`[webhook] send failed webhook=${webhook.id} status=${code} attempt=${attempt}`); + return { ok: false, error: `webhook send failed (${code})` }; + } + } +} diff --git a/src/worker-bootstrap.ts b/src/worker-bootstrap.ts index b3e99c4..dc21ba4 100644 --- a/src/worker-bootstrap.ts +++ b/src/worker-bootstrap.ts @@ -39,6 +39,7 @@ import { checkBwrapAvailable } from './engine/tools/sandbox.js'; import { SkillCatalog } from './engine/skills.js'; import { VapidKeyStore } from './vapid-store.js'; import { PushService } from './push-service.js'; +import { WebhookDeliveryService } from './webhook-service.js'; function runPreflight(configPath: string, dbPath: string): void { const errors: string[] = []; @@ -265,6 +266,28 @@ export async function start(opts: StartWorkerOptions = {}): Promise { logger.info('[startup] Web Push V2 disabled (notifications.push.enabled=false)'); } + // Workspace webhook notifications (Discord/Slack/Teams). Spec: issue #797. + // When `webhooks.enabled` is false (the default), we still mount the API + // routes so the UI can show a clear "disabled" signal, but workers do not + // fire any sends and test-send returns 503 (webhookService = null). + let webhookService: WebhookDeliveryService | null = null; + const webhooksCfg = config.webhooks; + if (webhooksCfg?.enabled) { + webhookService = new WebhookDeliveryService(repo, { + queueConcurrency: webhooksCfg.queueConcurrency, + perSendTimeoutMs: webhooksCfg.timeoutMs, + payloadMaxBytes: webhooksCfg.payloadMaxBytes, + maxRedirects: webhooksCfg.maxRedirects, + maxRetryAttempts: webhooksCfg.retry?.maxAttempts, + backoffMs: webhooksCfg.retry?.backoffMs, + publicBaseUrl: webhooksCfg.publicBaseUrl, + }); + workerManager.setWebhookService(webhookService); + logger.info('[startup] workspace webhook notifications enabled'); + } else { + logger.info('[startup] workspace webhook notifications disabled (webhooks.enabled=false)'); + } + const selectPiece = titleClient ? async (body: string, fileNames: string[], userId?: string): Promise => { const pieces = pieceCatalog.getForUser(userId ?? 'local'); @@ -353,6 +376,7 @@ export async function start(opts: StartWorkerOptions = {}): Promise { skillCatalog, pushService, vapidStore, + webhookService, }, port); // Graceful shutdown — see installWorkerShutdownHooks() docstring. diff --git a/src/worker-manager.ts b/src/worker-manager.ts index fc9235d..374a927 100644 --- a/src/worker-manager.ts +++ b/src/worker-manager.ts @@ -8,6 +8,7 @@ import type { McpTokenManager } from './mcp/token-manager.js'; import type { WorkerMetrics } from './metrics/worker-metrics.js'; import type { SkillCatalog } from './engine/skills.js'; import type { PushService } from './push-service.js'; +import type { WebhookDeliveryService } from './webhook-service.js'; export class WorkerManager { private workers: Worker[] = []; @@ -33,6 +34,7 @@ export class WorkerManager { private workerMetrics: WorkerMetrics | null = null; private skillCatalog: SkillCatalog | null = null; private pushService: PushService | null = null; + private webhookService: WebhookDeliveryService | null = null; constructor(repo: Repository, configManager: ConfigManager) { this.repo = repo; @@ -231,6 +233,18 @@ export class WorkerManager { } } + /** + * Workspace webhook notification service (Discord/Slack/Teams). Null + * disables webhook hooks across all workers (rebuild safe). Fanned out to + * every Worker on construct + on rebuild. Spec: issue #797. + */ + setWebhookService(service: WebhookDeliveryService | null): void { + this.webhookService = service; + for (const w of this.workers) { + w.setWebhookService(service); + } + } + private createWorkers(config: AppConfig): Worker[] { return config.provider.workers .filter(def => def.enabled !== false) @@ -248,6 +262,7 @@ export class WorkerManager { if (this.workerMetrics) w.setWorkerMetrics(this.workerMetrics); if (this.skillCatalog) w.setSkillCatalog(this.skillCatalog); if (this.pushService) w.setPushService(this.pushService); + if (this.webhookService) w.setWebhookService(this.webhookService); return w; } } diff --git a/src/worker.ts b/src/worker.ts index 2e6965d..a2c1ff2 100644 --- a/src/worker.ts +++ b/src/worker.ts @@ -571,6 +571,13 @@ export class Worker { * Spec: docs/superpowers/specs/2026-05-28-browser-notifications-v2-webpush.md. */ private pushService: import('./push-service.js').PushService | null = null; + /** + * Workspace webhook notification service (Discord/Slack/Teams). Null when + * disabled via config (`webhooks.enabled`) or when the worker is built + * without one (tests). Hooks fire via enqueue (fire-and-forget), same + * fan-out point as enqueuePush. Spec: issue #797. + */ + private webhookService: import('./webhook-service.js').WebhookDeliveryService | null = null; constructor( workerId: string, @@ -596,6 +603,10 @@ export class Worker { this.pushService = svc; } + public setWebhookService(svc: import('./webhook-service.js').WebhookDeliveryService | null): void { + this.webhookService = svc; + } + /** * Hot-swap the global config on a still-running worker. Used by * WorkerManager's differential rebuild: when a config change does NOT @@ -699,6 +710,57 @@ export class Worker { }); } + /** + * Fire a workspace webhook notification for a job status transition. + * Fire-and-forget — never throws and never awaits the underlying queue. + * Symmetric to enqueuePush but scoped to the job's space rather than its + * owner, and only fires for succeeded / failed / waiting_human (V1 scope, + * issue #797 — task start is intentionally excluded to reduce noise). + * Skips silently when: + * - webhook delivery is disabled (webhookService === null) + * - the job has no space (webhooks are workspace-level, not per-user) + * - the job is not a local task + * - the job is a reflection job (internal mechanism, not user-facing work) + * Per-webhook event filtering (e.g. a webhook only subscribed to `failed`) + * happens inside WebhookDeliveryService.enqueue, not here. + */ + private enqueueWebhook( + job: Job, + event: 'succeeded' | 'failed' | 'waiting_human', + ): void { + if (!this.webhookService) return; + if (job.taskKind === 'reflection') return; + if (!job.spaceId) return; + const localTaskId = getLocalTaskId(job.repo); + if (localTaskId === null) return; + let taskTitle = `Task #${localTaskId}`; + try { + const row = this.repo.getDb() + .prepare('SELECT title FROM local_tasks WHERE id = ?') + .get(localTaskId) as { title: string | null } | undefined; + if (row?.title) taskTitle = row.title; + } catch { + // best-effort; fall through with default title + } + let spaceName = ''; + try { + const row = this.repo.getDb() + .prepare('SELECT title FROM spaces WHERE id = ?') + .get(job.spaceId) as { title: string | null } | undefined; + if (row?.title) spaceName = row.title; + } catch { + // best-effort; fall through with empty space name + } + this.webhookService.enqueue({ + event, + spaceId: job.spaceId, + taskId: localTaskId, + taskTitle, + pieceName: job.pieceName, + spaceName, + }); + } + /** * Phase 3b: install (or remove) the Prometheus metrics handle. * Idempotent — calling with the same handle twice is fine. Null @@ -2835,6 +2897,7 @@ export class Worker { } await this.repo.updateJob(jobId, { status: 'succeeded', currentActivity: null }); this.enqueuePush(job, 'succeeded'); + this.enqueueWebhook(job, 'succeeded'); await maybeEnqueueReflection(this.repo, job, 'succeeded', this.config.reflection, this.config.provider.workers); let resultBody = result.finalOutput; if (resultBody) { @@ -2882,6 +2945,7 @@ export class Worker { waitReason: 'tool_request', }); this.enqueuePush(job, 'waiting_human'); + this.enqueueWebhook(job, 'waiting_human'); await this.repo.addAuditLog(jobId, 'job_tool_request', 'worker', { resumeMovement: result.resumeMovement, }); @@ -2897,6 +2961,7 @@ export class Worker { waitReason: 'package_request', }); this.enqueuePush(job, 'waiting_human'); + this.enqueueWebhook(job, 'waiting_human'); await this.repo.addAuditLog(jobId, 'job_package_request', 'worker', { resumeMovement: result.resumeMovement, }); @@ -2907,6 +2972,7 @@ export class Worker { askCount: job.askCount + 1, }); this.enqueuePush(job, 'waiting_human'); + this.enqueueWebhook(job, 'waiting_human'); await reporter.reportAsk(result.finalOutput); await this.repo.addAuditLog(jobId, 'job_ask', 'worker', { question: result.finalOutput, @@ -3170,6 +3236,7 @@ export class Worker { // V2 push: only on terminal fail. Intermediate retry attempts are // silenced (matches V1's 4-second debounce intent). this.enqueuePush(job, 'failed'); + this.enqueueWebhook(job, 'failed'); writeRetryHandoffSummary({ workspacePath: workspacePath ?? job.worktreePath, job, diff --git a/src/worker.webhook-enqueue.test.ts b/src/worker.webhook-enqueue.test.ts new file mode 100644 index 0000000..0046854 --- /dev/null +++ b/src/worker.webhook-enqueue.test.ts @@ -0,0 +1,191 @@ +/** + * Worker → WebhookDeliveryService fan-out (issue #797, PR1). + * + * Two layers, mirroring worker.job-guard.test.ts's approach of invoking a + * private method via bracket-cast (there is no dedicated test for the + * symmetric enqueuePush either — see push-service.test.ts for the delivery + * side; this file covers the worker-side trigger logic): + * + * 1. Unit tests on the private `enqueueWebhook` method: correct payload + * shape and every skip condition (no service, no space, reflection job, + * not a local task). + * 2. A source-level regression guard proving succeeded/failed/waiting_human + * call sites invoke enqueueWebhook and the running call site does not — + * `enqueueWebhook`'s TypeScript signature only accepts + * 'succeeded'|'failed'|'waiting_human', so a `running` call would fail + * to compile; this test guards against someone loosening that type and + * wiring it in anyway. + */ +import { describe, it, expect, vi } from 'vitest'; +import { readFileSync } from 'node:fs'; +import { fileURLToPath } from 'node:url'; +import { dirname, join } from 'node:path'; +import { Worker } from './worker.js'; +import type { AppConfig } from './config.js'; +import type { Job } from './db/repository.js'; + +const __dirname = dirname(fileURLToPath(import.meta.url)); + +function makeConfig(): AppConfig { + return { + provider: { model: 'test-model', workers: [{ id: 'worker-1', endpoint: 'http://localhost:11434/v1' }] }, + worktreeDir: '/tmp/worker-webhook-test', + concurrency: 1, + maxMovements: 30, + retry: { maxAttempts: 3, backoffSeconds: [60, 300, 900] }, + ask: { maxPerJob: 2 }, + subtasks: { maxDepth: 2 }, + safety: {}, + tools: { + searxngUrl: 'http://localhost:8080', visionModel: 'v', visionTimeout: 60, + visionMaxTokens: 1024, webfetchTimeout: 30, websearchTimeout: 15, webfetchAllowedHosts: [], + }, + } as AppConfig; +} + +function fakeRepo(rows: { taskTitle?: string | null; spaceTitle?: string | null } = {}) { + return { + getDb: () => ({ + prepare: (sql: string) => ({ + get: (..._args: unknown[]) => { + if (sql.includes('FROM local_tasks')) return { title: rows.taskTitle ?? null }; + if (sql.includes('FROM spaces')) return { title: rows.spaceTitle ?? null }; + return undefined; + }, + }), + }), + }; +} + +function makeJob(overrides: Partial = {}): Job { + return { + id: 'job-1', + repo: 'local/task-42', + issueNumber: 42, + status: 'succeeded', + pieceName: 'chat', + requiredProfile: 'auto', + taskClass: 'auto', + instruction: 'x', + attempt: 1, + maxAttempts: 3, + askCount: 0, + ownerId: 'user-1', + spaceId: 'space-1', + visibility: 'private', + visibilityScopeOrgId: null, + taskKind: 'agent', + ...overrides, + } as unknown as Job; +} + +function buildWorker(repo: ReturnType) { + const worker = new Worker('worker-1', 'http://localhost:11434/v1', 'm', repo as never, makeConfig()); + const enqueueWebhook = (worker as unknown as { enqueueWebhook: (job: Job, event: 'succeeded' | 'failed' | 'waiting_human') => void }).enqueueWebhook.bind(worker); + return { worker, enqueueWebhook }; +} + +describe('Worker.enqueueWebhook', () => { + it('fans out to the webhook service with the resolved task/space titles', () => { + const repo = fakeRepo({ taskTitle: 'My Task', spaceTitle: 'My Workspace' }); + const { worker, enqueueWebhook } = buildWorker(repo); + const service = { enqueue: vi.fn() }; + worker.setWebhookService(service as never); + + enqueueWebhook(makeJob(), 'succeeded'); + + expect(service.enqueue).toHaveBeenCalledTimes(1); + expect(service.enqueue).toHaveBeenCalledWith({ + event: 'succeeded', + spaceId: 'space-1', + taskId: 42, + taskTitle: 'My Task', + pieceName: 'chat', + spaceName: 'My Workspace', + }); + }); + + it('fires for failed and waiting_human too', () => { + const repo = fakeRepo(); + const { worker, enqueueWebhook } = buildWorker(repo); + const service = { enqueue: vi.fn() }; + worker.setWebhookService(service as never); + + enqueueWebhook(makeJob(), 'failed'); + enqueueWebhook(makeJob(), 'waiting_human'); + + expect(service.enqueue).toHaveBeenCalledTimes(2); + expect(service.enqueue.mock.calls[0][0].event).toBe('failed'); + expect(service.enqueue.mock.calls[1][0].event).toBe('waiting_human'); + }); + + it('falls back to a generic task title and empty space name when lookups miss', () => { + const repo = fakeRepo({ taskTitle: null, spaceTitle: null }); + const { worker, enqueueWebhook } = buildWorker(repo); + const service = { enqueue: vi.fn() }; + worker.setWebhookService(service as never); + + enqueueWebhook(makeJob(), 'succeeded'); + + expect(service.enqueue).toHaveBeenCalledWith(expect.objectContaining({ + taskTitle: 'Task #42', + spaceName: '', + })); + }); + + it('does nothing when webhookService is not configured', () => { + const repo = fakeRepo(); + const { enqueueWebhook } = buildWorker(repo); + // No setWebhookService call — service stays null. Should not throw. + expect(() => enqueueWebhook(makeJob(), 'succeeded')).not.toThrow(); + }); + + it('skips when the job has no space (personal / space-less job)', () => { + const repo = fakeRepo(); + const { worker, enqueueWebhook } = buildWorker(repo); + const service = { enqueue: vi.fn() }; + worker.setWebhookService(service as never); + + enqueueWebhook(makeJob({ spaceId: null }), 'succeeded'); + + expect(service.enqueue).not.toHaveBeenCalled(); + }); + + it('skips reflection jobs (internal mechanism, not user-facing)', () => { + const repo = fakeRepo(); + const { worker, enqueueWebhook } = buildWorker(repo); + const service = { enqueue: vi.fn() }; + worker.setWebhookService(service as never); + + enqueueWebhook(makeJob({ taskKind: 'reflection' }), 'succeeded'); + + expect(service.enqueue).not.toHaveBeenCalled(); + }); + + it('skips jobs that are not local tasks (no local/task-N repo id)', () => { + const repo = fakeRepo(); + const { worker, enqueueWebhook } = buildWorker(repo); + const service = { enqueue: vi.fn() }; + worker.setWebhookService(service as never); + + enqueueWebhook(makeJob({ repo: 'some/external-repo' }), 'succeeded'); + + expect(service.enqueue).not.toHaveBeenCalled(); + }); +}); + +describe('Worker source wiring (regression guard)', () => { + const source = readFileSync(join(__dirname, 'worker.ts'), 'utf-8'); + + it('calls enqueueWebhook next to enqueuePush for succeeded/failed/waiting_human', () => { + // Every waiting_human transition and the terminal succeeded/failed + // transitions must pair enqueuePush with enqueueWebhook. + const pushCalls = source.match(/this\.enqueuePush\(job, '(succeeded|failed|waiting_human)'\);\n\s*this\.enqueueWebhook\(job, '\1'\);/g) ?? []; + // 1 succeeded + 1 failed + 3 waiting_human call sites (tool_request / package_request / ask) = 5 + expect(pushCalls.length).toBe(5); + }); + + it('never calls enqueueWebhook for the running transition', () => { + expect(source).not.toMatch(/enqueueWebhook\(job, 'running'\)/); + }); +}); diff --git a/ui/src/api/spaces.ts b/ui/src/api/spaces.ts index 8afdadd..59c19fb 100644 --- a/ui/src/api/spaces.ts +++ b/ui/src/api/spaces.ts @@ -267,6 +267,96 @@ export async function removeSpacePythonPackage( return data as { packages: SpacePythonPackage[] }; } +// ─── Per-space webhook notifications (issue #797, PR1: Discord only) ───── +export type SpaceWebhookProvider = 'discord' | 'slack' | 'teams'; +export type SpaceWebhookEvent = 'succeeded' | 'failed' | 'waiting_human'; + +export interface SpaceWebhook { + id: string; + provider: SpaceWebhookProvider; + label: string; + events: SpaceWebhookEvent[]; + includeDetails: boolean; + enabled: boolean; + disabledReason: string | null; + lastSuccessAt: string | null; + lastFailureAt: string | null; + failureCount: number; + createdAt: string; + updatedAt: string; + // NOTE: no `url` / `urlEnc` field — the server never returns the webhook + // URL once saved (write-only secret). +} + +export async function fetchSpaceWebhooks(spaceId: string): Promise { + const res = await fetch(`${BASE}/local/spaces/${spaceId}/webhooks`); + const data = await res.json().catch(() => ({})); + if (!res.ok) throw new Error(data?.error ?? 'Failed to fetch webhooks'); + return (data?.webhooks ?? []) as SpaceWebhook[]; +} + +export async function createSpaceWebhook( + spaceId: string, + input: { provider: SpaceWebhookProvider; label: string; url: string; events: SpaceWebhookEvent[]; includeDetails?: boolean }, +): Promise { + const res = await fetch(`${BASE}/local/spaces/${spaceId}/webhooks`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(input), + }); + const data = await res.json().catch(() => ({})); + if (!res.ok) throw new Error(data?.error ?? 'Failed to create webhook'); + return data as SpaceWebhook; +} + +export async function updateSpaceWebhook( + spaceId: string, + webhookId: string, + patch: { label?: string; url?: string; events?: SpaceWebhookEvent[]; includeDetails?: boolean }, +): Promise { + const res = await fetch(`${BASE}/local/spaces/${spaceId}/webhooks/${webhookId}`, { + method: 'PUT', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(patch), + }); + const data = await res.json().catch(() => ({})); + if (!res.ok) throw new Error(data?.error ?? 'Failed to update webhook'); + return data as SpaceWebhook; +} + +export async function setSpaceWebhookEnabled( + spaceId: string, + webhookId: string, + enabled: boolean, +): Promise { + const res = await fetch(`${BASE}/local/spaces/${spaceId}/webhooks/${webhookId}`, { + method: 'PATCH', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ enabled }), + }); + const data = await res.json().catch(() => ({})); + if (!res.ok) throw new Error(data?.error ?? 'Failed to update webhook'); + return data as SpaceWebhook; +} + +export async function deleteSpaceWebhook(spaceId: string, webhookId: string): Promise { + const res = await fetch(`${BASE}/local/spaces/${spaceId}/webhooks/${webhookId}`, { method: 'DELETE' }); + if (!res.ok) { + const d = await res.json().catch(() => ({})); + throw new Error(d?.error ?? 'Failed to delete webhook'); + } +} + +export async function testSendSpaceWebhook( + spaceId: string, + webhookId: string, +): Promise<{ ok: boolean; error?: string }> { + const res = await fetch(`${BASE}/local/spaces/${spaceId}/webhooks/${webhookId}/test`, { method: 'POST' }); + const data = await res.json().catch(() => ({})); + if (!res.ok) return { ok: false, error: data?.error ?? 'Failed to send test notification' }; + return data as { ok: boolean; error?: string }; +} + export async function createSpace(input: { title: string; description?: string; diff --git a/ui/src/components/settings/SettingsSidebar.test.tsx b/ui/src/components/settings/SettingsSidebar.test.tsx index bdd891b..c410cc4 100644 --- a/ui/src/components/settings/SettingsSidebar.test.tsx +++ b/ui/src/components/settings/SettingsSidebar.test.tsx @@ -46,4 +46,27 @@ describe('SettingsSidebar search', () => { // Safety is under an adminOnly group → not searchable for a non-admin. expect(screen.queryByTestId('settings-search-result-safety')).not.toBeInTheDocument(); }); + + it('hides the A2A Delegations section when a2a is disabled (default fail-closed)', () => { + const onSelect = vi.fn(); + // Omitting a2aEnabled must default to hidden — the delegations API route + // only exists when a2a.enabled, so an always-shown section errors out. + renderWithProviders(); + expect(screen.queryByTestId('settings-nav-a2a-delegations')).not.toBeInTheDocument(); + // A sibling per-user section stays visible. + expect(screen.getByTestId('settings-nav-pets')).toBeInTheDocument(); + }); + + it('shows the A2A Delegations section when a2a is enabled', () => { + const onSelect = vi.fn(); + renderWithProviders(); + expect(screen.getByTestId('settings-nav-a2a-delegations')).toBeInTheDocument(); + }); + + it('does not surface A2A Delegations in search when a2a is disabled', () => { + const onSelect = vi.fn(); + renderWithProviders(); + fireEvent.change(screen.getByTestId('settings-search'), { target: { value: 'delegation' } }); + expect(screen.queryByTestId('settings-search-result-a2a-delegations')).not.toBeInTheDocument(); + }); }); diff --git a/ui/src/components/settings/SettingsSidebar.tsx b/ui/src/components/settings/SettingsSidebar.tsx index 6c3fb8c..9522484 100644 --- a/ui/src/components/settings/SettingsSidebar.tsx +++ b/ui/src/components/settings/SettingsSidebar.tsx @@ -6,6 +6,12 @@ interface SettingsSidebarProps { activeSection?: string; onSelectSection: (section: string) => void; isAdmin: boolean; + /** + * Whether A2A is enabled server-side. The A2A Delegations section is per-user + * but its API route (`/api/local/a2a/delegations`) only mounts when a2a.enabled, + * so the section is hidden unless this is true (defaults false = fail-closed). + */ + a2aEnabled?: boolean; } /** @@ -135,14 +141,23 @@ export const USER_SECTIONS: string[] = CONFIG_GROUPS .filter(g => !('adminOnly' in g) || !g.adminOnly) .flatMap(g => g.sections.map(s => s.id)); -export function SettingsSidebar({ activeSection, onSelectSection, isAdmin }: SettingsSidebarProps) { +/** Sections gated on a runtime feature flag rather than admin role. */ +function isSectionAvailable(sectionId: string, a2aEnabled: boolean): boolean { + if (sectionId === 'a2a-delegations') return a2aEnabled; + return true; +} + +export function SettingsSidebar({ activeSection, onSelectSection, isAdmin, a2aEnabled = false }: SettingsSidebarProps) { const { t } = useTranslation('settings'); const [query, setQuery] = useState(''); - const visibleGroups = CONFIG_GROUPS.filter(g => isAdmin || !('adminOnly' in g) || !g.adminOnly); + const visibleGroups = CONFIG_GROUPS + .filter(g => isAdmin || !('adminOnly' in g) || !g.adminOnly) + .map(g => ({ ...g, sections: g.sections.filter(s => isSectionAvailable(s.id, a2aEnabled)) })) + .filter(g => g.sections.length > 0); // Only sections the current user can actually open are searchable. const visibleIds = useMemo( - () => new Set(visibleGroups.flatMap(g => g.sections.map(s => s.id))), + () => new Set(visibleGroups.flatMap(g => g.sections.map(s => s.id))), [visibleGroups], ); const index = useMemo(() => buildSettingsSearchIndex().filter(e => visibleIds.has(e.sectionId)), [visibleIds]); diff --git a/ui/src/components/spaces/SpaceSettings.tsx b/ui/src/components/spaces/SpaceSettings.tsx index 0954e72..2125f43 100644 --- a/ui/src/components/spaces/SpaceSettings.tsx +++ b/ui/src/components/spaces/SpaceSettings.tsx @@ -22,6 +22,7 @@ import { SshConnectionsPanel } from '../userfolder/SshConnectionsPanel'; import { SpaceMembersPanel } from './SpaceMembersPanel'; import { SpaceBrowserPanel } from './SpaceBrowserPanel'; import { SpaceToolSettings } from './SpaceToolSettings'; +import { SpaceWebhookSettings } from './SpaceWebhookSettings'; import { PythonPackagesPanel } from './PythonPackagesPanel'; import { PieceEditor } from '../settings/PieceEditor'; import { usePieceList } from '../../hooks/usePieces'; @@ -31,7 +32,7 @@ import { useAuthState } from '../../App'; type ShowToast = (message: string, variant?: 'success' | 'error') => void; -type SettingsSection = 'agents' | 'memory' | 'pieces' | 'skills' | 'mcp' | 'ssh' | 'browser' | 'members' | 'tools' | 'python'; +type SettingsSection = 'agents' | 'memory' | 'pieces' | 'skills' | 'mcp' | 'ssh' | 'browser' | 'members' | 'tools' | 'python' | 'webhooks'; const SECTIONS: { id: SettingsSection; labelKey: string; testid: string }[] = [ { id: 'agents', labelKey: 'settings.nav.agents', testid: 'space-settings-nav-agents' }, @@ -43,6 +44,7 @@ const SECTIONS: { id: SettingsSection; labelKey: string; testid: string }[] = [ { id: 'browser', labelKey: 'settings.nav.browser', testid: 'space-settings-nav-browser' }, { id: 'tools', labelKey: 'settings.nav.tools', testid: 'space-settings-nav-tools' }, { id: 'python', labelKey: 'settings.nav.python', testid: 'space-settings-nav-python' }, + { id: 'webhooks', labelKey: 'settings.nav.webhooks', testid: 'space-settings-nav-webhooks' }, { id: 'members', labelKey: 'settings.nav.members', testid: 'space-settings-nav-members' }, ]; @@ -88,6 +90,7 @@ export function SpaceSettings({ spaceId, showToast }: { spaceId: string; showToa {section === 'browser' && } {section === 'tools' && } {section === 'python' && } + {section === 'webhooks' && } {section === 'members' && } diff --git a/ui/src/components/spaces/SpaceWebhookSettings.test.tsx b/ui/src/components/spaces/SpaceWebhookSettings.test.tsx new file mode 100644 index 0000000..8e0e70f --- /dev/null +++ b/ui/src/components/spaces/SpaceWebhookSettings.test.tsx @@ -0,0 +1,167 @@ +// @vitest-environment jsdom +/** + * Component tests for SpaceWebhookSettings (per-space webhook notification UI). + * + * api + App.useAuthState are mocked so no real network and canManage=true. + */ +import '../../test/dom-setup'; +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { screen, waitFor } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { renderWithProviders } from '../../test/render-helpers'; +import i18n from '../../i18n'; + +const { + fetchMock, createMock, deleteMock, setEnabledMock, testSendMock, fetchMembersMock, +} = vi.hoisted(() => ({ + fetchMock: vi.fn(), + createMock: vi.fn(), + deleteMock: vi.fn(), + setEnabledMock: vi.fn(), + testSendMock: vi.fn(), + fetchMembersMock: vi.fn(), +})); + +vi.mock('../../api', () => ({ + fetchSpaceWebhooks: fetchMock, + createSpaceWebhook: createMock, + updateSpaceWebhook: vi.fn(), + setSpaceWebhookEnabled: setEnabledMock, + deleteSpaceWebhook: deleteMock, + testSendSpaceWebhook: testSendMock, + fetchSpaceMembers: fetchMembersMock, +})); + +vi.mock('../../App', () => ({ + useAuthState: () => ({ mode: 'disabled' as const }), +})); + +import { SpaceWebhookSettings } from './SpaceWebhookSettings'; + +const ONE_WEBHOOK = [ + { + id: 'wh-1', + provider: 'discord' as const, + label: 'Dev channel', + events: ['succeeded', 'failed'] as const, + includeDetails: true, + enabled: true, + disabledReason: null, + lastSuccessAt: '2026-07-01T00:00:00Z', + lastFailureAt: null, + failureCount: 0, + createdAt: '2026-06-01T00:00:00Z', + updatedAt: '2026-06-01T00:00:00Z', + }, +]; + +const AUTO_DISABLED_WEBHOOK = [ + { + ...ONE_WEBHOOK[0], + id: 'wh-2', + enabled: false, + disabledReason: '10 consecutive delivery failures', + failureCount: 10, + }, +]; + +beforeEach(() => { + vi.clearAllMocks(); + void i18n.changeLanguage('ja'); + fetchMembersMock.mockResolvedValue([]); + fetchMock.mockResolvedValue([]); + createMock.mockResolvedValue(ONE_WEBHOOK[0]); + deleteMock.mockResolvedValue(undefined); + setEnabledMock.mockResolvedValue({ ...ONE_WEBHOOK[0], enabled: true }); + testSendMock.mockResolvedValue({ ok: true }); +}); + +describe('SpaceWebhookSettings', () => { + it('renders an empty state when there are no webhooks', async () => { + renderWithProviders(); + await waitFor(() => expect(screen.getByTestId('space-webhook-settings')).toBeInTheDocument()); + expect(screen.getByText('まだ何も登録されていません。')).toBeInTheDocument(); + }); + + it('adds a webhook via the form', async () => { + renderWithProviders(); + await waitFor(() => expect(screen.getByTestId('webhook-label-input')).toBeInTheDocument()); + + await userEvent.type(screen.getByTestId('webhook-label-input'), 'Dev channel'); + await userEvent.type(screen.getByTestId('webhook-url-input'), 'https://discord.com/api/webhooks/1/2'); + await userEvent.click(screen.getByText('追加')); + + await waitFor(() => expect(createMock).toHaveBeenCalledWith('s1', { + provider: 'discord', + label: 'Dev channel', + url: 'https://discord.com/api/webhooks/1/2', + events: ['succeeded', 'failed', 'waiting_human'], + includeDetails: true, + })); + }); + + it('lists an existing webhook and deletes it', async () => { + fetchMock.mockResolvedValue(ONE_WEBHOOK); + renderWithProviders(); + await waitFor(() => expect(screen.getByText('Dev channel')).toBeInTheDocument()); + + await userEvent.click(screen.getByTestId('webhook-delete-wh-1')); + await waitFor(() => expect(deleteMock).toHaveBeenCalledWith('s1', 'wh-1')); + }); + + it('sends a test notification via the per-webhook test button', async () => { + fetchMock.mockResolvedValue(ONE_WEBHOOK); + renderWithProviders(); + await waitFor(() => expect(screen.getByTestId('webhook-test-wh-1')).toBeInTheDocument()); + + await userEvent.click(screen.getByTestId('webhook-test-wh-1')); + await waitFor(() => expect(testSendMock).toHaveBeenCalledWith('s1', 'wh-1')); + await waitFor(() => expect(screen.getByText('テスト通知を送信しました。')).toBeInTheDocument()); + }); + + it('shows the auto-disabled badge and a re-enable action for a disabled webhook', async () => { + fetchMock.mockResolvedValue(AUTO_DISABLED_WEBHOOK); + renderWithProviders(); + await waitFor(() => expect(screen.getByTestId('webhook-disabled-badge-wh-2')).toBeInTheDocument()); + expect(screen.getByText('自動停止中')).toBeInTheDocument(); + expect(screen.getByText('10 consecutive delivery failures')).toBeInTheDocument(); + + const reenableBtn = screen.getByTestId('webhook-reenable-wh-2'); + await userEvent.click(reenableBtn); + await waitFor(() => expect(setEnabledMock).toHaveBeenCalledWith('s1', 'wh-2', true)); + }); + + it('disables the add button until label, url, and at least one event are present', async () => { + renderWithProviders(); + await waitFor(() => expect(screen.getByText('追加')).toBeDisabled()); + }); + + it('offers Discord, Slack, and Microsoft Teams in the provider select', async () => { + renderWithProviders(); + await waitFor(() => expect(screen.getByTestId('webhook-provider-select')).toBeInTheDocument()); + const select = screen.getByTestId('webhook-provider-select') as HTMLSelectElement; + const optionLabels = Array.from(select.options).map(o => o.value); + expect(optionLabels).toEqual(['discord', 'slack', 'teams']); + }); + + it('adds a Teams webhook via the form', async () => { + renderWithProviders(); + await waitFor(() => expect(screen.getByTestId('webhook-provider-select')).toBeInTheDocument()); + + await userEvent.selectOptions(screen.getByTestId('webhook-provider-select'), 'teams'); + await userEvent.type(screen.getByTestId('webhook-label-input'), 'Ops channel'); + await userEvent.type( + screen.getByTestId('webhook-url-input'), + 'https://example.webhook.office.com/webhookb2/xxx/IncomingWebhook/yyy/zzz', + ); + await userEvent.click(screen.getByText('追加')); + + await waitFor(() => expect(createMock).toHaveBeenCalledWith('s1', { + provider: 'teams', + label: 'Ops channel', + url: 'https://example.webhook.office.com/webhookb2/xxx/IncomingWebhook/yyy/zzz', + events: ['succeeded', 'failed', 'waiting_human'], + includeDetails: true, + })); + }); +}); diff --git a/ui/src/components/spaces/SpaceWebhookSettings.tsx b/ui/src/components/spaces/SpaceWebhookSettings.tsx new file mode 100644 index 0000000..dd7b2a1 --- /dev/null +++ b/ui/src/components/spaces/SpaceWebhookSettings.tsx @@ -0,0 +1,370 @@ +/** + * SpaceWebhookSettings.tsx — ワークスペースごとの Webhook 通知設定 UI + * + * PR1 で Discord、PR2 で Slack、PR3 で Microsoft Teams を実装。issue #797。 + * + * - タスク完了・失敗・回答待ちを Discord / Slack / Microsoft Teams チャンネルへ + * 通知する Webhook をワークスペース単位で追加・削除・テスト送信できる。 + * - Webhook URL は保存後に再表示されない(write-only)。変更は再入力で上書き。 + * - 3 回連続失敗で警告、10 回連続失敗で自動停止(enabled=0 + disabledReason)。 + * 自動停止後は編集権限メンバーが再有効化できる。 + * - GET/POST/PUT/PATCH/DELETE /api/local/spaces/:id/webhooks(canManageSpace が編集)。 + */ + +import { useState } from 'react'; +import { useTranslation } from 'react-i18next'; +import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'; +import { + fetchSpaceWebhooks, + fetchSpaceMembers, + createSpaceWebhook, + updateSpaceWebhook, + setSpaceWebhookEnabled, + deleteSpaceWebhook, + testSendSpaceWebhook, + type SpaceWebhook, + type SpaceWebhookEvent, + type SpaceWebhookProvider, +} from '../../api'; +import { useAuthState } from '../../App'; + +type ShowToast = (message: string, variant?: 'success' | 'error') => void; + +const ALL_EVENTS: SpaceWebhookEvent[] = ['succeeded', 'failed', 'waiting_human']; +const WARNING_FAILURE_THRESHOLD = 3; +// All three known providers have a working adapter as of PR3 (space-webhooks-api.ts IMPLEMENTED_PROVIDERS). +const IMPLEMENTED_PROVIDERS: SpaceWebhookProvider[] = ['discord', 'slack', 'teams']; +const URL_PLACEHOLDER: Record = { + discord: 'https://discord.com/api/webhooks/...', + slack: 'https://hooks.slack.com/services/...', + teams: 'https://xxx.webhook.office.com/webhookb2/...', +}; + +function errMsg(e: unknown): string { + return e instanceof Error ? e.message : String(e); +} + +export function SpaceWebhookSettings({ spaceId, showToast }: { spaceId: string; showToast?: ShowToast }) { + const { t } = useTranslation('spaces'); + const auth = useAuthState(); + const qc = useQueryClient(); + + const [provider, setProvider] = useState('discord'); + const [label, setLabel] = useState(''); + const [url, setUrl] = useState(''); + const [events, setEvents] = useState(['succeeded', 'failed', 'waiting_human']); + const [includeDetails, setIncludeDetails] = useState(true); + const [editingUrlFor, setEditingUrlFor] = useState(null); + const [editUrlValue, setEditUrlValue] = useState(''); + const [testResult, setTestResult] = useState>({}); + + const { data: members } = useQuery({ + queryKey: ['space-members', spaceId], + queryFn: () => fetchSpaceMembers(spaceId), + staleTime: 30_000, + }); + const ownerRow = (members ?? []).find(m => m.isOwner); + const canManage = + auth.mode === 'disabled' || + (auth.mode === 'authenticated' && + (auth.user.role === 'admin' || (!!ownerRow && ownerRow.userId === auth.user.id))); + + const { data, isLoading, isError, error } = useQuery({ + queryKey: ['space-webhooks', spaceId], + queryFn: () => fetchSpaceWebhooks(spaceId), + staleTime: 15_000, + }); + + const invalidate = () => qc.invalidateQueries({ queryKey: ['space-webhooks', spaceId] }); + + const createMut = useMutation({ + mutationFn: () => createSpaceWebhook(spaceId, { provider, label: label.trim(), url: url.trim(), events, includeDetails }), + onSuccess: () => { + setProvider('discord'); + setLabel(''); + setUrl(''); + setEvents(['succeeded', 'failed', 'waiting_human']); + setIncludeDetails(true); + showToast?.(t('webhooks.added'), 'success'); + void invalidate(); + }, + onError: (e) => showToast?.(t('webhooks.addFailed', { msg: errMsg(e) }), 'error'), + }); + + const deleteMut = useMutation({ + mutationFn: (id: string) => deleteSpaceWebhook(spaceId, id), + onSuccess: () => { showToast?.(t('webhooks.removed'), 'success'); void invalidate(); }, + onError: (e) => showToast?.(t('webhooks.removeFailed', { msg: errMsg(e) }), 'error'), + }); + + const enableMut = useMutation({ + mutationFn: ({ id, enabled }: { id: string; enabled: boolean }) => setSpaceWebhookEnabled(spaceId, id, enabled), + onSuccess: () => { void invalidate(); }, + onError: (e) => showToast?.(t('webhooks.updateFailed', { msg: errMsg(e) }), 'error'), + }); + + const updateUrlMut = useMutation({ + mutationFn: ({ id, newUrl }: { id: string; newUrl: string }) => updateSpaceWebhook(spaceId, id, { url: newUrl }), + onSuccess: () => { + setEditingUrlFor(null); + setEditUrlValue(''); + showToast?.(t('webhooks.urlUpdated'), 'success'); + void invalidate(); + }, + onError: (e) => showToast?.(t('webhooks.updateFailed', { msg: errMsg(e) }), 'error'), + }); + + const testMut = useMutation({ + mutationFn: (id: string) => testSendSpaceWebhook(spaceId, id), + onSuccess: (result, id) => { + setTestResult(prev => ({ ...prev, [id]: result.ok ? 'ok' : 'error' })); + showToast?.(result.ok ? t('webhooks.testSent') : t('webhooks.testFailed', { msg: result.error ?? '' }), result.ok ? 'success' : 'error'); + }, + onError: (e, id) => { + setTestResult(prev => ({ ...prev, [id]: 'error' })); + showToast?.(t('webhooks.testFailed', { msg: errMsg(e) }), 'error'); + }, + }); + + const busy = createMut.isPending || deleteMut.isPending || enableMut.isPending || updateUrlMut.isPending; + + const toggleEvent = (ev: SpaceWebhookEvent) => { + setEvents(prev => (prev.includes(ev) ? prev.filter(e => e !== ev) : [...prev, ev])); + }; + + const submit = () => { + if (!label.trim() || !url.trim() || events.length === 0 || createMut.isPending) return; + createMut.mutate(); + }; + + if (isLoading) { + return ( +
+
{t('common:loading')}
+
+ ); + } + if (isError || !data) { + return ( +
+
{t('webhooks.fetchError', { msg: errMsg(error) })}
+
+ ); + } + + return ( +
+
+
+

{t('webhooks.heading')}

+

{t('webhooks.intro')}

+
+ + {/* 追加フォーム */} +
+ + + setLabel(e.target.value)} + disabled={!canManage || busy} + placeholder={t('webhooks.labelPlaceholder')} + data-testid="webhook-label-input" + className="h-9 w-full rounded-md border border-hairline px-3 text-sm focus:border-accent focus:outline-none focus:ring-2 focus:ring-accent-ring disabled:opacity-50" + /> + setUrl(e.target.value)} + disabled={!canManage || busy} + placeholder={URL_PLACEHOLDER[provider]} + data-testid="webhook-url-input" + className="h-9 w-full rounded-md border border-hairline px-3 text-sm focus:border-accent focus:outline-none focus:ring-2 focus:ring-accent-ring disabled:opacity-50" + /> +
+ {ALL_EVENTS.map(ev => ( + + ))} +
+ + +

{t(`webhooks.urlHint.${provider}`)}

+
+ + {/* 一覧 */} +
+

+ {t('webhooks.listHeading')} +

+ {data.length === 0 ? ( +

{t('webhooks.none')}

+ ) : ( +
+ {data.map((wh: SpaceWebhook) => { + const isWarning = wh.enabled && wh.failureCount >= WARNING_FAILURE_THRESHOLD; + const isEditingUrl = editingUrlFor === wh.id; + return ( +
+
+ {wh.label} + {wh.provider} + {!wh.enabled && ( + + {t('webhooks.autoDisabledBadge')} + + )} + {wh.enabled && isWarning && ( + + {t('webhooks.warningBadge', { count: wh.failureCount })} + + )} +
+ +
+ {wh.events.map(ev => t(`webhooks.event.${ev}`)).join(' / ')} + {wh.includeDetails ? '' : ` · ${t('webhooks.detailsOff')}`} +
+ +
+
{t('webhooks.lastSuccess', { at: wh.lastSuccessAt ?? t('webhooks.never') })}
+
{t('webhooks.lastFailure', { at: wh.lastFailureAt ?? t('webhooks.never') })}
+
{t('webhooks.failureCount', { count: wh.failureCount })}
+ {wh.disabledReason &&
{wh.disabledReason}
} +
+ + {isEditingUrl ? ( +
+ setEditUrlValue(e.target.value)} + placeholder={URL_PLACEHOLDER[wh.provider]} + data-testid={`webhook-edit-url-input-${wh.id}`} + className="h-8 flex-1 rounded-md border border-hairline px-2 text-xs" + /> + + +
+ ) : ( +
+ + {testResult[wh.id] === 'ok' && ( + {t('webhooks.testSent')} + )} + {testResult[wh.id] === 'error' && ( + {t('webhooks.testFailedShort')} + )} + {canManage && ( + + )} + {!wh.enabled && canManage && ( + + )} + {wh.enabled && canManage && ( + + )} + {canManage && ( + + )} +
+ )} +
+ ); + })} +
+ )} +
+ + {!canManage && ( +

{t('webhooks.readonly')}

+ )} +
+
+ ); +} diff --git a/ui/src/content/help/00-changelog.md b/ui/src/content/help/00-changelog.md index ca6b6fa..f5d58bc 100644 --- a/ui/src/content/help/00-changelog.md +++ b/ui/src/content/help/00-changelog.md @@ -12,6 +12,22 @@ MAESTRO に入った、ユーザーに関係する主な変更を新しい順に > 機能に変更があるたび、このページを更新していきます。日付は変更が本番に入ったおおよその時期です。 +## 2026-07-10 — 「A2A 委任」設定が無効環境でエラーにならないようにしました + +A2A を有効にしていないサーバーでは、設定の「A2A 委任」項目を開くと読み込みエラーが表示されていました。この項目は A2A が有効なときだけ意味を持つため、無効なサーバーでは設定サイドバーに表示しないようにしました(→[外部エージェント連携(A2A)](./23-a2a.md))。 + +## 2026-07-10 — Webhook 通知が Microsoft Teams に対応し、Discord / Slack / Teams の3プロバイダが揃いました。 + +ワークスペースの Webhook 通知(設定 →「Webhook 通知」タブ)で、通知先に Microsoft Teams を選べるようになりました。追加フォームの「送信先」で Discord / Slack / Microsoft Teams を切り替え、Teams チャンネルの「コネクタ」から発行した従来の Incoming Webhook URL(`*.webhook.office.com`)を登録するだけで、タスクの完了・失敗・回答待ちを Teams チャンネルへ流せます(→[通知を受け取る](./07-notifications.md))。 + +## 2026-07-10 — Webhook 通知が Slack に対応しました + +ワークスペースの Webhook 通知(設定 →「Webhook 通知」タブ)で、通知先に Slack を選べるようになりました。追加フォームの「送信先」で Discord / Slack を切り替え、Slack の Incoming Webhook URL を登録するだけで、タスクの完了・失敗・回答待ちを Slack チャンネルへ流せます(→[通知を受け取る](./07-notifications.md))。 + +## 2026-07-09 — ワークスペース単位で Discord への Webhook 通知を設定できるようになりました + +ワークスペースの設定画面に「Webhook 通知」タブが追加され、タスクの完了・失敗・回答待ちを Discord チャンネルへ通知できるようになりました(Slack / Microsoft Teams は今後対応予定)。編集権限を持つメンバーが Webhook URL を登録・削除・テスト送信でき、URL は保存後に画面上へ再表示されません。3 回連続で送信に失敗すると警告が表示され、10 回連続で自動的に無効化されます(→[通知を受け取る](./07-notifications.md))。 + ## 2026-07-09 — 右上のユーザー操作をアカウントメニューに集約し、パスワード変更を設定→Preferenceに移動しました 画面右上に横並びだったダークモード切替・ユーザー名・パスワード変更・ログアウトを、ユーザーアイコンをクリックして開く **アカウントメニュー** にまとめました。パスワード変更は [システム設定](./17-settings.md) の Preference セクションへ移動し、Google / Gitea などの外部ログインアカウントでは変更不可であることが分かるように案内が表示されます。 diff --git a/ui/src/content/help/07-notifications.md b/ui/src/content/help/07-notifications.md index 5b3b909..472a42c 100644 --- a/ui/src/content/help/07-notifications.md +++ b/ui/src/content/help/07-notifications.md @@ -3,7 +3,7 @@ id: notifications title: 通知を受け取る category: basic order: 70 -keywords: [通知, ブラウザ通知, Web Push, プッシュ通知, Service Worker, VAPID] +keywords: [通知, ブラウザ通知, Web Push, プッシュ通知, Service Worker, VAPID, Webhook, Discord, Slack, Teams, Microsoft Teams] --- # 通知を受け取る @@ -67,3 +67,45 @@ V2 が利用できる環境では、「通知にタスクの詳細(タイト ## 管理者向けの設定 V2(Web Push)の有効化には、サーバー側で `config.yaml` の `notifications.push.enabled: true` 設定と VAPID 鍵が必要です(鍵は初回起動時に自動生成されます)。サーバー側設定の概要は [設定](17-settings.md)、管理操作は [管理者向け](19-admin.md) を参照してください。 + +## ワークスペースの Webhook 通知(Discord / Slack / Microsoft Teams) + +上記の V1/V2 は **個人向け**(自分が owner のタスクのみ)の通知ですが、Webhook 通知は **ワークスペース単位の共有通知** です。チームが使っている Discord・Slack・Microsoft Teams のチャンネルへ、タスクの完了・失敗・回答待ちを流せます。 + +### 設定手順(Discord) + +1. ワークスペースを開き、設定 → 「Webhook 通知」タブを開く(編集権限のあるメンバーのみ操作可能) +2. 「送信先」で Discord を選ぶ +3. Discord のチャンネル設定 → 連携サービス から Incoming Webhook の URL を発行する +4. ラベル・発行した URL・通知したいイベント(完了 / 失敗 / 回答待ち)を入力して追加する +5. 「テスト送信」で疎通を確認する + +### 設定手順(Slack) + +1. ワークスペースを開き、設定 → 「Webhook 通知」タブを開く(編集権限のあるメンバーのみ操作可能) +2. 「送信先」で Slack を選ぶ +3. Slack の [Incoming Webhooks](https://api.slack.com/messaging/webhooks) アプリをワークスペースに追加し、通知したいチャンネル向けの Webhook URL(`https://hooks.slack.com/services/...`)を発行する +4. ラベル・発行した URL・通知したいイベント(完了 / 失敗 / 回答待ち)を入力して追加する +5. 「テスト送信」で疎通を確認する + +### 設定手順(Microsoft Teams) + +1. ワークスペースを開き、設定 → 「Webhook 通知」タブを開く(編集権限のあるメンバーのみ操作可能) +2. 「送信先」で Microsoft Teams を選ぶ +3. 通知したい Teams チャンネルの「コネクタ」(Connectors)から従来の **Incoming Webhook** を追加し、URL(`https://xxx.webhook.office.com/webhookb2/...`)を発行する +4. ラベル・発行した URL・通知したいイベント(完了 / 失敗 / 回答待ち)を入力して追加する +5. 「テスト送信」で疎通を確認する + +> Microsoft は従来の Incoming Webhook コネクタ(MessageCard 形式)を段階的に非推奨化し、新しい「ワークフロー」(Power Automate、Adaptive Card 形式)への移行を進めています。現時点ではまだ広く使われている従来のコネクタ形式のみに対応しており、ワークフロー経由の URL は登録しても届きません。 + +通知本文にはデフォルトでタスクタイトル・piece 名・ワークスペース名・タスク ID・タスク詳細へのリンクが含まれます。「詳細を含める」を OFF にすると、これらを省いた最小限の通知になります。 + +### URL の扱い + +Webhook URL は保存後に画面へ再表示されません(secret として暗号化保存)。URL を変更したい場合は「URL を変更」から新しい URL を再入力してください。 + +### 失敗時の挙動 + +- 送信に成功すると連続失敗回数は 0 に戻ります +- **3 回連続失敗** すると設定画面に警告バッジが表示されます +- **10 回連続失敗** すると自動的に無効化されます(「自動停止中」バッジが表示される)。編集権限のあるメンバーが「再有効化」するか、URL を再設定してください diff --git a/ui/src/content/help/23-a2a.md b/ui/src/content/help/23-a2a.md index 2ea6d53..1faee4a 100644 --- a/ui/src/content/help/23-a2a.md +++ b/ui/src/content/help/23-a2a.md @@ -38,6 +38,8 @@ OAuth2 の標準フロー(認可コード + PKCE)をベースにしており ### 設定 → A2A 委任 から一覧・取り消し +この項目は A2A が有効なサーバーでのみ表示されます。管理者が `a2a.enabled: true` を設定していない場合、設定のサイドバーに「A2A 委任」は現れません。 + **設定 → A2A 委任** を開くと、自分が過去に承認した委任の一覧が表示されます。各行には次の情報が確認できます。 | 項目 | 内容 | diff --git a/ui/src/hooks/useSetupState.ts b/ui/src/hooks/useSetupState.ts index a067946..7c01025 100644 --- a/ui/src/hooks/useSetupState.ts +++ b/ui/src/hooks/useSetupState.ts @@ -11,6 +11,11 @@ export interface SetupStatus { deployHint: 'docker' | 'source'; /** True when a setup token must be supplied with mutating setup calls. */ tokenRequired: boolean; + /** + * True when A2A is enabled server-side. Drives whether the per-user A2A + * Delegations settings section is shown (its API route only exists when on). + */ + a2aEnabled?: boolean; } /** diff --git a/ui/src/i18n/locales/en/spaces.json b/ui/src/i18n/locales/en/spaces.json index dab5ac2..d2c2976 100644 --- a/ui/src/i18n/locales/en/spaces.json +++ b/ui/src/i18n/locales/en/spaces.json @@ -365,6 +365,7 @@ "browser": "Browser", "tools": "Tools", "python": "Python", + "webhooks": "Webhooks", "members": "Members" }, "pieces": { @@ -418,5 +419,55 @@ "addFailed": "Add failed: {{msg}}", "removed": "Package removed.", "removeFailed": "Remove failed: {{msg}}" + }, + "webhooks": { + "heading": "Webhook notifications", + "intro": "Send task completed / failed / waiting-for-you notifications to a Discord, Slack, or Microsoft Teams channel. The webhook URL is never shown again after it's saved.", + "addLabel": "Add a webhook", + "provider": { + "discord": "Discord", + "slack": "Slack", + "teams": "Microsoft Teams" + }, + "labelPlaceholder": "e.g. #dev-channel", + "urlHint": { + "discord": "Create an Incoming Webhook URL from Discord's channel Integrations settings and paste it here. The URL will not be shown again after saving.", + "slack": "Create an Incoming Webhook URL from Slack's Incoming Webhooks app and paste it here. The URL will not be shown again after saving.", + "teams": "Create a classic Incoming Webhook URL (*.webhook.office.com) from a Teams channel's Connectors settings and paste it here. The URL will not be shown again after saving. URLs from the newer Workflows (Power Automate) connector are not supported." + }, + "event": { + "succeeded": "Succeeded", + "failed": "Failed", + "waiting_human": "Waiting for you" + }, + "includeDetails": "Include details (task title, piece name, workspace name)", + "add": "Add", + "adding": "Adding…", + "listHeading": "Registered webhooks", + "none": "Nothing added yet.", + "remove": "Remove", + "disable": "Disable", + "reEnable": "Re-enable", + "changeUrl": "Change URL", + "test": "Send test", + "testing": "Sending…", + "testSent": "Test notification sent.", + "testFailed": "Test send failed: {{msg}}", + "testFailedShort": "Send failed", + "urlUpdated": "URL updated.", + "detailsOff": "no details", + "lastSuccess": "Last success: {{at}}", + "lastFailure": "Last failure: {{at}}", + "failureCount": "Consecutive failures: {{count}}", + "never": "never", + "autoDisabledBadge": "Auto-disabled", + "warningBadge": "{{count}} consecutive failures", + "readonly": "Only this workspace's owner or an admin can edit.", + "fetchError": "Failed to load webhooks: {{msg}}", + "added": "Webhook added.", + "addFailed": "Add failed: {{msg}}", + "removed": "Webhook removed.", + "removeFailed": "Remove failed: {{msg}}", + "updateFailed": "Update failed: {{msg}}" } } diff --git a/ui/src/i18n/locales/ja/spaces.json b/ui/src/i18n/locales/ja/spaces.json index c929222..cb6b4c3 100644 --- a/ui/src/i18n/locales/ja/spaces.json +++ b/ui/src/i18n/locales/ja/spaces.json @@ -365,6 +365,7 @@ "browser": "ブラウザ", "tools": "ツール", "python": "Python", + "webhooks": "Webhook 通知", "members": "メンバー" }, "pieces": { @@ -418,5 +419,55 @@ "addFailed": "追加に失敗しました: {{msg}}", "removed": "パッケージを削除しました。", "removeFailed": "削除に失敗しました: {{msg}}" + }, + "webhooks": { + "heading": "Webhook 通知", + "intro": "タスクの完了・失敗・回答待ちを Discord / Slack / Microsoft Teams チャンネルに通知します。Webhook URL は保存後に再表示されません。", + "addLabel": "Webhook を追加", + "provider": { + "discord": "Discord", + "slack": "Slack", + "teams": "Microsoft Teams" + }, + "labelPlaceholder": "例: #開発チャンネル", + "urlHint": { + "discord": "Discord の「連携サービス」からチャンネルの Incoming Webhook URL を発行し、貼り付けてください。URL は保存後に再表示されません。", + "slack": "Slack の「Incoming Webhooks」アプリからチャンネルの Webhook URL を発行し、貼り付けてください。URL は保存後に再表示されません。", + "teams": "Teams チャンネルの「コネクタ」から従来の Incoming Webhook URL(*.webhook.office.com)を発行し、貼り付けてください。URL は保存後に再表示されません。新しい「ワークフロー」(Power Automate)経由の URL には対応していません。" + }, + "event": { + "succeeded": "完了", + "failed": "失敗", + "waiting_human": "回答待ち" + }, + "includeDetails": "詳細を含める(タスクタイトル・piece 名・ワークスペース名)", + "add": "追加", + "adding": "追加中…", + "listHeading": "登録済みの Webhook", + "none": "まだ何も登録されていません。", + "remove": "削除", + "disable": "無効化", + "reEnable": "再有効化", + "changeUrl": "URL を変更", + "test": "テスト送信", + "testing": "送信中…", + "testSent": "テスト通知を送信しました。", + "testFailed": "テスト送信に失敗しました: {{msg}}", + "testFailedShort": "送信失敗", + "urlUpdated": "URL を更新しました。", + "detailsOff": "詳細なし", + "lastSuccess": "最終成功: {{at}}", + "lastFailure": "最終失敗: {{at}}", + "failureCount": "連続失敗回数: {{count}}", + "never": "なし", + "autoDisabledBadge": "自動停止中", + "warningBadge": "連続失敗 {{count}} 回", + "readonly": "編集はこのワークスペースのオーナーまたは管理者のみ可能です。", + "fetchError": "Webhook 一覧の取得に失敗しました: {{msg}}", + "added": "Webhook を追加しました。", + "addFailed": "追加に失敗しました: {{msg}}", + "removed": "Webhook を削除しました。", + "removeFailed": "削除に失敗しました: {{msg}}", + "updateFailed": "更新に失敗しました: {{msg}}" } } diff --git a/ui/src/pages/SettingsPage.tsx b/ui/src/pages/SettingsPage.tsx index 0b50a58..1668b6c 100644 --- a/ui/src/pages/SettingsPage.tsx +++ b/ui/src/pages/SettingsPage.tsx @@ -1,6 +1,7 @@ import { useEffect, useState } from 'react'; import { useTranslation } from 'react-i18next'; import { useUrlState } from '../hooks/useUrlState'; +import { useSetupState } from '../hooks/useSetupState'; import { SettingsSidebar, USER_SECTIONS, @@ -15,6 +16,10 @@ interface SettingsPageProps { export function SettingsPage({ isAdmin }: SettingsPageProps) { const { t } = useTranslation('settings'); const { urlState, setUrlState } = useUrlState(); + const { data: setup } = useSetupState(); + // A2A is off by default; its delegations API route only mounts when enabled. + // Hide the section (and reject deep-links to it) unless the server says a2a is on. + const a2aEnabled = setup?.a2aEnabled === true; // admin landing page: first LLM Workers (most-used setting). Non-admin // lands on preferences. The pre-Step-3 default was 'provider'. const fallbackSection = isAdmin ? 'llm-workers' : 'preferences'; @@ -22,9 +27,10 @@ export function SettingsPage({ isAdmin }: SettingsPageProps) { // Map legacy ids (provider / workspace / tools / browser-settings / // search-filter / browser-sessions) into the new sidebar layout. const requestedSection = LEGACY_SECTION_REDIRECT[rawRequested] ?? rawRequested; - const section = (!isAdmin && !USER_SECTIONS.includes(requestedSection)) - ? 'preferences' - : requestedSection; + const unavailableSection = + (!isAdmin && !USER_SECTIONS.includes(requestedSection)) || + (requestedSection === 'a2a-delegations' && !a2aEnabled); + const section = unavailableSection ? 'preferences' : requestedSection; // If the URL still carries a legacy id, rewrite it once so bookmarks // and the Back button line up with the new navigation. @@ -48,6 +54,14 @@ export function SettingsPage({ isAdmin }: SettingsPageProps) { } }, [isAdmin, urlState.section, setUrlState]); + // a2a 無効時に a2a-delegations へ直リンクされたら preferences に正規化する。 + // setup が解決してから判定する(有効サーバで初回 status 取得中に URL を書き換えないため)。 + useEffect(() => { + if (setup && !a2aEnabled && urlState.section === 'a2a-delegations') { + setUrlState(prev => ({ ...prev, section: 'preferences' as any })); + } + }, [setup, a2aEnabled, urlState.section, setUrlState]); + const handleSelectSection = (s: string) => { setUrlState(prev => ({ ...prev, section: s as any })); setMobileView('detail'); @@ -63,6 +77,7 @@ export function SettingsPage({ isAdmin }: SettingsPageProps) { activeSection={section} onSelectSection={handleSelectSection} isAdmin={isAdmin} + a2aEnabled={a2aEnabled} />