diff --git a/.gitignore b/.gitignore index 04e6f61..9d27f22 100644 --- a/.gitignore +++ b/.gitignore @@ -1,6 +1,7 @@ .DS_Store # Local Go build and test artifacts +.worktrees/ .gocache/ .tmp/ bin/ @@ -21,4 +22,4 @@ _site/ # Python bytecode __pycache__/ -*.py[cod] \ No newline at end of file +*.py[cod] diff --git a/README.md b/README.md index d4e6067..e2f1117 100644 --- a/README.md +++ b/README.md @@ -106,6 +106,7 @@ The command model is intentionally layered: | Write env to an arbitrary path / non-quickstart repo | `agora project env write ` | | Install self-test | `agora doctor --json` | | Project/workspace readiness | `agora project doctor --json` | +| Manage feature webhooks | `agora project webhook ... --json` | ### Env-related commands @@ -152,6 +153,7 @@ Use this when you want to: - export project env values with `project env` - write credentials to a dotenv file with `project env write` - inspect project readiness with `project doctor` +- manage feature-scoped webhook endpoints with `project webhook` ### `auth` diff --git a/docs/automation.md b/docs/automation.md index 5e23ac2..086f5a2 100644 --- a/docs/automation.md +++ b/docs/automation.md @@ -44,7 +44,7 @@ Use this guide for: - In non-interactive runs (`--yes`, JSON, CI, non-TTY), pass `--template` explicitly to `agora init`. The CLI now fails fast with `QUICKSTART_TEMPLATE_REQUIRED` instead of silently selecting a template. - Output mode precedence is: explicit CLI flag (`--json` or `--output`) first, user-set `AGORA_OUTPUT` second, then user-customized config file value, then **CI auto-detect → JSON** (see below), then pretty. - Set `AGORA_AGENT=` in automated environments to explicitly label agent traffic in the API `User-Agent`. When unset, the CLI may infer a coarse label such as `cursor`, `claude-code`, `cline`, `windsurf`, `codex`, or `aider` from known agent environment markers. Set `AGORA_AGENT_DISABLE_INFER=1` to disable inference. -- Use `agora mcp serve` to expose local Agora CLI tools to MCP-capable agents. The full surface is exposed: `agora.version`, `agora.introspect`, `agora.auth.{status,logout}`, `agora.config.{path,get}`, `agora.telemetry.status`, `agora.upgrade.check`, `agora.project.{list,show,use,create,doctor,env,env_write}`, `agora.project.feature.{list,status,enable}`, `agora.quickstart.{list,create,env_write}`, and `agora.init`. Authentication is intentionally **not** exposed via MCP because OAuth requires an interactive browser; run `agora login` once on the host first. +- Use `agora mcp serve` to expose local Agora CLI tools to MCP-capable agents. The full surface is exposed: `agora.version`, `agora.introspect`, `agora.auth.{status,logout}`, `agora.config.{path,get}`, `agora.telemetry.status`, `agora.upgrade.check`, `agora.project.{list,show,use,create,doctor,env,env_write}`, `agora.project.feature.{list,status,enable}`, `agora.project.webhook.{events,list,show,create,update,delete}`, `agora.quickstart.{list,create,env_write}`, and `agora.init`. Authentication is intentionally **not** exposed via MCP because OAuth requires an interactive browser; run `agora login` once on the host first. - Use `agora open --target docs` for the human GitHub Pages docs and `agora open --target docs-md` for the agent-facing raw Markdown index. In CI/non-TTY runs the command defaults to URL-only output unless `--browser` is set. The Markdown tree is published under predictable `/md/` URLs, for example `/md/commands.md`, `/md/automation.md`, and `/md/error-codes.md`. - Docs publishing reads `docs/site.env` for `CLI_DOCS_BASE_URL` and `CLI_DOCS_MD_BASE_URL`; staging Pages builds can override those environment variables at workflow time without changing docs content. The resolved values are published as `/docs.env` for transparency. - The CLI maintains a short-lived on-disk completion cache for `agora project use ` under `/cache/projects.json`. The cache is only used for completions when a **local unexpired session exists** (`session.json` with a non-empty access token and a future `expiresAt`, when present), so Tab does not suggest stale project names after logout or local session expiry. The cache TTL is 5 minutes by default; override with `AGORA_PROJECT_CACHE_TTL_SECONDS=` (set to `0` to disable). Cache files older than 24 h are pruned at every CLI startup. Set `AGORA_DISABLE_CACHE=1` to drop the cache on the next startup. The cache is invalidated automatically by `agora logout` and `agora project create` (the latter clears the file; it does not embed the new project until the next successful list fetch). To **force-refresh** the cached completion page, run `agora project list --refresh-cache` while authenticated; that command fetches the unfiltered first page used by completion and rewrites `projects.json` when it succeeds. @@ -930,6 +930,107 @@ Safe branch fields: - `status` - `projectId` +### `project webhook` + +Examples: + +```bash +./agora project webhook events --feature rtc --json +./agora project webhook create --project my-project --feature rtc --url https://example.com/webhook --events channel-created,user-joined --json +./agora project webhook show 42 --project my-project --feature rtc --with-secret --json +./agora project webhook update 42 --project my-project --feature rtc --url https://example.com/webhook2 --json +./agora project webhook delete 42 --project my-project --feature rtc --yes --json +``` + +Webhook commands are feature-scoped. Pass `--feature rtc`, `--feature rtm`, or `--feature convoai` and prefer explicit `--project ` for automation. + +`project webhook events` required `data` fields: +- `action` + Always `webhook-events`. +- `feature` +- `items` + Array of event objects. + +Each event item includes: +- `id` +- `key` + Stable CLI event key derived from the English display name, for example `channel-created`. +- `displayName` +- `eventType` +- `payload` when provided by the API. + +`project webhook list` required `data` fields: +- `action` + Always `webhook-list`. +- `projectId` +- `projectName` +- `feature` +- `events` + The available event catalog for the selected feature. +- `items` + Array of webhook config objects. + +Each list item includes: +- `configId` +- `url` +- `urlRegion` + Delivery region for webhook callbacks: `cn`, `sea`, `na`, or `eu`. +- `enabled` + Canonical webhook state field. Webhook JSON does not emit a string `status`. +- `eventIds` +- `events` + Event details that match `eventIds` when available. +- `retry` + Retry behavior when returned by the API. This field is read-only in the CLI. +- `useIpWhitelist` +- `secret` + Present when the backend returns a secret, redacted as `********`. `list` never emits a raw secret. + +`project webhook show`, `project webhook create`, and `project webhook update` required `data` fields: +- `action` + One of `webhook-show`, `webhook-create`, or `webhook-update`. +- `projectId` +- `projectName` +- `feature` +- `configId` +- `url` +- `urlRegion` + Delivery region for webhook callbacks: `cn`, `sea`, `na`, or `eu`. +- `enabled` + Canonical webhook state field. Webhook JSON does not emit a string `status`. +- `eventIds` +- `events` + Event details that match `eventIds` when available. +- `useIpWhitelist` +- `config` + Nested webhook config object with the same config fields. + +Optional top-level `data` fields for `show`, `create`, and `update` (also present in the nested `config` object when set): +- `secret` + Webhook signing secret. `create` returns the generated or caller-provided secret at `data.secret` and `data.config.secret` so automation can store it. `show` returns the raw secret only with `--with-secret`; otherwise it redacts `data.secret` and `data.config.secret` as `********`. `update` does not rotate secrets; when the backend returns a secret, `update` emits the redacted value. `list` has no top-level `secret`; item secrets are redacted as `items[].secret == "********"`. +- `retry` + Retry behavior when returned by the API. This field is read-only in the CLI and appears at `data.retry` and `data.config.retry`; `list` exposes it per item as `items[].retry`. + +`project webhook create` requires `--events [,...]`. `project webhook update` preserves existing values for omitted mutable fields. Use `--url`, `--events`, `--delivery-region`, `--enabled`, or `--disabled` to replace only those fields. `update` does not rotate or emit the raw secret. + +`project webhook delete` required `data` fields: +- `action` + Always `webhook-delete`. +- `projectId` +- `projectName` +- `feature` +- `configId` +- `deleted` + `true` after the remote config is deleted. + +Delete is destructive and requires confirmation. Pass `--yes` (or `-y`) in CLI automation; the MCP delete tool requires `confirm: true`. + +Safe branch fields by command shape: +- Event discovery: `feature`, `items[].id`, `items[].key` +- List: `projectId`, `feature`, `items[].configId`, `items[].enabled`, `items[].eventIds`, `items[].urlRegion` +- Show/create/update: `projectId`, `feature`, `configId`, `enabled`, `eventIds`, `urlRegion` +- Delete: `projectId`, `feature`, `configId`, `deleted` + ### `config path` Example: diff --git a/docs/commands.md b/docs/commands.md index 43c4195..fa968f8 100644 --- a/docs/commands.md +++ b/docs/commands.md @@ -35,7 +35,7 @@ Pseudo commands are root-level flags that emit their own JSON envelope rather th Manage Agora authentication -_No local flags. Inherited global flags still apply (see [Global Flags](#global-flags))._ +_No local flags. Inherited parent and global flags still apply; run `agora --help` for the full flag set._ ### `agora auth login` @@ -50,31 +50,31 @@ Authenticate with Agora Console Clear the local Agora session -_No local flags. Inherited global flags still apply (see [Global Flags](#global-flags))._ +_No local flags. Inherited parent and global flags still apply; run `agora --help` for the full flag set._ ### `agora auth status` Show the current auth status -_No local flags. Inherited global flags still apply (see [Global Flags](#global-flags))._ +_No local flags. Inherited parent and global flags still apply; run `agora --help` for the full flag set._ ### `agora config` Manage persisted Agora CLI defaults -_No local flags. Inherited global flags still apply (see [Global Flags](#global-flags))._ +_No local flags. Inherited parent and global flags still apply; run `agora --help` for the full flag set._ ### `agora config get` Read persisted CLI defaults -_No local flags. Inherited global flags still apply (see [Global Flags](#global-flags))._ +_No local flags. Inherited parent and global flags still apply; run `agora --help` for the full flag set._ ### `agora config path` Show the config file path -_No local flags. Inherited global flags still apply (see [Global Flags](#global-flags))._ +_No local flags. Inherited parent and global flags still apply; run `agora --help` for the full flag set._ ### `agora config update` @@ -96,13 +96,13 @@ Update persisted CLI defaults Diagnose the local Agora CLI install (PATH, version, network, auth, MCP host) -_No local flags. Inherited global flags still apply (see [Global Flags](#global-flags))._ +_No local flags. Inherited parent and global flags still apply; run `agora --help` for the full flag set._ ### `agora env-help` List every AGORA_* environment variable the CLI honors -_No local flags. Inherited global flags still apply (see [Global Flags](#global-flags))._ +_No local flags. Inherited parent and global flags still apply; run `agora --help` for the full flag set._ ### `agora init` @@ -123,7 +123,7 @@ Create a project, clone a quickstart, and write env in one flow Emit machine-readable command metadata -_No local flags. Inherited global flags still apply (see [Global Flags](#global-flags))._ +_No local flags. Inherited parent and global flags still apply; run `agora --help` for the full flag set._ ### `agora login` @@ -138,19 +138,19 @@ Authenticate with Agora Console Clear the local Agora session -_No local flags. Inherited global flags still apply (see [Global Flags](#global-flags))._ +_No local flags. Inherited parent and global flags still apply; run `agora --help` for the full flag set._ ### `agora mcp` Run Agora CLI as a local MCP server -_No local flags. Inherited global flags still apply (see [Global Flags](#global-flags))._ +_No local flags. Inherited parent and global flags still apply; run `agora --help` for the full flag set._ ### `agora mcp serve` Serve Agora CLI tools over MCP -_No local flags. Inherited global flags still apply (see [Global Flags](#global-flags))._ +_No local flags. Inherited parent and global flags still apply; run `agora --help` for the full flag set._ ### `agora open` @@ -166,7 +166,7 @@ Open Agora Console or CLI docs Manage remote Agora project resources -_No local flags. Inherited global flags still apply (see [Global Flags](#global-flags))._ +_No local flags. Inherited parent and global flags still apply; run `agora --help` for the full flag set._ ### `agora project create` @@ -215,25 +215,25 @@ Write project environment variables to a dotenv file Manage project feature state -_No local flags. Inherited global flags still apply (see [Global Flags](#global-flags))._ +_No local flags. Inherited parent and global flags still apply; run `agora --help` for the full flag set._ ### `agora project feature enable` Enable one feature for a project -_No local flags. Inherited global flags still apply (see [Global Flags](#global-flags))._ +_No local flags. Inherited parent and global flags still apply; run `agora --help` for the full flag set._ ### `agora project feature list` List feature status for a project -_No local flags. Inherited global flags still apply (see [Global Flags](#global-flags))._ +_No local flags. Inherited parent and global flags still apply; run `agora --help` for the full flag set._ ### `agora project feature status` Show one feature status -_No local flags. Inherited global flags still apply (see [Global Flags](#global-flags))._ +_No local flags. Inherited parent and global flags still apply; run `agora --help` for the full flag set._ ### `agora project list` @@ -250,19 +250,83 @@ List projects available to the current account Show one project -_No local flags. Inherited global flags still apply (see [Global Flags](#global-flags))._ +_No local flags. Inherited parent and global flags still apply; run `agora --help` for the full flag set._ ### `agora project use` Set the current project context -_No local flags. Inherited global flags still apply (see [Global Flags](#global-flags))._ +_No local flags. Inherited parent and global flags still apply; run `agora --help` for the full flag set._ + +### `agora project webhook` + +Manage project webhook configurations + +| Flag | Type | Default | Description | +|------|------|---------|-------------| +| `--feature` | `string` | — | project feature for webhook operations: rtc, rtm, or convoai | + +### `agora project webhook create` + +Create a webhook configuration + +| Flag | Type | Default | Description | +|------|------|---------|-------------| +| `--delivery-region` | `string` | — | webhook delivery region: cn, sea, na, or eu | +| `--events` | `string` | — | comma-separated webhook event keys, display names, or numeric IDs | +| `--project` | `string` | — | project ID or exact project name; defaults to the current project context | +| `--secret` | `string` | — | webhook signing secret; generated when omitted | +| `--url` | `string` | — | webhook endpoint URL | + +### `agora project webhook delete` + +Delete a webhook configuration + +| Flag | Type | Default | Description | +|------|------|---------|-------------| +| `--project` | `string` | — | project ID or exact project name; defaults to the current project context | + +### `agora project webhook events` + +List available webhook events for a feature + +_No local flags. Inherited parent and global flags still apply; run `agora --help` for the full flag set._ + +### `agora project webhook list` + +List webhook configurations for a project feature + +| Flag | Type | Default | Description | +|------|------|---------|-------------| +| `--project` | `string` | — | project ID or exact project name; defaults to the current project context | + +### `agora project webhook show` + +Show one webhook configuration + +| Flag | Type | Default | Description | +|------|------|---------|-------------| +| `--project` | `string` | — | project ID or exact project name; defaults to the current project context | +| `--with-secret` | `bool` | — | include the webhook secret in the response | + +### `agora project webhook update` + +Update a webhook configuration + +| Flag | Type | Default | Description | +|------|------|---------|-------------| +| `--delivery-region` | `string` | — | new webhook delivery region: cn, sea, na, or eu | +| `--disabled` | `bool` | — | disable the webhook configuration | +| `--enabled` | `bool` | — | enable the webhook configuration | +| `--events` | `string` | — | comma-separated replacement webhook event keys, display names, or numeric IDs | +| `--project` | `string` | — | project ID or exact project name; defaults to the current project context | +| `--url` | `string` | — | new webhook endpoint URL | ### `agora quickstart` Clone official standalone Agora quickstarts -_No local flags. Inherited global flags still apply (see [Global Flags](#global-flags))._ +_No local flags. Inherited parent and global flags still apply; run `agora --help` for the full flag set._ ### `agora quickstart create` @@ -279,7 +343,7 @@ Clone an official Agora quickstart into a new directory Write framework-specific env files for a quickstart repo -_No local flags. Inherited global flags still apply (see [Global Flags](#global-flags))._ +_No local flags. Inherited parent and global flags still apply; run `agora --help` for the full flag set._ ### `agora quickstart env write` @@ -303,7 +367,7 @@ List available official quickstarts Browse curated Agora workflows for humans and AI agents -_No local flags. Inherited global flags still apply (see [Global Flags](#global-flags))._ +_No local flags. Inherited parent and global flags still apply; run `agora --help` for the full flag set._ ### `agora skills list` @@ -318,37 +382,37 @@ List available skills Search skills by id, title, description, or tag -_No local flags. Inherited global flags still apply (see [Global Flags](#global-flags))._ +_No local flags. Inherited parent and global flags still apply; run `agora --help` for the full flag set._ ### `agora skills show` Show one skill in detail -_No local flags. Inherited global flags still apply (see [Global Flags](#global-flags))._ +_No local flags. Inherited parent and global flags still apply; run `agora --help` for the full flag set._ ### `agora telemetry` Inspect or update telemetry preferences -_No local flags. Inherited global flags still apply (see [Global Flags](#global-flags))._ +_No local flags. Inherited parent and global flags still apply; run `agora --help` for the full flag set._ ### `agora telemetry disable` Disable telemetry -_No local flags. Inherited global flags still apply (see [Global Flags](#global-flags))._ +_No local flags. Inherited parent and global flags still apply; run `agora --help` for the full flag set._ ### `agora telemetry enable` Enable telemetry -_No local flags. Inherited global flags still apply (see [Global Flags](#global-flags))._ +_No local flags. Inherited parent and global flags still apply; run `agora --help` for the full flag set._ ### `agora telemetry status` Show telemetry status -_No local flags. Inherited global flags still apply (see [Global Flags](#global-flags))._ +_No local flags. Inherited parent and global flags still apply; run `agora --help` for the full flag set._ ### `agora upgrade` @@ -362,7 +426,7 @@ Upgrade Agora CLI in place when installer-managed; otherwise print upgrade guida Show Agora CLI build information -_No local flags. Inherited global flags still apply (see [Global Flags](#global-flags))._ +_No local flags. Inherited parent and global flags still apply; run `agora --help` for the full flag set._ ### `agora whoami` diff --git a/docs/error-codes.md b/docs/error-codes.md index 9d2ffef..45078ae 100644 --- a/docs/error-codes.md +++ b/docs/error-codes.md @@ -86,6 +86,23 @@ These codes appear inside `data.checks[].issues[].code` and (for blocking issues |------|------|---------|----------| | `SKILL_NOT_FOUND` | 1 | `agora skills show ` was given an unknown skill ID. | Run `agora skills list` to see available IDs. | +### Project webhooks + +| Code | Exit | Meaning | Recovery | +|------|------|---------|----------| +| `WEBHOOK_FEATURE_REQUIRED` | 1 | A project webhook command that needs `--feature` did not receive one. | Pass `--feature rtc`, `--feature rtm`, or `--feature convoai`. | +| `WEBHOOK_FEATURE_INVALID` | 1 | The `--feature` value is not a known CLI feature. | Run `agora introspect --json` to inspect `data.enums.features`, then retry with a supported feature. | +| `WEBHOOK_CONFIG_ID_REQUIRED` | 1 | A webhook command that needs a config ID did not receive a positive integer ID. | Pass the `configId` from `agora project webhook list --feature --json`. | +| `WEBHOOK_CONFIG_NOT_FOUND` | 1 | The requested webhook config ID was not found in the backend response. | Run `agora project webhook list --feature ` and retry with an existing `configId`. | +| `WEBHOOK_URL_REQUIRED` | 1 | `agora project webhook create` did not receive a non-empty webhook endpoint URL. | Pass `--url https://...`. | +| `WEBHOOK_EVENTS_REQUIRED` | 1 | Create or update received no non-empty webhook event selections. | Run `agora project webhook events --feature ` and pass one or more `--event` values. | +| `WEBHOOK_EVENT_UNKNOWN` | 1 | An `--event` value did not match an event ID, event key, or exact display name for the selected feature. | Run `agora project webhook events --feature ` and retry with an `items[].id` or `items[].key`. | +| `WEBHOOK_EVENT_AMBIGUOUS` | 1 | An `--event` value matched multiple webhook events. | Retry with the numeric event ID from `agora project webhook events --feature `. | +| `WEBHOOK_SECRET_INVALID` | 1 | The provided `--secret` does not match the backend secret pattern. | Use 7-32 characters from `A-Z`, `a-z`, `0-9`, `_`, or `-`; omit `--secret` to generate one. | +| `WEBHOOK_DELIVERY_REGION_INVALID` | 1 | The provided `--delivery-region` is not supported. | Use `cn`, `sea`, `na`, or `eu`. | +| `WEBHOOK_ENABLED_FLAG_CONFLICT` | 1 | `agora project webhook update` received both `--enabled` and `--disabled`. | Pass only one state flag. | +| `CONFIRMATION_REQUIRED` | 1 | A destructive webhook operation was requested without explicit confirmation. | Pass `--yes` for CLI delete, or `confirm:true` for the MCP delete tool. | + ## Dynamic code families Some doctor codes are generated from the feature name at runtime. Agents should match by prefix. diff --git a/docs/llms.txt b/docs/llms.txt index 12a70f9..eacbe03 100644 --- a/docs/llms.txt +++ b/docs/llms.txt @@ -49,6 +49,7 @@ Check project health: agora project doctor --json - **MCP transport**: `agora mcp serve` uses stdio JSON-RPC. - **Auth prerequisite**: `agora.auth.login` is intentionally not exposed over MCP. Run `agora login` on the host first. - **Progress over MCP**: Long-running tools emit `notifications/progress` when the MCP `tools/call` params include `_meta.progressToken`. +- **Project webhooks**: MCP exposes `agora.project.webhook.{events,list,show,create,update,delete}` for feature-scoped webhook automation; delete requires `confirm: true`. - **When shelling out**: run commands like `agora init ... --json` and parse stdout line-by-line (progress events + final envelope). - **Capability discovery**: prefer `agora --help --all --json` or `agora introspect --json`. - **Environment catalog**: use `agora env-help --json` to enumerate all supported `AGORA_*` controls. @@ -87,6 +88,14 @@ agora project doctor --json agora project doctor --quiet ``` +### Project Webhooks +``` +agora project webhook events --feature rtc --json +agora project webhook create --project --feature rtc --url https://example.com/webhook --events channel-created,user-joined --json +agora project webhook list --project --feature rtc --json +agora project webhook delete --project --feature rtc --yes --json +``` + ### Configuration Management ``` agora config get diff --git a/docs/superpowers/plans/2026-06-07-project-webhook-implementation.md b/docs/superpowers/plans/2026-06-07-project-webhook-implementation.md new file mode 100644 index 0000000..eeaaded --- /dev/null +++ b/docs/superpowers/plans/2026-06-07-project-webhook-implementation.md @@ -0,0 +1,1391 @@ +# Project Webhook CLI Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Add `agora project webhook` commands for RTC, RTM, and ConvoAI webhook event discovery, create, list, show, update, delete, and MCP parity. + +**Architecture:** Keep webhook behavior in a focused `internal/cli/webhooks.go` file: backend DTOs, normalization, validation, API helpers, and command-level business methods live together. Register the Cobra surface under `project`, render pretty output through `render.go`, extend MCP with thin dispatch wrappers, and test end-to-end through the existing fake CLI BFF. + +**Tech Stack:** Go, Cobra, standard `crypto/rand`, standard `encoding/base64`, standard `regexp`, existing Agora CLI JSON envelope, existing MCP stdio server. + +--- + +## File Structure + +- Create `internal/cli/webhooks.go`: webhook DTOs, normalized structs, validation helpers, event resolution, secret generation, BFF API methods, and command business methods. +- Create `internal/cli/webhooks_test.go`: unit tests for validation, event key generation/resolution, secret generation, region defaulting, response extraction, and redaction. +- Modify `internal/cli/commands.go`: add `buildProjectWebhook()` and register it under `buildProjectCommand()`. +- Modify `internal/cli/render.go`: add pretty render cases for `project webhook events`, `list`, `show`, `create`, `update`, and `delete`. +- Modify `internal/cli/mcp.go`: add six webhook tools and dispatch cases, including destructive delete confirmation. +- Modify `internal/cli/mcp_test.go`: update the expected MCP tool surface. +- Modify `internal/cli/integration_test.go`: extend the fake BFF with NCS event/config endpoints and add webhook command integration tests. +- Modify `docs/automation.md`: document JSON payload shapes, examples, secret redaction, delete confirmation, and MCP tools. +- Modify `README.md`: add webhook to the command overview and examples. +- Regenerate `docs/commands.md` using the repo's doc generator after commands are registered. +- Modify `docs/llms.txt`: manually add webhook MCP tool names to the compact agent-facing MCP summary. + +## Task 0: Backend Confirmation Check + +**Files:** +- Modify: `docs/superpowers/specs/2026-06-07-project-webhook-design.md` + +- [ ] **Step 1: Confirm event ID mapping before production behavior is wired** + +Ask the backend owner this exact question: + +```text +For /api/cli/v1/projects/{projectId}/ncs-configs/{feature}, does the request field eventIds contain values from /api/cli/v1/ncs-events/{feature}.items[].eventId, not eventType? +``` + +Accepted confirmation: + +```text +eventIds uses ncs-events.items[].eventId. +``` + +- [ ] **Step 2: Record the confirmation in the design spec** + +Edit `docs/superpowers/specs/2026-06-07-project-webhook-design.md` and replace the final sentence in the event API paragraph with: + +```markdown +Backend owner confirmation: config `eventIds` are populated from event `eventId`, not `eventType`. +``` + +- [ ] **Step 3: Commit the confirmation** + +Run: + +```bash +git add docs/superpowers/specs/2026-06-07-project-webhook-design.md +git commit -m "docs: confirm webhook event id mapping" +``` + +Expected: one documentation commit. + +## Task 1: Webhook Unit Helpers + +**Files:** +- Create: `internal/cli/webhooks.go` +- Create: `internal/cli/webhooks_test.go` + +- [ ] **Step 1: Write failing helper tests** + +Add `internal/cli/webhooks_test.go`: + +```go +package cli + +import ( + "regexp" + "testing" +) + +func TestWebhookEventKeyFromDisplayName(t *testing.T) { + tests := map[string]string{ + "Channel Created": "channel-created", + " User.Joined / Left ": "user-joined-left", + "RTC_Recording.Started": "rtc-recording-started", + } + for input, want := range tests { + if got := webhookEventKey(input); got != want { + t.Fatalf("webhookEventKey(%q) = %q, want %q", input, got, want) + } + } +} + +func TestResolveWebhookEventInputs(t *testing.T) { + events := []webhookEvent{ + {ID: 1001, Key: "channel-created", DisplayName: "Channel Created"}, + {ID: 1002, Key: "channel-destroyed", DisplayName: "Channel Destroyed"}, + } + got, err := resolveWebhookEventIDs(events, []string{"channel-created", "1002", "Channel Created"}, "rtc") + if err != nil { + t.Fatal(err) + } + want := []int{1001, 1002} + if !webhookIntSlicesEqual(got, want) { + t.Fatalf("event IDs = %#v, want %#v", got, want) + } +} + +func TestResolveWebhookEventInputsRejectsUnknownAndAmbiguous(t *testing.T) { + events := []webhookEvent{ + {ID: 1001, Key: "same-name", DisplayName: "Same Name"}, + {ID: 1002, Key: "same-name", DisplayName: "Same--Name"}, + } + if _, err := resolveWebhookEventIDs(events, []string{"missing"}, "rtc"); !hasCLIErrorCode(err, "WEBHOOK_EVENT_UNKNOWN") { + t.Fatalf("expected WEBHOOK_EVENT_UNKNOWN, got %v", err) + } + if _, err := resolveWebhookEventIDs(events, []string{"same-name"}, "rtc"); !hasCLIErrorCode(err, "WEBHOOK_EVENT_AMBIGUOUS") { + t.Fatalf("expected WEBHOOK_EVENT_AMBIGUOUS, got %v", err) + } +} + +func TestGenerateWebhookSecretMatchesBackendPattern(t *testing.T) { + secret, err := generateWebhookSecret() + if err != nil { + t.Fatal(err) + } + if len(secret) != 32 { + t.Fatalf("secret length = %d, want 32", len(secret)) + } + if !regexp.MustCompile(`^[A-Za-z0-9_-]{7,32}$`).MatchString(secret) { + t.Fatalf("secret does not match backend pattern: %q", secret) + } +} + +func TestWebhookDeliveryRegionDefault(t *testing.T) { + tests := []struct { + controlPlane string + want string + }{ + {controlPlane: "global", want: "na"}, + {controlPlane: "cn", want: "cn"}, + {controlPlane: "", want: "na"}, + } + for _, tt := range tests { + if got := defaultWebhookDeliveryRegion(tt.controlPlane); got != tt.want { + t.Fatalf("defaultWebhookDeliveryRegion(%q) = %q, want %q", tt.controlPlane, got, tt.want) + } + } +} +``` + +- [ ] **Step 2: Run helper tests and verify they fail** + +Run: + +```bash +go test ./internal/cli -run 'TestWebhook(EventKey|Resolve|Generate|Delivery)' -count=1 +``` + +Expected: compile failure for undefined webhook helper symbols. + +- [ ] **Step 3: Add helper implementation** + +Create `internal/cli/webhooks.go` with these helper foundations: + +```go +package cli + +import ( + "crypto/rand" + "encoding/base64" + "fmt" + "regexp" + "sort" + "strconv" + "strings" +) + +const redactedWebhookSecret = "********" + +var webhookSecretPattern = regexp.MustCompile(`^[A-Za-z0-9_-]{7,32}$`) +var webhookEventKeyInvalidChars = regexp.MustCompile(`[^a-z0-9]+`) + +type webhookEvent struct { + ID int `json:"id"` + Key string `json:"key"` + DisplayName string `json:"displayName"` + EventType int `json:"eventType"` + Payload string `json:"payload,omitempty"` +} + +type webhookConfig struct { + ConfigID int `json:"configId"` + URL string `json:"url"` + URLRegion string `json:"urlRegion"` + Enabled bool `json:"enabled"` + EventIDs []int `json:"eventIds"` + Events []webhookEvent `json:"events,omitempty"` + Retry *bool `json:"retry,omitempty"` + UseIPWhitelist bool `json:"useIpWhitelist"` + Secret string `json:"secret,omitempty"` +} + +func webhookEventKey(displayName string) string { + value := strings.ToLower(strings.TrimSpace(displayName)) + value = webhookEventKeyInvalidChars.ReplaceAllString(value, "-") + return strings.Trim(value, "-") +} + +func validateWebhookFeature(feature string) error { + if strings.TrimSpace(feature) == "" { + return &cliError{Message: "feature is required", Code: "WEBHOOK_FEATURE_REQUIRED"} + } + if err := validateFeatureID(feature); err != nil { + return err + } + return nil +} + +func normalizeWebhookDeliveryRegion(value string) (string, error) { + value = strings.ToLower(strings.TrimSpace(value)) + switch value { + case "cn", "sea", "na", "eu": + return value, nil + default: + return "", &cliError{Message: "--delivery-region must be one of: cn, sea, na, eu", Code: "WEBHOOK_DELIVERY_REGION_INVALID"} + } +} + +func defaultWebhookDeliveryRegion(controlPlaneRegion string) string { + if strings.TrimSpace(controlPlaneRegion) == "cn" { + return "cn" + } + return "na" +} + +func generateWebhookSecret() (string, error) { + buf := make([]byte, 24) + if _, err := rand.Read(buf); err != nil { + return "", err + } + return base64.RawURLEncoding.EncodeToString(buf), nil +} + +func validateWebhookSecret(secret string) error { + if !webhookSecretPattern.MatchString(secret) { + return &cliError{Message: "webhook secret must match ^[A-Za-z0-9_-]{7,32}$", Code: "WEBHOOK_SECRET_INVALID"} + } + return nil +} + +func webhookIntSlicesEqual(a, b []int) bool { + if len(a) != len(b) { + return false + } + for i := range a { + if a[i] != b[i] { + return false + } + } + return true +} + +func resolveWebhookEventIDs(events []webhookEvent, inputs []string, feature string) ([]int, error) { + byID := map[int]webhookEvent{} + byKey := map[string][]webhookEvent{} + byDisplayName := map[string]webhookEvent{} + for _, event := range events { + byID[event.ID] = event + byKey[event.Key] = append(byKey[event.Key], event) + byDisplayName[event.DisplayName] = event + } + seen := map[int]bool{} + out := []int{} + for _, input := range inputs { + input = strings.TrimSpace(input) + if input == "" { + continue + } + if id, err := strconv.Atoi(input); err == nil { + if _, ok := byID[id]; !ok { + return nil, &cliError{Message: fmt.Sprintf("Unknown webhook event ID %d. Run `agora project webhook events --feature %s`.", id, feature), Code: "WEBHOOK_EVENT_UNKNOWN"} + } + if !seen[id] { + seen[id] = true + out = append(out, id) + } + continue + } + if matches := byKey[input]; len(matches) > 1 { + return nil, &cliError{Message: fmt.Sprintf("Webhook event key %q is ambiguous. Pass the numeric event ID.", input), Code: "WEBHOOK_EVENT_AMBIGUOUS"} + } else if len(matches) == 1 { + id := matches[0].ID + if !seen[id] { + seen[id] = true + out = append(out, id) + } + continue + } + if event, ok := byDisplayName[input]; ok { + if !seen[event.ID] { + seen[event.ID] = true + out = append(out, event.ID) + } + continue + } + return nil, &cliError{Message: fmt.Sprintf("Unknown webhook event key %q. Run `agora project webhook events --feature %s`.", input, feature), Code: "WEBHOOK_EVENT_UNKNOWN"} + } + sort.Ints(out) + return out, nil +} +``` + +- [ ] **Step 4: Add test-only CLI error helper** + +Append to `internal/cli/webhooks_test.go`: + +```go +func hasCLIErrorCode(err error, code string) bool { + if err == nil { + return false + } + structured, ok := err.(*cliError) + return ok && structured.Code == code +} +``` + +- [ ] **Step 5: Run helper tests and verify they pass** + +Run: + +```bash +go test ./internal/cli -run 'TestWebhook(EventKey|Resolve|Generate|Delivery)' -count=1 +``` + +Expected: `ok`. + +- [ ] **Step 6: Commit helper layer** + +Run: + +```bash +git add internal/cli/webhooks.go internal/cli/webhooks_test.go +git commit -m "feat: add webhook validation helpers" +``` + +Expected: one feature commit. + +## Task 2: Webhook API Adapter + +**Files:** +- Modify: `internal/cli/webhooks.go` +- Modify: `internal/cli/webhooks_test.go` + +- [ ] **Step 1: Write failing adapter tests** + +Append these tests: + +```go +func TestNormalizeWebhookEventsIgnoresChineseDisplayName(t *testing.T) { + resp := ncsEventListResponse{Items: []ncsEvent{{EventID: 1001, DisplayName: "Channel Created", DisplayNameCn: "频道创建", EventType: 7, Payload: `{"x":1}`}}} + got := normalizeWebhookEvents(resp) + if len(got) != 1 || got[0].Key != "channel-created" || got[0].ID != 1001 || got[0].EventType != 7 || got[0].Payload != `{"x":1}` { + t.Fatalf("unexpected normalized events: %#v", got) + } +} + +func TestSelectWebhookConfigFromCreateResponsePrefersSecret(t *testing.T) { + resp := ncsConfigListResponse{Items: []ncsConfig{ + {ConfigID: 41, URL: "https://example.com/webhook", URLRegion: "na", EventIDs: []int{1001}, Secret: "other", UpdatedAt: "2026-06-07T00:00:01Z"}, + {ConfigID: 42, URL: "https://example.com/webhook", URLRegion: "na", EventIDs: []int{1001}, Secret: "secret_123", UpdatedAt: "2026-06-07T00:00:02Z"}, + }} + got, err := selectCreatedWebhookConfig(resp, "https://example.com/webhook", "na", []int{1001}, "secret_123") + if err != nil { + t.Fatal(err) + } + if got.ConfigID != 42 { + t.Fatalf("configId = %d, want 42", got.ConfigID) + } +} + +func TestRedactWebhookConfigSecret(t *testing.T) { + cfg := webhookConfig{ConfigID: 42, Secret: "secret_123"} + got := redactWebhookConfigSecret(cfg, false) + if got.Secret != redactedWebhookSecret { + t.Fatalf("secret = %q, want redacted", got.Secret) + } + got = redactWebhookConfigSecret(cfg, true) + if got.Secret != "secret_123" { + t.Fatalf("secret = %q, want original", got.Secret) + } +} +``` + +- [ ] **Step 2: Run adapter tests and verify they fail** + +Run: + +```bash +go test ./internal/cli -run 'Test(NormalizeWebhook|SelectWebhook|RedactWebhook)' -count=1 +``` + +Expected: compile failure for missing adapter types/functions. + +- [ ] **Step 3: Add backend DTOs and normalization** + +Append to `internal/cli/webhooks.go`: + +```go +type ncsEventListResponse struct { + Items []ncsEvent `json:"items"` +} + +type ncsEvent struct { + EventID int `json:"eventId"` + DisplayName string `json:"displayName"` + DisplayNameCn string `json:"displayNameCn"` + EventType int `json:"eventType"` + Payload string `json:"payload"` +} + +type ncsConfigListResponse struct { + Items []ncsConfig `json:"items"` +} + +type ncsConfig struct { + ConfigID int `json:"configId"` + URL string `json:"url"` + URLRegion string `json:"urlRegion"` + Enabled bool `json:"enabled"` + EventIDs []int `json:"eventIds"` + Retry *bool `json:"retry"` + UseIPWhitelist bool `json:"useIpWhitelist"` + Secret string `json:"secret"` + CreatedAt string `json:"createdAt"` + UpdatedAt string `json:"updatedAt"` +} + +func normalizeWebhookEvents(resp ncsEventListResponse) []webhookEvent { + out := make([]webhookEvent, 0, len(resp.Items)) + for _, item := range resp.Items { + out = append(out, webhookEvent{ID: item.EventID, Key: webhookEventKey(item.DisplayName), DisplayName: item.DisplayName, EventType: item.EventType, Payload: item.Payload}) + } + return out +} + +func normalizeWebhookConfig(item ncsConfig, events []webhookEvent) webhookConfig { + byID := map[int]webhookEvent{} + for _, event := range events { + byID[event.ID] = event + } + cfgEvents := []webhookEvent{} + for _, id := range item.EventIDs { + if event, ok := byID[id]; ok { + cfgEvents = append(cfgEvents, event) + } + } + return webhookConfig{ConfigID: item.ConfigID, URL: item.URL, URLRegion: item.URLRegion, Enabled: item.Enabled, EventIDs: append([]int{}, item.EventIDs...), Events: cfgEvents, Retry: item.Retry, UseIPWhitelist: item.UseIPWhitelist, Secret: item.Secret} +} + +func redactWebhookConfigSecret(cfg webhookConfig, reveal bool) webhookConfig { + if reveal { + return cfg + } + if cfg.Secret != "" { + cfg.Secret = redactedWebhookSecret + } + return cfg +} + +func selectCreatedWebhookConfig(resp ncsConfigListResponse, url, urlRegion string, eventIDs []int, secret string) (ncsConfig, error) { + matches := []ncsConfig{} + for _, item := range resp.Items { + if secret != "" && item.Secret == secret { + return item, nil + } + if item.URL == url && item.URLRegion == urlRegion && webhookIntSlicesEqual(item.EventIDs, eventIDs) { + matches = append(matches, item) + } + } + if len(matches) == 0 { + return ncsConfig{}, &cliError{Message: "created webhook config was not found in backend response", Code: "WEBHOOK_CONFIG_NOT_FOUND"} + } + sort.Slice(matches, func(i, j int) bool { + if matches[i].UpdatedAt != matches[j].UpdatedAt { + return matches[i].UpdatedAt > matches[j].UpdatedAt + } + return matches[i].ConfigID > matches[j].ConfigID + }) + return matches[0], nil +} +``` + +- [ ] **Step 4: Add API helper methods** + +Append to `internal/cli/webhooks.go`: + +```go +func (a *App) listWebhookEvents(feature string) ([]webhookEvent, error) { + if err := validateWebhookFeature(feature); err != nil { + return nil, err + } + var out ncsEventListResponse + if err := a.apiRequest("GET", "/api/cli/v1/ncs-events/"+feature, nil, nil, &out); err != nil { + return nil, err + } + return normalizeWebhookEvents(out), nil +} + +func (a *App) listWebhookConfigs(projectID, feature string) (ncsConfigListResponse, error) { + var out ncsConfigListResponse + err := a.apiRequest("GET", "/api/cli/v1/projects/"+projectID+"/ncs-configs/"+feature, nil, nil, &out) + return out, err +} + +func (a *App) createWebhookConfig(projectID, feature string, body map[string]any) (ncsConfigListResponse, error) { + var out ncsConfigListResponse + err := a.apiRequest("POST", "/api/cli/v1/projects/"+projectID+"/ncs-configs/"+feature, nil, body, &out) + return out, err +} + +func (a *App) updateWebhookConfig(projectID, feature string, configID int, body map[string]any) (ncsConfigListResponse, error) { + var out ncsConfigListResponse + err := a.apiRequest("PUT", fmt.Sprintf("/api/cli/v1/projects/%s/ncs-configs/%s/%d", projectID, feature, configID), nil, body, &out) + return out, err +} + +func (a *App) deleteWebhookConfig(projectID, feature string, configID int) error { + out := map[string]any{} + return a.apiRequest("DELETE", fmt.Sprintf("/api/cli/v1/projects/%s/ncs-configs/%s/%d", projectID, feature, configID), nil, nil, &out) +} +``` + +- [ ] **Step 5: Run adapter tests** + +Run: + +```bash +go test ./internal/cli -run 'Test(NormalizeWebhook|SelectWebhook|RedactWebhook|WebhookEventKey|ResolveWebhook|GenerateWebhook|WebhookDelivery)' -count=1 +``` + +Expected: `ok`. + +- [ ] **Step 6: Commit adapter layer** + +Run: + +```bash +git add internal/cli/webhooks.go internal/cli/webhooks_test.go +git commit -m "feat: add webhook api adapter" +``` + +Expected: one feature commit. + +## Task 3: Fake BFF and Integration Tests + +**Files:** +- Modify: `internal/cli/integration_test.go` + +- [ ] **Step 1: Extend fake BFF state** + +Add these types near `fakeProject`: + +```go +type fakeNCSConfig struct { + ConfigID int `json:"configId"` + URL string `json:"url"` + URLRegion string `json:"urlRegion"` + Enabled bool `json:"enabled"` + EventIDs []int `json:"eventIds"` + Retry *bool `json:"retry,omitempty"` + UseIPWhitelist bool `json:"useIpWhitelist"` + Secret string `json:"secret"` + CreatedAt string `json:"createdAt"` + UpdatedAt string `json:"updatedAt"` +} +``` + +Extend `fakeCLIBFF`: + +```go +ncsConfigs map[string][]fakeNCSConfig +ncsBodies []map[string]any +``` + +Initialize it in `newFakeCLIBFF()`: + +```go +api := &fakeCLIBFF{projects: map[string]*fakeProject{}, ncsConfigs: map[string][]fakeNCSConfig{}} +``` + +- [ ] **Step 2: Add fake NCS routes** + +Add cases before the generic project `GET` route: + +```go +case r.Method == http.MethodGet && strings.HasPrefix(r.URL.Path, "/api/cli/v1/ncs-events/"): + feature := strings.TrimPrefix(r.URL.Path, "/api/cli/v1/ncs-events/") + _ = feature + _ = json.NewEncoder(w).Encode(map[string]any{"items": []map[string]any{ + {"eventId": 1001, "displayName": "Channel Created", "displayNameCn": "频道创建", "eventType": 1, "payload": `{"event":"created"}`}, + {"eventId": 1002, "displayName": "Channel Destroyed", "displayNameCn": "频道销毁", "eventType": 2, "payload": `{"event":"destroyed"}`}, + }}) +case strings.Contains(r.URL.Path, "/ncs-configs/"): + api.handleFakeNCSConfigs(w, r) +``` + +Add helper method after `newFakeCLIBFF()`: + +```go +func (api *fakeCLIBFF) handleFakeNCSConfigs(w http.ResponseWriter, r *http.Request) { + parts := strings.Split(strings.Trim(r.URL.Path, "/"), "/") + projectID := parts[4] + feature := parts[6] + key := projectID + "/" + feature + switch r.Method { + case http.MethodGet: + _ = json.NewEncoder(w).Encode(map[string]any{"items": api.ncsConfigs[key]}) + case http.MethodPost: + var body map[string]any + _ = json.NewDecoder(r.Body).Decode(&body) + api.mu.Lock() + api.ncsBodies = append(api.ncsBodies, body) + api.mu.Unlock() + config := fakeNCSConfig{ + ConfigID: 42 + len(api.ncsConfigs[key]), + URL: body["url"].(string), + URLRegion: body["urlRegion"].(string), + Enabled: body["enabled"].(bool), + EventIDs: floatSliceToInts(body["eventIds"].([]any)), + Retry: boolPtr(true), + UseIPWhitelist: body["useIpWhitelist"].(bool), + Secret: body["secret"].(string), + CreatedAt: "2026-06-07T00:00:01Z", + UpdatedAt: "2026-06-07T00:00:01Z", + } + api.ncsConfigs[key] = append(api.ncsConfigs[key], config) + _ = json.NewEncoder(w).Encode(map[string]any{"items": api.ncsConfigs[key]}) + case http.MethodPut: + configID, _ := strconv.Atoi(parts[7]) + var body map[string]any + _ = json.NewDecoder(r.Body).Decode(&body) + api.mu.Lock() + api.ncsBodies = append(api.ncsBodies, body) + api.mu.Unlock() + for i := range api.ncsConfigs[key] { + if api.ncsConfigs[key][i].ConfigID == configID { + api.ncsConfigs[key][i].URL = body["url"].(string) + api.ncsConfigs[key][i].URLRegion = body["urlRegion"].(string) + api.ncsConfigs[key][i].Enabled = body["enabled"].(bool) + api.ncsConfigs[key][i].EventIDs = floatSliceToInts(body["eventIds"].([]any)) + api.ncsConfigs[key][i].UseIPWhitelist = body["useIpWhitelist"].(bool) + api.ncsConfigs[key][i].UpdatedAt = "2026-06-07T00:00:02Z" + } + } + _ = json.NewEncoder(w).Encode(map[string]any{"items": api.ncsConfigs[key]}) + case http.MethodDelete: + configID, _ := strconv.Atoi(parts[7]) + next := []fakeNCSConfig{} + for _, item := range api.ncsConfigs[key] { + if item.ConfigID != configID { + next = append(next, item) + } + } + api.ncsConfigs[key] = next + _ = json.NewEncoder(w).Encode(map[string]any{"ok": true}) + } +} +``` + +Add helpers: + +```go +func boolPtr(value bool) *bool { return &value } + +func floatSliceToInts(values []any) []int { + out := make([]int, 0, len(values)) + for _, value := range values { + out = append(out, int(value.(float64))) + } + return out +} +``` + +- [ ] **Step 3: Write failing integration tests** + +Add tests that shell out through the built binary: + +```go +func TestProjectWebhookEventsJSON(t *testing.T) { + bin := buildTestBinary(t) + api := newFakeCLIBFF() + defer api.server.Close() + configHome := t.TempDir() + persistSessionForIntegration(t, configHome) + result := runCLI(t, bin, map[string]string{"XDG_CONFIG_HOME": configHome, "AGORA_CLI_BASE_URL": api.baseURL}, "project", "webhook", "events", "--feature", "rtc", "--json") + if result.exitCode != 0 || !strings.Contains(result.stdout, `"command":"project webhook events"`) || !strings.Contains(result.stdout, `"key":"channel-created"`) || strings.Contains(result.stdout, "displayNameCn") { + t.Fatalf("unexpected result: exit=%d stdout=%s stderr=%s", result.exitCode, result.stdout, result.stderr) + } +} + +func TestProjectWebhookCreateJSON(t *testing.T) { + bin := buildTestBinary(t) + api := newFakeCLIBFF() + defer api.server.Close() + project := buildFakeProject("demo", "prj_0001", "app_0001", "global") + api.projects[project.ProjectID] = &project + configHome := t.TempDir() + persistSessionForIntegration(t, configHome) + result := runCLI(t, bin, map[string]string{"XDG_CONFIG_HOME": configHome, "AGORA_CLI_BASE_URL": api.baseURL}, "project", "webhook", "create", "--project", "demo", "--feature", "rtc", "--url", "https://example.com/webhook", "--event", "channel-created", "--json") + if result.exitCode != 0 || !strings.Contains(result.stdout, `"urlRegion":"na"`) || !strings.Contains(result.stdout, `"enabled":true`) || !strings.Contains(result.stdout, `"secret":"`) { + t.Fatalf("unexpected result: exit=%d stdout=%s stderr=%s", result.exitCode, result.stdout, result.stderr) + } + if len(api.ncsBodies) != 1 || api.ncsBodies[0]["enabled"] != true || api.ncsBodies[0]["useIpWhitelist"] != false { + t.Fatalf("unexpected create body: %#v", api.ncsBodies) + } +} + +func TestProjectWebhookUpdateReadMergePut(t *testing.T) { + bin := buildTestBinary(t) + api := newFakeCLIBFF() + defer api.server.Close() + project := buildFakeProject("demo", "prj_0001", "app_0001", "global") + api.projects[project.ProjectID] = &project + api.ncsConfigs["prj_0001/rtc"] = []fakeNCSConfig{{ConfigID: 42, URL: "https://old.example/webhook", URLRegion: "eu", Enabled: true, EventIDs: []int{1001}, UseIPWhitelist: false, Secret: "secret_123"}} + configHome := t.TempDir() + persistSessionForIntegration(t, configHome) + result := runCLI(t, bin, map[string]string{"XDG_CONFIG_HOME": configHome, "AGORA_CLI_BASE_URL": api.baseURL}, "project", "webhook", "update", "42", "--project", "demo", "--feature", "rtc", "--url", "https://new.example/webhook", "--json") + if result.exitCode != 0 || strings.Contains(result.stdout, "secret_123") || !strings.Contains(result.stdout, `"secret":"********"`) { + t.Fatalf("unexpected result: exit=%d stdout=%s stderr=%s", result.exitCode, result.stdout, result.stderr) + } + last := api.ncsBodies[len(api.ncsBodies)-1] + if last["url"] != "https://new.example/webhook" || last["urlRegion"] != "eu" || last["enabled"] != true { + t.Fatalf("PUT body did not preserve fields: %#v", last) + } +} + +func TestProjectWebhookDeleteRequiresYesInJSON(t *testing.T) { + bin := buildTestBinary(t) + api := newFakeCLIBFF() + defer api.server.Close() + project := buildFakeProject("demo", "prj_0001", "app_0001", "global") + api.projects[project.ProjectID] = &project + configHome := t.TempDir() + persistSessionForIntegration(t, configHome) + result := runCLI(t, bin, map[string]string{"XDG_CONFIG_HOME": configHome, "AGORA_CLI_BASE_URL": api.baseURL}, "project", "webhook", "delete", "42", "--project", "demo", "--feature", "rtc", "--json") + if result.exitCode == 0 || !strings.Contains(result.stdout, `"code":"CONFIRMATION_REQUIRED"`) { + t.Fatalf("expected confirmation error, got exit=%d stdout=%s stderr=%s", result.exitCode, result.stdout, result.stderr) + } +} +``` + +Add these additional integration tests in the same section: + +```go +func TestProjectWebhookCreateExplicitSecretAndRejectInvalidSecret(t *testing.T) { + bin := buildTestBinary(t) + api := newFakeCLIBFF() + defer api.server.Close() + project := buildFakeProject("demo", "prj_0001", "app_0001", "global") + api.projects[project.ProjectID] = &project + configHome := t.TempDir() + persistSessionForIntegration(t, configHome) + + ok := runCLI(t, bin, map[string]string{"XDG_CONFIG_HOME": configHome, "AGORA_CLI_BASE_URL": api.baseURL}, "project", "webhook", "create", "--project", "demo", "--feature", "rtc", "--url", "https://example.com/webhook", "--event", "1001", "--secret", "secret_123", "--json") + if ok.exitCode != 0 || !strings.Contains(ok.stdout, `"secret":"secret_123"`) { + t.Fatalf("expected explicit secret success, got exit=%d stdout=%s stderr=%s", ok.exitCode, ok.stdout, ok.stderr) + } + + bad := runCLI(t, bin, map[string]string{"XDG_CONFIG_HOME": configHome, "AGORA_CLI_BASE_URL": api.baseURL}, "project", "webhook", "create", "--project", "demo", "--feature", "rtc", "--url", "https://example.com/webhook", "--event", "1001", "--secret", "this-secret-is-too-long-for-the-backend-pattern", "--json") + if bad.exitCode == 0 || !strings.Contains(bad.stdout, `"code":"WEBHOOK_SECRET_INVALID"`) { + t.Fatalf("expected invalid secret error, got exit=%d stdout=%s stderr=%s", bad.exitCode, bad.stdout, bad.stderr) + } +} + +func TestProjectWebhookCreateDefaultsCNDeliveryRegion(t *testing.T) { + bin := buildTestBinary(t) + api := newFakeCLIBFF() + defer api.server.Close() + project := buildFakeProject("demo-cn", "prj_cn", "app_cn", "cn") + api.projects[project.ProjectID] = &project + configHome := t.TempDir() + persistSessionForIntegration(t, configHome) + result := runCLI(t, bin, map[string]string{"XDG_CONFIG_HOME": configHome, "AGORA_CLI_BASE_URL": api.baseURL}, "project", "webhook", "create", "--project", "demo-cn", "--feature", "rtc", "--url", "https://example.cn/webhook", "--event", "channel-created", "--json") + if result.exitCode != 0 || !strings.Contains(result.stdout, `"urlRegion":"cn"`) { + t.Fatalf("expected cn default region, got exit=%d stdout=%s stderr=%s", result.exitCode, result.stdout, result.stderr) + } +} + +func TestProjectWebhookListRedactsAndShowWithSecretReveals(t *testing.T) { + bin := buildTestBinary(t) + api := newFakeCLIBFF() + defer api.server.Close() + project := buildFakeProject("demo", "prj_0001", "app_0001", "global") + api.projects[project.ProjectID] = &project + api.ncsConfigs["prj_0001/rtc"] = []fakeNCSConfig{{ConfigID: 42, URL: "https://example.com/webhook", URLRegion: "na", Enabled: true, EventIDs: []int{1001}, UseIPWhitelist: false, Secret: "secret_123"}} + configHome := t.TempDir() + persistSessionForIntegration(t, configHome) + + list := runCLI(t, bin, map[string]string{"XDG_CONFIG_HOME": configHome, "AGORA_CLI_BASE_URL": api.baseURL}, "project", "webhook", "list", "--project", "demo", "--feature", "rtc", "--json") + if list.exitCode != 0 || strings.Contains(list.stdout, "secret_123") || !strings.Contains(list.stdout, `"secret":"********"`) { + t.Fatalf("expected list redaction, got exit=%d stdout=%s stderr=%s", list.exitCode, list.stdout, list.stderr) + } + + show := runCLI(t, bin, map[string]string{"XDG_CONFIG_HOME": configHome, "AGORA_CLI_BASE_URL": api.baseURL}, "project", "webhook", "show", "42", "--project", "demo", "--feature", "rtc", "--with-secret", "--json") + if show.exitCode != 0 || !strings.Contains(show.stdout, `"secret":"secret_123"`) { + t.Fatalf("expected show --with-secret reveal, got exit=%d stdout=%s stderr=%s", show.exitCode, show.stdout, show.stderr) + } +} + +func TestProjectWebhookUpdateSecretFlagRejected(t *testing.T) { + bin := buildTestBinary(t) + api := newFakeCLIBFF() + defer api.server.Close() + configHome := t.TempDir() + persistSessionForIntegration(t, configHome) + result := runCLI(t, bin, map[string]string{"XDG_CONFIG_HOME": configHome, "AGORA_CLI_BASE_URL": api.baseURL}, "project", "webhook", "update", "42", "--feature", "rtc", "--secret", "secret_123", "--json") + if result.exitCode == 0 || !strings.Contains(result.stderr, "unknown flag: --secret") { + t.Fatalf("expected unknown --secret flag, got exit=%d stdout=%s stderr=%s", result.exitCode, result.stdout, result.stderr) + } +} +``` + +- [ ] **Step 4: Run integration tests and verify they fail** + +Run: + +```bash +go test ./internal/cli -run 'TestProjectWebhook' -count=1 +``` + +Expected: command-not-found or compile failures until CLI commands are added. + +- [ ] **Step 5: Commit fake BFF and failing tests only when compile is restored** + +After Task 4 makes these tests compile and pass, include these changes in the Task 4 commit instead of creating a red commit. + +Expected: no commit in this task. + +## Task 4: CLI Commands and Business Methods + +**Files:** +- Modify: `internal/cli/webhooks.go` +- Modify: `internal/cli/commands.go` +- Modify: `internal/cli/integration_test.go` + +- [ ] **Step 1: Add command result builders** + +Append to `internal/cli/webhooks.go`: + +```go +type webhookCreateOptions struct { + Feature string + Project string + URL string + EventInputs []string + Secret string + DeliveryRegion string +} + +type webhookUpdateOptions struct { + ConfigID int + Feature string + Project string + URL string + EventInputs []string + DeliveryRegion string + Enabled *bool +} + +func (a *App) projectWebhookEvents(feature string) (map[string]any, error) { + events, err := a.listWebhookEvents(feature) + if err != nil { + return nil, err + } + return map[string]any{"action": "webhook-events", "feature": feature, "items": events}, nil +} +``` + +Then add `projectWebhookList`, `projectWebhookShow`, `projectWebhookCreate`, `projectWebhookUpdate`, and `projectWebhookDelete` in the same file using the API helpers from Task 2. The methods must return maps with `action`, `projectId`, `projectName`, `feature`, and either `items` or a single normalized config field set. Use these exact action values: + +```go +"webhook-list" +"webhook-show" +"webhook-create" +"webhook-update" +"webhook-delete" +``` + +- [ ] **Step 2: Implement create behavior** + +In `projectWebhookCreate`, perform these operations in order: + +```go +target, err := a.resolveProjectTarget(opts.Project) +events, err := a.listWebhookEvents(opts.Feature) +eventIDs, err := resolveWebhookEventIDs(events, opts.EventInputs, opts.Feature) +secret := strings.TrimSpace(opts.Secret) +if secret == "" { secret, err = generateWebhookSecret() } +region := opts.DeliveryRegion +if region == "" { region = defaultWebhookDeliveryRegion(target.region) } else { region, err = normalizeWebhookDeliveryRegion(region) } +body := map[string]any{"enabled": true, "eventIds": eventIDs, "secret": secret, "url": opts.URL, "urlRegion": region, "useIpWhitelist": false} +resp, err := a.createWebhookConfig(target.project.ProjectID, opts.Feature, body) +selected, err := selectCreatedWebhookConfig(resp, opts.URL, region, eventIDs, secret) +cfg := normalizeWebhookConfig(selected, events) +``` + +Return the unredacted `cfg.Secret` for create. + +- [ ] **Step 3: Implement update behavior** + +In `projectWebhookUpdate`, perform GET-merge-PUT: + +```go +target, err := a.resolveProjectTarget(opts.Project) +events, err := a.listWebhookEvents(opts.Feature) +list, err := a.listWebhookConfigs(target.project.ProjectID, opts.Feature) +existing, err := findNCSConfigByID(list.Items, opts.ConfigID) +nextURL := existing.URL +nextEventIDs := existing.EventIDs +nextRegion := existing.URLRegion +nextEnabled := existing.Enabled +if opts.URL != "" { nextURL = opts.URL } +if len(opts.EventInputs) > 0 { nextEventIDs, err = resolveWebhookEventIDs(events, opts.EventInputs, opts.Feature) } +if opts.DeliveryRegion != "" { nextRegion, err = normalizeWebhookDeliveryRegion(opts.DeliveryRegion) } +if opts.Enabled != nil { nextEnabled = *opts.Enabled } +body := map[string]any{"enabled": nextEnabled, "eventIds": nextEventIDs, "url": nextURL, "urlRegion": nextRegion, "useIpWhitelist": existing.UseIPWhitelist} +resp, err := a.updateWebhookConfig(target.project.ProjectID, opts.Feature, opts.ConfigID, body) +selected, err := findNCSConfigByID(resp.Items, opts.ConfigID) +cfg := redactWebhookConfigSecret(normalizeWebhookConfig(selected, events), false) +``` + +Add `findNCSConfigByID` returning `WEBHOOK_CONFIG_NOT_FOUND` when absent. + +- [ ] **Step 4: Register `project webhook` under project** + +In `buildProjectCommand()`, add: + +```go +cmd.AddCommand(a.buildProjectWebhook()) +``` + +Add `buildProjectWebhook()` in `internal/cli/commands.go` with subcommands matching the spec: + +```go +func (a *App) buildProjectWebhook() *cobra.Command { + cmd := &cobra.Command{Use: "webhook", Short: "Manage project webhooks"} + cmd.AddCommand(/* events, list, show, create, update, delete */) + return cmd +} +``` + +Each subcommand must call `renderResult` with the stable labels: + +```go +"project webhook events" +"project webhook list" +"project webhook show" +"project webhook create" +"project webhook update" +"project webhook delete" +``` + +- [ ] **Step 5: Wire command flags** + +Use these Cobra flags: + +```go +events.Flags().StringVar(&feature, "feature", "", fmt.Sprintf("feature to inspect: %s", featureListString())) +list.Flags().StringVar(&feature, "feature", "", fmt.Sprintf("feature to inspect: %s", featureListString())) +list.Flags().StringVar(&project, "project", "", "project name or ID") +show.Flags().BoolVar(&withSecret, "with-secret", false, "reveal webhook secret when the backend returns it") +create.Flags().StringArrayVar(&eventsInput, "event", nil, "webhook event key, numeric ID, or display name; repeat for multiple events") +create.Flags().StringVar(&deliveryRegion, "delivery-region", "", "webhook delivery region: cn, sea, na, eu") +update.Flags().BoolVar(&enabled, "enabled", false, "enable the webhook") +update.Flags().BoolVar(&disabled, "disabled", false, "disable the webhook") +``` + +Use `Args` validators to require `config-id` on show/update/delete and reject both `--enabled` and `--disabled` on update with `WEBHOOK_ENABLED_FLAG_CONFLICT`. + +- [ ] **Step 6: Implement delete confirmation** + +In the delete command, fail in JSON/non-TTY unless `--yes` is set by the root flag: + +```go +if !a.rootYes && a.resolveOutputMode(cmd) == outputJSON { + return &cliError{Message: "Deletion requires --yes in JSON mode.", Code: "CONFIRMATION_REQUIRED"} +} +``` + +For pretty interactive mode, use the same stdin/TTY style as existing commands if a prompt helper exists. If there is no prompt helper in the repo, require `--yes` for all modes in v1 and document that behavior in the command long text. + +- [ ] **Step 7: Run integration tests** + +Run: + +```bash +go test ./internal/cli -run 'TestProjectWebhook' -count=1 +``` + +Expected: webhook integration tests pass. + +- [ ] **Step 8: Commit CLI behavior** + +Run: + +```bash +git add internal/cli/webhooks.go internal/cli/commands.go internal/cli/integration_test.go +git commit -m "feat: add project webhook commands" +``` + +Expected: one feature commit. + +## Task 5: Pretty Rendering + +**Files:** +- Modify: `internal/cli/render.go` +- Modify: `internal/cli/integration_test.go` + +- [ ] **Step 1: Write pretty output integration checks** + +Add one pretty-mode test: + +```go +func TestProjectWebhookEventsPrettyOmitsPayload(t *testing.T) { + bin := buildTestBinary(t) + api := newFakeCLIBFF() + defer api.server.Close() + configHome := t.TempDir() + persistSessionForIntegration(t, configHome) + result := runCLI(t, bin, map[string]string{"XDG_CONFIG_HOME": configHome, "AGORA_CLI_BASE_URL": api.baseURL}, "project", "webhook", "events", "--feature", "rtc") + if result.exitCode != 0 || !strings.Contains(result.stdout, "channel-created") || strings.Contains(result.stdout, `{"event":"created"}`) { + t.Fatalf("unexpected pretty output: exit=%d stdout=%s stderr=%s", result.exitCode, result.stdout, result.stderr) + } +} +``` + +- [ ] **Step 2: Run pretty test and verify it fails** + +Run: + +```bash +go test ./internal/cli -run 'TestProjectWebhookEventsPrettyOmitsPayload' -count=1 +``` + +Expected: output uses default JSON-ish pretty fallback until render cases are added. + +- [ ] **Step 3: Add render cases** + +In `renderResult`, add cases: + +```go +case "project webhook events": + m := data.(map[string]any) + fmt.Fprintf(out, "Webhook Events: %s\n", asString(m["feature"])) + if items, ok := m["items"].([]webhookEvent); ok { + for _, item := range items { + fmt.Fprintf(out, "- %s ID %d %s\n", item.Key, item.ID, item.DisplayName) + } + } +case "project webhook list": + m := data.(map[string]any) + printBlock(out, "Webhooks", [][2]string{{"Project", asString(m["projectName"])}, {"Feature", asString(m["feature"])}}) + if items, ok := m["items"].([]webhookConfig); ok { + for _, item := range items { + fmt.Fprintf(out, "- %d %s %s enabled=%v\n", item.ConfigID, item.URL, item.URLRegion, item.Enabled) + } + } +case "project webhook show", "project webhook create", "project webhook update": + m := data.(map[string]any) + printWebhookBlock(out, m) + if command == "project webhook create" { + fmt.Fprintln(out) + fmt.Fprintln(out, "Store this secret now. It may not be shown again.") + } +case "project webhook delete": + m := data.(map[string]any) + printBlock(out, "Webhook", [][2]string{{"Project", asString(m["projectName"])}, {"Feature", asString(m["feature"])}, {"Config ID", asString(m["configId"])}, {"Deleted", "true"}}) +``` + +Add helper: + +```go +func printWebhookBlock(out io.Writer, m map[string]any) { + events := "-" + if cfg, ok := m["config"].(webhookConfig); ok { + keys := []string{} + for _, event := range cfg.Events { + keys = append(keys, event.Key) + } + if len(keys) > 0 { + events = strings.Join(keys, ", ") + } + printBlock(out, "Webhook", [][2]string{{"Project", asString(m["projectName"])}, {"Feature", asString(m["feature"])}, {"Config ID", asString(cfg.ConfigID)}, {"URL", cfg.URL}, {"Events", events}, {"Delivery Region", renderWebhookDeliveryRegion(cfg.URLRegion)}, {"Enabled", asString(cfg.Enabled)}, {"Retry", asString(cfg.Retry)}, {"Secret", cfg.Secret}}) + } +} +``` + +Add: + +```go +func renderWebhookDeliveryRegion(value string) string { + switch value { + case "cn": + return "China (cn)" + case "sea": + return "Asia (sea)" + case "na": + return "North America (na)" + case "eu": + return "Europe (eu)" + default: + return value + } +} +``` + +- [ ] **Step 4: Run pretty and JSON webhook tests** + +Run: + +```bash +go test ./internal/cli -run 'TestProjectWebhook' -count=1 +``` + +Expected: all webhook integration tests pass. + +- [ ] **Step 5: Commit rendering** + +Run: + +```bash +git add internal/cli/render.go internal/cli/integration_test.go +git commit -m "feat: render project webhook output" +``` + +Expected: one feature commit. + +## Task 6: MCP Parity + +**Files:** +- Modify: `internal/cli/mcp.go` +- Modify: `internal/cli/mcp_test.go` + +- [ ] **Step 1: Update MCP surface test** + +In `internal/cli/mcp_test.go`, add expected tool names: + +```go +"agora.project.webhook.events", +"agora.project.webhook.list", +"agora.project.webhook.show", +"agora.project.webhook.create", +"agora.project.webhook.update", +"agora.project.webhook.delete", +``` + +- [ ] **Step 2: Run MCP test and verify it fails** + +Run: + +```bash +go test ./internal/cli -run 'TestMCPTools' -count=1 +``` + +Expected: missing tool names. + +- [ ] **Step 3: Add MCP descriptors** + +In `mcpTools()`, add: + +```go +mcpTool("agora.project.webhook.events", "List webhook events for a feature", map[string]string{"feature": "string"}), +mcpTool("agora.project.webhook.list", "List project webhook configs", map[string]string{"feature": "string", "project": "string"}), +mcpTool("agora.project.webhook.show", "Show one project webhook config", map[string]string{"configId": "number", "feature": "string", "project": "string", "withSecret": "boolean"}), +mcpTool("agora.project.webhook.create", "Create a project webhook config", map[string]string{"feature": "string", "project": "string", "url": "string", "events": "array", "secret": "string", "deliveryRegion": "string"}), +mcpTool("agora.project.webhook.update", "Update a project webhook config", map[string]string{"configId": "number", "feature": "string", "project": "string", "url": "string", "events": "array", "deliveryRegion": "string", "enabled": "boolean"}), +mcpTool("agora.project.webhook.delete", "Delete a project webhook config", map[string]string{"configId": "number", "feature": "string", "project": "string", "confirm": "boolean"}), +``` + +- [ ] **Step 4: Add MCP dispatch cases** + +In `callMCPTool`, add cases that call the business methods: + +```go +case "agora.project.webhook.events": + return a.projectWebhookEvents(stringArg(args, "feature")) +case "agora.project.webhook.list": + return a.projectWebhookList(stringArg(args, "feature"), stringArg(args, "project"), false) +case "agora.project.webhook.show": + return a.projectWebhookShow(intArg(args, "configId", 0), stringArg(args, "feature"), stringArg(args, "project"), boolArg(args, "withSecret", false)) +case "agora.project.webhook.create": + return a.projectWebhookCreate(webhookCreateOptions{Feature: stringArg(args, "feature"), Project: stringArg(args, "project"), URL: stringArg(args, "url"), EventInputs: stringSliceArg(args, "events"), Secret: stringArg(args, "secret"), DeliveryRegion: stringArg(args, "deliveryRegion")}) +case "agora.project.webhook.update": + enabled := optionalBoolArg(args, "enabled") + return a.projectWebhookUpdate(webhookUpdateOptions{ConfigID: intArg(args, "configId", 0), Feature: stringArg(args, "feature"), Project: stringArg(args, "project"), URL: stringArg(args, "url"), EventInputs: stringSliceArg(args, "events"), DeliveryRegion: stringArg(args, "deliveryRegion"), Enabled: enabled}) +case "agora.project.webhook.delete": + if !boolArg(args, "confirm", false) { + return nil, &cliError{Message: "Webhook deletion requires confirm: true.", Code: "CONFIRMATION_REQUIRED"} + } + return a.projectWebhookDelete(intArg(args, "configId", 0), stringArg(args, "feature"), stringArg(args, "project")) +``` + +Add helper: + +```go +func optionalBoolArg(args map[string]any, key string) *bool { + if value, ok := args[key].(bool); ok { + return &value + } + return nil +} +``` + +- [ ] **Step 5: Run MCP tests** + +Run: + +```bash +go test ./internal/cli -run 'TestMCP' -count=1 +``` + +Expected: `ok`. + +- [ ] **Step 6: Commit MCP parity** + +Run: + +```bash +git add internal/cli/mcp.go internal/cli/mcp_test.go +git commit -m "feat: expose project webhooks through mcp" +``` + +Expected: one feature commit. + +## Task 7: Docs and Generated Command Reference + +**Files:** +- Modify: `docs/automation.md` +- Modify: `README.md` +- Modify: `docs/commands.md` +- Modify: `docs/llms.txt` + +- [ ] **Step 1: Update automation docs** + +In `docs/automation.md`, add a `project webhook` section with these examples: + +```bash +./agora project webhook events --feature rtc --json +./agora project webhook create --project my-project --feature rtc --url https://example.com/webhook --event channel-created --json +./agora project webhook show 42 --project my-project --feature rtc --with-secret --json +./agora project webhook update 42 --project my-project --feature rtc --url https://example.com/webhook2 --json +./agora project webhook delete 42 --project my-project --feature rtc --yes --json +``` + +Document that `enabled` is the canonical state field and that `status` is not emitted for webhooks. + +- [ ] **Step 2: Update README command overview** + +Add under the `project` command list: + +```markdown +│ └── webhook +│ ├── events --feature List available webhook event keys and IDs +│ ├── list --feature List project webhook configs +│ ├── show --feature ... Show one webhook config +│ ├── create --feature ... Create a webhook config +│ ├── update --feature ... Update a webhook config +│ └── delete --feature ... Delete a webhook config +``` + +- [ ] **Step 3: Regenerate command docs** + +Run: + +```bash +go run ./cmd/gendocs +``` + +Expected: `docs/commands.md` includes `agora project webhook` and all subcommands. + +- [ ] **Step 4: Update `docs/llms.txt`** + +`docs/llms.txt` is manually maintained in this repo. In the MCP notes section, add one bullet: + +```markdown +- **Project webhooks**: MCP exposes `agora.project.webhook.{events,list,show,create,update,delete}` for feature-scoped webhook automation; delete requires `confirm: true`. +``` + +- [ ] **Step 5: Run doc example drift tests** + +Run: + +```bash +go test ./internal/cli -run 'TestAutomation|TestHelp|TestIntrospect' -count=1 +``` + +Expected: docs/help/introspection tests pass. + +- [ ] **Step 6: Commit docs** + +Run: + +```bash +git add docs/automation.md README.md docs/commands.md docs/llms.txt +git commit -m "docs: document project webhook commands" +``` + +Expected: one docs commit. + +## Task 8: Full Verification and Cleanup + +**Files:** +- Read: all modified files + +- [ ] **Step 1: Run formatting** + +Run: + +```bash +gofmt -w internal/cli/webhooks.go internal/cli/webhooks_test.go internal/cli/commands.go internal/cli/render.go internal/cli/mcp.go internal/cli/mcp_test.go internal/cli/integration_test.go +``` + +Expected: no command output. + +- [ ] **Step 2: Run full tests** + +Run: + +```bash +go test ./... +``` + +Expected: all packages pass. + +- [ ] **Step 3: Inspect command tree** + +Run: + +```bash +go build -o agora . +./agora --help --all --json +``` + +Expected: JSON envelope includes the six `agora project webhook ...` command paths. + +- [ ] **Step 4: Inspect worktree** + +Run: + +```bash +git status --short +``` + +Expected: no uncommitted files except intentional local binary `agora` if it is ignored. + +- [ ] **Step 5: Commit final formatting or generated residue** + +If Step 4 shows tracked formatting or generated-doc changes, commit them: + +```bash +git add +git commit -m "chore: finalize webhook cli implementation" +``` + +Expected: either one cleanup commit or no commit when the worktree is clean. + +## Self-Review + +Spec coverage: + +- Command UX: Task 4 registers `events`, `list`, `show`, `create`, `update`, and `delete` with required `--feature`, optional `--project`, and stable command labels. +- Event discovery and keys: Tasks 1, 2, 3, and 4 cover event fetching, key generation, numeric IDs, exact display names, unknown and ambiguous errors, and no `displayNameCn` output. +- Delivery region: Tasks 1 and 4 cover validation plus `global -> na` and `cn -> cn` defaults. +- Secret handling: Tasks 1, 2, 3, and 4 cover 32-character generated secrets, explicit override, create reveal, list/show/update redaction, and no update `--secret`. +- Data model and adapter: Task 2 covers `items` arrays, normalization, create response selection, and config/event shapes. +- Update read-modify-write: Tasks 3 and 4 include an integration test that verifies omitted fields are preserved. +- JSON contract: Tasks 3, 4, and 7 cover envelope labels, `enabled` canonical state, and docs. +- Delete confirmation: Tasks 3, 4, and 6 cover CLI `--yes` and MCP `confirm: true`. +- MCP parity: Task 6 covers descriptors, dispatch, and tests. +- Docs and generated references: Task 7 covers automation docs, README, command docs, and `docs/llms.txt`. + +Placeholder scan: run the forbidden-pattern search from the writing-plans skill and fix every match before executing. + +Type consistency: + +- `webhookEvent`, `webhookConfig`, `ncsEventListResponse`, `ncsConfigListResponse`, and `ncsConfig` are introduced before later tasks reference them. +- `projectWebhookCreate`, `projectWebhookUpdate`, and MCP dispatch use the same option type names. +- JSON keys use `configId`, `urlRegion`, `eventIds`, `useIpWhitelist`, and `enabled` consistently. diff --git a/docs/superpowers/specs/2026-06-07-project-webhook-design.md b/docs/superpowers/specs/2026-06-07-project-webhook-design.md new file mode 100644 index 0000000..8ae9d6a --- /dev/null +++ b/docs/superpowers/specs/2026-06-07-project-webhook-design.md @@ -0,0 +1,342 @@ +# Project Webhook CLI Design + +Date: 2026-06-07 + +## Context + +Agora CLI should support webhook creation, editing, deletion, and viewing for project-scoped developer workflows. The backend API names this resource NCS config, but NCS is internal language. The public CLI should use webhook terminology and reuse the existing feature model (`rtc`, `rtm`, `convoai`) instead of introducing a new user-facing product concept. + +Relevant backend endpoints from the Apifox NCS docs: + +| CLI need | Backend endpoint | +| --- | --- | +| List webhook events | `GET /api/cli/v1/ncs-events/{feature}` | +| List webhook configs | `GET /api/cli/v1/projects/{projectId}/ncs-configs/{feature}` | +| Create webhook config | `POST /api/cli/v1/projects/{projectId}/ncs-configs/{feature}` | +| Update webhook config | `PUT /api/cli/v1/projects/{projectId}/ncs-configs/{feature}/{configId}` | +| Delete webhook config | `DELETE /api/cli/v1/projects/{projectId}/ncs-configs/{feature}/{configId}` | + +Backend `productKey` is an internal implementation detail. For v1, supported public features are the current CLI feature catalog: `rtc`, `rtm`, and `convoai`. + +## Goals + +- Add `agora project webhook` commands for event discovery, list, show, create, update, and delete. +- Let developers select webhook events by readable CLI event keys instead of requiring numeric event IDs. +- Reuse existing project resolution and feature validation. +- Generate a secure webhook secret by default while allowing explicit override. +- Keep sensitive secrets redacted except when intentionally revealed. +- Preserve existing JSON envelope and documentation contracts. + +## Non-Goals + +- Do not expose an `ncs` command or backend product terminology. +- Do not add a new public `--product` concept. +- Do not support features outside `rtc`, `rtm`, and `convoai` in v1. +- Do not add webhook health-check or disable commands in v1. The first scope is creation, editing, deletion, viewing, and event discovery. + +## Command UX + +Add the command group under `project`: + +```bash +agora project webhook events --feature +agora project webhook list --feature [--project ] +agora project webhook show --feature [--project ] [--with-secret] +agora project webhook create --feature --url --event ... [--secret ] [--delivery-region ] [--project ] +agora project webhook update --feature [--url ] [--event ...] [--delivery-region ] [--enabled | --disabled] [--project ] +agora project webhook delete --feature [--project ] [--yes] +``` + +`feature` and `project` are flags on every webhook command, matching `project create` and `project doctor`. The webhook/config is the command subject, so it stays positional (``), while `feature` is a required scope flag and `project` is an optional scope flag. This differs from `project feature status `, where the feature itself is the subject and is therefore positional. `events` is feature-global (`GET /ncs-events/{feature}` has no `projectId`), so it takes `--feature` but not `--project`. + +`events --feature ` is a discovery command. It fetches available webhook events for the feature so developers do not need to guess backend event IDs. Pretty output shows a focused table — CLI event key, event ID, and display name. The remaining metadata (event type and payload example) is included in `--json` output only, since raw payload JSON would break terminal table alignment. + +Example flow: + +```bash +agora project webhook events --feature rtc +agora project webhook create \ + --feature rtc \ + --url https://example.com/webhook \ + --event channel-created \ + --event channel-destroyed +``` + +Event input rules: + +- `--event` accepts CLI event keys as the preferred path. +- Numeric event IDs are accepted as an escape hatch. +- Exact backend `displayName` values are accepted for compatibility, including values with spaces when shell-quoted. +- `create` requires at least one event. +- `update` only replaces event selections when at least one `--event` is provided. +- Unknown event keys return a helpful error suggesting `agora project webhook events --feature `. + +The CLI event key is generated from backend `displayName` by lowercasing it, replacing non-alphanumeric runs with `-`, and trimming leading/trailing separators. For example, `Channel Created` becomes `channel-created`. If two events generate the same key, the key is ambiguous and the user must pass the numeric event ID. + +## Delivery Region + +Webhook delivery region is separate from the existing CLI `--region` concept. Existing `--region` means login or project control-plane region (`global` or `cn`). Webhooks use backend `urlRegion` with these values: + +| Value | Meaning | +| --- | --- | +| `cn` | China | +| `sea` | Asia | +| `na` | North America | +| `eu` | Europe | + +Use `--delivery-region ` on `create` and `update`. + +Default when omitted: + +- selected project/control-plane region `global` -> `urlRegion: "na"` +- selected project/control-plane region `cn` -> `urlRegion: "cn"` + +Pretty output labels this field `Delivery Region`. JSON output uses `urlRegion` to match the backend field. + +## Secret Handling + +Backend webhook secrets must match `^[A-Za-z0-9_-]{7,32}$`. `create` generates a secure random secret when `--secret` is omitted. The generated value is 32 base64url characters without padding, produced from 24 random bytes. `--secret ` overrides the generated value and is validated against the backend pattern before the request is sent. + +Secret output rules: + +- `create` includes the secret in the command result because the developer needs to store it. +- `list` redacts secrets by default. +- `show` redacts secrets by default. +- `show --with-secret` reveals the secret if the backend returns it. +- `update` redacts the secret by default. The PUT response is an `NcsConfigListResponse` that includes `secret`, but `update` has no `--with-secret` flag, so the rendered config and JSON output mask it. +- Webhook secret rotation is not supported by the update endpoint. Users who need a new secret must create a replacement webhook and delete the old one. + +Empty webhook secrets are not supported in v1. + +## Data Model + +Add typed structs in a focused implementation file, `internal/cli/webhooks.go`. + +Normalized CLI event shape: + +```go +type webhookEvent struct { + ID int `json:"id"` + Key string `json:"key"` + DisplayName string `json:"displayName"` + EventType int `json:"eventType"` + Payload string `json:"payload,omitempty"` +} +``` + +Normalized CLI config shape: + +```go +type webhookConfig struct { + ConfigID int `json:"configId"` + URL string `json:"url"` + URLRegion string `json:"urlRegion"` + Enabled bool `json:"enabled"` + EventIDs []int `json:"eventIds"` + Events []webhookEvent `json:"events,omitempty"` + Retry *bool `json:"retry,omitempty"` + UseIPWhitelist bool `json:"useIpWhitelist"` + Secret string `json:"secret,omitempty"` +} +``` + +The backend response may include additional fields such as `appId`, `productId`, `projectId`, `vendorId`, `internal`, `createdAt`, and `updatedAt`. The CLI preserves stable fields needed by automation and support, but pretty output stays focused on project, feature, config ID, URL, delivery region, enabled state, and event keys. + +`retry` is read-only in v1. The CLI includes `retry` in output when the backend returns it, but create and update requests do not send a `retry` field and do not expose a retry flag. + +The event API returns an `items` array. Each backend event has `eventId`, `displayName`, `displayNameCn`, `eventType`, and `payload`. The CLI ignores `displayNameCn` and does not surface it in any output. The CLI uses `eventId` for config `eventIds`; `eventType` is retained only as event metadata for now because the config create/update schema labels `eventIds` as `event.id`. The implementation must not send `eventType` in config bodies. Backend owner confirmation: config `eventIds` are populated from event `eventId`, not `eventType`. + +Create requests send all backend-required fields: + +```json +{ + "enabled": true, + "eventIds": [1], + "secret": "generated-or-user-provided", + "url": "https://example.com/webhook", + "urlRegion": "na", + "useIpWhitelist": false +} +``` + +Create defaults are `enabled: true` and `useIpWhitelist: false`. + +Update requests also send every backend-required PUT field: `enabled`, `eventIds`, `url`, `urlRegion`, and `useIpWhitelist`. Because users may update only one flag, update must do read-modify-write: + +1. List existing configs for the feature. +2. Select the config with the requested `configId`. +3. Merge provided flags onto the existing config. +4. Send the complete PUT body. + +The update endpoint has no `secret` field. `update` must not expose `--secret`. + +List, create, and update responses are `NcsConfigListResponse` objects with an `items` array, not single config objects. The adapter extracts the relevant config: + +- `list` returns all normalized items. +- `show` lists configs and selects the requested `configId`. +- `update` selects the requested `configId` from the PUT response. +- `create` selects the item matching the generated or provided secret, requested URL, delivery region, and event IDs. Secret is expected to be the strongest match because the create response currently echoes it. If the backend stops returning `secret` on create, fall back to URL, delivery region, and event IDs; if multiple items match, choose the newest item by `updatedAt` when present, otherwise the highest `configId`. + +## JSON Contract + +All commands use the existing JSON envelope. Stable command labels: + +- `project webhook events` +- `project webhook list` +- `project webhook show` +- `project webhook create` +- `project webhook update` +- `project webhook delete` + +`enabled` is the canonical webhook state field in every webhook command payload. The CLI does not expose a separate string `status` for webhook enabled state; automation should branch on `enabled`. + +Example create data: + +```json +{ + "action": "webhook-create", + "projectId": "prj_123", + "projectName": "my-agent-demo", + "feature": "rtc", + "configId": 42, + "enabled": true, + "url": "https://example.com/webhook", + "urlRegion": "na", + "eventIds": [1001], + "events": [ + { + "id": 1001, + "key": "channel-created", + "displayName": "Channel Created", + "eventType": 1, + "payload": "{...}" + } + ], + "retry": true, + "useIpWhitelist": false, + "secret": "pUkA4FzTdI8iGtLA6m3o2qR9x_Nb7sYc" +} +``` + +Safe branch fields for automation: + +- `projectId` +- `feature` +- `configId` +- `enabled` +- `urlRegion` +- `eventIds` + +## Error Handling + +Use existing envelope behavior and classify errors where the CLI can provide stable codes: + +| Case | Error code | +| --- | --- | +| Missing URL on create | `WEBHOOK_URL_REQUIRED` | +| Missing events on create | `WEBHOOK_EVENTS_REQUIRED` | +| Unknown event key | `WEBHOOK_EVENT_UNKNOWN` | +| Duplicate or ambiguous event key | `WEBHOOK_EVENT_AMBIGUOUS` | +| Invalid delivery region | `WEBHOOK_DELIVERY_REGION_INVALID` | +| Invalid secret format | `WEBHOOK_SECRET_INVALID` | +| Missing config ID | `WEBHOOK_CONFIG_ID_REQUIRED` | +| Config not found when classifiable | `WEBHOOK_CONFIG_NOT_FOUND` | + +Feature validation reuses the existing feature catalog and error style so accepted values stay in one place. + +Delete confirmation: + +- In interactive pretty mode, prompt before deletion unless `--yes` is passed. +- In JSON, CI, or non-TTY contexts, fail fast unless `--yes` is passed. +- Follow the existing `--yes` convention: it confirms destructive actions but does not start unrelated interactive flows. + +## Rendering + +Use `renderResult` and `printBlock` for pretty output, following existing project commands. + +Single webhook operations render as: + +```text +Webhook + Project my-agent-demo + Feature rtc + Config ID 42 + URL https://example.com/webhook + Events channel-created, channel-destroyed + Delivery Region North America (na) + Enabled true + Retry true + Secret pUkA4FzTdI8iGtLA6m3o2qR9x_Nb7sYc +``` + +For create, print a short note after the block: + +```text +Store this secret now. It may not be shown again. +``` + +For list and show without `--with-secret`, render `Secret` as redacted if displayed at all. + +## MCP Parity + +Add MCP tools for agent workflows because existing project feature/env/init commands are mirrored through MCP: + +- `agora.project.webhook.events` +- `agora.project.webhook.list` +- `agora.project.webhook.show` +- `agora.project.webhook.create` +- `agora.project.webhook.update` +- `agora.project.webhook.delete` + +MCP inputs use feature names, event keys or event IDs, config IDs, URL, delivery region, and project. Secrets follow the same redaction and explicit reveal rules as CLI JSON output. + +MCP tool calls have no TTY, so the interactive delete prompt cannot apply. `agora.project.webhook.delete` is the first destructive tool in the MCP surface, so it must carry its own confirmation contract: + +- The tool exposes a required `confirm` boolean input. +- The deletion proceeds only when `confirm` is `true`. This is the MCP equivalent of `--yes`. +- When `confirm` is absent or `false`, the tool fails fast with a stable error and does not delete, mirroring the non-TTY CLI behavior. + +## Implementation Plan Outline + +1. Add webhook constants and validation helpers: + - supported delivery regions + - control-plane to delivery-region default mapping + - secure secret generation + - secret pattern validation + - event key generation + - event key/ID resolution +2. Add typed API helpers in `internal/cli/webhooks.go`. +3. Register `project webhook` commands in `commands.go`. +4. Add pretty render cases in `render.go`. +5. Add MCP descriptors and dispatch handlers in `mcp.go`. +6. Extend fake CLI BFF with NCS event/config endpoints. +7. Add integration and unit tests. +8. Update `docs/automation.md`, `README.md`, `docs/llms.txt` if MCP surface changes, and regenerate `docs/commands.md`. + +## Tests + +Integration tests should cover: + +- `webhook events --feature rtc --json` returns event keys plus backend event metadata. +- `webhook create` with event keys resolves IDs and generates a backend-valid secret. +- `webhook create --secret` forwards explicit secret. +- `webhook create --secret` rejects values that do not match `^[A-Za-z0-9_-]{7,32}$`. +- `webhook create` defaults delivery region to `na` for `global` project context. +- `webhook create` defaults delivery region to `cn` for `cn` project context. +- `webhook create` sends `enabled: true` and `useIpWhitelist: false`. +- `webhook list` redacts secret by default. +- `webhook show --with-secret` reveals secret when backend returns it. +- `webhook update` performs list-merge-put and preserves omitted required fields. +- `webhook update` updates URL, events, delivery region, and enabled state. +- `webhook update --secret` is rejected as an unknown flag. +- `webhook delete` requires `--yes` in JSON/non-TTY runs. +- Auth errors continue to return exit code `3` with `AUTH_UNAUTHENTICATED`. + +Unit tests should cover: + +- delivery-region validation and defaulting +- feature validation reuse +- event key generation, event ID resolution, unknown event suggestions, and ambiguous keys +- eventId versus eventType mapping for config `eventIds` +- generated secret format, backend pattern compliance, and entropy length +- redaction behavior diff --git a/internal/cli/commands.go b/internal/cli/commands.go index 191cd03..5f49096 100644 --- a/internal/cli/commands.go +++ b/internal/cli/commands.go @@ -6,6 +6,7 @@ import ( "fmt" "os" "path/filepath" + "strconv" "strings" "github.com/spf13/cobra" @@ -595,6 +596,7 @@ These commands do not clone local application code. Use "agora quickstart" for s cmd.AddCommand(a.buildProjectShow()) cmd.AddCommand(a.buildProjectEnv()) cmd.AddCommand(a.buildProjectFeature()) + cmd.AddCommand(a.buildProjectWebhook()) cmd.AddCommand(a.buildProjectDoctor()) return cmd } @@ -981,6 +983,273 @@ func (a *App) buildProjectFeature() *cobra.Command { return cmd } +func (a *App) buildProjectWebhook() *cobra.Command { + webhookFeature := "" + cmd := &cobra.Command{ + Use: "webhook", + Short: "Manage project webhook configurations", + Long: "List webhook events and manage webhook endpoint configurations for a project feature. The --feature flag is inherited by all webhook subcommands and may be placed before or after the subcommand.", + Example: example(` + agora project webhook events --feature rtc + agora project webhook --feature rtc events + agora project webhook list --feature rtc --project my-app + agora project webhook create --feature rtc --url https://example.com/webhook --events channel-created,user-joined --project my-app + agora project webhook update 42 --feature rtc --disabled --project my-app + agora project webhook delete 42 --feature rtc --project my-app --yes +`), + RunE: func(cmd *cobra.Command, args []string) error { + if len(args) > 0 { + return fmt.Errorf("unknown command %q for %q", args[0], cmd.CommandPath()) + } + return cmd.Help() + }, + } + cmd.PersistentFlags().StringVar(&webhookFeature, "feature", "", "project feature for webhook operations: rtc, rtm, or convoai") + + events := &cobra.Command{ + Use: "events", + Short: "List available webhook events for a feature", + Example: example(` + agora project webhook events --feature rtc + agora project webhook --feature rtc events --json +`), + RunE: func(cmd *cobra.Command, _ []string) error { + defer func() { + webhookFeature = "" + resetWebhookCommandFlags(cmd, "feature") + }() + data, err := a.projectWebhookEvents(webhookFeature) + if err != nil { + return err + } + return renderResult(cmd, "project webhook events", data) + }, + } + cmd.AddCommand(events) + + listProject := "" + list := &cobra.Command{ + Use: "list", + Short: "List webhook configurations for a project feature", + Example: example(` + agora project webhook list --feature rtc --project my-app + agora project webhook --feature rtc list --project prj_123 --json +`), + RunE: func(cmd *cobra.Command, _ []string) error { + defer func() { + webhookFeature = "" + listProject = "" + resetWebhookCommandFlags(cmd, "feature", "project") + }() + data, err := a.projectWebhookList(webhookFeature, listProject, false) + if err != nil { + return err + } + return renderResult(cmd, "project webhook list", data) + }, + } + list.Flags().StringVar(&listProject, "project", "", "project ID or exact project name; defaults to the current project context") + cmd.AddCommand(list) + + showProject := "" + showWithSecret := false + show := &cobra.Command{ + Use: "show ", + Short: "Show one webhook configuration", + Example: example(` + agora project webhook show 42 --feature rtc --project my-app + agora project webhook --feature rtc show 42 --project prj_123 --with-secret --json +`), + RunE: func(cmd *cobra.Command, args []string) error { + defer func() { + webhookFeature = "" + showProject = "" + showWithSecret = false + resetWebhookCommandFlags(cmd, "feature", "project", "with-secret") + }() + configID, err := parseWebhookConfigIDArg(args) + if err != nil { + return err + } + data, err := a.projectWebhookShow(configID, webhookFeature, showProject, showWithSecret) + if err != nil { + return err + } + return renderResult(cmd, "project webhook show", data) + }, + } + show.Flags().StringVar(&showProject, "project", "", "project ID or exact project name; defaults to the current project context") + show.Flags().BoolVar(&showWithSecret, "with-secret", false, "include the webhook secret in the response") + cmd.AddCommand(show) + + createProject := "" + createURL := "" + createEvents := "" + createSecret := "" + createDeliveryRegion := "" + create := &cobra.Command{ + Use: "create", + Short: "Create a webhook configuration", + Example: example(` + agora project webhook create --feature rtc --project my-app --url https://example.com/webhook --events channel-created + agora project webhook --feature rtc create --project prj_123 --url https://example.com/webhook --events 1001,1002 --delivery-region na --json +`), + RunE: func(cmd *cobra.Command, _ []string) error { + defer func() { + webhookFeature = "" + createProject = "" + createURL = "" + createEvents = "" + createSecret = "" + createDeliveryRegion = "" + resetWebhookCommandFlags(cmd, "feature", "project", "url", "events", "secret", "delivery-region") + }() + opts := webhookCreateOptions{ + Feature: webhookFeature, + Project: createProject, + URL: createURL, + EventInputs: []string{createEvents}, + Secret: createSecret, + DeliveryRegion: createDeliveryRegion, + } + data, err := a.projectWebhookCreate(opts) + if err != nil { + return err + } + return renderResult(cmd, "project webhook create", data) + }, + } + create.Flags().StringVar(&createProject, "project", "", "project ID or exact project name; defaults to the current project context") + create.Flags().StringVar(&createURL, "url", "", "webhook endpoint URL") + create.Flags().StringVar(&createEvents, "events", "", "comma-separated webhook event keys, display names, or numeric IDs") + create.Flags().StringVar(&createSecret, "secret", "", "webhook signing secret; generated when omitted") + create.Flags().StringVar(&createDeliveryRegion, "delivery-region", "", "webhook delivery region: cn, sea, na, or eu") + cmd.AddCommand(create) + + updateProject := "" + updateURL := "" + updateEvents := "" + updateDeliveryRegion := "" + updateEnabled := false + updateDisabled := false + update := &cobra.Command{ + Use: "update ", + Short: "Update a webhook configuration", + Example: example(` + agora project webhook update 42 --feature rtc --project my-app --url https://example.com/webhook2 + agora project webhook --feature rtc update 42 --project prj_123 --events 1001,1002 --enabled --json +`), + RunE: func(cmd *cobra.Command, args []string) error { + defer func() { + webhookFeature = "" + updateProject = "" + updateURL = "" + updateEvents = "" + updateDeliveryRegion = "" + updateEnabled = false + updateDisabled = false + resetWebhookCommandFlags(cmd, "feature", "project", "url", "events", "delivery-region", "enabled", "disabled") + }() + configID, err := parseWebhookConfigIDArg(args) + if err != nil { + return err + } + if cmd.Flags().Changed("enabled") && cmd.Flags().Changed("disabled") { + return &cliError{Message: "--enabled and --disabled cannot be used together", Code: "WEBHOOK_ENABLED_FLAG_CONFLICT"} + } + var enabled *bool + if cmd.Flags().Changed("enabled") { + value := updateEnabled + enabled = &value + } + if cmd.Flags().Changed("disabled") { + value := !updateDisabled + enabled = &value + } + var eventInputs []string + if cmd.Flags().Changed("events") { + eventInputs = []string{updateEvents} + } + updateOpts := webhookUpdateOptions{ + ConfigID: configID, + Feature: webhookFeature, + Project: updateProject, + URL: updateURL, + EventInputs: eventInputs, + DeliveryRegion: updateDeliveryRegion, + Enabled: enabled, + } + data, err := a.projectWebhookUpdate(updateOpts) + if err != nil { + return err + } + return renderResult(cmd, "project webhook update", data) + }, + } + update.Flags().StringVar(&updateProject, "project", "", "project ID or exact project name; defaults to the current project context") + update.Flags().StringVar(&updateURL, "url", "", "new webhook endpoint URL") + update.Flags().StringVar(&updateEvents, "events", "", "comma-separated replacement webhook event keys, display names, or numeric IDs") + update.Flags().StringVar(&updateDeliveryRegion, "delivery-region", "", "new webhook delivery region: cn, sea, na, or eu") + update.Flags().BoolVar(&updateEnabled, "enabled", false, "enable the webhook configuration") + update.Flags().BoolVar(&updateDisabled, "disabled", false, "disable the webhook configuration") + cmd.AddCommand(update) + + deleteProject := "" + deleteCmd := &cobra.Command{ + Use: "delete ", + Short: "Delete a webhook configuration", + Example: example(` + agora project webhook delete 42 --feature rtc --project my-app --yes + agora project webhook --feature rtc delete 42 --project prj_123 --yes --json +`), + RunE: func(cmd *cobra.Command, args []string) error { + defer func() { + webhookFeature = "" + deleteProject = "" + resetWebhookCommandFlags(cmd, "feature", "project") + }() + configID, err := parseWebhookConfigIDArg(args) + if err != nil { + return err + } + if !a.rootYes { + return &cliError{Message: "confirmation required; pass --yes to delete this webhook configuration", Code: "CONFIRMATION_REQUIRED"} + } + data, err := a.projectWebhookDelete(configID, webhookFeature, deleteProject) + if err != nil { + return err + } + return renderResult(cmd, "project webhook delete", data) + }, + } + deleteCmd.Flags().StringVar(&deleteProject, "project", "", "project ID or exact project name; defaults to the current project context") + cmd.AddCommand(deleteCmd) + + return cmd +} + +func resetWebhookCommandFlags(cmd *cobra.Command, names ...string) { + for _, name := range names { + if flag := cmd.Flags().Lookup(name); flag != nil { + flag.Changed = false + } + } +} + +func parseWebhookConfigIDArg(args []string) (int, error) { + if len(args) != 1 { + return 0, &cliError{Message: "webhook config ID is required", Code: "WEBHOOK_CONFIG_ID_REQUIRED"} + } + configID, err := strconv.Atoi(strings.TrimSpace(args[0])) + if err != nil { + return 0, &cliError{Message: "webhook config ID is required", Code: "WEBHOOK_CONFIG_ID_REQUIRED"} + } + if err := validateWebhookConfigID(configID); err != nil { + return 0, err + } + return configID, nil +} + func (a *App) buildProjectDoctor() *cobra.Command { var deep bool var feature string diff --git a/internal/cli/docgen.go b/internal/cli/docgen.go index c097687..1805cf2 100644 --- a/internal/cli/docgen.go +++ b/internal/cli/docgen.go @@ -63,7 +63,7 @@ func RenderCommandReference(out io.Writer, root *cobra.Command) error { b.WriteString("\n\n") } if len(cmd.Flags) == 0 { - b.WriteString("_No local flags. Inherited global flags still apply (see [Global Flags](#global-flags))._\n\n") + b.WriteString("_No local flags. Inherited parent and global flags still apply; run `agora --help` for the full flag set._\n\n") continue } writeFlagsTable(&b, cmd.Flags) diff --git a/internal/cli/integration_test.go b/internal/cli/integration_test.go index 9169007..fb13046 100644 --- a/internal/cli/integration_test.go +++ b/internal/cli/integration_test.go @@ -32,6 +32,7 @@ import ( "os/exec" "path/filepath" "regexp" + "strconv" "strings" "sync" "testing" @@ -219,6 +220,68 @@ func runCLI(t *testing.T, args []string, options cliRunOptions) cliResult { } } +func runExistingCLIApp(t *testing.T, app *App, args []string) cliResult { + t.Helper() + + originalArgs := os.Args + originalStdout := os.Stdout + originalStderr := os.Stderr + defer func() { + os.Args = originalArgs + os.Stdout = originalStdout + os.Stderr = originalStderr + }() + + stdoutReader, stdoutWriter, err := os.Pipe() + if err != nil { + t.Fatal(err) + } + stderrReader, stderrWriter, err := os.Pipe() + if err != nil { + _ = stdoutReader.Close() + _ = stdoutWriter.Close() + t.Fatal(err) + } + os.Stdout = stdoutWriter + os.Stderr = stderrWriter + defer func() { + _ = stdoutReader.Close() + _ = stderrReader.Close() + }() + + var stdoutBuf bytes.Buffer + var stderrBuf bytes.Buffer + var wg sync.WaitGroup + wg.Add(2) + go func() { + defer wg.Done() + _, _ = io.Copy(&stdoutBuf, stdoutReader) + }() + go func() { + defer wg.Done() + _, _ = io.Copy(&stderrBuf, stderrReader) + }() + + os.Args = append([]string{"agora"}, args...) + app.root.SetArgs(args) + code := 0 + if err := app.Execute(); err != nil { + if exitCode, ok := ExitCode(err); ok { + code = exitCode + } else if ErrorRendered(err) { + code = 1 + } else { + code = 1 + fmt.Fprintln(os.Stderr, err.Error()) + } + } + _ = stdoutWriter.Close() + _ = stderrWriter.Close() + wg.Wait() + + return cliResult{exitCode: code, stdout: stdoutBuf.String(), stderr: stderrBuf.String()} +} + func restoreProcessEnv(env []string) { os.Clearenv() for _, item := range env { @@ -360,6 +423,19 @@ type fakeProject struct { Vid int `json:"vid"` } +type fakeNCSConfig struct { + ConfigID int `json:"configId"` + URL string `json:"url"` + URLRegion string `json:"urlRegion"` + Enabled bool `json:"enabled"` + EventIDs []int `json:"eventIds"` + Retry *bool `json:"retry,omitempty"` + UseIPWhitelist bool `json:"useIpWhitelist,omitempty"` + Secret string `json:"secret,omitempty"` + CreatedAt string `json:"createdAt,omitempty"` + UpdatedAt string `json:"updatedAt,omitempty"` +} + func buildFakeProject(name, projectID, appID, region string) fakeProject { signKey := "4854d28b48a9439c9f2546e2216fc07a" useCase := "education" @@ -388,11 +464,13 @@ func buildFakeProject(name, projectID, appID, region string) fakeProject { // rtm2-config (RTM) feature flag toggles. Every request is captured under // `requests` so tests can assert headers (e.g. AGORA_AGENT propagation). type fakeCLIBFF struct { - server *http.Server - baseURL string - mu sync.Mutex - projects map[string]*fakeProject - requests []struct { + server *http.Server + baseURL string + mu sync.Mutex + projects map[string]*fakeProject + ncsConfigs map[string][]fakeNCSConfig + ncsBodies []map[string]any + requests []struct { Method string Pathname string Authorization string @@ -401,7 +479,7 @@ type fakeCLIBFF struct { } func newFakeCLIBFF() *fakeCLIBFF { - api := &fakeCLIBFF{projects: map[string]*fakeProject{}} + api := &fakeCLIBFF{projects: map[string]*fakeProject{}, ncsConfigs: map[string][]fakeNCSConfig{}} handler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { api.mu.Lock() api.requests = append(api.requests, struct { @@ -418,6 +496,25 @@ func newFakeCLIBFF() *fakeCLIBFF { api.mu.Unlock() switch { + case r.Method == http.MethodGet && isFakeNCSEventsPath(r.URL.Path): + _ = json.NewEncoder(w).Encode(map[string]any{ + "items": []map[string]any{ + { + "eventId": 1001, + "displayName": "Channel Created", + "displayNameCn": "频道创建", + "eventType": 1, + "payload": `{"event":"created"}`, + }, + { + "eventId": 1002, + "displayName": "Channel Destroyed", + "displayNameCn": "频道销毁", + "eventType": 2, + "payload": `{"event":"destroyed"}`, + }, + }, + }) case r.Method == http.MethodGet && r.URL.Path == "/api/cli/v1/projects": keyword := strings.ToLower(r.URL.Query().Get("keyword")) items := []map[string]any{} @@ -455,12 +552,15 @@ func newFakeCLIBFF() *fakeCLIBFF { project := buildFakeProject(name, projectID, appID, "global") api.projects[projectID] = &project _ = json.NewEncoder(w).Encode(project) + case isFakeNCSConfigsPath(r.URL.Path): + api.handleFakeNCSConfigs(w, r) + case strings.HasPrefix(r.URL.Path, "/api/cli/v1/projects/") && strings.Contains(r.URL.Path, "/ncs-configs/"): + http.NotFound(w, r) case strings.HasPrefix(r.URL.Path, "/api/cli/v1/projects/") && !strings.Contains(r.URL.Path, "/uap-configs/") && !strings.HasSuffix(r.URL.Path, "/rtm2-config"): projectID := strings.TrimPrefix(r.URL.Path, "/api/cli/v1/projects/") project, ok := api.projects[projectID] if !ok { - w.WriteHeader(http.StatusNotFound) - _, _ = io.WriteString(w, `{"code":"NOT_FOUND","message":"resource not found","requestId":"req-not-found"}`) + writeFakeProjectNotFound(w) return } _ = json.NewEncoder(w).Encode(project) @@ -523,6 +623,196 @@ func newFakeCLIBFF() *fakeCLIBFF { return api } +func (api *fakeCLIBFF) handleFakeNCSConfigs(w http.ResponseWriter, r *http.Request) { + parts := fakePathParts(r.URL.Path) + projectID := parts[4] + feature := parts[6] + key := projectID + "/" + feature + + switch r.Method { + case http.MethodGet: + if len(parts) != 7 { + http.NotFound(w, r) + return + } + api.mu.Lock() + if _, ok := api.projects[projectID]; !ok { + api.mu.Unlock() + writeFakeProjectNotFound(w) + return + } + items := append([]fakeNCSConfig(nil), api.ncsConfigs[key]...) + api.mu.Unlock() + _ = json.NewEncoder(w).Encode(map[string]any{"items": items}) + case http.MethodPost: + if len(parts) != 7 { + http.NotFound(w, r) + return + } + var body map[string]any + _ = json.NewDecoder(r.Body).Decode(&body) + api.mu.Lock() + if _, ok := api.projects[projectID]; !ok { + api.mu.Unlock() + writeFakeProjectNotFound(w) + return + } + api.ncsBodies = append(api.ncsBodies, body) + config := fakeNCSConfig{ + ConfigID: 42 + len(api.ncsConfigs[key]), + URL: stringFromBody(body, "url"), + URLRegion: stringFromBody(body, "urlRegion"), + Enabled: boolFromBody(body, "enabled"), + EventIDs: fakeEventIDsFromValue(body["eventIds"]), + Retry: fakeBoolPtr(true), + UseIPWhitelist: boolFromBody(body, "useIpWhitelist"), + Secret: stringFromBody(body, "secret"), + CreatedAt: "2026-06-07T00:00:01Z", + UpdatedAt: "2026-06-07T00:00:01Z", + } + api.ncsConfigs[key] = append(api.ncsConfigs[key], config) + items := append([]fakeNCSConfig(nil), api.ncsConfigs[key]...) + api.mu.Unlock() + _ = json.NewEncoder(w).Encode(map[string]any{"items": items}) + case http.MethodPut: + if len(parts) != 8 { + http.NotFound(w, r) + return + } + configID, _ := strconv.Atoi(parts[7]) + var body map[string]any + _ = json.NewDecoder(r.Body).Decode(&body) + api.mu.Lock() + if _, ok := api.projects[projectID]; !ok { + api.mu.Unlock() + writeFakeProjectNotFound(w) + return + } + api.ncsBodies = append(api.ncsBodies, body) + for i := range api.ncsConfigs[key] { + if api.ncsConfigs[key][i].ConfigID != configID { + continue + } + if value, ok := body["url"].(string); ok { + api.ncsConfigs[key][i].URL = value + } + if value, ok := body["urlRegion"].(string); ok { + api.ncsConfigs[key][i].URLRegion = value + } + if value, ok := body["enabled"].(bool); ok { + api.ncsConfigs[key][i].Enabled = value + } + if value, ok := body["eventIds"]; ok { + api.ncsConfigs[key][i].EventIDs = fakeEventIDsFromValue(value) + } + if value, ok := body["useIpWhitelist"].(bool); ok { + api.ncsConfigs[key][i].UseIPWhitelist = value + } + api.ncsConfigs[key][i].UpdatedAt = "2026-06-07T00:00:02Z" + } + items := append([]fakeNCSConfig(nil), api.ncsConfigs[key]...) + api.mu.Unlock() + _ = json.NewEncoder(w).Encode(map[string]any{"items": items}) + case http.MethodDelete: + if len(parts) != 8 { + http.NotFound(w, r) + return + } + configID, _ := strconv.Atoi(parts[7]) + api.mu.Lock() + if _, ok := api.projects[projectID]; !ok { + api.mu.Unlock() + writeFakeProjectNotFound(w) + return + } + next := []fakeNCSConfig{} + for _, item := range api.ncsConfigs[key] { + if item.ConfigID != configID { + next = append(next, item) + } + } + api.ncsConfigs[key] = next + api.mu.Unlock() + _ = json.NewEncoder(w).Encode(map[string]any{"ok": true}) + default: + http.NotFound(w, r) + } +} + +func fakePathParts(path string) []string { + return strings.Split(strings.TrimLeft(path, "/"), "/") +} + +func isFakeNCSEventsPath(path string) bool { + parts := fakePathParts(path) + return len(parts) == 5 && + parts[0] == "api" && + parts[1] == "cli" && + parts[2] == "v1" && + parts[3] == "ncs-events" && + parts[4] != "" +} + +func isFakeNCSConfigsPath(path string) bool { + parts := fakePathParts(path) + if len(parts) != 7 && len(parts) != 8 { + return false + } + return parts[0] == "api" && + parts[1] == "cli" && + parts[2] == "v1" && + parts[3] == "projects" && + parts[4] != "" && + parts[5] == "ncs-configs" && + parts[6] != "" && + (len(parts) == 7 || parts[7] != "") +} + +func writeFakeProjectNotFound(w http.ResponseWriter) { + w.WriteHeader(http.StatusNotFound) + _, _ = io.WriteString(w, `{"code":"NOT_FOUND","message":"resource not found","requestId":"req-not-found"}`) +} + +func fakeBoolPtr(value bool) *bool { + return &value +} + +func stringFromBody(body map[string]any, key string) string { + value, _ := body[key].(string) + return value +} + +func boolFromBody(body map[string]any, key string) bool { + value, _ := body[key].(bool) + return value +} + +func fakeEventIDsFromValue(value any) []int { + switch values := value.(type) { + case []int: + return append([]int(nil), values...) + case []float64: + out := make([]int, 0, len(values)) + for _, item := range values { + out = append(out, int(item)) + } + return out + case []any: + out := make([]int, 0, len(values)) + for _, item := range values { + switch typed := item.(type) { + case float64: + out = append(out, int(typed)) + case int: + out = append(out, typed) + } + } + return out + default: + return nil + } +} + // persistSessionForIntegration writes a fresh, valid-for-an-hour session // into the test's config home so tests do not need to walk through the // OAuth flow each time. @@ -551,3 +841,446 @@ func parseAuthURL(stderr string) string { } return "" } + +func TestFakeWebhookRouteMatchersRejectTrailingSlash(t *testing.T) { + tests := []struct { + name string + path string + wantEvents bool + wantConfigs bool + }{ + { + name: "events exact", + path: "/api/cli/v1/ncs-events/rtc", + wantEvents: true, + }, + { + name: "events trailing slash", + path: "/api/cli/v1/ncs-events/rtc/", + }, + { + name: "events extra segment", + path: "/api/cli/v1/ncs-events/rtc/extra", + }, + { + name: "config list exact", + path: "/api/cli/v1/projects/prj_0001/ncs-configs/rtc", + wantConfigs: true, + }, + { + name: "config list trailing slash", + path: "/api/cli/v1/projects/prj_0001/ncs-configs/rtc/", + }, + { + name: "config item exact", + path: "/api/cli/v1/projects/prj_0001/ncs-configs/rtc/42", + wantConfigs: true, + }, + { + name: "config item trailing slash", + path: "/api/cli/v1/projects/prj_0001/ncs-configs/rtc/42/", + }, + { + name: "config item extra segment", + path: "/api/cli/v1/projects/prj_0001/ncs-configs/rtc/42/extra", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if got := isFakeNCSEventsPath(tt.path); got != tt.wantEvents { + t.Fatalf("isFakeNCSEventsPath(%q) = %t, want %t", tt.path, got, tt.wantEvents) + } + if got := isFakeNCSConfigsPath(tt.path); got != tt.wantConfigs { + t.Fatalf("isFakeNCSConfigsPath(%q) = %t, want %t", tt.path, got, tt.wantConfigs) + } + }) + } +} + +func TestProjectWebhookEventsJSON(t *testing.T) { + configHome := t.TempDir() + api := newFakeCLIBFF() + defer api.server.Close() + persistSessionForIntegration(t, configHome) + + result := runCLI(t, []string{"project", "webhook", "events", "--feature", "rtc", "--json"}, cliRunOptions{env: webhookTestEnv(configHome, api.baseURL)}) + if result.exitCode != 0 || !strings.Contains(result.stdout, `"command":"project webhook events"`) || !strings.Contains(result.stdout, `"key":"channel-created"`) || !strings.Contains(result.stdout, `"id":1001`) || strings.Contains(result.stdout, "displayNameCn") || strings.Contains(result.stdout, "频道创建") { + t.Fatalf("unexpected webhook events result: exit=%d stdout=%s stderr=%s", result.exitCode, result.stdout, result.stderr) + } +} + +func TestProjectWebhookEventsTrimsFeatureBeforeAPIRequest(t *testing.T) { + configHome := t.TempDir() + api := newFakeCLIBFF() + defer api.server.Close() + persistSessionForIntegration(t, configHome) + + result := runCLI(t, []string{"project", "webhook", "events", "--feature", " rtc ", "--json"}, cliRunOptions{env: webhookTestEnv(configHome, api.baseURL)}) + if result.exitCode != 0 || !strings.Contains(result.stdout, `"feature":"rtc"`) || !strings.Contains(result.stdout, `"key":"channel-created"`) { + t.Fatalf("unexpected webhook events result for padded feature: exit=%d stdout=%s stderr=%s", result.exitCode, result.stdout, result.stderr) + } +} + +func TestProjectWebhookFeaturePersistentFlagBeforeSubcommand(t *testing.T) { + configHome := t.TempDir() + api := newFakeCLIBFF() + defer api.server.Close() + persistSessionForIntegration(t, configHome) + + result := runCLI(t, []string{"project", "webhook", "--feature", "rtc", "events", "--json"}, cliRunOptions{env: webhookTestEnv(configHome, api.baseURL)}) + if result.exitCode != 0 || !strings.Contains(result.stdout, `"command":"project webhook events"`) || !strings.Contains(result.stdout, `"feature":"rtc"`) || !strings.Contains(result.stdout, `"key":"channel-created"`) { + t.Fatalf("unexpected webhook events result with parent feature flag: exit=%d stdout=%s stderr=%s", result.exitCode, result.stdout, result.stderr) + } +} + +func TestProjectWebhookHelpShowsFeatureAndExamples(t *testing.T) { + parent := runCLI(t, []string{"project", "webhook", "--help"}, cliRunOptions{}) + if parent.exitCode != 0 || !strings.Contains(parent.stdout, "--feature string") || !strings.Contains(parent.stdout, "agora project webhook --feature rtc events") { + t.Fatalf("unexpected project webhook help: exit=%d stdout=%s stderr=%s", parent.exitCode, parent.stdout, parent.stderr) + } + + create := runCLI(t, []string{"project", "webhook", "create", "--help"}, cliRunOptions{}) + if create.exitCode != 0 || !strings.Contains(create.stdout, "agora project webhook create --feature rtc") || !strings.Contains(create.stdout, "agora project webhook --feature rtc create") || !strings.Contains(create.stdout, "--events 1001,1002") || strings.Contains(create.stdout, "--event ") { + t.Fatalf("unexpected project webhook create help: exit=%d stdout=%s stderr=%s", create.exitCode, create.stdout, create.stderr) + } +} + +func TestProjectWebhookEventsPrettyOmitsPayload(t *testing.T) { + configHome := t.TempDir() + api := newFakeCLIBFF() + defer api.server.Close() + persistSessionForIntegration(t, configHome) + + result := runCLI(t, []string{"project", "webhook", "events", "--feature", "rtc"}, cliRunOptions{env: webhookTestEnv(configHome, api.baseURL)}) + if result.exitCode != 0 || !strings.Contains(result.stdout, "channel-created") || !strings.Contains(result.stdout, "1001") || !strings.Contains(result.stdout, "Channel Created") || strings.Contains(result.stdout, `{"event":"created"}`) || strings.Contains(result.stdout, "payload") || strings.Contains(result.stdout, "eventType") { + t.Fatalf("unexpected webhook events pretty result: exit=%d stdout=%s stderr=%s", result.exitCode, result.stdout, result.stderr) + } +} + +func TestProjectWebhookEventsInvalidFeatureJSON(t *testing.T) { + configHome := t.TempDir() + api := newFakeCLIBFF() + defer api.server.Close() + persistSessionForIntegration(t, configHome) + + result := runCLI(t, []string{"project", "webhook", "events", "--feature", "bogus", "--json"}, cliRunOptions{env: webhookTestEnv(configHome, api.baseURL)}) + if result.exitCode == 0 || !strings.Contains(result.stdout, `"code":"WEBHOOK_FEATURE_INVALID"`) || result.stderr != "" { + t.Fatalf("expected invalid webhook feature error, got exit=%d stdout=%s stderr=%s", result.exitCode, result.stdout, result.stderr) + } +} + +func TestProjectWebhookCreateJSON(t *testing.T) { + configHome := t.TempDir() + api := newFakeCLIBFF() + defer api.server.Close() + project := buildFakeProject("demo", "prj_0001", "app_0001", "global") + api.projects[project.ProjectID] = &project + persistSessionForIntegration(t, configHome) + + result := runCLI(t, []string{"project", "webhook", "create", "--project", "demo", "--feature", "rtc", "--url", "https://example.com/webhook", "--events", "channel-created,1002", "--json"}, cliRunOptions{env: webhookTestEnv(configHome, api.baseURL)}) + if result.exitCode != 0 || !strings.Contains(result.stdout, `"command":"project webhook create"`) || !strings.Contains(result.stdout, `"configId":42`) || !strings.Contains(result.stdout, `"urlRegion":"na"`) || !strings.Contains(result.stdout, `"enabled":true`) || !strings.Contains(result.stdout, `"secret":"`) || strings.Contains(result.stdout, "displayNameCn") { + t.Fatalf("unexpected webhook create result: exit=%d stdout=%s stderr=%s", result.exitCode, result.stdout, result.stderr) + } + if len(api.ncsBodies) != 1 { + t.Fatalf("expected one create body, got %#v", api.ncsBodies) + } + body := api.ncsBodies[0] + if body["url"] != "https://example.com/webhook" || body["urlRegion"] != "na" || body["enabled"] != true || body["useIpWhitelist"] != false { + t.Fatalf("unexpected create body: %#v", body) + } + if got := fakeEventIDsFromValue(body["eventIds"]); !webhookIntSlicesEqual(got, []int{1001, 1002}) { + t.Fatalf("expected create body to use eventIds 1001 and 1002, got %#v", body["eventIds"]) + } + secret, _ := body["secret"].(string) + if !webhookSecretPattern.MatchString(secret) { + t.Fatalf("expected generated secret matching backend pattern, got %#v", body) + } +} + +func TestProjectWebhookCreateRejectsSingularEventFlag(t *testing.T) { + configHome := t.TempDir() + api := newFakeCLIBFF() + defer api.server.Close() + project := buildFakeProject("demo", "prj_0001", "app_0001", "global") + api.projects[project.ProjectID] = &project + persistSessionForIntegration(t, configHome) + + result := runCLI(t, []string{"project", "webhook", "create", "--project", "demo", "--feature", "rtc", "--url", "https://example.com/webhook", "--event", "1001", "--json"}, cliRunOptions{env: webhookTestEnv(configHome, api.baseURL)}) + if result.exitCode == 0 || !strings.Contains(result.stdout, "unknown flag: --event") || result.stderr != "" { + t.Fatalf("expected singular --event flag to be rejected, got exit=%d stdout=%s stderr=%s", result.exitCode, result.stdout, result.stderr) + } + if len(api.ncsBodies) != 0 { + t.Fatalf("rejected singular --event flag should not POST, got bodies %#v", api.ncsBodies) + } +} + +func TestProjectWebhookUpdateReadMergePut(t *testing.T) { + configHome := t.TempDir() + api := newFakeCLIBFF() + defer api.server.Close() + project := buildFakeProject("demo", "prj_0001", "app_0001", "global") + api.projects[project.ProjectID] = &project + api.ncsConfigs["prj_0001/rtc"] = []fakeNCSConfig{{ + ConfigID: 42, + URL: "https://old.example/webhook", + URLRegion: "eu", + Enabled: true, + EventIDs: []int{1001}, + Retry: fakeBoolPtr(true), + UseIPWhitelist: false, + Secret: "secret_123", + CreatedAt: "2026-06-07T00:00:01Z", + UpdatedAt: "2026-06-07T00:00:01Z", + }} + persistSessionForIntegration(t, configHome) + + result := runCLI(t, []string{"project", "webhook", "update", "42", "--project", "demo", "--feature", "rtc", "--url", "https://new.example/webhook", "--json"}, cliRunOptions{env: webhookTestEnv(configHome, api.baseURL)}) + if result.exitCode != 0 || strings.Contains(result.stdout, "secret_123") || !strings.Contains(result.stdout, `"secret":"********"`) || strings.Contains(result.stdout, "displayNameCn") { + t.Fatalf("unexpected webhook update result: exit=%d stdout=%s stderr=%s", result.exitCode, result.stdout, result.stderr) + } + if len(api.ncsBodies) != 1 { + t.Fatalf("expected one PUT body, got %#v", api.ncsBodies) + } + last := api.ncsBodies[len(api.ncsBodies)-1] + if last["url"] != "https://new.example/webhook" || last["urlRegion"] != "eu" || last["enabled"] != true || last["useIpWhitelist"] != false { + t.Fatalf("PUT body did not preserve existing fields: %#v", last) + } + if _, ok := last["secret"]; ok { + t.Fatalf("PUT body must not include secret: %#v", last) + } + if got := fakeEventIDsFromValue(last["eventIds"]); !webhookIntSlicesEqual(got, []int{1001}) { + t.Fatalf("PUT body did not preserve event IDs: %#v", last) + } + stored := api.ncsConfigs["prj_0001/rtc"][0] + if stored.Secret != "secret_123" || stored.URL != "https://new.example/webhook" { + t.Fatalf("fake PUT should preserve secret and update request fields, got %#v", stored) + } +} + +func TestProjectWebhookUpdateEventsCommaSeparated(t *testing.T) { + configHome := t.TempDir() + api := newFakeCLIBFF() + defer api.server.Close() + project := buildFakeProject("demo", "prj_0001", "app_0001", "global") + api.projects[project.ProjectID] = &project + api.ncsConfigs["prj_0001/rtc"] = []fakeNCSConfig{{ + ConfigID: 42, + URL: "https://old.example/webhook", + URLRegion: "na", + Enabled: true, + EventIDs: []int{1001}, + UseIPWhitelist: false, + Secret: "secret_123", + CreatedAt: "2026-06-07T00:00:01Z", + UpdatedAt: "2026-06-07T00:00:01Z", + }} + persistSessionForIntegration(t, configHome) + + result := runCLI(t, []string{"project", "webhook", "update", "42", "--project", "demo", "--feature", "rtc", "--events", "1002, channel-created", "--json"}, cliRunOptions{env: webhookTestEnv(configHome, api.baseURL)}) + if result.exitCode != 0 || !strings.Contains(result.stdout, `"command":"project webhook update"`) { + t.Fatalf("unexpected webhook update result: exit=%d stdout=%s stderr=%s", result.exitCode, result.stdout, result.stderr) + } + if len(api.ncsBodies) != 1 { + t.Fatalf("expected one PUT body, got %#v", api.ncsBodies) + } + if got := fakeEventIDsFromValue(api.ncsBodies[0]["eventIds"]); !webhookIntSlicesEqual(got, []int{1001, 1002}) { + t.Fatalf("expected PUT body to use comma-separated event IDs, got %#v", api.ncsBodies[0]["eventIds"]) + } +} + +func TestProjectWebhookUpdateDoesNotReuseEnabledFlagAcrossExecutions(t *testing.T) { + cliRunMu.Lock() + defer cliRunMu.Unlock() + + configHome := t.TempDir() + api := newFakeCLIBFF() + defer api.server.Close() + project := buildFakeProject("demo", "prj_0001", "app_0001", "global") + api.projects[project.ProjectID] = &project + api.ncsConfigs["prj_0001/rtc"] = []fakeNCSConfig{ + { + ConfigID: 42, + URL: "https://first.example/webhook", + URLRegion: "na", + Enabled: true, + EventIDs: []int{1001}, + Retry: fakeBoolPtr(true), + UseIPWhitelist: false, + Secret: "secret_123", + CreatedAt: "2026-06-07T00:00:01Z", + UpdatedAt: "2026-06-07T00:00:01Z", + }, + { + ConfigID: 43, + URL: "https://second.example/webhook", + URLRegion: "eu", + Enabled: true, + EventIDs: []int{1002}, + Retry: fakeBoolPtr(false), + UseIPWhitelist: false, + Secret: "secret_456", + CreatedAt: "2026-06-07T00:00:01Z", + UpdatedAt: "2026-06-07T00:00:01Z", + }, + } + persistSessionForIntegration(t, configHome) + + originalEnv := os.Environ() + defer restoreProcessEnv(originalEnv) + runEnv := helperEnv(os.Environ(), map[string]string{"AGORA_DISABLE_CI_DETECT": "1"}) + for key, value := range webhookTestEnv(configHome, api.baseURL) { + runEnv = helperEnv(runEnv, map[string]string{key: value}) + } + restoreProcessEnv(runEnv) + + app, err := NewApp() + if err != nil { + t.Fatal(err) + } + + first := runExistingCLIApp(t, app, []string{"project", "webhook", "update", "42", "--project", "demo", "--feature", "rtc", "--disabled", "--json"}) + if first.exitCode != 0 { + t.Fatalf("first update failed: exit=%d stdout=%s stderr=%s", first.exitCode, first.stdout, first.stderr) + } + if len(api.ncsBodies) != 1 { + t.Fatalf("expected one PUT body after first update, got %#v", api.ncsBodies) + } + if api.ncsBodies[0]["enabled"] != false { + t.Fatalf("expected first update body to disable config 42, got %#v", api.ncsBodies[0]) + } + if _, ok := api.ncsBodies[0]["retry"]; ok { + t.Fatalf("PUT body must not include retry: %#v", api.ncsBodies[0]) + } + + second := runExistingCLIApp(t, app, []string{"project", "webhook", "update", "43", "--project", "demo", "--feature", "rtc", "--url", "https://second-new.example/webhook", "--json"}) + if second.exitCode != 0 { + t.Fatalf("second update failed: exit=%d stdout=%s stderr=%s", second.exitCode, second.stdout, second.stderr) + } + if len(api.ncsBodies) != 2 { + t.Fatalf("expected two PUT bodies after second update, got %#v", api.ncsBodies) + } + secondBody := api.ncsBodies[1] + if secondBody["enabled"] != true { + t.Fatalf("expected second update to preserve existing enabled=true, got %#v", secondBody) + } + if _, ok := secondBody["retry"]; ok { + t.Fatalf("PUT body must not include retry: %#v", secondBody) + } +} + +func TestProjectWebhookDeleteRequiresYesInJSON(t *testing.T) { + configHome := t.TempDir() + api := newFakeCLIBFF() + defer api.server.Close() + project := buildFakeProject("demo", "prj_0001", "app_0001", "global") + api.projects[project.ProjectID] = &project + api.ncsConfigs["prj_0001/rtc"] = []fakeNCSConfig{{ConfigID: 42, URL: "https://example.com/webhook", URLRegion: "na", Enabled: true, EventIDs: []int{1001}, Secret: "secret_123"}} + persistSessionForIntegration(t, configHome) + + result := runCLI(t, []string{"project", "webhook", "delete", "42", "--project", "demo", "--feature", "rtc", "--json"}, cliRunOptions{env: webhookTestEnv(configHome, api.baseURL)}) + if result.exitCode == 0 || !strings.Contains(result.stdout, `"code":"CONFIRMATION_REQUIRED"`) || result.stderr != "" { + t.Fatalf("expected confirmation error, got exit=%d stdout=%s stderr=%s", result.exitCode, result.stdout, result.stderr) + } + if len(api.ncsConfigs["prj_0001/rtc"]) != 1 { + t.Fatalf("delete without --yes should not remove config: %#v", api.ncsConfigs["prj_0001/rtc"]) + } + + confirmed := runCLI(t, []string{"project", "webhook", "delete", "42", "--project", "demo", "--feature", "rtc", "--yes", "--json"}, cliRunOptions{env: webhookTestEnv(configHome, api.baseURL)}) + if confirmed.exitCode != 0 || !strings.Contains(confirmed.stdout, `"command":"project webhook delete"`) || !strings.Contains(confirmed.stdout, `"deleted":true`) { + t.Fatalf("unexpected confirmed delete result: exit=%d stdout=%s stderr=%s", confirmed.exitCode, confirmed.stdout, confirmed.stderr) + } + if len(api.ncsConfigs["prj_0001/rtc"]) != 0 { + t.Fatalf("expected confirmed delete to remove config: %#v", api.ncsConfigs["prj_0001/rtc"]) + } +} + +func TestProjectWebhookCreateExplicitSecretAndRejectInvalidSecret(t *testing.T) { + configHome := t.TempDir() + api := newFakeCLIBFF() + defer api.server.Close() + project := buildFakeProject("demo", "prj_0001", "app_0001", "global") + api.projects[project.ProjectID] = &project + persistSessionForIntegration(t, configHome) + + ok := runCLI(t, []string{"project", "webhook", "create", "--project", "demo", "--feature", "rtc", "--url", "https://example.com/webhook", "--events", "1001", "--secret", "secret_123", "--json"}, cliRunOptions{env: webhookTestEnv(configHome, api.baseURL)}) + if ok.exitCode != 0 || !strings.Contains(ok.stdout, `"secret":"secret_123"`) || strings.Contains(ok.stdout, "displayNameCn") { + t.Fatalf("expected explicit secret success, got exit=%d stdout=%s stderr=%s", ok.exitCode, ok.stdout, ok.stderr) + } + + bad := runCLI(t, []string{"project", "webhook", "create", "--project", "demo", "--feature", "rtc", "--url", "https://example.com/webhook", "--events", "1001", "--secret", "this-secret-is-too-long-for-the-backend-pattern", "--json"}, cliRunOptions{env: webhookTestEnv(configHome, api.baseURL)}) + if bad.exitCode == 0 || !strings.Contains(bad.stdout, `"code":"WEBHOOK_SECRET_INVALID"`) || bad.stderr != "" { + t.Fatalf("expected invalid secret error, got exit=%d stdout=%s stderr=%s", bad.exitCode, bad.stdout, bad.stderr) + } + if len(api.ncsBodies) != 1 { + t.Fatalf("invalid secret should be rejected before POST, got bodies %#v", api.ncsBodies) + } +} + +func TestProjectWebhookCreateDefaultsCNDeliveryRegion(t *testing.T) { + configHome := t.TempDir() + api := newFakeCLIBFF() + defer api.server.Close() + project := buildFakeProject("demo-cn", "prj_cn", "app_cn", "cn") + api.projects[project.ProjectID] = &project + persistSessionForIntegration(t, configHome) + + result := runCLI(t, []string{"project", "webhook", "create", "--project", "demo-cn", "--feature", "rtc", "--url", "https://example.cn/webhook", "--events", "channel-created", "--json"}, cliRunOptions{env: webhookTestEnv(configHome, api.baseURL)}) + if result.exitCode != 0 || !strings.Contains(result.stdout, `"urlRegion":"cn"`) || strings.Contains(result.stdout, "displayNameCn") { + t.Fatalf("expected cn default region, got exit=%d stdout=%s stderr=%s", result.exitCode, result.stdout, result.stderr) + } + if len(api.ncsBodies) != 1 || api.ncsBodies[0]["urlRegion"] != "cn" { + t.Fatalf("expected create body to default to cn delivery region, got %#v", api.ncsBodies) + } +} + +func TestProjectWebhookListRedactsAndShowWithSecretReveals(t *testing.T) { + configHome := t.TempDir() + api := newFakeCLIBFF() + defer api.server.Close() + project := buildFakeProject("demo", "prj_0001", "app_0001", "global") + api.projects[project.ProjectID] = &project + api.ncsConfigs["prj_0001/rtc"] = []fakeNCSConfig{{ + ConfigID: 42, + URL: "https://example.com/webhook", + URLRegion: "na", + Enabled: true, + EventIDs: []int{1001}, + Retry: fakeBoolPtr(true), + UseIPWhitelist: false, + Secret: "secret_123", + CreatedAt: "2026-06-07T00:00:01Z", + UpdatedAt: "2026-06-07T00:00:01Z", + }} + persistSessionForIntegration(t, configHome) + + list := runCLI(t, []string{"project", "webhook", "list", "--project", "demo", "--feature", "rtc", "--json"}, cliRunOptions{env: webhookTestEnv(configHome, api.baseURL)}) + if list.exitCode != 0 || strings.Contains(list.stdout, "secret_123") || !strings.Contains(list.stdout, `"secret":"********"`) || strings.Contains(list.stdout, "displayNameCn") { + t.Fatalf("expected list redaction, got exit=%d stdout=%s stderr=%s", list.exitCode, list.stdout, list.stderr) + } + + show := runCLI(t, []string{"project", "webhook", "show", "42", "--project", "demo", "--feature", "rtc", "--with-secret", "--json"}, cliRunOptions{env: webhookTestEnv(configHome, api.baseURL)}) + if show.exitCode != 0 || !strings.Contains(show.stdout, `"secret":"secret_123"`) || strings.Contains(show.stdout, "displayNameCn") { + t.Fatalf("expected show --with-secret reveal, got exit=%d stdout=%s stderr=%s", show.exitCode, show.stdout, show.stderr) + } +} + +func TestProjectWebhookUpdateSecretFlagRejected(t *testing.T) { + configHome := t.TempDir() + api := newFakeCLIBFF() + defer api.server.Close() + persistSessionForIntegration(t, configHome) + + result := runCLI(t, []string{"project", "webhook", "update", "42", "--feature", "rtc", "--secret", "secret_123", "--json"}, cliRunOptions{env: webhookTestEnv(configHome, api.baseURL)}) + if result.exitCode == 0 || !strings.Contains(result.stdout, "unknown flag: --secret") || result.stderr != "" { + t.Fatalf("expected unknown --secret flag, got exit=%d stdout=%s stderr=%s", result.exitCode, result.stdout, result.stderr) + } +} + +func webhookTestEnv(configHome, apiBaseURL string) map[string]string { + return map[string]string{ + "XDG_CONFIG_HOME": configHome, + "AGORA_API_BASE_URL": apiBaseURL, + "AGORA_LOG_LEVEL": "error", + } +} diff --git a/internal/cli/mcp.go b/internal/cli/mcp.go index b147d98..b919d17 100644 --- a/internal/cli/mcp.go +++ b/internal/cli/mcp.go @@ -7,6 +7,7 @@ import ( "errors" "fmt" "io" + "math" "strings" "time" @@ -206,6 +207,39 @@ func mcpTools() []map[string]any { mcpTool("agora.project.feature.status", "Show one feature status", map[string]string{"feature": "string", "project": "string"}), mcpTool("agora.project.feature.enable", "Enable one feature for a project", map[string]string{"feature": "string", "project": "string"}), + // Project webhooks + mcpTool("agora.project.webhook.events", "List available webhook events for a feature", map[string]string{"feature": "string"}), + mcpTool("agora.project.webhook.list", "List webhook configurations for a project feature", map[string]string{"feature": "string", "project": "string"}), + mcpTool("agora.project.webhook.show", "Show one webhook configuration", map[string]string{ + "configId": "integer", + "feature": "string", + "project": "string", + "withSecret": "boolean", + }), + mcpTool("agora.project.webhook.create", "Create a webhook configuration", map[string]string{ + "feature": "string", + "project": "string", + "url": "string", + "events": "array", + "secret": "string", + "deliveryRegion": "string", + }), + mcpTool("agora.project.webhook.update", "Update a webhook configuration", map[string]string{ + "configId": "integer", + "feature": "string", + "project": "string", + "url": "string", + "events": "array", + "deliveryRegion": "string", + "enabled": "boolean", + }), + mcpTool("agora.project.webhook.delete", "Delete a webhook configuration", map[string]string{ + "configId": "integer", + "feature": "string", + "project": "string", + "confirm": "boolean", + }), + // Quickstart mcpTool("agora.quickstart.list", "List quickstart templates", nil), mcpTool("agora.quickstart.create", "Clone a quickstart and optionally bind a project", map[string]string{ @@ -416,6 +450,63 @@ func (a *App) callMCPTool(name string, args map[string]any, progress progressEmi } return a.projectFeatureEnable(feature, stringArg(args, "project")) + case "agora.project.webhook.events": + return a.projectWebhookEvents(stringArg(args, "feature")) + + case "agora.project.webhook.list": + return a.projectWebhookList(stringArg(args, "feature"), stringArg(args, "project"), false) + + case "agora.project.webhook.show": + configID, err := configIDArg(args, "configId") + if err != nil { + return nil, err + } + return a.projectWebhookShow( + configID, + stringArg(args, "feature"), + stringArg(args, "project"), + boolArg(args, "withSecret", false), + ) + + case "agora.project.webhook.create": + return a.projectWebhookCreate(webhookCreateOptions{ + Feature: stringArg(args, "feature"), + Project: stringArg(args, "project"), + URL: stringArg(args, "url"), + EventInputs: stringSliceArg(args, "events"), + Secret: stringArg(args, "secret"), + DeliveryRegion: stringArg(args, "deliveryRegion"), + }) + + case "agora.project.webhook.update": + configID, err := configIDArg(args, "configId") + if err != nil { + return nil, err + } + return a.projectWebhookUpdate(webhookUpdateOptions{ + ConfigID: configID, + Feature: stringArg(args, "feature"), + Project: stringArg(args, "project"), + URL: stringArg(args, "url"), + EventInputs: stringSliceArg(args, "events"), + DeliveryRegion: stringArg(args, "deliveryRegion"), + Enabled: optionalBoolArg(args, "enabled"), + }) + + case "agora.project.webhook.delete": + configID, err := configIDArg(args, "configId") + if err != nil { + return nil, err + } + if !boolArg(args, "confirm", false) { + return nil, &cliError{Message: "confirmation required; pass confirm:true to delete this webhook configuration", Code: "CONFIRMATION_REQUIRED"} + } + return a.projectWebhookDelete( + configID, + stringArg(args, "feature"), + stringArg(args, "project"), + ) + case "agora.quickstart.list": items := []map[string]any{} for _, template := range quickstartTemplates() { @@ -500,6 +591,36 @@ func boolArg(args map[string]any, key string, fallback bool) bool { return fallback } +func configIDArg(args map[string]any, key string) (int, error) { + value, ok := args[key] + if !ok || value == nil { + return 0, webhookConfigIDRequiredError() + } + switch v := value.(type) { + case int: + if v > 0 { + return v, nil + } + case float64: + maxInt := int(^uint(0) >> 1) + if v > 0 && !math.IsInf(v, 0) && !math.IsNaN(v) && math.Trunc(v) == v && v <= float64(maxInt) { + return int(v), nil + } + } + return 0, webhookConfigIDRequiredError() +} + +func webhookConfigIDRequiredError() *cliError { + return &cliError{Message: "webhook config ID is required", Code: "WEBHOOK_CONFIG_ID_REQUIRED"} +} + +func optionalBoolArg(args map[string]any, key string) *bool { + if value, ok := args[key].(bool); ok { + return &value + } + return nil +} + // stringSliceArg coerces an "array of string" MCP argument into a Go // slice. Accepts either a real []any payload (the JSON-RPC default) or // a single comma-separated string for shells that flatten arrays. diff --git a/internal/cli/mcp_test.go b/internal/cli/mcp_test.go index 5dd04fe..42f790a 100644 --- a/internal/cli/mcp_test.go +++ b/internal/cli/mcp_test.go @@ -3,6 +3,7 @@ package cli import ( "bytes" "encoding/json" + "errors" "path/filepath" "strconv" "strings" @@ -124,6 +125,12 @@ func TestMCPToolsListCoversFullSurface(t *testing.T) { "agora.project.list", "agora.project.show", "agora.project.use", + "agora.project.webhook.create", + "agora.project.webhook.delete", + "agora.project.webhook.events", + "agora.project.webhook.list", + "agora.project.webhook.show", + "agora.project.webhook.update", "agora.quickstart.create", "agora.quickstart.env_write", "agora.quickstart.list", @@ -161,6 +168,95 @@ func TestMCPToolsListCoversFullSurface(t *testing.T) { } } +func TestMCPProjectWebhookDeleteRequiresConfirm(t *testing.T) { + a := newTestApp(t) + _, err := a.callMCPTool("agora.project.webhook.delete", map[string]any{ + "configId": float64(42), + "feature": "rtc", + "project": "demo", + "confirm": false, + }, nil) + if err == nil { + t.Fatal("expected confirmation error") + } + var cliErr *cliError + if !errors.As(err, &cliErr) { + t.Fatalf("expected *cliError, got %T: %v", err, err) + } + if cliErr.Code != "CONFIRMATION_REQUIRED" { + t.Fatalf("code = %q, want CONFIRMATION_REQUIRED", cliErr.Code) + } +} + +func TestMCPProjectWebhookRejectsFractionalConfigID(t *testing.T) { + a := newTestApp(t) + _, err := a.callMCPTool("agora.project.webhook.show", map[string]any{ + "configId": float64(42.9), + "feature": "rtc", + "project": "demo", + "withSecret": false, + }, nil) + assertCLIErrorCode(t, err, "WEBHOOK_CONFIG_ID_REQUIRED") +} + +func TestMCPProjectWebhookRejectsMissingConfigID(t *testing.T) { + a := newTestApp(t) + _, err := a.callMCPTool("agora.project.webhook.update", map[string]any{ + "feature": "rtc", + "project": "demo", + }, nil) + assertCLIErrorCode(t, err, "WEBHOOK_CONFIG_ID_REQUIRED") +} + +func TestMCPProjectWebhookConfigIDSchemaUsesInteger(t *testing.T) { + for _, tool := range mcpTools() { + name, _ := tool["name"].(string) + if name != "agora.project.webhook.show" && name != "agora.project.webhook.update" && name != "agora.project.webhook.delete" { + continue + } + inputSchema := tool["inputSchema"].(map[string]any) + properties := inputSchema["properties"].(map[string]any) + configID := properties["configId"].(map[string]any) + if configID["type"] != "integer" { + t.Fatalf("%s configId type = %v, want integer", name, configID["type"]) + } + } +} + +func TestMCPConfigIDArgAcceptsIntegralFloat64(t *testing.T) { + got, err := configIDArg(map[string]any{"configId": float64(42)}, "configId") + if err != nil { + t.Fatalf("configIDArg returned error: %v", err) + } + if got != 42 { + t.Fatalf("configIDArg = %d, want 42", got) + } +} + +func TestMCPOptionalBoolArgFalseIsNonNil(t *testing.T) { + got := optionalBoolArg(map[string]any{"enabled": false}, "enabled") + if got == nil { + t.Fatal("optionalBoolArg returned nil for false") + } + if *got { + t.Fatal("optionalBoolArg returned true, want false") + } +} + +func assertCLIErrorCode(t *testing.T, err error, want string) { + t.Helper() + if err == nil { + t.Fatalf("expected %s error", want) + } + var cliErr *cliError + if !errors.As(err, &cliErr) { + t.Fatalf("expected *cliError, got %T: %v", err, err) + } + if cliErr.Code != want { + t.Fatalf("code = %q, want %s", cliErr.Code, want) + } +} + // TestMCPVersionToolReturnsBuildInfo runs end-to-end through serveMCP // for a no-arg, no-network tool to verify the request/response loop // works, including the content[0].text envelope. diff --git a/internal/cli/render.go b/internal/cli/render.go index 76221d2..36df78e 100644 --- a/internal/cli/render.go +++ b/internal/cli/render.go @@ -118,6 +118,24 @@ func renderResult(cmd *cobra.Command, command string, data any) error { case "project feature status", "project feature enable": m := data.(map[string]any) printBlock(out, "Feature", [][2]string{{"Feature", asString(m["feature"])}, {"Project", asString(m["projectName"])}, {"Status", asString(m["status"])}, {"Message", asString(m["message"])}}) + case "project webhook events": + printWebhookEvents(out, data.(map[string]any)) + case "project webhook list": + printWebhookList(out, data.(map[string]any)) + case "project webhook show", "project webhook update": + printWebhookBlock(out, data.(map[string]any)) + case "project webhook create": + printWebhookBlock(out, data.(map[string]any)) + fmt.Fprintln(out) + fmt.Fprintln(out, "Store this secret now. It may not be shown again.") + case "project webhook delete": + m := data.(map[string]any) + printBlock(out, "Webhook", [][2]string{ + {"Project", asString(m["projectName"])}, + {"Feature", asString(m["feature"])}, + {"Config ID", asString(m["configId"])}, + {"Deleted", asString(m["deleted"])}, + }) case "project list": m := data.(map[string]any) total, _ := m["total"].(int) @@ -221,6 +239,122 @@ func redactSensitive(v any) string { } } +func printWebhookEvents(out io.Writer, m map[string]any) { + printBlock(out, "Webhook Events", [][2]string{{"Feature", asString(m["feature"])}}) + if items, ok := m["items"].([]webhookEvent); ok && len(items) > 0 { + fmt.Fprintln(out) + for _, item := range items { + fmt.Fprintf(out, "- %s: %d (%s)\n", item.Key, item.ID, item.DisplayName) + } + } +} + +func printWebhookList(out io.Writer, m map[string]any) { + printBlock(out, "Webhooks", [][2]string{ + {"Project", asString(m["projectName"])}, + {"Feature", asString(m["feature"])}, + }) + if items, ok := m["items"].([]webhookConfig); ok && len(items) > 0 { + fmt.Fprintln(out) + for _, item := range items { + fmt.Fprintf(out, "- %d: %s (%s, enabled %s)\n", item.ConfigID, asString(item.URL), renderWebhookDeliveryRegion(item.URLRegion), asString(item.Enabled)) + } + } +} + +func printWebhookBlock(out io.Writer, m map[string]any) { + cfg, ok := m["config"].(webhookConfig) + if !ok { + cfg = webhookConfig{ + ConfigID: asInt(m["configId"]), + URL: asString(m["url"]), + Enabled: asBool(m["enabled"]), + Secret: asString(m["secret"]), + } + if value, _ := m["urlRegion"].(string); value != "" { + cfg.URLRegion = value + } + if events, ok := m["events"].([]webhookEvent); ok { + cfg.Events = events + } + if eventIDs, ok := m["eventIds"].([]int); ok { + cfg.EventIDs = eventIDs + } + if retry, ok := m["retry"].(*bool); ok { + cfg.Retry = retry + } + } + rows := [][2]string{ + {"Project", asString(m["projectName"])}, + {"Feature", asString(m["feature"])}, + {"Config ID", asString(cfg.ConfigID)}, + {"URL", asString(cfg.URL)}, + {"Events", webhookEventKeys(cfg.Events, cfg.EventIDs)}, + {"Delivery Region", renderWebhookDeliveryRegion(cfg.URLRegion)}, + {"Enabled", asString(cfg.Enabled)}, + } + if cfg.Retry != nil { + rows = append(rows, [2]string{"Retry", asString(*cfg.Retry)}) + } + rows = append(rows, [2]string{"Secret", asString(cfg.Secret)}) + printBlock(out, "Webhook", rows) +} + +func renderWebhookDeliveryRegion(value string) string { + switch strings.ToLower(strings.TrimSpace(value)) { + case "cn": + return "China (cn)" + case "sea": + return "Asia (sea)" + case "na": + return "North America (na)" + case "eu": + return "Europe (eu)" + default: + return asString(value) + } +} + +func webhookEventKeys(events []webhookEvent, eventIDs []int) string { + if len(events) > 0 { + keys := make([]string, 0, len(events)) + for _, event := range events { + if event.Key != "" { + keys = append(keys, event.Key) + continue + } + keys = append(keys, asString(event.ID)) + } + return strings.Join(keys, ", ") + } + if len(eventIDs) > 0 { + ids := make([]string, 0, len(eventIDs)) + for _, id := range eventIDs { + ids = append(ids, asString(id)) + } + return strings.Join(ids, ", ") + } + return "-" +} + +func asInt(v any) int { + switch x := v.(type) { + case int: + return x + case int64: + return int(x) + case float64: + return int(x) + default: + return 0 + } +} + +func asBool(v any) bool { + value, _ := v.(bool) + return value +} + // printBlock renders a key-value block with right-padded labels. An empty // title suppresses the header row, useful when stacking multiple blocks // under a single section. diff --git a/internal/cli/webhooks.go b/internal/cli/webhooks.go new file mode 100644 index 0000000..fa22ac2 --- /dev/null +++ b/internal/cli/webhooks.go @@ -0,0 +1,659 @@ +package cli + +import ( + "crypto/rand" + "encoding/base64" + "fmt" + "regexp" + "sort" + "strconv" + "strings" +) + +const redactedWebhookSecret = "********" + +var webhookSecretPattern = regexp.MustCompile(`^[A-Za-z0-9_-]{7,32}$`) +var webhookEventKeyInvalidChars = regexp.MustCompile(`[^a-z0-9]+`) + +type webhookEvent struct { + ID int `json:"id"` + Key string `json:"key"` + DisplayName string `json:"displayName"` + EventType int `json:"eventType"` + Payload string `json:"payload,omitempty"` +} + +type webhookConfig struct { + ConfigID int `json:"configId"` + URL string `json:"url"` + URLRegion string `json:"urlRegion"` + Enabled bool `json:"enabled"` + EventIDs []int `json:"eventIds"` + Events []webhookEvent `json:"events,omitempty"` + Retry *bool `json:"retry,omitempty"` + UseIPWhitelist bool `json:"useIpWhitelist"` + Secret string `json:"secret,omitempty"` +} + +type webhookCreateOptions struct { + Feature string + Project string + URL string + EventInputs []string + Secret string + DeliveryRegion string +} + +type webhookUpdateOptions struct { + ConfigID int + Feature string + Project string + URL string + EventInputs []string + DeliveryRegion string + Enabled *bool +} + +type ncsEventListResponse struct { + Items []ncsEvent `json:"items"` +} + +type ncsEvent struct { + EventID int `json:"eventId"` + DisplayName string `json:"displayName"` + DisplayNameCn string `json:"displayNameCn"` + EventType int `json:"eventType"` + Payload string `json:"payload"` +} + +type ncsConfigListResponse struct { + Items []ncsConfig `json:"items"` +} + +type ncsConfig struct { + ConfigID int `json:"configId"` + URL string `json:"url"` + URLRegion string `json:"urlRegion"` + Enabled bool `json:"enabled"` + EventIDs []int `json:"eventIds"` + Retry *bool `json:"retry"` + UseIPWhitelist bool `json:"useIpWhitelist"` + Secret string `json:"secret"` + CreatedAt string `json:"createdAt"` + UpdatedAt string `json:"updatedAt"` +} + +func webhookEventKey(displayName string) string { + key := strings.ToLower(strings.TrimSpace(displayName)) + key = webhookEventKeyInvalidChars.ReplaceAllString(key, "-") + return strings.Trim(key, "-") +} + +func normalizeWebhookEvents(resp ncsEventListResponse) []webhookEvent { + events := make([]webhookEvent, 0, len(resp.Items)) + for _, item := range resp.Items { + events = append(events, webhookEvent{ + ID: item.EventID, + Key: webhookEventKey(item.DisplayName), + DisplayName: item.DisplayName, + EventType: item.EventType, + Payload: item.Payload, + }) + } + return events +} + +func normalizeWebhookConfig(item ncsConfig, events []webhookEvent) webhookConfig { + eventsByID := make(map[int]webhookEvent, len(events)) + for _, event := range events { + eventsByID[event.ID] = event + } + + matchedEvents := make([]webhookEvent, 0, len(item.EventIDs)) + for _, id := range item.EventIDs { + if event, ok := eventsByID[id]; ok { + matchedEvents = append(matchedEvents, event) + } + } + + eventIDs := append([]int(nil), item.EventIDs...) + return webhookConfig{ + ConfigID: item.ConfigID, + URL: item.URL, + URLRegion: item.URLRegion, + Enabled: item.Enabled, + EventIDs: eventIDs, + Events: matchedEvents, + Retry: item.Retry, + UseIPWhitelist: item.UseIPWhitelist, + Secret: item.Secret, + } +} + +func redactWebhookConfigSecret(cfg webhookConfig, reveal bool) webhookConfig { + if reveal || cfg.Secret == "" { + return cfg + } + cfg.Secret = redactedWebhookSecret + return cfg +} + +func selectCreatedWebhookConfig(resp ncsConfigListResponse, url, urlRegion string, eventIDs []int, secret string) (ncsConfig, error) { + matchesRequestedShape := func(item ncsConfig) bool { + return item.URL == url && + item.URLRegion == urlRegion && + webhookIntSetEqual(item.EventIDs, eventIDs) + } + + if secret != "" { + if match, ok := bestWebhookConfigCandidate(resp.Items, func(item ncsConfig) bool { + return item.Secret == secret && matchesRequestedShape(item) + }); ok { + return match, nil + } + } + + if match, ok := bestWebhookConfigCandidate(resp.Items, matchesRequestedShape); ok { + return match, nil + } + + return ncsConfig{}, &cliError{ + Message: "created webhook config was not found in the API response.", + Code: "WEBHOOK_CONFIG_NOT_FOUND", + } +} + +func bestWebhookConfigCandidate(items []ncsConfig, matches func(ncsConfig) bool) (ncsConfig, bool) { + var best ncsConfig + found := false + for _, item := range items { + if !matches(item) { + continue + } + if !found || item.UpdatedAt > best.UpdatedAt || (item.UpdatedAt == best.UpdatedAt && item.ConfigID > best.ConfigID) { + best = item + found = true + } + } + return best, found +} + +func (a *App) listWebhookEvents(feature string) ([]webhookEvent, error) { + feature, err := normalizeWebhookFeature(feature) + if err != nil { + return nil, err + } + var out ncsEventListResponse + err = a.apiRequest("GET", "/api/cli/v1/ncs-events/"+feature, nil, nil, &out) + if err != nil { + return nil, err + } + return normalizeWebhookEvents(out), nil +} + +func (a *App) listWebhookConfigs(projectID, feature string) (ncsConfigListResponse, error) { + var out ncsConfigListResponse + err := a.apiRequest("GET", "/api/cli/v1/projects/"+projectID+"/ncs-configs/"+feature, nil, nil, &out) + return out, err +} + +func (a *App) createWebhookConfig(projectID, feature string, body map[string]any) (ncsConfigListResponse, error) { + var out ncsConfigListResponse + err := a.apiRequest("POST", "/api/cli/v1/projects/"+projectID+"/ncs-configs/"+feature, nil, body, &out) + return out, err +} + +func (a *App) updateWebhookConfig(projectID, feature string, configID int, body map[string]any) (ncsConfigListResponse, error) { + var out ncsConfigListResponse + err := a.apiRequest("PUT", "/api/cli/v1/projects/"+projectID+"/ncs-configs/"+feature+"/"+strconv.Itoa(configID), nil, body, &out) + return out, err +} + +func (a *App) deleteWebhookConfig(projectID, feature string, configID int) error { + out := map[string]any{} + return a.apiRequest("DELETE", "/api/cli/v1/projects/"+projectID+"/ncs-configs/"+feature+"/"+strconv.Itoa(configID), nil, nil, &out) +} + +func (a *App) projectWebhookEvents(feature string) (map[string]any, error) { + feature, err := normalizeWebhookFeature(feature) + if err != nil { + return nil, err + } + events, err := a.listWebhookEvents(feature) + if err != nil { + return nil, err + } + return map[string]any{ + "action": "webhook-events", + "feature": feature, + "items": events, + }, nil +} + +func (a *App) projectWebhookList(feature, project string, revealSecrets bool) (map[string]any, error) { + feature, err := normalizeWebhookFeature(feature) + if err != nil { + return nil, err + } + target, err := a.resolveProjectTarget(project) + if err != nil { + return nil, err + } + events, err := a.listWebhookEvents(feature) + if err != nil { + return nil, err + } + configs, err := a.listWebhookConfigs(target.project.ProjectID, feature) + if err != nil { + return nil, err + } + items := make([]webhookConfig, 0, len(configs.Items)) + for _, item := range configs.Items { + cfg := normalizeWebhookConfig(item, events) + items = append(items, redactWebhookConfigSecret(cfg, revealSecrets)) + } + return map[string]any{ + "action": "webhook-list", + "events": events, + "feature": feature, + "items": items, + "projectId": target.project.ProjectID, + "projectName": target.project.Name, + }, nil +} + +func (a *App) projectWebhookShow(configID int, feature, project string, withSecret bool) (map[string]any, error) { + if err := validateWebhookConfigID(configID); err != nil { + return nil, err + } + feature, err := normalizeWebhookFeature(feature) + if err != nil { + return nil, err + } + target, err := a.resolveProjectTarget(project) + if err != nil { + return nil, err + } + events, err := a.listWebhookEvents(feature) + if err != nil { + return nil, err + } + configs, err := a.listWebhookConfigs(target.project.ProjectID, feature) + if err != nil { + return nil, err + } + item, err := findNCSConfigByID(configs.Items, configID) + if err != nil { + return nil, err + } + cfg := redactWebhookConfigSecret(normalizeWebhookConfig(item, events), withSecret) + return webhookConfigResult("webhook-show", target, feature, cfg), nil +} + +func (a *App) projectWebhookCreate(opts webhookCreateOptions) (map[string]any, error) { + feature, err := normalizeWebhookFeature(opts.Feature) + if err != nil { + return nil, err + } + url := strings.TrimSpace(opts.URL) + if url == "" { + return nil, &cliError{Message: "webhook URL is required", Code: "WEBHOOK_URL_REQUIRED"} + } + eventInputs := nonEmptyWebhookEventInputs(opts.EventInputs) + if len(eventInputs) == 0 { + return nil, &cliError{Message: "at least one webhook event is required", Code: "WEBHOOK_EVENTS_REQUIRED"} + } + target, err := a.resolveProjectTarget(opts.Project) + if err != nil { + return nil, err + } + events, err := a.listWebhookEvents(feature) + if err != nil { + return nil, err + } + eventIDs, err := resolveWebhookEventIDs(events, eventInputs, feature) + if err != nil { + return nil, err + } + secret := strings.TrimSpace(opts.Secret) + if secret == "" { + secret, err = generateWebhookSecret() + if err != nil { + return nil, err + } + } + if err := validateWebhookSecret(secret); err != nil { + return nil, err + } + urlRegion := "" + if strings.TrimSpace(opts.DeliveryRegion) != "" { + urlRegion, err = normalizeWebhookDeliveryRegion(opts.DeliveryRegion) + if err != nil { + return nil, err + } + } else { + urlRegion = defaultWebhookDeliveryRegion(target.region) + } + body := map[string]any{ + "enabled": true, + "eventIds": eventIDs, + "secret": secret, + "url": url, + "urlRegion": urlRegion, + "useIpWhitelist": false, + } + resp, err := a.createWebhookConfig(target.project.ProjectID, feature, body) + if err != nil { + return nil, err + } + item, err := selectCreatedWebhookConfig(resp, url, urlRegion, eventIDs, secret) + if err != nil { + return nil, err + } + cfg := normalizeWebhookConfig(item, events) + return webhookConfigResult("webhook-create", target, feature, cfg), nil +} + +func (a *App) projectWebhookUpdate(opts webhookUpdateOptions) (map[string]any, error) { + if err := validateWebhookConfigID(opts.ConfigID); err != nil { + return nil, err + } + feature, err := normalizeWebhookFeature(opts.Feature) + if err != nil { + return nil, err + } + target, err := a.resolveProjectTarget(opts.Project) + if err != nil { + return nil, err + } + events, err := a.listWebhookEvents(feature) + if err != nil { + return nil, err + } + configs, err := a.listWebhookConfigs(target.project.ProjectID, feature) + if err != nil { + return nil, err + } + existing, err := findNCSConfigByID(configs.Items, opts.ConfigID) + if err != nil { + return nil, err + } + + url := existing.URL + if strings.TrimSpace(opts.URL) != "" { + url = strings.TrimSpace(opts.URL) + } + urlRegion := existing.URLRegion + if strings.TrimSpace(opts.DeliveryRegion) != "" { + urlRegion, err = normalizeWebhookDeliveryRegion(opts.DeliveryRegion) + if err != nil { + return nil, err + } + } + enabled := existing.Enabled + if opts.Enabled != nil { + enabled = *opts.Enabled + } + eventIDs := append([]int(nil), existing.EventIDs...) + if len(opts.EventInputs) > 0 { + eventInputs := nonEmptyWebhookEventInputs(opts.EventInputs) + if len(eventInputs) == 0 { + return nil, &cliError{Message: "at least one webhook event is required", Code: "WEBHOOK_EVENTS_REQUIRED"} + } + eventIDs, err = resolveWebhookEventIDs(events, eventInputs, feature) + if err != nil { + return nil, err + } + } + + body := map[string]any{ + "enabled": enabled, + "eventIds": eventIDs, + "url": url, + "urlRegion": urlRegion, + "useIpWhitelist": existing.UseIPWhitelist, + } + resp, err := a.updateWebhookConfig(target.project.ProjectID, feature, opts.ConfigID, body) + if err != nil { + return nil, err + } + item, err := findNCSConfigByID(resp.Items, opts.ConfigID) + if err != nil { + return nil, err + } + cfg := redactWebhookConfigSecret(normalizeWebhookConfig(item, events), false) + return webhookConfigResult("webhook-update", target, feature, cfg), nil +} + +func (a *App) projectWebhookDelete(configID int, feature, project string) (map[string]any, error) { + if err := validateWebhookConfigID(configID); err != nil { + return nil, err + } + feature, err := normalizeWebhookFeature(feature) + if err != nil { + return nil, err + } + target, err := a.resolveProjectTarget(project) + if err != nil { + return nil, err + } + if err := a.deleteWebhookConfig(target.project.ProjectID, feature, configID); err != nil { + return nil, err + } + return map[string]any{ + "action": "webhook-delete", + "configId": configID, + "deleted": true, + "feature": feature, + "projectId": target.project.ProjectID, + "projectName": target.project.Name, + }, nil +} + +func validateWebhookFeature(feature string) error { + _, err := normalizeWebhookFeature(feature) + return err +} + +func normalizeWebhookFeature(feature string) (string, error) { + feature = strings.TrimSpace(feature) + if feature == "" { + return "", &cliError{Message: "webhook feature is required", Code: "WEBHOOK_FEATURE_REQUIRED"} + } + if !isKnownFeature(feature) { + return "", &cliError{ + Message: fmt.Sprintf("invalid webhook feature %q. Choose one of: %s.", feature, featureListString()), + Code: "WEBHOOK_FEATURE_INVALID", + } + } + return feature, nil +} + +func validateWebhookConfigID(configID int) error { + if configID <= 0 { + return &cliError{Message: "webhook config ID is required", Code: "WEBHOOK_CONFIG_ID_REQUIRED"} + } + return nil +} + +func normalizeWebhookDeliveryRegion(value string) (string, error) { + region := strings.ToLower(strings.TrimSpace(value)) + switch region { + case "cn", "sea", "na", "eu": + return region, nil + default: + return "", &cliError{ + Message: fmt.Sprintf("invalid webhook delivery region %q. Choose one of: cn, sea, na, eu.", value), + Code: "WEBHOOK_DELIVERY_REGION_INVALID", + } + } +} + +func defaultWebhookDeliveryRegion(controlPlaneRegion string) string { + if strings.ToLower(strings.TrimSpace(controlPlaneRegion)) == "cn" { + return "cn" + } + return "na" +} + +func generateWebhookSecret() (string, error) { + raw := make([]byte, 24) + if _, err := rand.Read(raw); err != nil { + return "", err + } + return base64.RawURLEncoding.EncodeToString(raw), nil +} + +func validateWebhookSecret(secret string) error { + if webhookSecretPattern.MatchString(secret) { + return nil + } + return &cliError{ + Message: "webhook secret must be 7-32 URL-safe characters.", + Code: "WEBHOOK_SECRET_INVALID", + } +} + +func webhookIntSlicesEqual(a, b []int) bool { + if len(a) != len(b) { + return false + } + for i := range a { + if a[i] != b[i] { + return false + } + } + return true +} + +func webhookIntSetEqual(a, b []int) bool { + if len(a) != len(b) { + return false + } + sortedA := append([]int(nil), a...) + sortedB := append([]int(nil), b...) + sort.Ints(sortedA) + sort.Ints(sortedB) + return webhookIntSlicesEqual(sortedA, sortedB) +} + +func resolveWebhookEventIDs(events []webhookEvent, inputs []string, feature string) ([]int, error) { + byID := make(map[int]webhookEvent, len(events)) + byKey := make(map[string][]webhookEvent, len(events)) + byDisplayName := make(map[string][]webhookEvent, len(events)) + for _, event := range events { + byID[event.ID] = event + if event.Key != "" { + byKey[event.Key] = append(byKey[event.Key], event) + } + byDisplayName[event.DisplayName] = append(byDisplayName[event.DisplayName], event) + } + + selected := make(map[int]struct{}, len(inputs)) + for _, input := range inputs { + value := strings.TrimSpace(input) + if value == "" { + continue + } + if id, err := strconv.Atoi(value); err == nil { + if _, ok := byID[id]; !ok { + return nil, unknownWebhookEventError(input, feature) + } + selected[id] = struct{}{} + continue + } + if matches := byKey[value]; len(matches) > 0 { + ids := uniqueWebhookEventIDs(matches) + if len(ids) > 1 { + return nil, &cliError{ + Message: fmt.Sprintf("webhook event %q is ambiguous. Use the numeric event ID instead.", input), + Code: "WEBHOOK_EVENT_AMBIGUOUS", + } + } + selected[ids[0]] = struct{}{} + continue + } + if matches := byDisplayName[value]; len(matches) > 0 { + ids := uniqueWebhookEventIDs(matches) + if len(ids) > 1 { + return nil, &cliError{ + Message: fmt.Sprintf("webhook event %q is ambiguous. Use the numeric event ID instead.", input), + Code: "WEBHOOK_EVENT_AMBIGUOUS", + } + } + selected[ids[0]] = struct{}{} + continue + } + return nil, unknownWebhookEventError(input, feature) + } + + out := make([]int, 0, len(selected)) + for id := range selected { + out = append(out, id) + } + sort.Ints(out) + return out, nil +} + +func uniqueWebhookEventIDs(events []webhookEvent) []int { + seen := make(map[int]struct{}, len(events)) + ids := make([]int, 0, len(events)) + for _, event := range events { + if _, ok := seen[event.ID]; ok { + continue + } + seen[event.ID] = struct{}{} + ids = append(ids, event.ID) + } + return ids +} + +func unknownWebhookEventError(input string, feature string) error { + return &cliError{ + Message: fmt.Sprintf("unknown webhook event %q. Run `agora project webhook events --feature %s` to see available events.", input, strings.TrimSpace(feature)), + Code: "WEBHOOK_EVENT_UNKNOWN", + } +} + +func findNCSConfigByID(items []ncsConfig, configID int) (ncsConfig, error) { + for _, item := range items { + if item.ConfigID == configID { + return item, nil + } + } + return ncsConfig{}, &cliError{ + Message: fmt.Sprintf("webhook config %d was not found.", configID), + Code: "WEBHOOK_CONFIG_NOT_FOUND", + } +} + +func nonEmptyWebhookEventInputs(inputs []string) []string { + out := make([]string, 0, len(inputs)) + for _, input := range inputs { + for _, part := range strings.Split(input, ",") { + if trimmed := strings.TrimSpace(part); trimmed != "" { + out = append(out, trimmed) + } + } + } + return out +} + +func webhookConfigResult(action string, target projectTarget, feature string, cfg webhookConfig) map[string]any { + return map[string]any{ + "action": action, + "config": cfg, + "configId": cfg.ConfigID, + "enabled": cfg.Enabled, + "eventIds": cfg.EventIDs, + "events": cfg.Events, + "feature": feature, + "projectId": target.project.ProjectID, + "projectName": target.project.Name, + "retry": cfg.Retry, + "secret": cfg.Secret, + "url": cfg.URL, + "urlRegion": cfg.URLRegion, + "useIpWhitelist": cfg.UseIPWhitelist, + } +} diff --git a/internal/cli/webhooks_test.go b/internal/cli/webhooks_test.go new file mode 100644 index 0000000..41e4abe --- /dev/null +++ b/internal/cli/webhooks_test.go @@ -0,0 +1,368 @@ +package cli + +import ( + "errors" + "reflect" + "strings" + "testing" +) + +func TestWebhookEventKeyFromDisplayName(t *testing.T) { + tests := []struct { + name string + displayName string + want string + }{ + {name: "lowercases and hyphenates words", displayName: "User Joined", want: "user-joined"}, + {name: "collapses invalid runs", displayName: " RTC: User_JOINED!!! ", want: "rtc-user-joined"}, + {name: "trims generated separators", displayName: "---Recording Started---", want: "recording-started"}, + {name: "drops non ascii separators", displayName: "中文 Event", want: "event"}, + {name: "all invalid becomes empty", displayName: "!!!", want: ""}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := webhookEventKey(tt.displayName) + if got != tt.want { + t.Fatalf("webhookEventKey(%q) = %q, want %q", tt.displayName, got, tt.want) + } + }) + } +} + +func TestNormalizeWebhookEventsIgnoresChineseDisplayName(t *testing.T) { + resp := ncsEventListResponse{ + Items: []ncsEvent{ + {EventID: 1001, DisplayName: "Channel Created", DisplayNameCn: "频道创建", EventType: 7, Payload: `{"x":1}`}, + }, + } + + got := normalizeWebhookEvents(resp) + want := []webhookEvent{ + {ID: 1001, Key: "channel-created", DisplayName: "Channel Created", EventType: 7, Payload: `{"x":1}`}, + } + if !reflect.DeepEqual(got, want) { + t.Fatalf("normalizeWebhookEvents() = %#v, want %#v", got, want) + } +} + +func TestNormalizeWebhookConfigMapsKnownEventsOnly(t *testing.T) { + retry := true + events := []webhookEvent{ + {ID: 1001, Key: "channel-created", DisplayName: "Channel Created"}, + {ID: 1002, Key: "channel-deleted", DisplayName: "Channel Deleted"}, + } + item := ncsConfig{ + ConfigID: 7, + URL: "https://example.com/hook", + URLRegion: "na", + Enabled: true, + EventIDs: []int{1002, 9999, 1001}, + Retry: &retry, + UseIPWhitelist: true, + Secret: "secret_123", + } + + got := normalizeWebhookConfig(item, events) + if got.ConfigID != item.ConfigID || got.URL != item.URL || got.URLRegion != item.URLRegion || !got.Enabled || got.Retry != &retry || !got.UseIPWhitelist || got.Secret != item.Secret { + t.Fatalf("normalizeWebhookConfig() did not copy stable fields: %#v", got) + } + if !reflect.DeepEqual(got.EventIDs, item.EventIDs) { + t.Fatalf("EventIDs = %v, want %v", got.EventIDs, item.EventIDs) + } + wantEvents := []webhookEvent{events[1], events[0]} + if !reflect.DeepEqual(got.Events, wantEvents) { + t.Fatalf("Events = %#v, want %#v", got.Events, wantEvents) + } +} + +func TestSelectWebhookConfigFromCreateResponsePrefersSecret(t *testing.T) { + resp := ncsConfigListResponse{ + Items: []ncsConfig{ + {ConfigID: 17, URL: "https://example.com/hook", URLRegion: "na", EventIDs: []int{1001}, Secret: "other_secret"}, + {ConfigID: 42, URL: "https://example.com/hook", URLRegion: "na", EventIDs: []int{1001}, Secret: "secret_123"}, + }, + } + + got, err := selectCreatedWebhookConfig(resp, "https://example.com/hook", "na", []int{1001}, "secret_123") + if err != nil { + t.Fatal(err) + } + if got.ConfigID != 42 { + t.Fatalf("selected configId = %d, want 42", got.ConfigID) + } +} + +func TestSelectWebhookConfigDoesNotSelectSecretWithWrongShape(t *testing.T) { + resp := ncsConfigListResponse{ + Items: []ncsConfig{ + {ConfigID: 99, URL: "https://old.example.com/hook", URLRegion: "eu", EventIDs: []int{1002}, Secret: "secret_123", UpdatedAt: "2026-01-04T00:00:00Z"}, + {ConfigID: 42, URL: "https://example.com/hook", URLRegion: "na", EventIDs: []int{1001}, Secret: "secret_123", UpdatedAt: "2026-01-03T00:00:00Z"}, + }, + } + + got, err := selectCreatedWebhookConfig(resp, "https://example.com/hook", "na", []int{1001}, "secret_123") + if err != nil { + t.Fatal(err) + } + if got.ConfigID != 42 { + t.Fatalf("selected configId = %d, want 42", got.ConfigID) + } +} + +func TestSelectWebhookConfigFallbackMatchesEventIDsAsSet(t *testing.T) { + resp := ncsConfigListResponse{ + Items: []ncsConfig{ + {ConfigID: 42, URL: "https://example.com/hook", URLRegion: "na", EventIDs: []int{1002, 1001}}, + }, + } + + got, err := selectCreatedWebhookConfig(resp, "https://example.com/hook", "na", []int{1001, 1002}, "") + if err != nil { + t.Fatal(err) + } + if got.ConfigID != 42 { + t.Fatalf("selected configId = %d, want 42", got.ConfigID) + } +} + +func TestSelectWebhookConfigFallbackPicksNewestThenHighestID(t *testing.T) { + resp := ncsConfigListResponse{ + Items: []ncsConfig{ + {ConfigID: 17, URL: "https://example.com/hook", URLRegion: "na", EventIDs: []int{1001}, UpdatedAt: "2026-01-02T00:00:00Z"}, + {ConfigID: 42, URL: "https://example.com/hook", URLRegion: "na", EventIDs: []int{1001}, UpdatedAt: "2026-01-03T00:00:00Z"}, + {ConfigID: 50, URL: "https://example.com/hook", URLRegion: "na", EventIDs: []int{1001}, UpdatedAt: "2026-01-03T00:00:00Z"}, + {ConfigID: 99, URL: "https://example.com/hook", URLRegion: "na", EventIDs: []int{1001, 1002}, UpdatedAt: "2026-01-04T00:00:00Z"}, + }, + } + + got, err := selectCreatedWebhookConfig(resp, "https://example.com/hook", "na", []int{1001}, "") + if err != nil { + t.Fatal(err) + } + if got.ConfigID != 50 { + t.Fatalf("selected configId = %d, want 50", got.ConfigID) + } +} + +func TestSelectWebhookConfigReturnsNotFound(t *testing.T) { + _, err := selectCreatedWebhookConfig(ncsConfigListResponse{ + Items: []ncsConfig{ + {ConfigID: 17, URL: "https://example.com/hook", URLRegion: "eu", EventIDs: []int{1001}}, + }, + }, "https://example.com/hook", "na", []int{1001}, "") + if !hasCLIErrorCode(err, "WEBHOOK_CONFIG_NOT_FOUND") { + t.Fatalf("expected WEBHOOK_CONFIG_NOT_FOUND, got %T %v", err, err) + } +} + +func TestRedactWebhookConfigSecret(t *testing.T) { + cfg := webhookConfig{ConfigID: 42, Secret: "secret_123"} + + redacted := redactWebhookConfigSecret(cfg, false) + if redacted.Secret != redactedWebhookSecret { + t.Fatalf("redacted secret = %q, want %q", redacted.Secret, redactedWebhookSecret) + } + + revealed := redactWebhookConfigSecret(cfg, true) + if revealed.Secret != cfg.Secret { + t.Fatalf("revealed secret = %q, want %q", revealed.Secret, cfg.Secret) + } +} + +func TestResolveWebhookEventInputs(t *testing.T) { + events := []webhookEvent{ + {ID: 30, Key: "channel-user-left", DisplayName: "Channel User Left"}, + {ID: 10, Key: "channel-user-joined", DisplayName: "Channel User Joined"}, + {ID: 20, Key: "recording_started", DisplayName: "Recording Started"}, + } + + got, err := resolveWebhookEventIDs(events, []string{ + "channel-user-joined", + "30", + "Recording Started", + "10", + }, "rtc") + if err != nil { + t.Fatal(err) + } + want := []int{10, 20, 30} + if !reflect.DeepEqual(got, want) { + t.Fatalf("resolved event IDs = %v, want %v", got, want) + } +} + +func TestResolveWebhookEventInputsRejectsUnknownAndAmbiguous(t *testing.T) { + events := []webhookEvent{ + {ID: 10, Key: "channel-user-joined", DisplayName: "Channel User Joined"}, + {ID: 20, Key: "recording-started", DisplayName: "Recording Started"}, + {ID: 30, Key: "recording-started", DisplayName: "Recording Started Duplicate"}, + } + + _, err := resolveWebhookEventIDs(events, []string{"not-real"}, "rtc") + if !hasCLIErrorCode(err, "WEBHOOK_EVENT_UNKNOWN") { + t.Fatalf("expected WEBHOOK_EVENT_UNKNOWN, got %T %v", err, err) + } + if !strings.Contains(err.Error(), "agora project webhook events --feature rtc") { + t.Fatalf("expected list suggestion in error, got %q", err.Error()) + } + + _, err = resolveWebhookEventIDs(events, []string{"recording-started"}, "rtc") + if !hasCLIErrorCode(err, "WEBHOOK_EVENT_AMBIGUOUS") { + t.Fatalf("expected WEBHOOK_EVENT_AMBIGUOUS, got %T %v", err, err) + } + if !strings.Contains(strings.ToLower(err.Error()), "numeric event id") { + t.Fatalf("expected numeric event ID guidance, got %q", err.Error()) + } +} + +func TestWebhookResolveEventInputsIgnoresEmptyValues(t *testing.T) { + events := []webhookEvent{ + {ID: 10, Key: "channel-user-joined", DisplayName: "Channel User Joined"}, + } + + got, err := resolveWebhookEventIDs(events, []string{"", " ", "channel-user-joined"}, "rtc") + if err != nil { + t.Fatal(err) + } + want := []int{10} + if !reflect.DeepEqual(got, want) { + t.Fatalf("resolved event IDs = %v, want %v", got, want) + } +} + +func TestNonEmptyWebhookEventInputsSplitsCommaSeparatedValues(t *testing.T) { + got := nonEmptyWebhookEventInputs([]string{"1001, 1002,, channel-created ", " "}) + want := []string{"1001", "1002", "channel-created"} + if !reflect.DeepEqual(got, want) { + t.Fatalf("nonEmptyWebhookEventInputs() = %v, want %v", got, want) + } +} + +func TestWebhookResolveEventInputsDoesNotUseGeneratedDisplayNameKey(t *testing.T) { + events := []webhookEvent{ + {ID: 10, Key: "backend-event-key", DisplayName: "Display Name"}, + } + + _, err := resolveWebhookEventIDs(events, []string{"display-name"}, "rtc") + if !hasCLIErrorCode(err, "WEBHOOK_EVENT_UNKNOWN") { + t.Fatalf("expected WEBHOOK_EVENT_UNKNOWN, got %T %v", err, err) + } + + got, err := resolveWebhookEventIDs(events, []string{"Display Name"}, "rtc") + if err != nil { + t.Fatal(err) + } + want := []int{10} + if !reflect.DeepEqual(got, want) { + t.Fatalf("resolved event IDs = %v, want %v", got, want) + } +} + +func TestWebhookResolveEventInputsRejectsAmbiguousDisplayName(t *testing.T) { + events := []webhookEvent{ + {ID: 10, Key: "first-key", DisplayName: "Recording Finished"}, + {ID: 20, Key: "second-key", DisplayName: "Recording Finished"}, + } + + _, err := resolveWebhookEventIDs(events, []string{"Recording Finished"}, "rtc") + if !hasCLIErrorCode(err, "WEBHOOK_EVENT_AMBIGUOUS") { + t.Fatalf("expected WEBHOOK_EVENT_AMBIGUOUS, got %T %v", err, err) + } + if !strings.Contains(strings.ToLower(err.Error()), "numeric event id") { + t.Fatalf("expected numeric event ID guidance, got %q", err.Error()) + } +} + +func TestGenerateWebhookSecretMatchesBackendPattern(t *testing.T) { + secret, err := generateWebhookSecret() + if err != nil { + t.Fatal(err) + } + if len(secret) != 32 { + t.Fatalf("secret length = %d, want 32", len(secret)) + } + if !webhookSecretPattern.MatchString(secret) { + t.Fatalf("secret %q does not match backend pattern", secret) + } + if err := validateWebhookSecret(secret); err != nil { + t.Fatalf("generated secret did not validate: %v", err) + } +} + +func TestWebhookDeliveryRegionDefault(t *testing.T) { + tests := []struct { + controlPlaneRegion string + want string + }{ + {controlPlaneRegion: "cn", want: "cn"}, + {controlPlaneRegion: "global", want: "na"}, + {controlPlaneRegion: "", want: "na"}, + {controlPlaneRegion: "eu", want: "na"}, + } + + for _, tt := range tests { + t.Run(tt.controlPlaneRegion, func(t *testing.T) { + got := defaultWebhookDeliveryRegion(tt.controlPlaneRegion) + if got != tt.want { + t.Fatalf("defaultWebhookDeliveryRegion(%q) = %q, want %q", tt.controlPlaneRegion, got, tt.want) + } + }) + } +} + +func TestValidateWebhookFeature(t *testing.T) { + if err := validateWebhookFeature("rtc"); err != nil { + t.Fatalf("expected known feature to validate, got %v", err) + } + if err := validateWebhookFeature(""); !hasCLIErrorCode(err, "WEBHOOK_FEATURE_REQUIRED") { + t.Fatalf("expected WEBHOOK_FEATURE_REQUIRED, got %T %v", err, err) + } + if err := validateWebhookFeature("unknown"); err == nil || hasCLIErrorCode(err, "WEBHOOK_FEATURE_REQUIRED") { + t.Fatalf("expected validateFeatureID error for unknown feature, got %T %v", err, err) + } +} + +func TestValidateWebhookSecret(t *testing.T) { + if err := validateWebhookSecret("abc_DEF-123"); err != nil { + t.Fatalf("expected valid secret, got %v", err) + } + for _, secret := range []string{"", "short", strings.Repeat("a", 33), "has spaces"} { + err := validateWebhookSecret(secret) + if !hasCLIErrorCode(err, "WEBHOOK_SECRET_INVALID") { + t.Fatalf("validateWebhookSecret(%q) expected WEBHOOK_SECRET_INVALID, got %T %v", secret, err, err) + } + } +} + +func TestValidateWebhookDeliveryRegion(t *testing.T) { + for _, input := range []string{"cn", " SEA ", "na", "EU"} { + got, err := normalizeWebhookDeliveryRegion(input) + if err != nil { + t.Fatalf("normalizeWebhookDeliveryRegion(%q): %v", input, err) + } + if got != strings.ToLower(strings.TrimSpace(input)) { + t.Fatalf("normalizeWebhookDeliveryRegion(%q) = %q", input, got) + } + } + if _, err := normalizeWebhookDeliveryRegion("global"); !hasCLIErrorCode(err, "WEBHOOK_DELIVERY_REGION_INVALID") { + t.Fatalf("expected WEBHOOK_DELIVERY_REGION_INVALID, got %T %v", err, err) + } +} + +func TestWebhookIntSlicesEqual(t *testing.T) { + if !webhookIntSlicesEqual([]int{1, 2}, []int{1, 2}) { + t.Fatal("expected equal slices") + } + if webhookIntSlicesEqual([]int{1, 2}, []int{2, 1}) { + t.Fatal("expected order-sensitive mismatch") + } + if webhookIntSlicesEqual([]int{1}, []int{1, 2}) { + t.Fatal("expected length mismatch") + } +} + +func hasCLIErrorCode(err error, code string) bool { + var structured *cliError + return errors.As(err, &structured) && structured.Code == code +}