sync: update from private repo (a5db2ab0)
All checks were successful
CI / build-and-test (push) Successful in 8m58s

This commit is contained in:
oss-sync 2026-07-10 00:51:05 +00:00
parent 2044f0a2c4
commit a67c40d33c
45 changed files with 3599 additions and 15 deletions

View File

@ -500,6 +500,21 @@ tools:
# 起動時に vapid_current_path に鍵が無ければ自動生成、mode 0600 で保存。 # 起動時に vapid_current_path に鍵が無ければ自動生成、mode 0600 で保存。
# 鍵をローテーションする場合: npm run vapid-rotate # 鍵をローテーションする場合: 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 ────────────────────────────────────────── # ── A2A OAuth2 Authorization Server ──────────────────────────────────────────
# Requires auth to be active (consent screen uses req.user). # Requires auth to be active (consent screen uses req.user).
# #

View File

@ -60,6 +60,7 @@ exports[`createCoreServer route registration order > pins the auth-active + Conf
"ROUTE get /", "ROUTE get /",
"ROUTE get /api/version", "ROUTE get /api/version",
"ROUTE get /api/repos", "ROUTE get /api/repos",
"USE name=router path=^\\/api\\/?(?=\\/|$)",
"ROUTE get /api/local/tasks", "ROUTE get /api/local/tasks",
"ROUTE post /api/local/tasks", "ROUTE post /api/local/tasks",
"ROUTE get /api/local/tasks/:taskId", "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 /",
"ROUTE get /api/version", "ROUTE get /api/version",
"ROUTE get /api/repos", "ROUTE get /api/repos",
"USE name=router path=^\\/api\\/?(?=\\/|$)",
"ROUTE get /api/local/tasks", "ROUTE get /api/local/tasks",
"ROUTE post /api/local/tasks", "ROUTE post /api/local/tasks",
"ROUTE get /api/local/tasks/:taskId", "ROUTE get /api/local/tasks/:taskId",

View File

@ -10,6 +10,7 @@ import { createA2aOidcProvider, mountA2aOidc } from './a2a/oidc-provider.js';
import { createA2aClientsAdminRouter } from './a2a/a2a-clients-admin-api.js'; import { createA2aClientsAdminRouter } from './a2a/a2a-clients-admin-api.js';
import { createA2aRouter, createSpaceA2aAdminRouter } from './a2a/a2a-api.js'; import { createA2aRouter, createSpaceA2aAdminRouter } from './a2a/a2a-api.js';
import { mountA2aRequestHandler } from './a2a/request-handler.js'; import { mountA2aRequestHandler } from './a2a/request-handler.js';
import { A2aLimiter, resolveA2aLimits } from './a2a/limiter.js';
import { A2aTaskReconciler } from './a2a/reconciler.js'; import { A2aTaskReconciler } from './a2a/reconciler.js';
import { createUserDelegationsRouter, createAdminDelegationsRouter } from './a2a/delegations-api.js'; import { createUserDelegationsRouter, createAdminDelegationsRouter } from './a2a/delegations-api.js';
@ -50,10 +51,13 @@ export function setupA2aSubsystem(
const a2aBaseUrl = (() => { const a2aBaseUrl = (() => {
try { return new URL(String(a2aCfg.resourceAudience)).origin; } catch { return String(a2aCfg.issuer); } 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 エンドポイント /a2aSDK DefaultRequestHandler 経由) // JSON-RPC エンドポイント /a2aSDK DefaultRequestHandler 経由)
// /a2a は /api 配下でないため requireAuth に掛からない。認証は UserBuilder が担う。 // /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 を共有する構成では // bridge は単一プロセス前提。複数プロセスが同一 DB を共有する構成では
// lease が必要になる(将来課題)。 // lease が必要になる(将来課題)。
const a2aReconciler = new A2aTaskReconciler({ repo }); const a2aReconciler = new A2aTaskReconciler({ repo });

View File

@ -16,6 +16,7 @@ import { computeEffectiveScope } from './delegation.js';
import { canManageSpace } from '../visibility.js'; import { canManageSpace } from '../visibility.js';
import { viewerOf } from '../space-viewer.js'; import { viewerOf } from '../space-viewer.js';
import { logger } from '../../logger.js'; import { logger } from '../../logger.js';
import type { A2aLimiter } from './limiter.js';
export interface A2aRouterDeps { export interface A2aRouterDeps {
provider: Provider; provider: Provider;
@ -23,6 +24,12 @@ export interface A2aRouterDeps {
baseUrl: string; baseUrl: string;
issuer: string; issuer: string;
version: string; version: string;
/**
* limiterextended card scope
* grant 429 scope
* JSON-RPC getAuthenticatedExtendedAgentCard limiter
*/
limiter?: A2aLimiter;
} }
/** /**
@ -61,6 +68,15 @@ export function createA2aRouter(deps: A2aRouterDeps): Router {
return; 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 防止) // acting user の可視スペース・スキルを delegation の AND 交差で算出IDOR 防止)
const scope = await computeEffectiveScope(deps.repo, principal); const scope = await computeEffectiveScope(deps.repo, principal);
if (!scope) { if (!scope) {

View File

@ -10,6 +10,8 @@ import { join } from 'path';
import { Repository } from '../../db/repository.js'; import { Repository } from '../../db/repository.js';
import { createA2aOidcProvider, mountA2aOidc } from './oidc-provider.js'; import { createA2aOidcProvider, mountA2aOidc } from './oidc-provider.js';
import { createA2aRouter } from './a2a-api.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'; 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 が機能するようにする。 // テスト専用: http/127.0.0.1 で cookie を non-secure にして cookie jar が機能するようにする。
(provider as any).proxy = false; (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 }); mountA2aOidc(app, provider, { repo, resourceAudience: audience });
app.use(createA2aRouter({ app.use(createA2aRouter({
provider, repo, provider, repo,
baseUrl: base, baseUrl: base,
issuer: `${base}/oidc`, issuer: `${base}/oidc`,
version: '1.0.0-test', 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(); }); afterAll(() => { server?.close(); });
@ -216,6 +230,53 @@ describe('A2A card e2e (real token → scoped extended card + negative paths)',
expect(ids).not.toContain('summarize'); 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 () => { it('cross-space AND-intersection: non-granted space skills are absent from extended card', async () => {
// space-aresearchと space-banalyzeが両方ユーザーから見えるが、 // space-aresearchと space-banalyzeが両方ユーザーから見えるが、
// space-a のみに同意 → resolveEffectiveSpaces が space-b を AND 交差で排除し、 // space-a のみに同意 → resolveEffectiveSpaces が space-b を AND 交差で排除し、

View File

@ -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 ─────────── // ─── E2E: tasks/get via HTTP → JSON-RPC error when unauthenticated ───────────
describe('mountA2aRequestHandler tasks/get auth gate (E2E)', () => { describe('mountA2aRequestHandler tasks/get auth gate (E2E)', () => {

View File

@ -29,6 +29,12 @@ export interface MountA2aRequestHandlerDeps {
issuer: string; issuer: string;
version: string; version: string;
limits?: Partial<A2aLimits>; limits?: Partial<A2aLimits>;
/**
* limiter REST
* 1 grant JSON-RPC
* `limits` 後方互換: limiter
*/
limiter?: A2aLimiter;
} }
type CardDeps = { baseUrl: string; issuer: string; version: string }; type CardDeps = { baseUrl: string; issuer: string; version: string };
@ -124,6 +130,20 @@ export class AuthGatedRequestHandler extends DefaultRequestHandler {
return super.cancelTask(params, context); return super.cancelTask(params, context);
} }
/**
* RPCscope 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() * super.resubscribe()
* throw JsonRpcTransportHandler * throw JsonRpcTransportHandler
@ -271,8 +291,8 @@ export function makeExtendedCardProvider(
* makeA2aUserBuilderUserBuilder * makeA2aUserBuilderUserBuilder
*/ */
export function mountA2aRequestHandler(app: Express, deps: MountA2aRequestHandlerDeps): void { export function mountA2aRequestHandler(app: Express, deps: MountA2aRequestHandlerDeps): void {
const limits = resolveA2aLimits(deps.limits); const limiter = deps.limiter ?? new A2aLimiter(resolveA2aLimits(deps.limits));
const limiter = new A2aLimiter(limits); const limits = limiter.limits;
const taskStore = new SqliteA2aTaskStore(deps.repo); const taskStore = new SqliteA2aTaskStore(deps.repo);
const executor = new MaestroA2aExecutor(deps.repo, { limiter }); const executor = new MaestroA2aExecutor(deps.repo, { limiter });
const cardDeps: CardDeps = { const cardDeps: CardDeps = {

View File

@ -97,6 +97,9 @@ export interface CoreServerOptions {
pushService?: import('../push-service.js').PushService | null; pushService?: import('../push-service.js').PushService | null;
/** Notifications V2 — VAPID key store. Required when pushService is set. */ /** Notifications V2 — VAPID key store. Required when pushService is set. */
vapidStore?: import('../vapid-store.js').VapidKeyStore | null; 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 * TCP port the bridge will listen on. Forwarded to admin endpoints
* (e.g. /api/admin/gateway/status) so the UI can label the gateway * (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', worktreeDir: loadConfig().worktreeDir ?? './data/worktrees',
authActive, authActive,
getPythonPackagesConfig: () => loadConfig().pythonPackages, getPythonPackagesConfig: () => loadConfig().pythonPackages,
webhookService: opts.webhookService ?? null,
})); }));
// BackendStatusRegistry singleton — probes every configured worker // BackendStatusRegistry singleton — probes every configured worker

View File

@ -118,6 +118,19 @@ describe('setup-api: GET /api/setup/status', () => {
expect(res.body.authActive).toBe(true); expect(res.body.authActive).toBe(true);
expect(res.body.tokenRequired).toBe(false); 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', () => { describe('setup-api: token gate on /api/setup/apply', () => {

View File

@ -302,10 +302,15 @@ export function mountSetupApi(
try { try {
const needsSetup = await computeNeedsSetup(configManager); const needsSetup = await computeNeedsSetup(configManager);
const tokenRequired = !authActive && readSetupToken(dataDir) !== null; 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) { } catch (e) {
logger.warn(`[setup-api] status failed: ${String(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' });
} }
}); });

View File

@ -11,6 +11,7 @@ import { registerSpaceInviteRoutes } from './space-invite-api.js';
import { registerSpaceAppShareRoutes } from './space-app-share-api.js'; import { registerSpaceAppShareRoutes } from './space-app-share-api.js';
import { registerSpaceToolPolicyRoutes } from './space-tool-policy-api.js'; import { registerSpaceToolPolicyRoutes } from './space-tool-policy-api.js';
import { registerSpacePythonPackagesRoutes } from './space-python-packages-api.js'; import { registerSpacePythonPackagesRoutes } from './space-python-packages-api.js';
import { registerSpaceWebhooksRoutes } from './space-webhooks-api.js';
import { viewerOf } from './space-viewer.js'; import { viewerOf } from './space-viewer.js';
import { logger } from '../logger.js'; import { logger } from '../logger.js';
import { sendOfficePreview, handleOfficePreviewError } from './office-preview.js'; import { sendOfficePreview, handleOfficePreviewError } from './office-preview.js';
@ -39,6 +40,8 @@ export interface SpaceApiDeps {
uploadLimitMb?: number; uploadLimitMb?: number;
/** Per-space Python packages のライブ configloadConfig() を都度読む getter。未配線 = 無効扱い。 */ /** Per-space Python packages のライブ configloadConfig() を都度読む getter。未配線 = 無効扱い。 */
getPythonPackagesConfig?: () => import('../config.js').PythonPackagesConfigShape | undefined; 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 に分離。 // Python パッケージ管理 GET/POST/DELETE は space-python-packages-api.ts に分離。
registerSpacePythonPackagesRoutes(router, deps); registerSpacePythonPackagesRoutes(router, deps);
// Webhook 通知Discord/Slack/TeamsCRUD + test-send は space-webhooks-api.ts に分離。
registerSpaceWebhooksRoutes(router, deps);
// ─── スペース間資格情報コピー ────────────────────────────────────── // ─── スペース間資格情報コピー ──────────────────────────────────────
// 両スペースの管理権canManageSpaceを持つユーザーのみ利用可能。 // 両スペースの管理権canManageSpaceを持つユーザーのみ利用可能。
// 成功時に audit_log へ記録する。 // 成功時に audit_log へ記録する。

View File

@ -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 = <seeded 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<SendResult>;
}
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<ReturnType<typeof seed>>;
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<string, unknown> = {}) {
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<SendResult> => ({ 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<SendResult> => ({ 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<SendResult> => ({ 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();
});
});
});

View File

@ -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<Awaited<ReturnType<typeof repo.getSpace>>> } | 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<typeof repo.updateSpaceWebhook>[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' });
}
});
}

View File

@ -517,6 +517,29 @@ export interface NotificationsConfig {
push?: PushNotificationsConfig; 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 { export interface AppConfig {
/** /**
* Schema version. `2` = v2 layout (`llm.*` / `storage.*`). Missing or `1` * Schema version. `2` = v2 layout (`llm.*` / `storage.*`). Missing or `1`
@ -570,6 +593,15 @@ export interface AppConfig {
mcp?: Partial<McpRuntimeConfig>; mcp?: Partial<McpRuntimeConfig>;
ssh?: Partial<SshRuntimeConfig>; ssh?: Partial<SshRuntimeConfig>;
notifications?: NotificationsConfig; 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<ServerConfig>; server?: Partial<ServerConfig>;
a2a?: { a2a?: {
enabled?: boolean; enabled?: boolean;

View File

@ -244,6 +244,7 @@ export function runMigrations(db: Database.Database): void {
migrateTaskCommentIndex(db); migrateTaskCommentIndex(db);
migrateBackfillTaskCommentIndex(db); migrateBackfillTaskCommentIndex(db);
migrateA2aTaskDelegationColumns(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_pathCREATE TABLE IF NOT EXISTS
* url_enc AES-256-GCM Webhook URLsrc/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 * a2a_tasks Plan 2C-1 Task 2
* dual-path: schema.sql CREATE TABLE * dual-path: schema.sql CREATE TABLE

View File

@ -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);
});
});

View File

@ -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<string | number | Buffer> = [];
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 };
}

View File

@ -22,6 +22,8 @@ import * as llmUsageRepo from './repositories/llm-usage.js';
import { LlmUsageIncrement, LlmUsageDailyAgg, LlmUsageHourlyIncrement, LlmUsageHourlyRow } from './repositories/llm-usage.js'; import { LlmUsageIncrement, LlmUsageDailyAgg, LlmUsageHourlyIncrement, LlmUsageHourlyRow } from './repositories/llm-usage.js';
import * as notificationsRepo from './repositories/notifications.js'; import * as notificationsRepo from './repositories/notifications.js';
import { PushSubscriptionRecord, UpsertPushSubscriptionInput, NotificationPrefs, NotificationPrefsUpdate } 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 auditRepo from './repositories/audit.js';
import * as appSharesRepo from './repositories/app-shares.js'; import * as appSharesRepo from './repositories/app-shares.js';
import * as provenanceRepo from './repositories/provenance.js'; import * as provenanceRepo from './repositories/provenance.js';
@ -35,6 +37,7 @@ import { CalendarEvent, CalendarDayCounts, CalendarDayTask, CrossSpaceCalendarMo
export { enumerateLocalDays } from './repositories/calendar.js'; export { enumerateLocalDays } from './repositories/calendar.js';
export type { CalendarEvent, CalendarDayCounts, CalendarDayTask, CrossCalendarSpace, CrossSpaceCalendarMonth } 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 { 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 { LlmUsageIncrement, LlmUsageDailyAgg, LlmUsageHourlyIncrement, LlmUsageHourlyRow } from './repositories/llm-usage.js';
export type { GatewayVirtualKeySource, GatewayVirtualKey, GatewayKeyUsage } from './repositories/gateway.js'; export type { GatewayVirtualKeySource, GatewayVirtualKey, GatewayKeyUsage } from './repositories/gateway.js';
export type { ToolRequestCategory, ToolRequestStatus, ToolRequest, ToolRequestAggregate, PackageRequest, PackageRequestStatus } from './repositories/tool-requests.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); 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 { close(): void {
this.db.close(); this.db.close();
} }

View File

@ -906,6 +906,35 @@ CREATE TABLE IF NOT EXISTS user_notification_prefs (
updated_at TEXT NOT NULL DEFAULT (datetime('now')) 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 -- 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 -- 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". -- (upgrade path) so the task-list display SELECT never hits "no such table".

View File

@ -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();
});
});

View File

@ -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<WebhookEventKind, string> = {
succeeded: 'タスク完了',
failed: 'タスク失敗',
waiting_human: '回答待ち',
};
const EVENT_EMOJI: Record<WebhookEventKind, string> = {
succeeded: '✅',
failed: '❌',
waiting_human: '❓',
};
// Discord embed color (decimal RGB): green / red / amber.
const EVENT_COLOR: Record<WebhookEventKind, number> = {
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 },
],
},
],
};
}

View File

@ -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');
});
});

View File

@ -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<WebhookEventKind, string> = {
succeeded: 'タスク完了',
failed: 'タスク失敗',
waiting_human: '回答待ち',
};
const EVENT_EMOJI: Record<WebhookEventKind, string> = {
succeeded: '✅',
failed: '❌',
waiting_human: '❓',
};
export interface SlackWebhookPayload {
text: string;
blocks: Array<Record<string, unknown>>;
}
/**
* 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}` },
],
},
],
};
}

View File

@ -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');
});
});

View File

@ -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<WebhookEventKind, string> = {
succeeded: 'タスク完了',
failed: 'タスク失敗',
waiting_human: '回答待ち',
};
const EVENT_EMOJI: Record<WebhookEventKind, string> = {
succeeded: '✅',
failed: '❌',
waiting_human: '❓',
};
// MessageCard themeColor is a bare hex triplet (no leading '#'). green / red / amber.
const EVENT_COLOR: Record<WebhookEventKind, string> = {
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 } : {}),
};
}

View File

@ -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;

353
src/webhook-service.test.ts Normal file
View File

@ -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<typeof import('./engine/tools/shared/ssrf.js')>();
return { ...actual, ssrfSafeFetch: vi.fn(actual.ssrfSafeFetch) };
});
import { ssrfSafeFetch } from './engine/tools/shared/ssrf.js';
const ssrfMock = ssrfSafeFetch as unknown as ReturnType<typeof vi.fn>;
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> = {}): 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' });
});
});

273
src/webhook-service.ts Normal file
View File

@ -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<WebhookServiceOptions> = {
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<Record<SpaceWebhookRecord['provider'], WebhookAdapter>> = {
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<void> {
return new Promise(resolve => setTimeout(resolve, ms));
}
function withTimeout<T>(promise: Promise<T>, ms: number): Promise<T> {
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<WebhookServiceOptions>;
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<void> {
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<SendResult> {
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<SendResult>;
}
private async sendWithRetry(webhook: SpaceWebhookRecord, event: NotificationEvent): Promise<SendResult> {
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})` };
}
}
}

View File

@ -39,6 +39,7 @@ import { checkBwrapAvailable } from './engine/tools/sandbox.js';
import { SkillCatalog } from './engine/skills.js'; import { SkillCatalog } from './engine/skills.js';
import { VapidKeyStore } from './vapid-store.js'; import { VapidKeyStore } from './vapid-store.js';
import { PushService } from './push-service.js'; import { PushService } from './push-service.js';
import { WebhookDeliveryService } from './webhook-service.js';
function runPreflight(configPath: string, dbPath: string): void { function runPreflight(configPath: string, dbPath: string): void {
const errors: string[] = []; const errors: string[] = [];
@ -265,6 +266,28 @@ export async function start(opts: StartWorkerOptions = {}): Promise<void> {
logger.info('[startup] Web Push V2 disabled (notifications.push.enabled=false)'); 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 const selectPiece = titleClient
? async (body: string, fileNames: string[], userId?: string): Promise<string> => { ? async (body: string, fileNames: string[], userId?: string): Promise<string> => {
const pieces = pieceCatalog.getForUser(userId ?? 'local'); const pieces = pieceCatalog.getForUser(userId ?? 'local');
@ -353,6 +376,7 @@ export async function start(opts: StartWorkerOptions = {}): Promise<void> {
skillCatalog, skillCatalog,
pushService, pushService,
vapidStore, vapidStore,
webhookService,
}, port); }, port);
// Graceful shutdown — see installWorkerShutdownHooks() docstring. // Graceful shutdown — see installWorkerShutdownHooks() docstring.

View File

@ -8,6 +8,7 @@ import type { McpTokenManager } from './mcp/token-manager.js';
import type { WorkerMetrics } from './metrics/worker-metrics.js'; import type { WorkerMetrics } from './metrics/worker-metrics.js';
import type { SkillCatalog } from './engine/skills.js'; import type { SkillCatalog } from './engine/skills.js';
import type { PushService } from './push-service.js'; import type { PushService } from './push-service.js';
import type { WebhookDeliveryService } from './webhook-service.js';
export class WorkerManager { export class WorkerManager {
private workers: Worker[] = []; private workers: Worker[] = [];
@ -33,6 +34,7 @@ export class WorkerManager {
private workerMetrics: WorkerMetrics | null = null; private workerMetrics: WorkerMetrics | null = null;
private skillCatalog: SkillCatalog | null = null; private skillCatalog: SkillCatalog | null = null;
private pushService: PushService | null = null; private pushService: PushService | null = null;
private webhookService: WebhookDeliveryService | null = null;
constructor(repo: Repository, configManager: ConfigManager) { constructor(repo: Repository, configManager: ConfigManager) {
this.repo = repo; 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[] { private createWorkers(config: AppConfig): Worker[] {
return config.provider.workers return config.provider.workers
.filter(def => def.enabled !== false) .filter(def => def.enabled !== false)
@ -248,6 +262,7 @@ export class WorkerManager {
if (this.workerMetrics) w.setWorkerMetrics(this.workerMetrics); if (this.workerMetrics) w.setWorkerMetrics(this.workerMetrics);
if (this.skillCatalog) w.setSkillCatalog(this.skillCatalog); if (this.skillCatalog) w.setSkillCatalog(this.skillCatalog);
if (this.pushService) w.setPushService(this.pushService); if (this.pushService) w.setPushService(this.pushService);
if (this.webhookService) w.setWebhookService(this.webhookService);
return w; return w;
} }
} }

View File

@ -571,6 +571,13 @@ export class Worker {
* Spec: docs/superpowers/specs/2026-05-28-browser-notifications-v2-webpush.md. * Spec: docs/superpowers/specs/2026-05-28-browser-notifications-v2-webpush.md.
*/ */
private pushService: import('./push-service.js').PushService | null = null; 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( constructor(
workerId: string, workerId: string,
@ -596,6 +603,10 @@ export class Worker {
this.pushService = svc; 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 * Hot-swap the global config on a still-running worker. Used by
* WorkerManager's differential rebuild: when a config change does NOT * 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. * Phase 3b: install (or remove) the Prometheus metrics handle.
* Idempotent calling with the same handle twice is fine. Null * 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 }); await this.repo.updateJob(jobId, { status: 'succeeded', currentActivity: null });
this.enqueuePush(job, 'succeeded'); this.enqueuePush(job, 'succeeded');
this.enqueueWebhook(job, 'succeeded');
await maybeEnqueueReflection(this.repo, job, 'succeeded', this.config.reflection, this.config.provider.workers); await maybeEnqueueReflection(this.repo, job, 'succeeded', this.config.reflection, this.config.provider.workers);
let resultBody = result.finalOutput; let resultBody = result.finalOutput;
if (resultBody) { if (resultBody) {
@ -2882,6 +2945,7 @@ export class Worker {
waitReason: 'tool_request', waitReason: 'tool_request',
}); });
this.enqueuePush(job, 'waiting_human'); this.enqueuePush(job, 'waiting_human');
this.enqueueWebhook(job, 'waiting_human');
await this.repo.addAuditLog(jobId, 'job_tool_request', 'worker', { await this.repo.addAuditLog(jobId, 'job_tool_request', 'worker', {
resumeMovement: result.resumeMovement, resumeMovement: result.resumeMovement,
}); });
@ -2897,6 +2961,7 @@ export class Worker {
waitReason: 'package_request', waitReason: 'package_request',
}); });
this.enqueuePush(job, 'waiting_human'); this.enqueuePush(job, 'waiting_human');
this.enqueueWebhook(job, 'waiting_human');
await this.repo.addAuditLog(jobId, 'job_package_request', 'worker', { await this.repo.addAuditLog(jobId, 'job_package_request', 'worker', {
resumeMovement: result.resumeMovement, resumeMovement: result.resumeMovement,
}); });
@ -2907,6 +2972,7 @@ export class Worker {
askCount: job.askCount + 1, askCount: job.askCount + 1,
}); });
this.enqueuePush(job, 'waiting_human'); this.enqueuePush(job, 'waiting_human');
this.enqueueWebhook(job, 'waiting_human');
await reporter.reportAsk(result.finalOutput); await reporter.reportAsk(result.finalOutput);
await this.repo.addAuditLog(jobId, 'job_ask', 'worker', { await this.repo.addAuditLog(jobId, 'job_ask', 'worker', {
question: result.finalOutput, question: result.finalOutput,
@ -3170,6 +3236,7 @@ export class Worker {
// V2 push: only on terminal fail. Intermediate retry attempts are // V2 push: only on terminal fail. Intermediate retry attempts are
// silenced (matches V1's 4-second debounce intent). // silenced (matches V1's 4-second debounce intent).
this.enqueuePush(job, 'failed'); this.enqueuePush(job, 'failed');
this.enqueueWebhook(job, 'failed');
writeRetryHandoffSummary({ writeRetryHandoffSummary({
workspacePath: workspacePath ?? job.worktreePath, workspacePath: workspacePath ?? job.worktreePath,
job, job,

View File

@ -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> = {}): 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<typeof fakeRepo>) {
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'\)/);
});
});

View File

@ -267,6 +267,96 @@ export async function removeSpacePythonPackage(
return data as { packages: SpacePythonPackage[] }; 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<SpaceWebhook[]> {
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<SpaceWebhook> {
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<SpaceWebhook> {
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<SpaceWebhook> {
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<void> {
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: { export async function createSpace(input: {
title: string; title: string;
description?: string; description?: string;

View File

@ -46,4 +46,27 @@ describe('SettingsSidebar search', () => {
// Safety is under an adminOnly group → not searchable for a non-admin. // Safety is under an adminOnly group → not searchable for a non-admin.
expect(screen.queryByTestId('settings-search-result-safety')).not.toBeInTheDocument(); 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(<SettingsSidebar isAdmin onSelectSection={onSelect} />);
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(<SettingsSidebar isAdmin a2aEnabled onSelectSection={onSelect} />);
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(<SettingsSidebar isAdmin onSelectSection={onSelect} />);
fireEvent.change(screen.getByTestId('settings-search'), { target: { value: 'delegation' } });
expect(screen.queryByTestId('settings-search-result-a2a-delegations')).not.toBeInTheDocument();
});
}); });

View File

@ -6,6 +6,12 @@ interface SettingsSidebarProps {
activeSection?: string; activeSection?: string;
onSelectSection: (section: string) => void; onSelectSection: (section: string) => void;
isAdmin: boolean; 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) .filter(g => !('adminOnly' in g) || !g.adminOnly)
.flatMap(g => g.sections.map(s => s.id)); .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 { t } = useTranslation('settings');
const [query, setQuery] = useState(''); 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. // Only sections the current user can actually open are searchable.
const visibleIds = useMemo( const visibleIds = useMemo(
() => new Set(visibleGroups.flatMap(g => g.sections.map(s => s.id))), () => new Set<string>(visibleGroups.flatMap(g => g.sections.map(s => s.id))),
[visibleGroups], [visibleGroups],
); );
const index = useMemo(() => buildSettingsSearchIndex().filter(e => visibleIds.has(e.sectionId)), [visibleIds]); const index = useMemo(() => buildSettingsSearchIndex().filter(e => visibleIds.has(e.sectionId)), [visibleIds]);

View File

@ -22,6 +22,7 @@ import { SshConnectionsPanel } from '../userfolder/SshConnectionsPanel';
import { SpaceMembersPanel } from './SpaceMembersPanel'; import { SpaceMembersPanel } from './SpaceMembersPanel';
import { SpaceBrowserPanel } from './SpaceBrowserPanel'; import { SpaceBrowserPanel } from './SpaceBrowserPanel';
import { SpaceToolSettings } from './SpaceToolSettings'; import { SpaceToolSettings } from './SpaceToolSettings';
import { SpaceWebhookSettings } from './SpaceWebhookSettings';
import { PythonPackagesPanel } from './PythonPackagesPanel'; import { PythonPackagesPanel } from './PythonPackagesPanel';
import { PieceEditor } from '../settings/PieceEditor'; import { PieceEditor } from '../settings/PieceEditor';
import { usePieceList } from '../../hooks/usePieces'; import { usePieceList } from '../../hooks/usePieces';
@ -31,7 +32,7 @@ import { useAuthState } from '../../App';
type ShowToast = (message: string, variant?: 'success' | 'error') => void; 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 }[] = [ const SECTIONS: { id: SettingsSection; labelKey: string; testid: string }[] = [
{ id: 'agents', labelKey: 'settings.nav.agents', testid: 'space-settings-nav-agents' }, { 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: 'browser', labelKey: 'settings.nav.browser', testid: 'space-settings-nav-browser' },
{ id: 'tools', labelKey: 'settings.nav.tools', testid: 'space-settings-nav-tools' }, { id: 'tools', labelKey: 'settings.nav.tools', testid: 'space-settings-nav-tools' },
{ id: 'python', labelKey: 'settings.nav.python', testid: 'space-settings-nav-python' }, { 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' }, { 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' && <SpaceBrowserPanel spaceId={spaceId} showToast={showToast} />} {section === 'browser' && <SpaceBrowserPanel spaceId={spaceId} showToast={showToast} />}
{section === 'tools' && <SpaceToolSettings spaceId={spaceId} showToast={showToast} />} {section === 'tools' && <SpaceToolSettings spaceId={spaceId} showToast={showToast} />}
{section === 'python' && <PythonPackagesPanel spaceId={spaceId} showToast={showToast} />} {section === 'python' && <PythonPackagesPanel spaceId={spaceId} showToast={showToast} />}
{section === 'webhooks' && <SpaceWebhookSettings spaceId={spaceId} showToast={showToast} />}
{section === 'members' && <SpaceMembersPanel spaceId={spaceId} showToast={showToast} />} {section === 'members' && <SpaceMembersPanel spaceId={spaceId} showToast={showToast} />}
</div> </div>
</div> </div>

View File

@ -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(<SpaceWebhookSettings spaceId="s1" />);
await waitFor(() => expect(screen.getByTestId('space-webhook-settings')).toBeInTheDocument());
expect(screen.getByText('まだ何も登録されていません。')).toBeInTheDocument();
});
it('adds a webhook via the form', async () => {
renderWithProviders(<SpaceWebhookSettings spaceId="s1" />);
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(<SpaceWebhookSettings spaceId="s1" />);
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(<SpaceWebhookSettings spaceId="s1" />);
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(<SpaceWebhookSettings spaceId="s1" />);
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(<SpaceWebhookSettings spaceId="s1" />);
await waitFor(() => expect(screen.getByText('追加')).toBeDisabled());
});
it('offers Discord, Slack, and Microsoft Teams in the provider select', async () => {
renderWithProviders(<SpaceWebhookSettings spaceId="s1" />);
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(<SpaceWebhookSettings spaceId="s1" />);
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,
}));
});
});

View File

@ -0,0 +1,370 @@
/**
* SpaceWebhookSettings.tsx Webhook UI
*
* PR1 DiscordPR2 SlackPR3 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/webhookscanManageSpace
*/
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<SpaceWebhookProvider, string> = {
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<SpaceWebhookProvider>('discord');
const [label, setLabel] = useState('');
const [url, setUrl] = useState('');
const [events, setEvents] = useState<SpaceWebhookEvent[]>(['succeeded', 'failed', 'waiting_human']);
const [includeDetails, setIncludeDetails] = useState(true);
const [editingUrlFor, setEditingUrlFor] = useState<string | null>(null);
const [editUrlValue, setEditUrlValue] = useState('');
const [testResult, setTestResult] = useState<Record<string, 'ok' | 'error'>>({});
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 (
<div className="h-full overflow-y-auto"><div className="max-w-2xl mx-auto px-6 py-8">
<div className="text-[13px] text-slate-400">{t('common:loading')}</div>
</div></div>
);
}
if (isError || !data) {
return (
<div className="h-full overflow-y-auto"><div className="max-w-2xl mx-auto px-6 py-8">
<div className="text-[13px] text-red-600">{t('webhooks.fetchError', { msg: errMsg(error) })}</div>
</div></div>
);
}
return (
<div className="h-full overflow-y-auto" data-testid="space-webhook-settings">
<div className="max-w-2xl mx-auto px-6 py-8 space-y-6">
<div>
<h2 className="text-base font-semibold text-slate-900 mb-1">{t('webhooks.heading')}</h2>
<p className="text-[13px] text-slate-500 leading-relaxed">{t('webhooks.intro')}</p>
</div>
{/* 追加フォーム */}
<section className="space-y-2 rounded-md border border-hairline bg-surface/40 p-4">
<label className="block text-[11px] font-semibold uppercase tracking-wide text-slate-500">
{t('webhooks.addLabel')}
</label>
<select
value={provider}
onChange={e => setProvider(e.target.value as SpaceWebhookProvider)}
disabled={!canManage || busy}
data-testid="webhook-provider-select"
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"
>
{IMPLEMENTED_PROVIDERS.map(p => (
<option key={p} value={p}>{t(`webhooks.provider.${p}`)}</option>
))}
</select>
<input
value={label}
onChange={e => 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"
/>
<input
value={url}
onChange={e => 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"
/>
<div className="flex flex-wrap items-center gap-3 text-[13px] text-slate-600">
{ALL_EVENTS.map(ev => (
<label key={ev} className="flex items-center gap-1.5">
<input
type="checkbox"
checked={events.includes(ev)}
onChange={() => toggleEvent(ev)}
disabled={!canManage || busy}
/>
{t(`webhooks.event.${ev}`)}
</label>
))}
</div>
<label className="flex items-center gap-1.5 text-[13px] text-slate-600">
<input
type="checkbox"
checked={includeDetails}
onChange={e => setIncludeDetails(e.target.checked)}
disabled={!canManage || busy}
/>
{t('webhooks.includeDetails')}
</label>
<button
type="button"
onClick={submit}
disabled={!canManage || busy || !label.trim() || !url.trim() || events.length === 0}
className="px-4 py-1.5 rounded-md text-sm font-semibold bg-accent text-white hover:bg-accent/90 transition-colors disabled:opacity-40 disabled:cursor-not-allowed"
>
{createMut.isPending ? t('webhooks.adding') : t('webhooks.add')}
</button>
<p className="text-2xs text-slate-500 leading-relaxed">{t(`webhooks.urlHint.${provider}`)}</p>
</section>
{/* 一覧 */}
<section>
<h3 className="text-[11px] font-semibold uppercase tracking-wide text-slate-500 mb-2">
{t('webhooks.listHeading')}
</h3>
{data.length === 0 ? (
<p className="text-[13px] text-slate-400">{t('webhooks.none')}</p>
) : (
<div className="rounded-md border border-hairline bg-surface/40 divide-y divide-hairline overflow-hidden">
{data.map((wh: SpaceWebhook) => {
const isWarning = wh.enabled && wh.failureCount >= WARNING_FAILURE_THRESHOLD;
const isEditingUrl = editingUrlFor === wh.id;
return (
<div key={wh.id} className="px-3 py-3 space-y-2" data-testid={`webhook-row-${wh.id}`}>
<div className="flex items-center gap-2 flex-wrap">
<span className="text-[13px] font-medium text-slate-900">{wh.label}</span>
<span className="text-2xs text-slate-400 uppercase">{wh.provider}</span>
{!wh.enabled && (
<span
data-testid={`webhook-disabled-badge-${wh.id}`}
className="text-2xs rounded px-1.5 py-0.5 bg-red-100 text-red-700 dark:bg-red-900/30 dark:text-red-300"
>
{t('webhooks.autoDisabledBadge')}
</span>
)}
{wh.enabled && isWarning && (
<span className="text-2xs rounded px-1.5 py-0.5 bg-amber-100 text-amber-700 dark:bg-amber-900/30 dark:text-amber-300">
{t('webhooks.warningBadge', { count: wh.failureCount })}
</span>
)}
</div>
<div className="text-2xs text-slate-500">
{wh.events.map(ev => t(`webhooks.event.${ev}`)).join(' / ')}
{wh.includeDetails ? '' : ` · ${t('webhooks.detailsOff')}`}
</div>
<div className="text-2xs text-slate-500 space-y-0.5">
<div>{t('webhooks.lastSuccess', { at: wh.lastSuccessAt ?? t('webhooks.never') })}</div>
<div>{t('webhooks.lastFailure', { at: wh.lastFailureAt ?? t('webhooks.never') })}</div>
<div>{t('webhooks.failureCount', { count: wh.failureCount })}</div>
{wh.disabledReason && <div className="text-red-600">{wh.disabledReason}</div>}
</div>
{isEditingUrl ? (
<div className="flex gap-2">
<input
value={editUrlValue}
onChange={e => 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"
/>
<button
type="button"
onClick={() => updateUrlMut.mutate({ id: wh.id, newUrl: editUrlValue.trim() })}
disabled={!editUrlValue.trim() || updateUrlMut.isPending}
className="text-xs text-accent hover:underline disabled:opacity-40"
>
{t('common:save')}
</button>
<button
type="button"
onClick={() => { setEditingUrlFor(null); setEditUrlValue(''); }}
className="text-xs text-slate-500 hover:underline"
>
{t('common:cancel')}
</button>
</div>
) : (
<div className="flex flex-wrap items-center gap-3">
<button
type="button"
onClick={() => testMut.mutate(wh.id)}
disabled={!canManage || testMut.isPending}
data-testid={`webhook-test-${wh.id}`}
className="text-xs text-accent hover:underline disabled:opacity-40 disabled:cursor-not-allowed"
>
{testMut.isPending ? t('webhooks.testing') : t('webhooks.test')}
</button>
{testResult[wh.id] === 'ok' && (
<span className="text-2xs text-green-600">{t('webhooks.testSent')}</span>
)}
{testResult[wh.id] === 'error' && (
<span className="text-2xs text-red-600">{t('webhooks.testFailedShort')}</span>
)}
{canManage && (
<button
type="button"
onClick={() => { setEditingUrlFor(wh.id); setEditUrlValue(''); }}
className="text-xs text-slate-600 hover:underline"
>
{t('webhooks.changeUrl')}
</button>
)}
{!wh.enabled && canManage && (
<button
type="button"
onClick={() => enableMut.mutate({ id: wh.id, enabled: true })}
disabled={enableMut.isPending}
data-testid={`webhook-reenable-${wh.id}`}
className="text-xs text-accent hover:underline disabled:opacity-40"
>
{t('webhooks.reEnable')}
</button>
)}
{wh.enabled && canManage && (
<button
type="button"
onClick={() => enableMut.mutate({ id: wh.id, enabled: false })}
disabled={enableMut.isPending}
className="text-xs text-slate-500 hover:underline disabled:opacity-40"
>
{t('webhooks.disable')}
</button>
)}
{canManage && (
<button
type="button"
onClick={() => deleteMut.mutate(wh.id)}
disabled={busy}
data-testid={`webhook-delete-${wh.id}`}
className="text-xs text-red-600 hover:text-red-700 disabled:opacity-40 disabled:cursor-not-allowed"
>
{t('webhooks.remove')}
</button>
)}
</div>
)}
</div>
);
})}
</div>
)}
</section>
{!canManage && (
<p className="text-[13px] text-slate-400">{t('webhooks.readonly')}</p>
)}
</div>
</div>
);
}

View File

@ -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に移動しました ## 2026-07-09 — 右上のユーザー操作をアカウントメニューに集約し、パスワード変更を設定→Preferenceに移動しました
画面右上に横並びだったダークモード切替・ユーザー名・パスワード変更・ログアウトを、ユーザーアイコンをクリックして開く **アカウントメニュー** にまとめました。パスワード変更は [システム設定](./17-settings.md) の Preference セクションへ移動し、Google / Gitea などの外部ログインアカウントでは変更不可であることが分かるように案内が表示されます。 画面右上に横並びだったダークモード切替・ユーザー名・パスワード変更・ログアウトを、ユーザーアイコンをクリックして開く **アカウントメニュー** にまとめました。パスワード変更は [システム設定](./17-settings.md) の Preference セクションへ移動し、Google / Gitea などの外部ログインアカウントでは変更不可であることが分かるように案内が表示されます。

View File

@ -3,7 +3,7 @@ id: notifications
title: 通知を受け取る title: 通知を受け取る
category: basic category: basic
order: 70 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 が利用できる環境では、「通知にタスクの詳細(タイト
## 管理者向けの設定 ## 管理者向けの設定
V2Web Pushの有効化には、サーバー側で `config.yaml``notifications.push.enabled: true` 設定と VAPID 鍵が必要です(鍵は初回起動時に自動生成されます)。サーバー側設定の概要は [設定](17-settings.md)、管理操作は [管理者向け](19-admin.md) を参照してください。 V2Web 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 を再設定してください

View File

@ -38,6 +38,8 @@ OAuth2 の標準フロー(認可コード + PKCEをベースにしており
### 設定 → A2A 委任 から一覧・取り消し ### 設定 → A2A 委任 から一覧・取り消し
この項目は A2A が有効なサーバーでのみ表示されます。管理者が `a2a.enabled: true` を設定していない場合、設定のサイドバーに「A2A 委任」は現れません。
**設定 → A2A 委任** を開くと、自分が過去に承認した委任の一覧が表示されます。各行には次の情報が確認できます。 **設定 → A2A 委任** を開くと、自分が過去に承認した委任の一覧が表示されます。各行には次の情報が確認できます。
| 項目 | 内容 | | 項目 | 内容 |

View File

@ -11,6 +11,11 @@ export interface SetupStatus {
deployHint: 'docker' | 'source'; deployHint: 'docker' | 'source';
/** True when a setup token must be supplied with mutating setup calls. */ /** True when a setup token must be supplied with mutating setup calls. */
tokenRequired: boolean; 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;
} }
/** /**

View File

@ -365,6 +365,7 @@
"browser": "Browser", "browser": "Browser",
"tools": "Tools", "tools": "Tools",
"python": "Python", "python": "Python",
"webhooks": "Webhooks",
"members": "Members" "members": "Members"
}, },
"pieces": { "pieces": {
@ -418,5 +419,55 @@
"addFailed": "Add failed: {{msg}}", "addFailed": "Add failed: {{msg}}",
"removed": "Package removed.", "removed": "Package removed.",
"removeFailed": "Remove failed: {{msg}}" "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}}"
} }
} }

View File

@ -365,6 +365,7 @@
"browser": "ブラウザ", "browser": "ブラウザ",
"tools": "ツール", "tools": "ツール",
"python": "Python", "python": "Python",
"webhooks": "Webhook 通知",
"members": "メンバー" "members": "メンバー"
}, },
"pieces": { "pieces": {
@ -418,5 +419,55 @@
"addFailed": "追加に失敗しました: {{msg}}", "addFailed": "追加に失敗しました: {{msg}}",
"removed": "パッケージを削除しました。", "removed": "パッケージを削除しました。",
"removeFailed": "削除に失敗しました: {{msg}}" "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}}"
} }
} }

View File

@ -1,6 +1,7 @@
import { useEffect, useState } from 'react'; import { useEffect, useState } from 'react';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import { useUrlState } from '../hooks/useUrlState'; import { useUrlState } from '../hooks/useUrlState';
import { useSetupState } from '../hooks/useSetupState';
import { import {
SettingsSidebar, SettingsSidebar,
USER_SECTIONS, USER_SECTIONS,
@ -15,6 +16,10 @@ interface SettingsPageProps {
export function SettingsPage({ isAdmin }: SettingsPageProps) { export function SettingsPage({ isAdmin }: SettingsPageProps) {
const { t } = useTranslation('settings'); const { t } = useTranslation('settings');
const { urlState, setUrlState } = useUrlState(); 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 // admin landing page: first LLM Workers (most-used setting). Non-admin
// lands on preferences. The pre-Step-3 default was 'provider'. // lands on preferences. The pre-Step-3 default was 'provider'.
const fallbackSection = isAdmin ? 'llm-workers' : 'preferences'; const fallbackSection = isAdmin ? 'llm-workers' : 'preferences';
@ -22,9 +27,10 @@ export function SettingsPage({ isAdmin }: SettingsPageProps) {
// Map legacy ids (provider / workspace / tools / browser-settings / // Map legacy ids (provider / workspace / tools / browser-settings /
// search-filter / browser-sessions) into the new sidebar layout. // search-filter / browser-sessions) into the new sidebar layout.
const requestedSection = LEGACY_SECTION_REDIRECT[rawRequested] ?? rawRequested; const requestedSection = LEGACY_SECTION_REDIRECT[rawRequested] ?? rawRequested;
const section = (!isAdmin && !USER_SECTIONS.includes(requestedSection)) const unavailableSection =
? 'preferences' (!isAdmin && !USER_SECTIONS.includes(requestedSection)) ||
: requestedSection; (requestedSection === 'a2a-delegations' && !a2aEnabled);
const section = unavailableSection ? 'preferences' : requestedSection;
// If the URL still carries a legacy id, rewrite it once so bookmarks // If the URL still carries a legacy id, rewrite it once so bookmarks
// and the Back button line up with the new navigation. // and the Back button line up with the new navigation.
@ -48,6 +54,14 @@ export function SettingsPage({ isAdmin }: SettingsPageProps) {
} }
}, [isAdmin, urlState.section, setUrlState]); }, [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) => { const handleSelectSection = (s: string) => {
setUrlState(prev => ({ ...prev, section: s as any })); setUrlState(prev => ({ ...prev, section: s as any }));
setMobileView('detail'); setMobileView('detail');
@ -63,6 +77,7 @@ export function SettingsPage({ isAdmin }: SettingsPageProps) {
activeSection={section} activeSection={section}
onSelectSection={handleSelectSection} onSelectSection={handleSelectSection}
isAdmin={isAdmin} isAdmin={isAdmin}
a2aEnabled={a2aEnabled}
/> />
</div> </div>