From 9b3cae4adcc4ae38df9541a225114808342bac52 Mon Sep 17 00:00:00 2001 From: plutoless Date: Sun, 7 Jun 2026 01:44:33 -0700 Subject: [PATCH 01/31] docs: add project webhook cli design --- .../2026-06-07-project-webhook-design.md | 285 ++++++++++++++++++ 1 file changed, 285 insertions(+) create mode 100644 docs/superpowers/specs/2026-06-07-project-webhook-design.md 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..48d11c3 --- /dev/null +++ b/docs/superpowers/specs/2026-06-07-project-webhook-design.md @@ -0,0 +1,285 @@ +# 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/?productKey={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 event names 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 +agora project webhook list [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 ...] [--secret ] [--delivery-region ] [--enabled | --disabled] [--project ] +agora project webhook delete --feature [--project ] [--yes] +``` + +`events ` is a discovery command. It fetches available webhook events for the feature and displays event names, IDs, and descriptions so developers do not need to guess backend event IDs. + +Example flow: + +```bash +agora project webhook events rtc +agora project webhook create \ + --feature rtc \ + --url https://example.com/webhook \ + --event channel.created \ + --event channel.destroyed +``` + +Event input rules: + +- `--event` accepts event names as the preferred path. +- Numeric event IDs are accepted as an escape hatch. +- `create` requires at least one event. +- `update` only replaces event selections when at least one `--event` is provided. +- Unknown event names return a helpful error suggesting `agora project webhook events `. + +## 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 + +`create` generates a secure random secret when `--secret` is omitted. The generated value uses the prefix `whsec_` followed by 32 random bytes encoded with base64url without padding. `--secret ` overrides the generated value. + +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 --secret ` reports `secretUpdated: true` and does not echo the value by default. + +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"` + Name string `json:"name"` + Description string `json:"description,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 names. + +`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 adapter normalizes backend events into `id`, `name`, and optional `description`. The CLI contract is these normalized names, not the raw backend field names. + +## 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` + +Example create data: + +```json +{ + "action": "webhook-create", + "projectId": "prj_123", + "projectName": "my-agent-demo", + "feature": "rtc", + "configId": 42, + "status": "enabled", + "url": "https://example.com/webhook", + "urlRegion": "na", + "eventIds": [1001], + "events": [ + { + "id": 1001, + "name": "channel.created", + "description": "Fired when an RTC channel is created" + } + ], + "retry": true, + "useIpWhitelist": false, + "secret": "whsec_example" +} +``` + +Safe branch fields for automation: + +- `projectId` +- `feature` +- `configId` +- `status` +- `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 name | `WEBHOOK_EVENT_UNKNOWN` | +| Duplicate or ambiguous event name | `WEBHOOK_EVENT_AMBIGUOUS` | +| Invalid delivery region | `WEBHOOK_DELIVERY_REGION_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 whsec_... +``` + +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 names, config IDs, URL, delivery region, and project. Secrets follow the same redaction and explicit reveal rules as CLI JSON output. + +## Implementation Plan Outline + +1. Add webhook constants and validation helpers: + - supported delivery regions + - control-plane to delivery-region default mapping + - secure secret generation + - event name/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 rtc --json` returns named events. +- `webhook create` with event names resolves IDs and generates a secret. +- `webhook create --secret` forwards explicit secret. +- `webhook create` defaults delivery region to `na` for `global` project context. +- `webhook create` defaults delivery region to `cn` for `cn` project context. +- `webhook list` redacts secret by default. +- `webhook show --with-secret` reveals secret when backend returns it. +- `webhook update` updates URL, events, delivery region, enabled state, and secret. +- `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-name resolution, unknown event suggestions, and ambiguous names +- generated secret format and entropy length +- redaction behavior From 0e955c6b94165dc55cf9d9ddf94d60bf0bea05fa Mon Sep 17 00:00:00 2001 From: plutoless Date: Sun, 7 Jun 2026 02:24:15 -0700 Subject: [PATCH 02/31] docs: address webhook design review --- .../2026-06-07-project-webhook-design.md | 100 +++++++++++++----- 1 file changed, 74 insertions(+), 26 deletions(-) diff --git a/docs/superpowers/specs/2026-06-07-project-webhook-design.md b/docs/superpowers/specs/2026-06-07-project-webhook-design.md index 48d11c3..41941cf 100644 --- a/docs/superpowers/specs/2026-06-07-project-webhook-design.md +++ b/docs/superpowers/specs/2026-06-07-project-webhook-design.md @@ -10,7 +10,7 @@ Relevant backend endpoints from the Apifox NCS docs: | CLI need | Backend endpoint | | --- | --- | -| List webhook events | `GET /api/cli/v1/ncs-events/?productKey={feature}` | +| 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}` | @@ -21,7 +21,7 @@ Backend `productKey` is an internal implementation detail. For v1, supported pub ## Goals - Add `agora project webhook` commands for event discovery, list, show, create, update, and delete. -- Let developers select webhook events by readable event names instead of requiring numeric event IDs. +- 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. @@ -42,12 +42,12 @@ Add the command group under `project`: agora project webhook events agora project webhook list [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 ...] [--secret ] [--delivery-region ] [--enabled | --disabled] [--project ] +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] ``` -`events ` is a discovery command. It fetches available webhook events for the feature and displays event names, IDs, and descriptions so developers do not need to guess backend event IDs. +`events ` is a discovery command. It fetches available webhook events for the feature and displays CLI event keys, backend event IDs, backend event types, display names, Chinese display names, and payload examples so developers do not need to guess backend event IDs. Example flow: @@ -56,17 +56,20 @@ agora project webhook events rtc agora project webhook create \ --feature rtc \ --url https://example.com/webhook \ - --event channel.created \ - --event channel.destroyed + --event channel-created \ + --event channel-destroyed ``` Event input rules: -- `--event` accepts event names as the preferred path. +- `--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 names return a helpful error suggesting `agora project webhook events `. +- Unknown event keys return a helpful error suggesting `agora project webhook events `. + +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 @@ -90,7 +93,7 @@ Pretty output labels this field `Delivery Region`. JSON output uses `urlRegion` ## Secret Handling -`create` generates a secure random secret when `--secret` is omitted. The generated value uses the prefix `whsec_` followed by 32 random bytes encoded with base64url without padding. `--secret ` overrides the generated value. +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: @@ -98,7 +101,7 @@ Secret output rules: - `list` redacts secrets by default. - `show` redacts secrets by default. - `show --with-secret` reveals the secret if the backend returns it. -- `update --secret ` reports `secretUpdated: true` and does not echo the value by default. +- 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. @@ -110,9 +113,12 @@ Normalized CLI event shape: ```go type webhookEvent struct { - ID int `json:"id"` - Name string `json:"name"` - Description string `json:"description,omitempty"` + ID int `json:"id"` + Key string `json:"key"` + DisplayName string `json:"displayName"` + DisplayNameCn string `json:"displayNameCn,omitempty"` + EventType int `json:"eventType"` + Payload string `json:"payload,omitempty"` } ``` @@ -136,7 +142,38 @@ The backend response may include additional fields such as `appId`, `productId`, `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 adapter normalizes backend events into `id`, `name`, and optional `description`. The CLI contract is these normalized names, not the raw backend field names. +The event API returns an `items` array. Each backend event has `eventId`, `displayName`, `displayNameCn`, `eventType`, and `payload`. 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. + +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 requested URL, delivery region, event IDs, and secret. If multiple items match, choose the newest item by `updatedAt` when present, otherwise the highest `configId`. ## JSON Contract @@ -165,13 +202,16 @@ Example create data: "events": [ { "id": 1001, - "name": "channel.created", - "description": "Fired when an RTC channel is created" + "key": "channel-created", + "displayName": "Channel Created", + "displayNameCn": "频道创建", + "eventType": 1, + "payload": "{...}" } ], "retry": true, "useIpWhitelist": false, - "secret": "whsec_example" + "secret": "pUkA4FzTdI8iGtLA6m3o2qR9x_Nb7sYc" } ``` @@ -195,6 +235,7 @@ Use existing envelope behavior and classify errors where the CLI can provide sta | Unknown event name | `WEBHOOK_EVENT_UNKNOWN` | | Duplicate or ambiguous event name | `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` | @@ -218,11 +259,11 @@ Webhook Feature rtc Config ID 42 URL https://example.com/webhook - Events channel.created, channel.destroyed + Events channel-created, channel-destroyed Delivery Region North America (na) Enabled true Retry true - Secret whsec_... + Secret pUkA4FzTdI8iGtLA6m3o2qR9x_Nb7sYc ``` For create, print a short note after the block: @@ -244,7 +285,7 @@ Add MCP tools for agent workflows because existing project feature/env/init comm - `agora.project.webhook.update` - `agora.project.webhook.delete` -MCP inputs use feature names, event names, config IDs, URL, delivery region, and project. Secrets follow the same redaction and explicit reveal rules as CLI JSON output. +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. ## Implementation Plan Outline @@ -252,6 +293,8 @@ MCP inputs use feature names, event names, config IDs, URL, delivery region, and - supported delivery regions - control-plane to delivery-region default mapping - secure secret generation + - secret pattern validation + - event key generation - event name/ID resolution 2. Add typed API helpers in `internal/cli/webhooks.go`. 3. Register `project webhook` commands in `commands.go`. @@ -265,14 +308,18 @@ MCP inputs use feature names, event names, config IDs, URL, delivery region, and Integration tests should cover: -- `webhook events rtc --json` returns named events. -- `webhook create` with event names resolves IDs and generates a secret. +- `webhook events 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` updates URL, events, delivery region, enabled state, and secret. +- `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`. @@ -280,6 +327,7 @@ Unit tests should cover: - delivery-region validation and defaulting - feature validation reuse -- event-name resolution, unknown event suggestions, and ambiguous names -- generated secret format and entropy length +- 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 From c39be1b49a9a8624b307333a742208ddb21b491a Mon Sep 17 00:00:00 2001 From: plutoless Date: Sun, 7 Jun 2026 02:37:12 -0700 Subject: [PATCH 03/31] docs: clarify webhook enabled contract --- .../specs/2026-06-07-project-webhook-design.md | 13 ++++++++----- 1 file changed, 8 insertions(+), 5 deletions(-) diff --git a/docs/superpowers/specs/2026-06-07-project-webhook-design.md b/docs/superpowers/specs/2026-06-07-project-webhook-design.md index 41941cf..3f57dca 100644 --- a/docs/superpowers/specs/2026-06-07-project-webhook-design.md +++ b/docs/superpowers/specs/2026-06-07-project-webhook-design.md @@ -138,11 +138,11 @@ type webhookConfig struct { } ``` -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 names. +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 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. +The event API returns an `items` array. Each backend event has `eventId`, `displayName`, `displayNameCn`, `eventType`, and `payload`. 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. Before coding against production, confirm with the backend owner that config `eventIds` must be populated from `eventId`, not `eventType`. Create requests send all backend-required fields: @@ -173,7 +173,7 @@ List, create, and update responses are `NcsConfigListResponse` objects with an ` - `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 requested URL, delivery region, event IDs, and secret. If multiple items match, choose the newest item by `updatedAt` when present, otherwise the highest `configId`. +- `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 @@ -186,6 +186,8 @@ All commands use the existing JSON envelope. Stable command labels: - `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 @@ -195,7 +197,7 @@ Example create data: "projectName": "my-agent-demo", "feature": "rtc", "configId": 42, - "status": "enabled", + "enabled": true, "url": "https://example.com/webhook", "urlRegion": "na", "eventIds": [1001], @@ -220,7 +222,7 @@ Safe branch fields for automation: - `projectId` - `feature` - `configId` -- `status` +- `enabled` - `urlRegion` - `eventIds` @@ -296,6 +298,7 @@ MCP inputs use feature names, event keys or event IDs, config IDs, URL, delivery - secret pattern validation - event key generation - event name/ID resolution + - explicit backend confirmation that config `eventIds` uses event `eventId`, not `eventType` 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`. From 36514e9f616fec86341a1173bc581b83bd6bcdaa Mon Sep 17 00:00:00 2001 From: plutoless Date: Sun, 7 Jun 2026 03:47:39 -0700 Subject: [PATCH 04/31] docs: refine webhook command scope --- .../2026-06-07-project-webhook-design.md | 31 ++++++++++++------- 1 file changed, 19 insertions(+), 12 deletions(-) diff --git a/docs/superpowers/specs/2026-06-07-project-webhook-design.md b/docs/superpowers/specs/2026-06-07-project-webhook-design.md index 3f57dca..834619a 100644 --- a/docs/superpowers/specs/2026-06-07-project-webhook-design.md +++ b/docs/superpowers/specs/2026-06-07-project-webhook-design.md @@ -39,20 +39,22 @@ Backend `productKey` is an internal implementation detail. For v1, supported pub Add the command group under `project`: ```bash -agora project webhook events -agora project webhook list [project] +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] ``` -`events ` is a discovery command. It fetches available webhook events for the feature and displays CLI event keys, backend event IDs, backend event types, display names, Chinese display names, and payload examples so developers do not need to guess backend event IDs. +`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 rtc +agora project webhook events --feature rtc agora project webhook create \ --feature rtc \ --url https://example.com/webhook \ @@ -67,7 +69,7 @@ Event input rules: - 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 `. +- 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. @@ -101,6 +103,7 @@ Secret output rules: - `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. @@ -116,7 +119,6 @@ type webhookEvent struct { ID int `json:"id"` Key string `json:"key"` DisplayName string `json:"displayName"` - DisplayNameCn string `json:"displayNameCn,omitempty"` EventType int `json:"eventType"` Payload string `json:"payload,omitempty"` } @@ -142,7 +144,7 @@ The backend response may include additional fields such as `appId`, `productId`, `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 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. Before coding against production, confirm with the backend owner that config `eventIds` must be populated from `eventId`, not `eventType`. +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. Before coding against production, confirm with the backend owner that config `eventIds` must be populated from `eventId`, not `eventType`. Create requests send all backend-required fields: @@ -206,7 +208,6 @@ Example create data: "id": 1001, "key": "channel-created", "displayName": "Channel Created", - "displayNameCn": "频道创建", "eventType": 1, "payload": "{...}" } @@ -234,8 +235,8 @@ Use existing envelope behavior and classify errors where the CLI can provide sta | --- | --- | | Missing URL on create | `WEBHOOK_URL_REQUIRED` | | Missing events on create | `WEBHOOK_EVENTS_REQUIRED` | -| Unknown event name | `WEBHOOK_EVENT_UNKNOWN` | -| Duplicate or ambiguous event name | `WEBHOOK_EVENT_AMBIGUOUS` | +| 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` | @@ -289,6 +290,12 @@ Add MCP tools for agent workflows because existing project feature/env/init comm 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: @@ -297,7 +304,7 @@ MCP inputs use feature names, event keys or event IDs, config IDs, URL, delivery - secure secret generation - secret pattern validation - event key generation - - event name/ID resolution + - event key/ID resolution - explicit backend confirmation that config `eventIds` uses event `eventId`, not `eventType` 2. Add typed API helpers in `internal/cli/webhooks.go`. 3. Register `project webhook` commands in `commands.go`. @@ -311,7 +318,7 @@ MCP inputs use feature names, event keys or event IDs, config IDs, URL, delivery Integration tests should cover: -- `webhook events rtc --json` returns event keys plus backend event metadata. +- `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}$`. From 2d45a977b9de9e6ef42700dda16a15a3525bf155 Mon Sep 17 00:00:00 2001 From: plutoless Date: Sun, 7 Jun 2026 03:55:21 -0700 Subject: [PATCH 05/31] docs: add webhook implementation plan --- ...26-06-07-project-webhook-implementation.md | 1391 +++++++++++++++++ 1 file changed, 1391 insertions(+) create mode 100644 docs/superpowers/plans/2026-06-07-project-webhook-implementation.md 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. From 5a805c9b901062d06804cbc1cae09b5563ed540d Mon Sep 17 00:00:00 2001 From: plutoless Date: Sun, 7 Jun 2026 04:09:40 -0700 Subject: [PATCH 06/31] chore: ignore local worktrees --- .gitignore | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) 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] From 1e4dac98fe980f120bdc2e63891c2e01afdd45dc Mon Sep 17 00:00:00 2001 From: plutoless Date: Sun, 7 Jun 2026 04:12:17 -0700 Subject: [PATCH 07/31] docs: confirm webhook event id mapping --- docs/superpowers/specs/2026-06-07-project-webhook-design.md | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/docs/superpowers/specs/2026-06-07-project-webhook-design.md b/docs/superpowers/specs/2026-06-07-project-webhook-design.md index 834619a..8ae9d6a 100644 --- a/docs/superpowers/specs/2026-06-07-project-webhook-design.md +++ b/docs/superpowers/specs/2026-06-07-project-webhook-design.md @@ -144,7 +144,7 @@ The backend response may include additional fields such as `appId`, `productId`, `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. Before coding against production, confirm with the backend owner that config `eventIds` must be populated from `eventId`, not `eventType`. +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: @@ -305,7 +305,6 @@ MCP tool calls have no TTY, so the interactive delete prompt cannot apply. `agor - secret pattern validation - event key generation - event key/ID resolution - - explicit backend confirmation that config `eventIds` uses event `eventId`, not `eventType` 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`. From 796f2ec301fb0cdeae2617aaaeebc55956e8e215 Mon Sep 17 00:00:00 2001 From: plutoless Date: Sun, 7 Jun 2026 04:22:17 -0700 Subject: [PATCH 08/31] feat: add webhook validation helpers --- internal/cli/webhooks.go | 174 ++++++++++++++++++++++++++++++++++ internal/cli/webhooks_test.go | 170 +++++++++++++++++++++++++++++++++ 2 files changed, 344 insertions(+) create mode 100644 internal/cli/webhooks.go create mode 100644 internal/cli/webhooks_test.go diff --git a/internal/cli/webhooks.go b/internal/cli/webhooks.go new file mode 100644 index 0000000..04a3531 --- /dev/null +++ b/internal/cli/webhooks.go @@ -0,0 +1,174 @@ +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 string `json:"eventType"` + Payload any `json:"payload,omitempty"` +} + +type webhookConfig struct { + ConfigID string `json:"configId"` + URL string `json:"url"` + URLRegion string `json:"urlRegion"` + Enabled bool `json:"enabled"` + EventIDs []int `json:"eventIds"` + Events []webhookEvent `json:"events,omitempty"` + Retry any `json:"retry,omitempty"` + UseIPWhitelist bool `json:"useIpWhitelist"` + Secret string `json:"secret,omitempty"` +} + +func webhookEventKey(displayName string) string { + key := strings.ToLower(strings.TrimSpace(displayName)) + key = webhookEventKeyInvalidChars.ReplaceAllString(key, "-") + return strings.Trim(key, "-") +} + +func validateWebhookFeature(feature string) error { + if strings.TrimSpace(feature) == "" { + return &cliError{Message: "webhook feature is required", Code: "WEBHOOK_FEATURE_REQUIRED"} + } + return validateFeatureID(feature) +} + +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 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) + } + generatedKey := webhookEventKey(event.DisplayName) + if generatedKey != "" { + byKey[generatedKey] = append(byKey[generatedKey], event) + } + byDisplayName[event.DisplayName] = event + } + + selected := make(map[int]struct{}, len(inputs)) + for _, input := range inputs { + value := strings.TrimSpace(input) + if value == "" { + return nil, unknownWebhookEventError(input, feature) + } + 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 event, ok := byDisplayName[value]; ok { + selected[event.ID] = 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", + } +} diff --git a/internal/cli/webhooks_test.go b/internal/cli/webhooks_test.go new file mode 100644 index 0000000..08136f5 --- /dev/null +++ b/internal/cli/webhooks_test.go @@ -0,0 +1,170 @@ +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 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: "", DisplayName: "Recording Started!"}, + } + + _, 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 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 +} From ae45536eed35ac4912b19c9e0c3c5115fd45ea5a Mon Sep 17 00:00:00 2001 From: plutoless Date: Sun, 7 Jun 2026 04:25:44 -0700 Subject: [PATCH 09/31] fix: align webhook helper payload types --- internal/cli/webhooks.go | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/internal/cli/webhooks.go b/internal/cli/webhooks.go index 04a3531..ee89a2c 100644 --- a/internal/cli/webhooks.go +++ b/internal/cli/webhooks.go @@ -19,18 +19,18 @@ type webhookEvent struct { ID int `json:"id"` Key string `json:"key"` DisplayName string `json:"displayName"` - EventType string `json:"eventType"` - Payload any `json:"payload,omitempty"` + EventType int `json:"eventType"` + Payload string `json:"payload,omitempty"` } type webhookConfig struct { - ConfigID string `json:"configId"` + 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 any `json:"retry,omitempty"` + Retry *bool `json:"retry,omitempty"` UseIPWhitelist bool `json:"useIpWhitelist"` Secret string `json:"secret,omitempty"` } From 3ef5c294a84e5ebf9e0cc7ee293a55aaeafb80a5 Mon Sep 17 00:00:00 2001 From: plutoless Date: Sun, 7 Jun 2026 04:29:42 -0700 Subject: [PATCH 10/31] fix: tighten webhook event resolution --- internal/cli/webhooks.go | 6 +----- internal/cli/webhooks_test.go | 37 ++++++++++++++++++++++++++++++++++- 2 files changed, 37 insertions(+), 6 deletions(-) diff --git a/internal/cli/webhooks.go b/internal/cli/webhooks.go index ee89a2c..e031745 100644 --- a/internal/cli/webhooks.go +++ b/internal/cli/webhooks.go @@ -107,10 +107,6 @@ func resolveWebhookEventIDs(events []webhookEvent, inputs []string, feature stri if event.Key != "" { byKey[event.Key] = append(byKey[event.Key], event) } - generatedKey := webhookEventKey(event.DisplayName) - if generatedKey != "" { - byKey[generatedKey] = append(byKey[generatedKey], event) - } byDisplayName[event.DisplayName] = event } @@ -118,7 +114,7 @@ func resolveWebhookEventIDs(events []webhookEvent, inputs []string, feature stri for _, input := range inputs { value := strings.TrimSpace(input) if value == "" { - return nil, unknownWebhookEventError(input, feature) + continue } if id, err := strconv.Atoi(value); err == nil { if _, ok := byID[id]; !ok { diff --git a/internal/cli/webhooks_test.go b/internal/cli/webhooks_test.go index 08136f5..9a4b253 100644 --- a/internal/cli/webhooks_test.go +++ b/internal/cli/webhooks_test.go @@ -56,7 +56,7 @@ 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: "", DisplayName: "Recording Started!"}, + {ID: 30, Key: "recording-started", DisplayName: "Recording Started Duplicate"}, } _, err := resolveWebhookEventIDs(events, []string{"not-real"}, "rtc") @@ -76,6 +76,41 @@ func TestResolveWebhookEventInputsRejectsUnknownAndAmbiguous(t *testing.T) { } } +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 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 TestGenerateWebhookSecretMatchesBackendPattern(t *testing.T) { secret, err := generateWebhookSecret() if err != nil { From 325069797c992b4ea53d5745f43a0a5952e12866 Mon Sep 17 00:00:00 2001 From: plutoless Date: Sun, 7 Jun 2026 04:35:23 -0700 Subject: [PATCH 11/31] fix: reject ambiguous webhook display names --- internal/cli/webhooks.go | 15 +++++++++++---- internal/cli/webhooks_test.go | 15 +++++++++++++++ 2 files changed, 26 insertions(+), 4 deletions(-) diff --git a/internal/cli/webhooks.go b/internal/cli/webhooks.go index e031745..67481a1 100644 --- a/internal/cli/webhooks.go +++ b/internal/cli/webhooks.go @@ -101,13 +101,13 @@ func webhookIntSlicesEqual(a, b []int) bool { 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)) + 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] = event + byDisplayName[event.DisplayName] = append(byDisplayName[event.DisplayName], event) } selected := make(map[int]struct{}, len(inputs)) @@ -134,8 +134,15 @@ func resolveWebhookEventIDs(events []webhookEvent, inputs []string, feature stri selected[ids[0]] = struct{}{} continue } - if event, ok := byDisplayName[value]; ok { - selected[event.ID] = struct{}{} + 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) diff --git a/internal/cli/webhooks_test.go b/internal/cli/webhooks_test.go index 9a4b253..97be483 100644 --- a/internal/cli/webhooks_test.go +++ b/internal/cli/webhooks_test.go @@ -111,6 +111,21 @@ func TestWebhookResolveEventInputsDoesNotUseGeneratedDisplayNameKey(t *testing.T } } +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 { From e32d343893061f012094e98f0f688709106ea14f Mon Sep 17 00:00:00 2001 From: plutoless Date: Sun, 7 Jun 2026 04:43:51 -0700 Subject: [PATCH 12/31] feat: add webhook api adapter --- internal/cli/webhooks.go | 151 ++++++++++++++++++++++++++++++++++ internal/cli/webhooks_test.go | 107 ++++++++++++++++++++++++ 2 files changed, 258 insertions(+) diff --git a/internal/cli/webhooks.go b/internal/cli/webhooks.go index 67481a1..1e20ac6 100644 --- a/internal/cli/webhooks.go +++ b/internal/cli/webhooks.go @@ -35,12 +35,163 @@ type webhookConfig struct { Secret string `json:"secret,omitempty"` } +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) { + if secret != "" { + if match, ok := bestWebhookConfigCandidate(resp.Items, func(item ncsConfig) bool { + return item.Secret == secret + }); ok { + return match, nil + } + } + + if match, ok := bestWebhookConfigCandidate(resp.Items, func(item ncsConfig) bool { + return item.URL == url && + item.URLRegion == urlRegion && + webhookIntSlicesEqual(item.EventIDs, eventIDs) + }); 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) { + if err := validateWebhookFeature(feature); 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 validateWebhookFeature(feature string) error { if strings.TrimSpace(feature) == "" { return &cliError{Message: "webhook feature is required", Code: "WEBHOOK_FEATURE_REQUIRED"} diff --git a/internal/cli/webhooks_test.go b/internal/cli/webhooks_test.go index 97be483..5cc5ee0 100644 --- a/internal/cli/webhooks_test.go +++ b/internal/cli/webhooks_test.go @@ -30,6 +30,113 @@ func TestWebhookEventKeyFromDisplayName(t *testing.T) { } } +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://almost.example.com/hook", URLRegion: "eu", EventIDs: []int{1002}, 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 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"}, From 4d6f8c322102147ae19fcb7eed51a26d9c9b6d60 Mon Sep 17 00:00:00 2001 From: plutoless Date: Sun, 7 Jun 2026 04:49:55 -0700 Subject: [PATCH 13/31] fix: tighten webhook create selection --- internal/cli/webhooks.go | 25 +++++++++++++++++++------ internal/cli/webhooks_test.go | 35 ++++++++++++++++++++++++++++++++++- 2 files changed, 53 insertions(+), 7 deletions(-) diff --git a/internal/cli/webhooks.go b/internal/cli/webhooks.go index 1e20ac6..d996f45 100644 --- a/internal/cli/webhooks.go +++ b/internal/cli/webhooks.go @@ -120,19 +120,21 @@ func redactWebhookConfigSecret(cfg webhookConfig, reveal bool) webhookConfig { } 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 + return item.Secret == secret && matchesRequestedShape(item) }); ok { return match, nil } } - if match, ok := bestWebhookConfigCandidate(resp.Items, func(item ncsConfig) bool { - return item.URL == url && - item.URLRegion == urlRegion && - webhookIntSlicesEqual(item.EventIDs, eventIDs) - }); ok { + if match, ok := bestWebhookConfigCandidate(resp.Items, matchesRequestedShape); ok { return match, nil } @@ -249,6 +251,17 @@ func webhookIntSlicesEqual(a, b []int) bool { 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)) diff --git a/internal/cli/webhooks_test.go b/internal/cli/webhooks_test.go index 5cc5ee0..bd8a247 100644 --- a/internal/cli/webhooks_test.go +++ b/internal/cli/webhooks_test.go @@ -80,7 +80,7 @@ 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://almost.example.com/hook", URLRegion: "eu", EventIDs: []int{1002}, Secret: "secret_123"}, + {ConfigID: 42, URL: "https://example.com/hook", URLRegion: "na", EventIDs: []int{1001}, Secret: "secret_123"}, }, } @@ -93,6 +93,39 @@ func TestSelectWebhookConfigFromCreateResponsePrefersSecret(t *testing.T) { } } +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{ From f727ff8ada6e353041ee9958e5a640b1be196e07 Mon Sep 17 00:00:00 2001 From: plutoless Date: Sun, 7 Jun 2026 05:00:06 -0700 Subject: [PATCH 14/31] test: add project webhook integration coverage --- internal/cli/integration_test.go | 373 ++++++++++++++++++++++++++++++- 1 file changed, 367 insertions(+), 6 deletions(-) diff --git a/internal/cli/integration_test.go b/internal/cli/integration_test.go index 9169007..b8cd4ea 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" @@ -360,6 +361,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 +402,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 +417,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 +434,25 @@ func newFakeCLIBFF() *fakeCLIBFF { api.mu.Unlock() switch { + case r.Method == http.MethodGet && strings.HasPrefix(r.URL.Path, "/api/cli/v1/ncs-events/"): + _ = 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,6 +490,8 @@ func newFakeCLIBFF() *fakeCLIBFF { project := buildFakeProject(name, projectID, appID, "global") api.projects[projectID] = &project _ = json.NewEncoder(w).Encode(project) + case strings.HasPrefix(r.URL.Path, "/api/cli/v1/projects/") && strings.Contains(r.URL.Path, "/ncs-configs/"): + api.handleFakeNCSConfigs(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] @@ -523,6 +560,131 @@ func newFakeCLIBFF() *fakeCLIBFF { return api } +func (api *fakeCLIBFF) handleFakeNCSConfigs(w http.ResponseWriter, r *http.Request) { + parts := strings.Split(strings.Trim(r.URL.Path, "/"), "/") + if len(parts) < 7 { + http.NotFound(w, r) + return + } + 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: 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) + _ = json.NewEncoder(w).Encode(map[string]any{"items": api.ncsConfigs[key]}) + 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() + api.ncsBodies = append(api.ncsBodies, body) + api.mu.Unlock() + 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" + } + _ = json.NewEncoder(w).Encode(map[string]any{"items": api.ncsConfigs[key]}) + case http.MethodDelete: + if len(parts) < 8 { + http.NotFound(w, r) + return + } + 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}) + default: + http.NotFound(w, r) + } +} + +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 +713,202 @@ func parseAuthURL(stderr string) string { } return "" } + +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 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", "--event", "channel-created", "--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}) { + t.Fatalf("expected create body to use eventId 1001, got %#v", body["eventIds"]) + } + secret, _ := body["secret"].(string) + if !webhookSecretPattern.MatchString(secret) { + t.Fatalf("expected generated secret matching backend pattern, got %#v", body) + } +} + +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 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", "--event", "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", "--event", "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", "--event", "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.stderr, "unknown flag: --secret") { + 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", + } +} From aa7356b10a62978b807b5295308d1bf0940ebb95 Mon Sep 17 00:00:00 2001 From: plutoless Date: Sun, 7 Jun 2026 05:04:24 -0700 Subject: [PATCH 15/31] test: fix webhook json flag error assertion --- internal/cli/integration_test.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/internal/cli/integration_test.go b/internal/cli/integration_test.go index b8cd4ea..40c3fea 100644 --- a/internal/cli/integration_test.go +++ b/internal/cli/integration_test.go @@ -900,7 +900,7 @@ func TestProjectWebhookUpdateSecretFlagRejected(t *testing.T) { 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.stderr, "unknown flag: --secret") { + 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) } } From def92b7c275719128f4dcbc2855076666c629268 Mon Sep 17 00:00:00 2001 From: plutoless Date: Sun, 7 Jun 2026 05:10:42 -0700 Subject: [PATCH 16/31] test: harden fake webhook bff routes --- internal/cli/integration_test.go | 98 ++++++++++++++++++++++++++------ 1 file changed, 82 insertions(+), 16 deletions(-) diff --git a/internal/cli/integration_test.go b/internal/cli/integration_test.go index 40c3fea..2129e6f 100644 --- a/internal/cli/integration_test.go +++ b/internal/cli/integration_test.go @@ -434,7 +434,7 @@ func newFakeCLIBFF() *fakeCLIBFF { api.mu.Unlock() switch { - case r.Method == http.MethodGet && strings.HasPrefix(r.URL.Path, "/api/cli/v1/ncs-events/"): + case r.Method == http.MethodGet && isFakeNCSEventsPath(r.URL.Path): _ = json.NewEncoder(w).Encode(map[string]any{ "items": []map[string]any{ { @@ -490,14 +490,15 @@ func newFakeCLIBFF() *fakeCLIBFF { project := buildFakeProject(name, projectID, appID, "global") api.projects[projectID] = &project _ = json.NewEncoder(w).Encode(project) - case strings.HasPrefix(r.URL.Path, "/api/cli/v1/projects/") && strings.Contains(r.URL.Path, "/ncs-configs/"): + 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) @@ -561,24 +562,40 @@ func newFakeCLIBFF() *fakeCLIBFF { } func (api *fakeCLIBFF) handleFakeNCSConfigs(w http.ResponseWriter, r *http.Request) { - parts := strings.Split(strings.Trim(r.URL.Path, "/"), "/") - if len(parts) < 7 { - http.NotFound(w, r) - return - } + parts := fakePathParts(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]}) + 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) - api.mu.Unlock() config := fakeNCSConfig{ ConfigID: 42 + len(api.ncsConfigs[key]), URL: stringFromBody(body, "url"), @@ -592,9 +609,11 @@ func (api *fakeCLIBFF) handleFakeNCSConfigs(w http.ResponseWriter, r *http.Reque 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]}) + 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 { + if len(parts) != 8 { http.NotFound(w, r) return } @@ -602,8 +621,12 @@ func (api *fakeCLIBFF) handleFakeNCSConfigs(w http.ResponseWriter, r *http.Reque 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) - api.mu.Unlock() for i := range api.ncsConfigs[key] { if api.ncsConfigs[key][i].ConfigID != configID { continue @@ -625,13 +648,21 @@ func (api *fakeCLIBFF) handleFakeNCSConfigs(w http.ResponseWriter, r *http.Reque } api.ncsConfigs[key][i].UpdatedAt = "2026-06-07T00:00:02Z" } - _ = json.NewEncoder(w).Encode(map[string]any{"items": api.ncsConfigs[key]}) + 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 { + 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 { @@ -639,12 +670,47 @@ func (api *fakeCLIBFF) handleFakeNCSConfigs(w http.ResponseWriter, r *http.Reque } } 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.Trim(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 } From eec092ef5f248da3c844f207be215c3eb9f66463 Mon Sep 17 00:00:00 2001 From: plutoless Date: Sun, 7 Jun 2026 05:16:17 -0700 Subject: [PATCH 17/31] test: reject trailing webhook fake routes --- internal/cli/integration_test.go | 58 +++++++++++++++++++++++++++++++- 1 file changed, 57 insertions(+), 1 deletion(-) diff --git a/internal/cli/integration_test.go b/internal/cli/integration_test.go index 2129e6f..8024aba 100644 --- a/internal/cli/integration_test.go +++ b/internal/cli/integration_test.go @@ -678,7 +678,7 @@ func (api *fakeCLIBFF) handleFakeNCSConfigs(w http.ResponseWriter, r *http.Reque } func fakePathParts(path string) []string { - return strings.Split(strings.Trim(path, "/"), "/") + return strings.Split(strings.TrimLeft(path, "/"), "/") } func isFakeNCSEventsPath(path string) bool { @@ -780,6 +780,62 @@ 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() From 14f98bbe1c06a7d19f70ea92eddb8a87fece21c2 Mon Sep 17 00:00:00 2001 From: plutoless Date: Sun, 7 Jun 2026 05:23:40 -0700 Subject: [PATCH 18/31] feat: add project webhook commands --- internal/cli/commands.go | 177 +++++++++++++++++++++++ internal/cli/webhooks.go | 294 +++++++++++++++++++++++++++++++++++++++ 2 files changed, 471 insertions(+) diff --git a/internal/cli/commands.go b/internal/cli/commands.go index 191cd03..6f087e3 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,181 @@ func (a *App) buildProjectFeature() *cobra.Command { return cmd } +func (a *App) buildProjectWebhook() *cobra.Command { + cmd := &cobra.Command{ + Use: "webhook", + Short: "Manage project webhook configurations", + Long: "List webhook events and manage webhook endpoint configurations for a project feature.", + Example: example(` + agora project webhook events --feature rtc + agora project webhook list --feature rtc --project my-app + agora project webhook create --feature rtc --url https://example.com/webhook --event channel-created --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() + }, + } + + eventsFeature := "" + events := &cobra.Command{ + Use: "events", + Short: "List available webhook events for a feature", + RunE: func(cmd *cobra.Command, _ []string) error { + data, err := a.projectWebhookEvents(eventsFeature) + if err != nil { + return err + } + return renderResult(cmd, "project webhook events", data) + }, + } + events.Flags().StringVar(&eventsFeature, "feature", "", "project feature whose webhook events should be listed") + cmd.AddCommand(events) + + listFeature := "" + listProject := "" + list := &cobra.Command{ + Use: "list", + Short: "List webhook configurations for a project feature", + RunE: func(cmd *cobra.Command, _ []string) error { + data, err := a.projectWebhookList(listFeature, listProject, false) + if err != nil { + return err + } + return renderResult(cmd, "project webhook list", data) + }, + } + list.Flags().StringVar(&listFeature, "feature", "", "project feature whose webhook configurations should be listed") + list.Flags().StringVar(&listProject, "project", "", "project ID or exact project name; defaults to the current project context") + cmd.AddCommand(list) + + showFeature := "" + showProject := "" + showWithSecret := false + show := &cobra.Command{ + Use: "show ", + Short: "Show one webhook configuration", + RunE: func(cmd *cobra.Command, args []string) error { + configID, err := parseWebhookConfigIDArg(args) + if err != nil { + return err + } + data, err := a.projectWebhookShow(configID, showFeature, showProject, showWithSecret) + if err != nil { + return err + } + return renderResult(cmd, "project webhook show", data) + }, + } + show.Flags().StringVar(&showFeature, "feature", "", "project feature for the webhook configuration") + 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) + + createOpts := webhookCreateOptions{} + create := &cobra.Command{ + Use: "create", + Short: "Create a webhook configuration", + RunE: func(cmd *cobra.Command, _ []string) error { + data, err := a.projectWebhookCreate(createOpts) + if err != nil { + return err + } + return renderResult(cmd, "project webhook create", data) + }, + } + create.Flags().StringVar(&createOpts.Feature, "feature", "", "project feature for the webhook configuration") + create.Flags().StringVar(&createOpts.Project, "project", "", "project ID or exact project name; defaults to the current project context") + create.Flags().StringVar(&createOpts.URL, "url", "", "webhook endpoint URL") + create.Flags().StringArrayVar(&createOpts.EventInputs, "event", nil, "webhook event key, display name, or numeric ID; repeat to subscribe to multiple events") + create.Flags().StringVar(&createOpts.Secret, "secret", "", "webhook signing secret; generated when omitted") + create.Flags().StringVar(&createOpts.DeliveryRegion, "delivery-region", "", "webhook delivery region: cn, sea, na, or eu") + cmd.AddCommand(create) + + updateOpts := webhookUpdateOptions{} + updateEnabled := false + updateDisabled := false + update := &cobra.Command{ + Use: "update ", + Short: "Update a webhook configuration", + RunE: func(cmd *cobra.Command, args []string) error { + 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"} + } + updateOpts.ConfigID = configID + if cmd.Flags().Changed("enabled") { + enabled := updateEnabled + updateOpts.Enabled = &enabled + } + if cmd.Flags().Changed("disabled") { + enabled := !updateDisabled + updateOpts.Enabled = &enabled + } + data, err := a.projectWebhookUpdate(updateOpts) + if err != nil { + return err + } + return renderResult(cmd, "project webhook update", data) + }, + } + update.Flags().StringVar(&updateOpts.Feature, "feature", "", "project feature for the webhook configuration") + update.Flags().StringVar(&updateOpts.Project, "project", "", "project ID or exact project name; defaults to the current project context") + update.Flags().StringVar(&updateOpts.URL, "url", "", "new webhook endpoint URL") + update.Flags().StringArrayVar(&updateOpts.EventInputs, "event", nil, "replacement webhook event key, display name, or numeric ID; repeat to subscribe to multiple events") + update.Flags().StringVar(&updateOpts.DeliveryRegion, "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) + + deleteFeature := "" + deleteProject := "" + deleteCmd := &cobra.Command{ + Use: "delete ", + Short: "Delete a webhook configuration", + RunE: func(cmd *cobra.Command, args []string) error { + 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, deleteFeature, deleteProject) + if err != nil { + return err + } + return renderResult(cmd, "project webhook delete", data) + }, + } + deleteCmd.Flags().StringVar(&deleteFeature, "feature", "", "project feature for the webhook configuration") + deleteCmd.Flags().StringVar(&deleteProject, "project", "", "project ID or exact project name; defaults to the current project context") + cmd.AddCommand(deleteCmd) + + return cmd +} + +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/webhooks.go b/internal/cli/webhooks.go index d996f45..ed6cc5f 100644 --- a/internal/cli/webhooks.go +++ b/internal/cli/webhooks.go @@ -35,6 +35,25 @@ type webhookConfig struct { 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"` } @@ -194,6 +213,233 @@ func (a *App) deleteWebhookConfig(projectID, feature string, configID int) error 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) { + events, err := a.listWebhookEvents(feature) + if err != nil { + return nil, err + } + return map[string]any{ + "action": "webhook-events", + "feature": strings.TrimSpace(feature), + "items": events, + }, nil +} + +func (a *App) projectWebhookList(feature, project string, revealSecrets bool) (map[string]any, error) { + if err := validateWebhookFeature(feature); 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, strings.TrimSpace(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": strings.TrimSpace(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 + } + if err := validateWebhookFeature(feature); 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, strings.TrimSpace(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, strings.TrimSpace(feature), cfg), nil +} + +func (a *App) projectWebhookCreate(opts webhookCreateOptions) (map[string]any, error) { + if err := validateWebhookFeature(opts.Feature); 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(opts.Feature) + if err != nil { + return nil, err + } + eventIDs, err := resolveWebhookEventIDs(events, eventInputs, opts.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, strings.TrimSpace(opts.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, strings.TrimSpace(opts.Feature), cfg), nil +} + +func (a *App) projectWebhookUpdate(opts webhookUpdateOptions) (map[string]any, error) { + if err := validateWebhookConfigID(opts.ConfigID); err != nil { + return nil, err + } + if err := validateWebhookFeature(opts.Feature); err != nil { + return nil, err + } + target, err := a.resolveProjectTarget(opts.Project) + if err != nil { + return nil, err + } + events, err := a.listWebhookEvents(opts.Feature) + if err != nil { + return nil, err + } + configs, err := a.listWebhookConfigs(target.project.ProjectID, strings.TrimSpace(opts.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, opts.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, strings.TrimSpace(opts.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, strings.TrimSpace(opts.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 + } + if err := validateWebhookFeature(feature); err != nil { + return nil, err + } + target, err := a.resolveProjectTarget(project) + if err != nil { + return nil, err + } + if err := a.deleteWebhookConfig(target.project.ProjectID, strings.TrimSpace(feature), configID); err != nil { + return nil, err + } + return map[string]any{ + "action": "webhook-delete", + "configId": configID, + "deleted": true, + "feature": strings.TrimSpace(feature), + "projectId": target.project.ProjectID, + "projectName": target.project.Name, + }, nil +} + func validateWebhookFeature(feature string) error { if strings.TrimSpace(feature) == "" { return &cliError{Message: "webhook feature is required", Code: "WEBHOOK_FEATURE_REQUIRED"} @@ -201,6 +447,13 @@ func validateWebhookFeature(feature string) error { return validateFeatureID(feature) } +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 { @@ -339,3 +592,44 @@ func unknownWebhookEventError(input string, feature string) error { 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 { + if trimmed := strings.TrimSpace(input); 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, + } +} From 783fb4f20903bcfbcaae0a5029b6d0d07d0fdb10 Mon Sep 17 00:00:00 2001 From: plutoless Date: Sun, 7 Jun 2026 05:33:50 -0700 Subject: [PATCH 19/31] fix: harden project webhook command state --- internal/cli/commands.go | 111 ++++++++++++++++++---- internal/cli/integration_test.go | 154 +++++++++++++++++++++++++++++++ internal/cli/webhooks.go | 11 ++- 3 files changed, 255 insertions(+), 21 deletions(-) diff --git a/internal/cli/commands.go b/internal/cli/commands.go index 6f087e3..918e7a1 100644 --- a/internal/cli/commands.go +++ b/internal/cli/commands.go @@ -1008,6 +1008,10 @@ func (a *App) buildProjectWebhook() *cobra.Command { Use: "events", Short: "List available webhook events for a feature", RunE: func(cmd *cobra.Command, _ []string) error { + defer func() { + eventsFeature = "" + resetWebhookCommandFlags(cmd, "feature") + }() data, err := a.projectWebhookEvents(eventsFeature) if err != nil { return err @@ -1024,6 +1028,11 @@ func (a *App) buildProjectWebhook() *cobra.Command { Use: "list", Short: "List webhook configurations for a project feature", RunE: func(cmd *cobra.Command, _ []string) error { + defer func() { + listFeature = "" + listProject = "" + resetWebhookCommandFlags(cmd, "feature", "project") + }() data, err := a.projectWebhookList(listFeature, listProject, false) if err != nil { return err @@ -1042,6 +1051,12 @@ func (a *App) buildProjectWebhook() *cobra.Command { Use: "show ", Short: "Show one webhook configuration", RunE: func(cmd *cobra.Command, args []string) error { + defer func() { + showFeature = "" + showProject = "" + showWithSecret = false + resetWebhookCommandFlags(cmd, "feature", "project", "with-secret") + }() configID, err := parseWebhookConfigIDArg(args) if err != nil { return err @@ -1058,33 +1073,69 @@ func (a *App) buildProjectWebhook() *cobra.Command { show.Flags().BoolVar(&showWithSecret, "with-secret", false, "include the webhook secret in the response") cmd.AddCommand(show) - createOpts := webhookCreateOptions{} + createFeature := "" + createProject := "" + createURL := "" + createEvents := []string(nil) + createSecret := "" + createDeliveryRegion := "" create := &cobra.Command{ Use: "create", Short: "Create a webhook configuration", RunE: func(cmd *cobra.Command, _ []string) error { - data, err := a.projectWebhookCreate(createOpts) + defer func() { + createFeature = "" + createProject = "" + createURL = "" + createEvents = nil + createSecret = "" + createDeliveryRegion = "" + resetWebhookCommandFlags(cmd, "feature", "project", "url", "event", "secret", "delivery-region") + }() + opts := webhookCreateOptions{ + Feature: createFeature, + Project: createProject, + URL: createURL, + EventInputs: append([]string(nil), 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(&createOpts.Feature, "feature", "", "project feature for the webhook configuration") - create.Flags().StringVar(&createOpts.Project, "project", "", "project ID or exact project name; defaults to the current project context") - create.Flags().StringVar(&createOpts.URL, "url", "", "webhook endpoint URL") - create.Flags().StringArrayVar(&createOpts.EventInputs, "event", nil, "webhook event key, display name, or numeric ID; repeat to subscribe to multiple events") - create.Flags().StringVar(&createOpts.Secret, "secret", "", "webhook signing secret; generated when omitted") - create.Flags().StringVar(&createOpts.DeliveryRegion, "delivery-region", "", "webhook delivery region: cn, sea, na, or eu") + create.Flags().StringVar(&createFeature, "feature", "", "project feature for the webhook configuration") + 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().StringArrayVar(&createEvents, "event", nil, "webhook event key, display name, or numeric ID; repeat to subscribe to multiple events") + 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) - updateOpts := webhookUpdateOptions{} + updateFeature := "" + updateProject := "" + updateURL := "" + updateEvents := []string(nil) + updateDeliveryRegion := "" updateEnabled := false updateDisabled := false update := &cobra.Command{ Use: "update ", Short: "Update a webhook configuration", RunE: func(cmd *cobra.Command, args []string) error { + defer func() { + updateFeature = "" + updateProject = "" + updateURL = "" + updateEvents = nil + updateDeliveryRegion = "" + updateEnabled = false + updateDisabled = false + resetWebhookCommandFlags(cmd, "feature", "project", "url", "event", "delivery-region", "enabled", "disabled") + }() configID, err := parseWebhookConfigIDArg(args) if err != nil { return err @@ -1092,14 +1143,23 @@ func (a *App) buildProjectWebhook() *cobra.Command { if cmd.Flags().Changed("enabled") && cmd.Flags().Changed("disabled") { return &cliError{Message: "--enabled and --disabled cannot be used together", Code: "WEBHOOK_ENABLED_FLAG_CONFLICT"} } - updateOpts.ConfigID = configID + var enabled *bool if cmd.Flags().Changed("enabled") { - enabled := updateEnabled - updateOpts.Enabled = &enabled + value := updateEnabled + enabled = &value } if cmd.Flags().Changed("disabled") { - enabled := !updateDisabled - updateOpts.Enabled = &enabled + value := !updateDisabled + enabled = &value + } + updateOpts := webhookUpdateOptions{ + ConfigID: configID, + Feature: updateFeature, + Project: updateProject, + URL: updateURL, + EventInputs: append([]string(nil), updateEvents...), + DeliveryRegion: updateDeliveryRegion, + Enabled: enabled, } data, err := a.projectWebhookUpdate(updateOpts) if err != nil { @@ -1108,11 +1168,11 @@ func (a *App) buildProjectWebhook() *cobra.Command { return renderResult(cmd, "project webhook update", data) }, } - update.Flags().StringVar(&updateOpts.Feature, "feature", "", "project feature for the webhook configuration") - update.Flags().StringVar(&updateOpts.Project, "project", "", "project ID or exact project name; defaults to the current project context") - update.Flags().StringVar(&updateOpts.URL, "url", "", "new webhook endpoint URL") - update.Flags().StringArrayVar(&updateOpts.EventInputs, "event", nil, "replacement webhook event key, display name, or numeric ID; repeat to subscribe to multiple events") - update.Flags().StringVar(&updateOpts.DeliveryRegion, "delivery-region", "", "new webhook delivery region: cn, sea, na, or eu") + update.Flags().StringVar(&updateFeature, "feature", "", "project feature for the webhook configuration") + 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().StringArrayVar(&updateEvents, "event", nil, "replacement webhook event key, display name, or numeric ID; repeat to subscribe to multiple events") + 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) @@ -1123,6 +1183,11 @@ func (a *App) buildProjectWebhook() *cobra.Command { Use: "delete ", Short: "Delete a webhook configuration", RunE: func(cmd *cobra.Command, args []string) error { + defer func() { + deleteFeature = "" + deleteProject = "" + resetWebhookCommandFlags(cmd, "feature", "project") + }() configID, err := parseWebhookConfigIDArg(args) if err != nil { return err @@ -1144,6 +1209,14 @@ func (a *App) buildProjectWebhook() *cobra.Command { 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"} diff --git a/internal/cli/integration_test.go b/internal/cli/integration_test.go index 8024aba..42eb52a 100644 --- a/internal/cli/integration_test.go +++ b/internal/cli/integration_test.go @@ -220,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 { @@ -848,6 +910,18 @@ func TestProjectWebhookEventsJSON(t *testing.T) { } } +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() @@ -919,6 +993,86 @@ func TestProjectWebhookUpdateReadMergePut(t *testing.T) { } } +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() diff --git a/internal/cli/webhooks.go b/internal/cli/webhooks.go index ed6cc5f..44a92f1 100644 --- a/internal/cli/webhooks.go +++ b/internal/cli/webhooks.go @@ -441,10 +441,17 @@ func (a *App) projectWebhookDelete(configID int, feature, project string) (map[s } func validateWebhookFeature(feature string) error { - if strings.TrimSpace(feature) == "" { + feature = strings.TrimSpace(feature) + if feature == "" { return &cliError{Message: "webhook feature is required", Code: "WEBHOOK_FEATURE_REQUIRED"} } - return validateFeatureID(feature) + if !isKnownFeature(feature) { + return &cliError{ + Message: fmt.Sprintf("invalid webhook feature %q. Choose one of: %s.", feature, featureListString()), + Code: "WEBHOOK_FEATURE_INVALID", + } + } + return nil } func validateWebhookConfigID(configID int) error { From 0fbd48ddbeafbd01ea707eb3665d0930f51a5b68 Mon Sep 17 00:00:00 2001 From: plutoless Date: Sun, 7 Jun 2026 05:58:02 -0700 Subject: [PATCH 20/31] feat: render project webhook output --- internal/cli/integration_test.go | 12 +++ internal/cli/render.go | 134 +++++++++++++++++++++++++++++++ 2 files changed, 146 insertions(+) diff --git a/internal/cli/integration_test.go b/internal/cli/integration_test.go index 42eb52a..20751a3 100644 --- a/internal/cli/integration_test.go +++ b/internal/cli/integration_test.go @@ -910,6 +910,18 @@ func TestProjectWebhookEventsJSON(t *testing.T) { } } +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() 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. From 2bc1b091f7357d3c0409b8eba827aa372b0b075e Mon Sep 17 00:00:00 2001 From: plutoless Date: Sun, 7 Jun 2026 06:06:17 -0700 Subject: [PATCH 21/31] feat: expose project webhooks through mcp --- internal/cli/mcp.go | 85 ++++++++++++++++++++++++++++++++++++++++ internal/cli/mcp_test.go | 27 +++++++++++++ 2 files changed, 112 insertions(+) diff --git a/internal/cli/mcp.go b/internal/cli/mcp.go index b147d98..de83f63 100644 --- a/internal/cli/mcp.go +++ b/internal/cli/mcp.go @@ -206,6 +206,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": "number", + "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": "number", + "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": "number", + "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 +449,51 @@ 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": + 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": + 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: optionalBoolArg(args, "enabled"), + }) + + case "agora.project.webhook.delete": + 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( + intArg(args, "configId", 0), + stringArg(args, "feature"), + stringArg(args, "project"), + ) + case "agora.quickstart.list": items := []map[string]any{} for _, template := range quickstartTemplates() { @@ -500,6 +578,13 @@ func boolArg(args map[string]any, key string, fallback bool) bool { return fallback } +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..e22ffc0 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,26 @@ 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) + } +} + // 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. From 8b12aaa7dab2f4a1d4b649be2856456f41777e42 Mon Sep 17 00:00:00 2001 From: plutoless Date: Sun, 7 Jun 2026 06:13:54 -0700 Subject: [PATCH 22/31] fix: validate webhook config ids in mcp --- internal/cli/mcp.go | 48 ++++++++++++++++++++++++---- internal/cli/mcp_test.go | 69 ++++++++++++++++++++++++++++++++++++++++ 2 files changed, 111 insertions(+), 6 deletions(-) diff --git a/internal/cli/mcp.go b/internal/cli/mcp.go index de83f63..b919d17 100644 --- a/internal/cli/mcp.go +++ b/internal/cli/mcp.go @@ -7,6 +7,7 @@ import ( "errors" "fmt" "io" + "math" "strings" "time" @@ -210,7 +211,7 @@ func mcpTools() []map[string]any { 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": "number", + "configId": "integer", "feature": "string", "project": "string", "withSecret": "boolean", @@ -224,7 +225,7 @@ func mcpTools() []map[string]any { "deliveryRegion": "string", }), mcpTool("agora.project.webhook.update", "Update a webhook configuration", map[string]string{ - "configId": "number", + "configId": "integer", "feature": "string", "project": "string", "url": "string", @@ -233,7 +234,7 @@ func mcpTools() []map[string]any { "enabled": "boolean", }), mcpTool("agora.project.webhook.delete", "Delete a webhook configuration", map[string]string{ - "configId": "number", + "configId": "integer", "feature": "string", "project": "string", "confirm": "boolean", @@ -456,8 +457,12 @@ func (a *App) callMCPTool(name string, args map[string]any, progress progressEmi 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( - intArg(args, "configId", 0), + configID, stringArg(args, "feature"), stringArg(args, "project"), boolArg(args, "withSecret", false), @@ -474,8 +479,12 @@ func (a *App) callMCPTool(name string, args map[string]any, progress progressEmi }) case "agora.project.webhook.update": + configID, err := configIDArg(args, "configId") + if err != nil { + return nil, err + } return a.projectWebhookUpdate(webhookUpdateOptions{ - ConfigID: intArg(args, "configId", 0), + ConfigID: configID, Feature: stringArg(args, "feature"), Project: stringArg(args, "project"), URL: stringArg(args, "url"), @@ -485,11 +494,15 @@ func (a *App) callMCPTool(name string, args map[string]any, progress progressEmi }) 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( - intArg(args, "configId", 0), + configID, stringArg(args, "feature"), stringArg(args, "project"), ) @@ -578,6 +591,29 @@ 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 diff --git a/internal/cli/mcp_test.go b/internal/cli/mcp_test.go index e22ffc0..42f790a 100644 --- a/internal/cli/mcp_test.go +++ b/internal/cli/mcp_test.go @@ -188,6 +188,75 @@ func TestMCPProjectWebhookDeleteRequiresConfirm(t *testing.T) { } } +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. From 5e3f573947b1576fb4ba727f8ec602ffe7e26cd3 Mon Sep 17 00:00:00 2001 From: plutoless Date: Sun, 7 Jun 2026 06:19:02 -0700 Subject: [PATCH 23/31] docs: document project webhook commands --- README.md | 11 +++++- docs/automation.md | 90 +++++++++++++++++++++++++++++++++++++++++++++- docs/commands.md | 69 +++++++++++++++++++++++++++++++++++ docs/llms.txt | 9 +++++ 4 files changed, 177 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index d4e6067..da2f7b9 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 @@ -113,7 +114,14 @@ The command model is intentionally layered: agora init # recommended: project + clone + env ├── project │ ├── env Print project env values (no file write) -│ └── env write Generic dotenv block (AGORA_* or NEXT_*) +│ ├── env write Generic dotenv block (AGORA_* or NEXT_*) +│ └── 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 └── quickstart └── env write [dir] Template-specific env file and key names ``` @@ -152,6 +160,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..406026e 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,94 @@ 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 --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 +``` + +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. + +`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 config fields: +- `secret` + Webhook signing secret. For `create`, the secret is generated when `--secret` is omitted and returned in JSON so automation can store it. For `list`, `show`, and `update`, secrets are redacted as `********` unless the command supports and receives `--with-secret`. +- `retry` + Retry behavior when returned by the API. + +`project webhook update` preserves existing values for omitted mutable fields. Use `--url`, repeated `--event`, `--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: +- `projectId` +- `feature` +- `configId` +- `enabled` +- `deleted` +- `eventIds` +- `urlRegion` + ### `config path` Example: diff --git a/docs/commands.md b/docs/commands.md index 43c4195..0330b4a 100644 --- a/docs/commands.md +++ b/docs/commands.md @@ -258,6 +258,75 @@ Set the current project context _No local flags. Inherited global flags still apply (see [Global Flags](#global-flags))._ +### `agora project webhook` + +Manage project webhook configurations + +_No local flags. Inherited global flags still apply (see [Global Flags](#global-flags))._ + +### `agora project webhook create` + +Create a webhook configuration + +| Flag | Type | Default | Description | +|------|------|---------|-------------| +| `--delivery-region` | `string` | — | webhook delivery region: cn, sea, na, or eu | +| `--event` | `stringArray` | `[]` | webhook event key, display name, or numeric ID; repeat to subscribe to multiple events | +| `--feature` | `string` | — | project feature for the webhook configuration | +| `--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 | +|------|------|---------|-------------| +| `--feature` | `string` | — | project feature for the webhook configuration | +| `--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 + +| Flag | Type | Default | Description | +|------|------|---------|-------------| +| `--feature` | `string` | — | project feature whose webhook events should be listed | + +### `agora project webhook list` + +List webhook configurations for a project feature + +| Flag | Type | Default | Description | +|------|------|---------|-------------| +| `--feature` | `string` | — | project feature whose webhook configurations should be listed | +| `--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 | +|------|------|---------|-------------| +| `--feature` | `string` | — | project feature for the webhook configuration | +| `--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 | +| `--event` | `stringArray` | `[]` | replacement webhook event key, display name, or numeric ID; repeat to subscribe to multiple events | +| `--feature` | `string` | — | project feature for the webhook configuration | +| `--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 diff --git a/docs/llms.txt b/docs/llms.txt index 12a70f9..5bbbd73 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 --event channel-created --json +agora project webhook list --project --feature rtc --json +agora project webhook delete --project --feature rtc --yes --json +``` + ### Configuration Management ``` agora config get From 2cb599fa6696f2ba4115b6a313f94f2e1f44533b Mon Sep 17 00:00:00 2001 From: plutoless Date: Sun, 7 Jun 2026 06:24:49 -0700 Subject: [PATCH 24/31] docs: refine project webhook documentation --- README.md | 9 +-------- docs/automation.md | 12 ++++-------- 2 files changed, 5 insertions(+), 16 deletions(-) diff --git a/README.md b/README.md index da2f7b9..e2f1117 100644 --- a/README.md +++ b/README.md @@ -114,14 +114,7 @@ The command model is intentionally layered: agora init # recommended: project + clone + env ├── project │ ├── env Print project env values (no file write) -│ ├── env write Generic dotenv block (AGORA_* or NEXT_*) -│ └── 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 +│ └── env write Generic dotenv block (AGORA_* or NEXT_*) └── quickstart └── env write [dir] Template-specific env file and key names ``` diff --git a/docs/automation.md b/docs/automation.md index 406026e..9b51df8 100644 --- a/docs/automation.md +++ b/docs/automation.md @@ -1009,14 +1009,10 @@ Optional config fields: Delete is destructive and requires confirmation. Pass `--yes` (or `-y`) in CLI automation; the MCP delete tool requires `confirm: true`. -Safe branch fields: -- `projectId` -- `feature` -- `configId` -- `enabled` -- `deleted` -- `eventIds` -- `urlRegion` +Safe branch fields by command shape: +- Event discovery: `feature`, `items[].id`, `items[].key` +- List/show/create/update: `projectId`, `feature`, `configId` when present, `enabled`, `eventIds`, `urlRegion` +- Delete: `projectId`, `feature`, `configId`, `deleted` ### `config path` From c67c014295330636a8a4d581d13eb7614b337988 Mon Sep 17 00:00:00 2001 From: plutoless Date: Sun, 7 Jun 2026 06:28:27 -0700 Subject: [PATCH 25/31] docs: clarify webhook list safe fields --- docs/automation.md | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/docs/automation.md b/docs/automation.md index 9b51df8..97170ed 100644 --- a/docs/automation.md +++ b/docs/automation.md @@ -1011,7 +1011,8 @@ Delete is destructive and requires confirmation. Pass `--yes` (or `-y`) in CLI a Safe branch fields by command shape: - Event discovery: `feature`, `items[].id`, `items[].key` -- List/show/create/update: `projectId`, `feature`, `configId` when present, `enabled`, `eventIds`, `urlRegion` +- 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` From 2ba504d310f1af71e8bf51329bd70338f38a6f5f Mon Sep 17 00:00:00 2001 From: plutoless Date: Sun, 7 Jun 2026 06:34:05 -0700 Subject: [PATCH 26/31] docs: clarify webhook secret fields --- docs/automation.md | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/docs/automation.md b/docs/automation.md index 97170ed..69bed08 100644 --- a/docs/automation.md +++ b/docs/automation.md @@ -989,11 +989,11 @@ Each event item includes: - `config` Nested webhook config object with the same config fields. -Optional 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. For `create`, the secret is generated when `--secret` is omitted and returned in JSON so automation can store it. For `list`, `show`, and `update`, secrets are redacted as `********` unless the command supports and receives `--with-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. + 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 update` preserves existing values for omitted mutable fields. Use `--url`, repeated `--event`, `--delivery-region`, `--enabled`, or `--disabled` to replace only those fields. `update` does not rotate or emit the raw secret. From 64e2f1f5baa809afc8ba3742972147a4352af2f0 Mon Sep 17 00:00:00 2001 From: plutoless Date: Sun, 7 Jun 2026 06:37:47 -0700 Subject: [PATCH 27/31] docs: document webhook list fields --- docs/automation.md | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) diff --git a/docs/automation.md b/docs/automation.md index 69bed08..c98319e 100644 --- a/docs/automation.md +++ b/docs/automation.md @@ -970,6 +970,22 @@ Each event item includes: - `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`. From 6ad47c8be6adea8f555f6b2fb7ed6eb65cde9b86 Mon Sep 17 00:00:00 2001 From: plutoless Date: Sun, 7 Jun 2026 06:50:44 -0700 Subject: [PATCH 28/31] fix: document webhook errors and normalize feature --- docs/error-codes.md | 17 ++++++++ internal/cli/integration_test.go | 12 ++++++ internal/cli/webhooks.go | 67 +++++++++++++++++++------------- 3 files changed, 70 insertions(+), 26 deletions(-) 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/internal/cli/integration_test.go b/internal/cli/integration_test.go index 20751a3..fd73af8 100644 --- a/internal/cli/integration_test.go +++ b/internal/cli/integration_test.go @@ -910,6 +910,18 @@ func TestProjectWebhookEventsJSON(t *testing.T) { } } +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 TestProjectWebhookEventsPrettyOmitsPayload(t *testing.T) { configHome := t.TempDir() api := newFakeCLIBFF() diff --git a/internal/cli/webhooks.go b/internal/cli/webhooks.go index 44a92f1..a6f76d6 100644 --- a/internal/cli/webhooks.go +++ b/internal/cli/webhooks.go @@ -179,11 +179,12 @@ func bestWebhookConfigCandidate(items []ncsConfig, matches func(ncsConfig) bool) } func (a *App) listWebhookEvents(feature string) ([]webhookEvent, error) { - if err := validateWebhookFeature(feature); err != nil { + 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) + err = a.apiRequest("GET", "/api/cli/v1/ncs-events/"+feature, nil, nil, &out) if err != nil { return nil, err } @@ -214,19 +215,24 @@ func (a *App) deleteWebhookConfig(projectID, feature string, configID int) error } 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": strings.TrimSpace(feature), + "feature": feature, "items": events, }, nil } func (a *App) projectWebhookList(feature, project string, revealSecrets bool) (map[string]any, error) { - if err := validateWebhookFeature(feature); err != nil { + feature, err := normalizeWebhookFeature(feature) + if err != nil { return nil, err } target, err := a.resolveProjectTarget(project) @@ -237,7 +243,7 @@ func (a *App) projectWebhookList(feature, project string, revealSecrets bool) (m if err != nil { return nil, err } - configs, err := a.listWebhookConfigs(target.project.ProjectID, strings.TrimSpace(feature)) + configs, err := a.listWebhookConfigs(target.project.ProjectID, feature) if err != nil { return nil, err } @@ -249,7 +255,7 @@ func (a *App) projectWebhookList(feature, project string, revealSecrets bool) (m return map[string]any{ "action": "webhook-list", "events": events, - "feature": strings.TrimSpace(feature), + "feature": feature, "items": items, "projectId": target.project.ProjectID, "projectName": target.project.Name, @@ -260,7 +266,8 @@ func (a *App) projectWebhookShow(configID int, feature, project string, withSecr if err := validateWebhookConfigID(configID); err != nil { return nil, err } - if err := validateWebhookFeature(feature); err != nil { + feature, err := normalizeWebhookFeature(feature) + if err != nil { return nil, err } target, err := a.resolveProjectTarget(project) @@ -271,7 +278,7 @@ func (a *App) projectWebhookShow(configID int, feature, project string, withSecr if err != nil { return nil, err } - configs, err := a.listWebhookConfigs(target.project.ProjectID, strings.TrimSpace(feature)) + configs, err := a.listWebhookConfigs(target.project.ProjectID, feature) if err != nil { return nil, err } @@ -280,11 +287,12 @@ func (a *App) projectWebhookShow(configID int, feature, project string, withSecr return nil, err } cfg := redactWebhookConfigSecret(normalizeWebhookConfig(item, events), withSecret) - return webhookConfigResult("webhook-show", target, strings.TrimSpace(feature), cfg), nil + return webhookConfigResult("webhook-show", target, feature, cfg), nil } func (a *App) projectWebhookCreate(opts webhookCreateOptions) (map[string]any, error) { - if err := validateWebhookFeature(opts.Feature); err != nil { + feature, err := normalizeWebhookFeature(opts.Feature) + if err != nil { return nil, err } url := strings.TrimSpace(opts.URL) @@ -299,11 +307,11 @@ func (a *App) projectWebhookCreate(opts webhookCreateOptions) (map[string]any, e if err != nil { return nil, err } - events, err := a.listWebhookEvents(opts.Feature) + events, err := a.listWebhookEvents(feature) if err != nil { return nil, err } - eventIDs, err := resolveWebhookEventIDs(events, eventInputs, opts.Feature) + eventIDs, err := resolveWebhookEventIDs(events, eventInputs, feature) if err != nil { return nil, err } @@ -334,7 +342,7 @@ func (a *App) projectWebhookCreate(opts webhookCreateOptions) (map[string]any, e "urlRegion": urlRegion, "useIpWhitelist": false, } - resp, err := a.createWebhookConfig(target.project.ProjectID, strings.TrimSpace(opts.Feature), body) + resp, err := a.createWebhookConfig(target.project.ProjectID, feature, body) if err != nil { return nil, err } @@ -343,25 +351,26 @@ func (a *App) projectWebhookCreate(opts webhookCreateOptions) (map[string]any, e return nil, err } cfg := normalizeWebhookConfig(item, events) - return webhookConfigResult("webhook-create", target, strings.TrimSpace(opts.Feature), cfg), nil + 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 } - if err := validateWebhookFeature(opts.Feature); err != nil { + 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(opts.Feature) + events, err := a.listWebhookEvents(feature) if err != nil { return nil, err } - configs, err := a.listWebhookConfigs(target.project.ProjectID, strings.TrimSpace(opts.Feature)) + configs, err := a.listWebhookConfigs(target.project.ProjectID, feature) if err != nil { return nil, err } @@ -391,7 +400,7 @@ func (a *App) projectWebhookUpdate(opts webhookUpdateOptions) (map[string]any, e if len(eventInputs) == 0 { return nil, &cliError{Message: "at least one webhook event is required", Code: "WEBHOOK_EVENTS_REQUIRED"} } - eventIDs, err = resolveWebhookEventIDs(events, eventInputs, opts.Feature) + eventIDs, err = resolveWebhookEventIDs(events, eventInputs, feature) if err != nil { return nil, err } @@ -404,7 +413,7 @@ func (a *App) projectWebhookUpdate(opts webhookUpdateOptions) (map[string]any, e "urlRegion": urlRegion, "useIpWhitelist": existing.UseIPWhitelist, } - resp, err := a.updateWebhookConfig(target.project.ProjectID, strings.TrimSpace(opts.Feature), opts.ConfigID, body) + resp, err := a.updateWebhookConfig(target.project.ProjectID, feature, opts.ConfigID, body) if err != nil { return nil, err } @@ -413,45 +422,51 @@ func (a *App) projectWebhookUpdate(opts webhookUpdateOptions) (map[string]any, e return nil, err } cfg := redactWebhookConfigSecret(normalizeWebhookConfig(item, events), false) - return webhookConfigResult("webhook-update", target, strings.TrimSpace(opts.Feature), cfg), nil + 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 } - if err := validateWebhookFeature(feature); err != nil { + 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, strings.TrimSpace(feature), configID); err != nil { + 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": strings.TrimSpace(feature), + "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"} + return "", &cliError{Message: "webhook feature is required", Code: "WEBHOOK_FEATURE_REQUIRED"} } if !isKnownFeature(feature) { - return &cliError{ + return "", &cliError{ Message: fmt.Sprintf("invalid webhook feature %q. Choose one of: %s.", feature, featureListString()), Code: "WEBHOOK_FEATURE_INVALID", } } - return nil + return feature, nil } func validateWebhookConfigID(configID int) error { From a43817d6d4c76197711afb9b88d536db1918f6ee Mon Sep 17 00:00:00 2001 From: plutoless Date: Sun, 7 Jun 2026 08:13:51 -0700 Subject: [PATCH 29/31] fix: improve webhook help and feature flag placement --- docs/commands.md | 71 +++++++++++++++----------------- internal/cli/commands.go | 65 ++++++++++++++++++----------- internal/cli/docgen.go | 2 +- internal/cli/integration_test.go | 24 +++++++++++ 4 files changed, 98 insertions(+), 64 deletions(-) diff --git a/docs/commands.md b/docs/commands.md index 0330b4a..e5ccb29 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,21 @@ 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 -_No local flags. Inherited global flags still apply (see [Global Flags](#global-flags))._ +| Flag | Type | Default | Description | +|------|------|---------|-------------| +| `--feature` | `string` | — | project feature for webhook operations: rtc, rtm, or convoai | ### `agora project webhook create` @@ -272,7 +274,6 @@ Create a webhook configuration |------|------|---------|-------------| | `--delivery-region` | `string` | — | webhook delivery region: cn, sea, na, or eu | | `--event` | `stringArray` | `[]` | webhook event key, display name, or numeric ID; repeat to subscribe to multiple events | -| `--feature` | `string` | — | project feature for the webhook configuration | | `--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 | @@ -283,16 +284,13 @@ Delete a webhook configuration | Flag | Type | Default | Description | |------|------|---------|-------------| -| `--feature` | `string` | — | project feature for the webhook configuration | | `--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 -| Flag | Type | Default | Description | -|------|------|---------|-------------| -| `--feature` | `string` | — | project feature whose webhook events should be listed | +_No local flags. Inherited parent and global flags still apply; run `agora --help` for the full flag set._ ### `agora project webhook list` @@ -300,7 +298,6 @@ List webhook configurations for a project feature | Flag | Type | Default | Description | |------|------|---------|-------------| -| `--feature` | `string` | — | project feature whose webhook configurations should be listed | | `--project` | `string` | — | project ID or exact project name; defaults to the current project context | ### `agora project webhook show` @@ -309,7 +306,6 @@ Show one webhook configuration | Flag | Type | Default | Description | |------|------|---------|-------------| -| `--feature` | `string` | — | project feature for the webhook configuration | | `--project` | `string` | — | project ID or exact project name; defaults to the current project context | | `--with-secret` | `bool` | — | include the webhook secret in the response | @@ -323,7 +319,6 @@ Update a webhook configuration | `--disabled` | `bool` | — | disable the webhook configuration | | `--enabled` | `bool` | — | enable the webhook configuration | | `--event` | `stringArray` | `[]` | replacement webhook event key, display name, or numeric ID; repeat to subscribe to multiple events | -| `--feature` | `string` | — | project feature for the webhook configuration | | `--project` | `string` | — | project ID or exact project name; defaults to the current project context | | `--url` | `string` | — | new webhook endpoint URL | @@ -331,7 +326,7 @@ Update a webhook configuration 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` @@ -348,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` @@ -372,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` @@ -387,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` @@ -431,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/internal/cli/commands.go b/internal/cli/commands.go index 918e7a1..aa89758 100644 --- a/internal/cli/commands.go +++ b/internal/cli/commands.go @@ -984,12 +984,14 @@ func (a *App) buildProjectFeature() *cobra.Command { } 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.", + 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 --event channel-created --project my-app agora project webhook update 42 --feature rtc --disabled --project my-app @@ -1002,57 +1004,65 @@ func (a *App) buildProjectWebhook() *cobra.Command { return cmd.Help() }, } + cmd.PersistentFlags().StringVar(&webhookFeature, "feature", "", "project feature for webhook operations: rtc, rtm, or convoai") - eventsFeature := "" 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() { - eventsFeature = "" + webhookFeature = "" resetWebhookCommandFlags(cmd, "feature") }() - data, err := a.projectWebhookEvents(eventsFeature) + data, err := a.projectWebhookEvents(webhookFeature) if err != nil { return err } return renderResult(cmd, "project webhook events", data) }, } - events.Flags().StringVar(&eventsFeature, "feature", "", "project feature whose webhook events should be listed") cmd.AddCommand(events) - listFeature := "" 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() { - listFeature = "" + webhookFeature = "" listProject = "" resetWebhookCommandFlags(cmd, "feature", "project") }() - data, err := a.projectWebhookList(listFeature, listProject, false) + data, err := a.projectWebhookList(webhookFeature, listProject, false) if err != nil { return err } return renderResult(cmd, "project webhook list", data) }, } - list.Flags().StringVar(&listFeature, "feature", "", "project feature whose webhook configurations should be listed") list.Flags().StringVar(&listProject, "project", "", "project ID or exact project name; defaults to the current project context") cmd.AddCommand(list) - showFeature := "" 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() { - showFeature = "" + webhookFeature = "" showProject = "" showWithSecret = false resetWebhookCommandFlags(cmd, "feature", "project", "with-secret") @@ -1061,19 +1071,17 @@ func (a *App) buildProjectWebhook() *cobra.Command { if err != nil { return err } - data, err := a.projectWebhookShow(configID, showFeature, showProject, showWithSecret) + data, err := a.projectWebhookShow(configID, webhookFeature, showProject, showWithSecret) if err != nil { return err } return renderResult(cmd, "project webhook show", data) }, } - show.Flags().StringVar(&showFeature, "feature", "", "project feature for the webhook configuration") 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) - createFeature := "" createProject := "" createURL := "" createEvents := []string(nil) @@ -1082,9 +1090,13 @@ func (a *App) buildProjectWebhook() *cobra.Command { 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 --event channel-created + agora project webhook --feature rtc create --project prj_123 --url https://example.com/webhook --event 1001 --delivery-region na --json +`), RunE: func(cmd *cobra.Command, _ []string) error { defer func() { - createFeature = "" + webhookFeature = "" createProject = "" createURL = "" createEvents = nil @@ -1093,7 +1105,7 @@ func (a *App) buildProjectWebhook() *cobra.Command { resetWebhookCommandFlags(cmd, "feature", "project", "url", "event", "secret", "delivery-region") }() opts := webhookCreateOptions{ - Feature: createFeature, + Feature: webhookFeature, Project: createProject, URL: createURL, EventInputs: append([]string(nil), createEvents...), @@ -1107,7 +1119,6 @@ func (a *App) buildProjectWebhook() *cobra.Command { return renderResult(cmd, "project webhook create", data) }, } - create.Flags().StringVar(&createFeature, "feature", "", "project feature for the webhook configuration") 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().StringArrayVar(&createEvents, "event", nil, "webhook event key, display name, or numeric ID; repeat to subscribe to multiple events") @@ -1115,7 +1126,6 @@ func (a *App) buildProjectWebhook() *cobra.Command { create.Flags().StringVar(&createDeliveryRegion, "delivery-region", "", "webhook delivery region: cn, sea, na, or eu") cmd.AddCommand(create) - updateFeature := "" updateProject := "" updateURL := "" updateEvents := []string(nil) @@ -1125,9 +1135,13 @@ func (a *App) buildProjectWebhook() *cobra.Command { 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 --event 1001 --enabled --json +`), RunE: func(cmd *cobra.Command, args []string) error { defer func() { - updateFeature = "" + webhookFeature = "" updateProject = "" updateURL = "" updateEvents = nil @@ -1154,7 +1168,7 @@ func (a *App) buildProjectWebhook() *cobra.Command { } updateOpts := webhookUpdateOptions{ ConfigID: configID, - Feature: updateFeature, + Feature: webhookFeature, Project: updateProject, URL: updateURL, EventInputs: append([]string(nil), updateEvents...), @@ -1168,7 +1182,6 @@ func (a *App) buildProjectWebhook() *cobra.Command { return renderResult(cmd, "project webhook update", data) }, } - update.Flags().StringVar(&updateFeature, "feature", "", "project feature for the webhook configuration") 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().StringArrayVar(&updateEvents, "event", nil, "replacement webhook event key, display name, or numeric ID; repeat to subscribe to multiple events") @@ -1177,14 +1190,17 @@ func (a *App) buildProjectWebhook() *cobra.Command { update.Flags().BoolVar(&updateDisabled, "disabled", false, "disable the webhook configuration") cmd.AddCommand(update) - deleteFeature := "" 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() { - deleteFeature = "" + webhookFeature = "" deleteProject = "" resetWebhookCommandFlags(cmd, "feature", "project") }() @@ -1195,14 +1211,13 @@ func (a *App) buildProjectWebhook() *cobra.Command { if !a.rootYes { return &cliError{Message: "confirmation required; pass --yes to delete this webhook configuration", Code: "CONFIRMATION_REQUIRED"} } - data, err := a.projectWebhookDelete(configID, deleteFeature, deleteProject) + data, err := a.projectWebhookDelete(configID, webhookFeature, deleteProject) if err != nil { return err } return renderResult(cmd, "project webhook delete", data) }, } - deleteCmd.Flags().StringVar(&deleteFeature, "feature", "", "project feature for the webhook configuration") deleteCmd.Flags().StringVar(&deleteProject, "project", "", "project ID or exact project name; defaults to the current project context") cmd.AddCommand(deleteCmd) 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 fd73af8..e10554a 100644 --- a/internal/cli/integration_test.go +++ b/internal/cli/integration_test.go @@ -922,6 +922,30 @@ func TestProjectWebhookEventsTrimsFeatureBeforeAPIRequest(t *testing.T) { } } +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") { + 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() From de0c2ecb72e2ba832eee5182b0e217ac1e669c9a Mon Sep 17 00:00:00 2001 From: plutoless Date: Sun, 7 Jun 2026 08:36:03 -0700 Subject: [PATCH 30/31] docs: clarify repeated webhook events --- docs/commands.md | 4 ++-- internal/cli/commands.go | 8 ++++---- internal/cli/integration_test.go | 2 +- 3 files changed, 7 insertions(+), 7 deletions(-) diff --git a/docs/commands.md b/docs/commands.md index e5ccb29..1f58473 100644 --- a/docs/commands.md +++ b/docs/commands.md @@ -273,7 +273,7 @@ Create a webhook configuration | Flag | Type | Default | Description | |------|------|---------|-------------| | `--delivery-region` | `string` | — | webhook delivery region: cn, sea, na, or eu | -| `--event` | `stringArray` | `[]` | webhook event key, display name, or numeric ID; repeat to subscribe to multiple events | +| `--event` | `stringArray` | `[]` | webhook event key, display name, or numeric ID; repeat --event for multiple events | | `--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 | @@ -318,7 +318,7 @@ Update a webhook configuration | `--delivery-region` | `string` | — | new webhook delivery region: cn, sea, na, or eu | | `--disabled` | `bool` | — | disable the webhook configuration | | `--enabled` | `bool` | — | enable the webhook configuration | -| `--event` | `stringArray` | `[]` | replacement webhook event key, display name, or numeric ID; repeat to subscribe to multiple events | +| `--event` | `stringArray` | `[]` | replacement webhook event key, display name, or numeric ID; repeat --event for multiple events | | `--project` | `string` | — | project ID or exact project name; defaults to the current project context | | `--url` | `string` | — | new webhook endpoint URL | diff --git a/internal/cli/commands.go b/internal/cli/commands.go index aa89758..bfbb0b1 100644 --- a/internal/cli/commands.go +++ b/internal/cli/commands.go @@ -1092,7 +1092,7 @@ func (a *App) buildProjectWebhook() *cobra.Command { Short: "Create a webhook configuration", Example: example(` agora project webhook create --feature rtc --project my-app --url https://example.com/webhook --event channel-created - agora project webhook --feature rtc create --project prj_123 --url https://example.com/webhook --event 1001 --delivery-region na --json + agora project webhook --feature rtc create --project prj_123 --url https://example.com/webhook --event 1001 --event 1002 --delivery-region na --json `), RunE: func(cmd *cobra.Command, _ []string) error { defer func() { @@ -1121,7 +1121,7 @@ func (a *App) buildProjectWebhook() *cobra.Command { } 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().StringArrayVar(&createEvents, "event", nil, "webhook event key, display name, or numeric ID; repeat to subscribe to multiple events") + create.Flags().StringArrayVar(&createEvents, "event", nil, "webhook event key, display name, or numeric ID; repeat --event for multiple events") 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) @@ -1137,7 +1137,7 @@ func (a *App) buildProjectWebhook() *cobra.Command { 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 --event 1001 --enabled --json + agora project webhook --feature rtc update 42 --project prj_123 --event 1001 --event 1002 --enabled --json `), RunE: func(cmd *cobra.Command, args []string) error { defer func() { @@ -1184,7 +1184,7 @@ func (a *App) buildProjectWebhook() *cobra.Command { } 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().StringArrayVar(&updateEvents, "event", nil, "replacement webhook event key, display name, or numeric ID; repeat to subscribe to multiple events") + update.Flags().StringArrayVar(&updateEvents, "event", nil, "replacement webhook event key, display name, or numeric ID; repeat --event for multiple events") 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") diff --git a/internal/cli/integration_test.go b/internal/cli/integration_test.go index e10554a..9a5f290 100644 --- a/internal/cli/integration_test.go +++ b/internal/cli/integration_test.go @@ -941,7 +941,7 @@ func TestProjectWebhookHelpShowsFeatureAndExamples(t *testing.T) { } 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") { + 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, "--event 1001 --event 1002") { t.Fatalf("unexpected project webhook create help: exit=%d stdout=%s stderr=%s", create.exitCode, create.stdout, create.stderr) } } From 22eb11a5ad5e90f966884011bce3c3407e574f6b Mon Sep 17 00:00:00 2001 From: plutoless Date: Sun, 7 Jun 2026 08:44:25 -0700 Subject: [PATCH 31/31] feat: use comma separated webhook events flag --- docs/automation.md | 4 +-- docs/commands.md | 4 +-- docs/llms.txt | 2 +- internal/cli/commands.go | 32 +++++++++-------- internal/cli/integration_test.go | 62 ++++++++++++++++++++++++++++---- internal/cli/webhooks.go | 6 ++-- internal/cli/webhooks_test.go | 8 +++++ 7 files changed, 90 insertions(+), 28 deletions(-) diff --git a/docs/automation.md b/docs/automation.md index c98319e..086f5a2 100644 --- a/docs/automation.md +++ b/docs/automation.md @@ -936,7 +936,7 @@ 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 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 @@ -1011,7 +1011,7 @@ Optional top-level `data` fields for `show`, `create`, and `update` (also presen - `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 update` preserves existing values for omitted mutable fields. Use `--url`, repeated `--event`, `--delivery-region`, `--enabled`, or `--disabled` to replace only those fields. `update` does not rotate or emit the raw secret. +`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` diff --git a/docs/commands.md b/docs/commands.md index 1f58473..fa968f8 100644 --- a/docs/commands.md +++ b/docs/commands.md @@ -273,7 +273,7 @@ Create a webhook configuration | Flag | Type | Default | Description | |------|------|---------|-------------| | `--delivery-region` | `string` | — | webhook delivery region: cn, sea, na, or eu | -| `--event` | `stringArray` | `[]` | webhook event key, display name, or numeric ID; repeat --event for multiple events | +| `--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 | @@ -318,7 +318,7 @@ Update a webhook configuration | `--delivery-region` | `string` | — | new webhook delivery region: cn, sea, na, or eu | | `--disabled` | `bool` | — | disable the webhook configuration | | `--enabled` | `bool` | — | enable the webhook configuration | -| `--event` | `stringArray` | `[]` | replacement webhook event key, display name, or numeric ID; repeat --event for multiple events | +| `--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 | diff --git a/docs/llms.txt b/docs/llms.txt index 5bbbd73..eacbe03 100644 --- a/docs/llms.txt +++ b/docs/llms.txt @@ -91,7 +91,7 @@ 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 --event channel-created --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 ``` diff --git a/internal/cli/commands.go b/internal/cli/commands.go index bfbb0b1..5f49096 100644 --- a/internal/cli/commands.go +++ b/internal/cli/commands.go @@ -993,7 +993,7 @@ func (a *App) buildProjectWebhook() *cobra.Command { 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 --event channel-created --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 `), @@ -1084,31 +1084,31 @@ func (a *App) buildProjectWebhook() *cobra.Command { createProject := "" createURL := "" - createEvents := []string(nil) + 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 --event channel-created - agora project webhook --feature rtc create --project prj_123 --url https://example.com/webhook --event 1001 --event 1002 --delivery-region na --json + 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 = nil + createEvents = "" createSecret = "" createDeliveryRegion = "" - resetWebhookCommandFlags(cmd, "feature", "project", "url", "event", "secret", "delivery-region") + resetWebhookCommandFlags(cmd, "feature", "project", "url", "events", "secret", "delivery-region") }() opts := webhookCreateOptions{ Feature: webhookFeature, Project: createProject, URL: createURL, - EventInputs: append([]string(nil), createEvents...), + EventInputs: []string{createEvents}, Secret: createSecret, DeliveryRegion: createDeliveryRegion, } @@ -1121,14 +1121,14 @@ func (a *App) buildProjectWebhook() *cobra.Command { } 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().StringArrayVar(&createEvents, "event", nil, "webhook event key, display name, or numeric ID; repeat --event for multiple events") + 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 := []string(nil) + updateEvents := "" updateDeliveryRegion := "" updateEnabled := false updateDisabled := false @@ -1137,18 +1137,18 @@ func (a *App) buildProjectWebhook() *cobra.Command { 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 --event 1001 --event 1002 --enabled --json + 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 = nil + updateEvents = "" updateDeliveryRegion = "" updateEnabled = false updateDisabled = false - resetWebhookCommandFlags(cmd, "feature", "project", "url", "event", "delivery-region", "enabled", "disabled") + resetWebhookCommandFlags(cmd, "feature", "project", "url", "events", "delivery-region", "enabled", "disabled") }() configID, err := parseWebhookConfigIDArg(args) if err != nil { @@ -1166,12 +1166,16 @@ func (a *App) buildProjectWebhook() *cobra.Command { 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: append([]string(nil), updateEvents...), + EventInputs: eventInputs, DeliveryRegion: updateDeliveryRegion, Enabled: enabled, } @@ -1184,7 +1188,7 @@ func (a *App) buildProjectWebhook() *cobra.Command { } 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().StringArrayVar(&updateEvents, "event", nil, "replacement webhook event key, display name, or numeric ID; repeat --event for multiple events") + 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") diff --git a/internal/cli/integration_test.go b/internal/cli/integration_test.go index 9a5f290..fb13046 100644 --- a/internal/cli/integration_test.go +++ b/internal/cli/integration_test.go @@ -941,7 +941,7 @@ func TestProjectWebhookHelpShowsFeatureAndExamples(t *testing.T) { } 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, "--event 1001 --event 1002") { + 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) } } @@ -978,7 +978,7 @@ func TestProjectWebhookCreateJSON(t *testing.T) { 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", "channel-created", "--json"}, cliRunOptions{env: webhookTestEnv(configHome, api.baseURL)}) + 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) } @@ -989,8 +989,8 @@ func TestProjectWebhookCreateJSON(t *testing.T) { 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}) { - t.Fatalf("expected create body to use eventId 1001, got %#v", body["eventIds"]) + 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) { @@ -998,6 +998,23 @@ func TestProjectWebhookCreateJSON(t *testing.T) { } } +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() @@ -1041,6 +1058,37 @@ func TestProjectWebhookUpdateReadMergePut(t *testing.T) { } } +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() @@ -1155,12 +1203,12 @@ func TestProjectWebhookCreateExplicitSecretAndRejectInvalidSecret(t *testing.T) api.projects[project.ProjectID] = &project persistSessionForIntegration(t, configHome) - ok := runCLI(t, []string{"project", "webhook", "create", "--project", "demo", "--feature", "rtc", "--url", "https://example.com/webhook", "--event", "1001", "--secret", "secret_123", "--json"}, cliRunOptions{env: webhookTestEnv(configHome, api.baseURL)}) + 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", "--event", "1001", "--secret", "this-secret-is-too-long-for-the-backend-pattern", "--json"}, cliRunOptions{env: webhookTestEnv(configHome, api.baseURL)}) + 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) } @@ -1177,7 +1225,7 @@ func TestProjectWebhookCreateDefaultsCNDeliveryRegion(t *testing.T) { 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", "--event", "channel-created", "--json"}, cliRunOptions{env: webhookTestEnv(configHome, api.baseURL)}) + 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) } diff --git a/internal/cli/webhooks.go b/internal/cli/webhooks.go index a6f76d6..fa22ac2 100644 --- a/internal/cli/webhooks.go +++ b/internal/cli/webhooks.go @@ -630,8 +630,10 @@ func findNCSConfigByID(items []ncsConfig, configID int) (ncsConfig, error) { func nonEmptyWebhookEventInputs(inputs []string) []string { out := make([]string, 0, len(inputs)) for _, input := range inputs { - if trimmed := strings.TrimSpace(input); trimmed != "" { - out = append(out, trimmed) + for _, part := range strings.Split(input, ",") { + if trimmed := strings.TrimSpace(part); trimmed != "" { + out = append(out, trimmed) + } } } return out diff --git a/internal/cli/webhooks_test.go b/internal/cli/webhooks_test.go index bd8a247..41e4abe 100644 --- a/internal/cli/webhooks_test.go +++ b/internal/cli/webhooks_test.go @@ -231,6 +231,14 @@ func TestWebhookResolveEventInputsIgnoresEmptyValues(t *testing.T) { } } +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"},