Add support ticket and organization commands - #39
Conversation
Adds two things the CLI could not do before: seeing the organization hierarchy, and working with Bitmovin support tickets. `bitmovin account organizations list` lists every visible organization with its type, parentId and active marker, sub-orgs ordered directly under their parent; `--type root|sub` and `--parent <id>` narrow it. The hierarchy is derived from the parentId of the flat listing because the per-organization `sub-organizations` endpoint answers `1001 An organization with the given id does not exist` for ids that same listing returns — `config list organizations` now uses the shared derivation instead of that endpoint. `bitmovin support tickets list|get|create|comment` covers the ticket endpoints, which the generated SDK does not expose, via a small REST helper that reuses the CLI's credential resolution (API key / OAuth precedence, silent refresh, X-Api-Client identification) and maps failures onto the same error shape SDK calls produce. `--organization` (alias `--tenant-org`) scopes any of them to a sub-organization via X-Tenant-Org-Id. Creating and commenting cannot be undone via the API, so both print the exact payload plus a warning and require an explicit confirmation; `--yes` skips the prompt and is required non-interactively — without a TTY or in --json mode the commands refuse to send rather than silently filing a real ticket. Also handled: `list` rejects an offset that is not a page boundary (the API silently serves an earlier page), `comment` reads the ticket's modifiedAt and sends it as the required updatedStamp so callers never hit the misleading "1004 … Check your JSON syntax", and the 403 hint now names the organization the request was actually scoped to. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
`bitmovin-open-api` marks `/v1/account/zendesk/tickets` as deprecated ("Use
`/support/tickets` instead"). support-service serves both from one controller —
`@RequestMapping("/tickets", "/public/tickets")` behind the two gateway routes —
so these are literally the same handlers and the payloads are unchanged.
Verified equivalent against production before switching: both routes return
total=9 with the same first ticket for the same tenant org, and the detail
endpoint returns the same caseId/modifiedAt. Read paths re-checked through the
CLI after the change (list, get with comments), and `create` still refuses to
send without confirmation (exit 2, payload printed, no request made).
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…st strength Follow-up to the five-dimension review of #39. Nothing here changes what the commands are for; it closes gaps where they could do the wrong thing quietly. **Scope safety.** `--organization ""` (an unset shell variable in CI) resolved to the empty string, which dropped the `X-Tenant-Org-Id` header while still sending `organizationId: ""` — and the API treats blank as absent, so a ticket meant for a sub-org was filed against the credential's own organization, with the preview showing no organization at all. Blank is now rejected. **Organizations were silently truncated.** The generated SDK's `organizations.list()` accepts no query parameters, so it returned only the API's default first page. On a larger account, sub-orgs whose parent sat on a later page rendered as roots, and `--parent` reported a visible org as invisible. Confirmed the SDK still has no paged signature in the latest published 1.277.0 (and no support-ticket module at all, so the REST helper stays justified). Now paged through in full, and a short page while the API reports more fails loudly instead of returning a partial hierarchy. **A 2xx with an unparsable body was treated as success.** An intercepting gateway answering a create POST with an HTML page yielded `{}` and the CLI reported a ticket that was never filed. The envelope and its `status` are now required, and requests refuse redirects (undici does not strip the custom `X-Api-Key` across origins the way it strips `Authorization`) and carry a 30s timeout. **The comment collision stamp protected nothing.** It was read after the body was composed, so it always matched current server state and no collision could ever be detected. The stamp now comes from a read whose newest comment is shown in the confirmation, so a reply written against stale state is rejected rather than silently accepted. Also: the offset hint used `Math.round`, suggesting a page that skips results; filter values were validated trimmed but sent raw, so `--status "open, pending"` round-tripped to a 400; `--type sub` filtered on `parentId` while displaying `type`; the `Showing X-Y of N` footer reported the API's pending-only count as a grand total; `--allow-file-access` was silently dropped outside `--category encoding` (the API accepts and discards it, so the check must be local); attachment URLs — capability links the API documents as usable by anyone holding them — were printed unredacted, now behind `--show-secrets`; ticket text is stripped of control characters so a customer comment cannot rewrite the rendered conversation or forge the `(Bitmovin)` attribution; `--body-file` is capped and previewed head-and-tail so a large file cannot scroll the warning off screen. **Previews now always go to stderr, including under `--json`.** The docs claimed both write commands print the exact payload; in JSON mode they printed nothing, which is exactly the scripted path the docs recommend. stdout stays clean JSON. **Tests.** Mutation testing showed the confirmation gate, `getAuthHeaders`, the sub-limit offset boundary and the totals line were entirely unpinned — the gate and auth headers because every command test mocks them. Added direct tests for all four plus the new behaviour; eight planted mutations, including "canPrompt always true" and "confirm defaults to yes", now each fail the suite. 309 tests. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…e confirm policy The four "should fix now" items from the architecture review of #39. **`--organization` is now honourable by any command.** It lived in `lib/organizations.ts` and only the REST-backed support commands could act on it: `getClient()` hardcoded the configured organization and `getApi()` accepted no override, so adding the flag to, say, `encoding jobs list` would have parsed happily and been ignored while `--help` claimed otherwise. `getClient()` now takes a tenant-organization override, `getApi()` passes it, and the flag is `BaseCommand.tenantOrgFlag` next to `baseFlags`. Pinned by a test asserting the override reaches the SDK constructor. **Credential and organization scope resolved in one place.** Commands were threading `flags['api-key']` by hand and calling a config-reading helper, which would have diverged from SDK-backed commands the moment a credential-affecting base flag landed (the in-flight `feature/config-profiles` branch adds `--profile`; REST commands would have kept sending the default profile's credentials — a wrong-tenant write, since create/comment share that path). Now `BaseCommand.requestScope()` derives both from the parsed flags, and tenant resolution is a pure function in `lib/tenant.ts`. It is its own module rather than part of `client.ts` for a practical reason: every command test mocks `client.js` wholesale, so importing it from there broke 16 unrelated test files. `lib/tenant.ts` also leaves `organizations.ts` as pure account-resource derivation instead of three unrelated concerns in one file. **Create fields declared once.** `CREATE_TICKET_FIELDS` generates both the oclif flags and the payload mapping. The same ~18 fields were listed three times and the call site cast the parsed flags, so a field added to two of the three compiled cleanly and silently never reached the API. **One destructive-action policy.** `confirmDestructive()` and a shared `yesFlag` replace the ~16 lines duplicated in both write commands. It returns a distinct `unconfirmable` outcome so "the user declined" and "nobody could be asked" keep different exit codes — collapsing them is how a scripted run files something silently. `encoding jobs delete`/`stop` can adopt it rather than adding a fourth convention to the three the repo already had. Three mutations verify the new seams: ignoring the org override, making confirmDestructive always proceed, and dropping a mapped field value each fail the suite. 315 tests. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Review context for @SInCEThis went through a five-dimension review (correctness, architecture, documentation, maintainability, security) before landing here. Everything raised is now addressed; three commits, cleanest read is commit-by-commit:
Things worth your eye specifically
Two API behaviours that forced local checks — both verified against
Known follow-ups, deliberately not in this PR
Testing: 315 tests. The suite is mutation-verified rather than just green — eleven planted mutations each fail, including "canPrompt always true", "confirm defaults to yes", "getAuthHeaders sends no credential", and "getClient ignores the org override". Worth knowing why: the confirmation gate and |
…cover the table
The maintainability dimension (which 529'd twice and finally ran against
`9245a95`) found a live bug I introduced plus two real regressions in the
refactor that preceded it.
**`abbreviate(text, 300, 0)` printed the whole text and called it truncated.**
`slice(-0)` is `slice(0)`, so the head-only preview added in the comment
confirmation emitted head-300, "[4700 characters omitted]", and then all 5000
characters. Worst of both: the label lied and the "PUBLIC comment" warning
scrolled away — exactly the failure the preview exists to prevent.
**The one-table refactor traded compile-time safety for none.**
`Object.fromEntries` widened the return to `{[k: string]: Flag}`, which erased the
18 flag names from oclif's parsed-flags type; `flags['sdk-version']` stopped
compiling and the only thing keeping the command building was the
`as CreateTicketFlags` cast. So the drift the table was meant to eliminate had
simply moved. The generator now returns exact per-flag types (boolean fields
`boolean`, the rest `string | undefined`) and the cast is gone entirely, so the
flag→payload chain is type-checked again.
**And the table had no test.** 11 of 20 payload keys could be renamed or deleted
with the suite green — my previous commit message claimed otherwise, which was
true only of the `values` translations and `organizationId`. There is now an
exhaustive payload assertion, so renaming `subject` to `title` or dropping an
entry fails.
Also from that review:
- `requestScope()` was not adopted by the two organization commands — the drift it
exists to prevent already existed inside the PR. Both now use it, and
`listOrganizations` no longer takes an SDK client it never used (both callers
were constructing one, resolving credentials and refreshing OAuth, to discard it).
- The organization paging loop had no iteration bound: a proxy that strips query
parameters would loop forever and grow the array without limit.
- Terminal sanitization was missing on the comment confirmation's ticket subject
(escape sequences there can overwrite the warning right above the y/N prompt) and
on the requester/organization names in `tickets get`.
- `config list organizations` nested an orphan sub-org under whichever unrelated
root preceded it, asserting a parent relationship that does not exist. That case
only became reachable when this PR started deriving the tree from `parentId`.
- 409, network failure and timeout had no CLI handling, so the collision protection
the docs promise surfaced as `API error: 409` and a dropped connection as a bare
`TypeError: fetch failed` with a stack. The timeout wording does not suggest
retrying: it fires after the request was sent, so for a create the ticket may
exist.
- `--sort createdAt:desc` was validated case-insensitively and sent raw, so the API
ignored the direction; `MAX_BODY_LENGTH` bounded only `--body-file`, not `--body`.
Six mutations verify the new work — reintroducing the abbreviate bug, renaming a
payload key, deleting a field entry, sending the sort direction raw, removing the
paging bound, and dropping `--api-key` — each fail. 323 tests.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Update: maintainability review landed, and it found a live bug — plus a correction to my earlier commentThe fifth review dimension finally completed (it hit API errors twice before) and reviewed the code after the architecture refactor. It found one real bug and two regressions that refactor introduced. All fixed in Correction to my previous comment. I wrote that "dropping a mapped field value fails the suite". That was only true for the The live bug: The typing regression: Also fixed from that review: 323 tests, 17 mutations verified across the three fix rounds. Two notes on the new ones: the paging-bound test carries an explicit 10s timeout so removing the bound fails fast instead of hanging CI, and the Still open, unchanged from my earlier comment: the |
SInCE
left a comment
There was a problem hiding this comment.
Code review (high effort)
Reviewed the 5-commit diff and read the two author comments first — everything documented there as known/accepted (Ctrl+D prompt hang, config-direct reads pending feature/config-profiles, attachments, rarer create fields, UNKNOWN category filtering, confirmDestructive not yet adopted by encoding jobs delete/stop) is excluded from the findings below.
The PR is unusually well-defended (mutation-tested, careful comments). Two items are worth blocking on; the rest are polish. Requesting changes for the two high-severity ones — happy to approve once #1 and #2 are addressed (or consciously deferred).
Must fix
get --jsonleaks attachment download URLs, bypassing the--show-secretsmasking (data exposure).account organizations listdeclares--organizationbut ignores it, and throws on--organization ""for a flag that has no effect — contradicting the flag's own "can never be silently ignored" invariant, and its printed usage hint.
Worth fixing
sanitizeForTerminalkeeps\r, leaving a terminal line-overwrite vector inside its own threat model.describeTransportFailuremisclassifies anyTypeErrormentioning "network"/"socket" as a transport failure, for every command.validateSortvsnormalizeSorttrim mismatch → spaced sort spuriously rejected.- 403 handler names the config org for SDK errors (latent until an SDK command adopts the flag).
Cleanup
resolveBodyduplicated betweencreate.tsandcomment.ts(already drifting).- Misplaced doc comment above
normalizeSort. - Dead defensive full-list re-sort in
toOrganizationRows. limit/offsetflag pair re-declared (9th copy) — a sharedpaginationFlagswould fit.
Checked and cleared (no change needed)
normalizeEnumFilternot lowercasing is fine — per the code's own note the API uppercases filter values server-side (unlike sort direction, which is whynormalizeSortexists).getTicketbefore the confirm gate incomment.tsis intentional (supplies the collision stamp + latest-comment preview).config list organizationsfailing hard on a short page is deliberate.buildCreateTicketPayloadcategory gate is correct (oclif boolean flags default toundefined, notfalse, when absent).
- Mask attachment download URLs in --json, not only in the human view - Drop the no-op --organization from `account organizations list` - Strip stray \r in sanitizeForTerminal, keeping CRLF line breaks - Classify transport failures by cause code, not message substrings - Name the resolved organization on 403s from SDK-backed commands - Trim in validateSort so a spaced --sort is no longer rejected - Share one bounded body resolver between `create` and `comment` - Move the misplaced doc comment onto normalizeEnumFilter - Skip the dead full-list re-sort in toOrganizationRows - Declare --limit/--offset once as BaseCommand.paginationFlags Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01NL9FjbdrXuJ7QjJqD1ivjL
|
All 10 items addressed in 97d7a41 — both must-fixes, the four worth-fixing, and the four cleanups. Nothing consciously deferred; each thread has a reply with the specifics. Two decisions worth calling out:
Also folded in, same threat model as #3: attachment file names are now sanitized before printing (they are chosen by whoever uploaded the file). 337 tests pass, lint and tsc clean. Each fix was mutation-checked — reverting it individually fails the new test. |
SInCE
left a comment
There was a problem hiding this comment.
Security review (high effort, second pass)
Re-reviewed at head 97d7a41 with a security focus. The 10 issues from the previous round are all fixed, and I confirmed those fixes introduced no new weaknesses. The REST layer is clean on injection: encodeURIComponent(caseId) neutralizes path traversal, undici rejects CRLF header injection before send, no prototype-pollution sink, no ReDoS, redirect:'error' prevents credential-forwarding, the confirm gate fails closed, and the collision stamp is unbypassable.
Requesting changes on one item — #1, a real --show-secrets bypass (capability URLs leak via comment body/htmlBody). The rest are worth-fixing hardening and defense-in-depth; the standout is #6, which is the root cause of several others: sanitize at the output boundary and #2/#3/#8/#10 collapse into one fix.
Must fix
- Inline capability URLs in comment
body/htmlBodyare never redacted →--show-secretsbypass inget --json.
Worth fixing
- API error text (
developerMessage/message) printed raw → ANSI/CR injection on the error path. - Org
namecolumn rendered unsanitized in the org-list commands (get.tssanitizes the same field). - Untrusted response body echoed into
developerMessage(to stdout under--json). - Empty/whitespace config org bypasses the empty-string scope guard (flag-only).
- Root cause: terminal-safety is per-print-site, not at the
outputData/formatTableboundary.
Defense-in-depth / consistency
- Unbounded
response.text()— memory-exhaustion DoS from a hostile/compromised endpoint. - Attachment
urlprinted unsanitized under--show-secrets. tickets get --show-secretsprints no exposure warning (account infodoes).- Create/comment confirmation preview prints the body unsanitized above the y/N prompt.
Checked and cleared
Path traversal, CRLF header injection, prototype pollution, ReDoS, redirect/credential-forwarding, confirm-gate fail-closed, collision stamp, create header/body org match (non-empty case), API key / OAuth token never printed.
| ...(options.body !== undefined && {body: JSON.stringify(options.body)}), | ||
| }); | ||
|
|
||
| const text = await response.text(); |
There was a problem hiding this comment.
Defense-in-depth — unbounded response.text() (memory-exhaustion DoS).
The whole response body is buffered into a string with no size cap; AbortSignal.timeout(30s) bounds time, not bytes. A malicious/compromised endpoint (or MITM — X-Api-Key is a custom header, and only redirects, not body size, are refused) returning a multi-GB body OOM-kills the CLI, in contrast to the carefully capped 65 KB outbound side. Consider a Content-Length pre-check or a bounded read.
There was a problem hiding this comment.
Not taken, deliberately. The endpoint is api.bitmovin.com over TLS with redirects refused, so this needs a compromised API or a MITM holding a valid cert, and the payoff is OOM-killing a short-lived CLI process on the user’s own machine — no data crosses a trust boundary. I did shrink what we keep from an unexpected body to 200 characters and stopped trimming the whole string to produce it (#4), which removes the only place the full body was being processed beyond the parse. Happy to add a Content-Length pre-check if you feel strongly, but I would rather not add a code path we cannot test against a real adversary.
- Move sanitizeForTerminal to lib/sanitize.ts, its own concern - Sanitize every rendered table cell in output.ts, not per print site - Sanitize API-supplied error text on the human error path - Treat a blank configured organization as none, matching the flag guard - Refuse `config set <key> ""` instead of storing a value that means nothing - Warn about exposure when `tickets get --show-secrets` is passed - Sanitize the revealed attachment URL and the previewed body - Shorten the non-envelope response excerpt, sliced before trimming - Scope the redaction docstring to the structured attachment URL Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01NL9FjbdrXuJ7QjJqD1ivjL
|
Second round addressed in a0e5251 — 7 taken as written, 2 taken in part, 1 declined with a reason. Per-thread replies have the detail. Taken: #2 (error text sanitized), #3 (via the boundary fix), #5 (blank configured org — sharpest finding of the round; Taken in part:
Declined: #7 — TLS to a pinned host with redirects refused, so it needs a compromised API or a valid-cert MITM, and the payoff is OOM-killing a short-lived CLI on the user's own machine. Say the word and I will add a One correction on #6: the boundary fix does not cover #2, #8 and #10 — those write straight to stderr/stdout and never pass through 348 tests pass, lint and tsc clean. Every fix in this round was mutation-checked individually. |
SInCE
left a comment
There was a problem hiding this comment.
Approving ✅
Both review rounds are fully addressed, verified against the code at a0e5251 (not just the replies):
- Correctness round (10) — fixed in
97d7a41. - Security round (10) — fixed in
a0e5251: 7 taken as written, 2 taken in part (#1 docstring scoped, #4 excerpt capped + slice-before-trim), 1 declined (#7) with sound reasoning.
The REST layer is clean on injection/SSRF/prototype-pollution/ReDoS/credential-forwarding, the confirm gate fails closed, the collision stamp is unbypassable, and terminal-safety now lives at the output boundary (lib/sanitize.ts + output.ts) so future rendered fields are safe by default. CI green, 348 tests, lint + tsc clean, each fix mutation-checked. This supersedes my earlier changes-requested reviews.
Two optional, non-blocking follow-ups (either can be a later PR):
- #7 — a
Content-Lengthpre-check onresponse.text()as defense-in-depth against a malicious/MITM endpoint (you reasonably declined it; noting for the record). - #1 — closing the inline-image capability-URL case (a link inside comment text) server-side or via an opt-in
--redact-linksflag; the docstring overclaim itself is fixed.
Nice work — thorough and well-defended throughout.
What
Adds two things the CLI could not do before: listing organizations (including the sub-org hierarchy) and working with support tickets.
Sub-org targeting is via
--organization, which sets theX-Tenant-Org-Idheader, so tickets can be listed and filed for a sub-organization rather than only the credential's own org.Creating a ticket asks first, and refuses rather than guessing
createandcommentprint the exact payload with a warning that the ticket is real and cannot be withdrawn via the API, then prompt with the default set to no.With no TTY, or in
--jsonmode, and without--yes, the command errors out (exit 2) and sends nothing:This is deliberate and load-bearing: while verifying these endpoints by hand I filed three junk tickets into production Zendesk because I sent throwaway payloads to a write endpoint I expected to reject me. A CLI that makes that easy is a CLI that will do it to someone else.
organizationIdis never a separate flag — it is always pinned to theX-Tenant-Org-Idvalue, so the "body organizationId must match the authorized org" rejection is unreachable from the CLI.Validation happens before the request
Each of these fails locally with an actionable message instead of a confusing API error:
--offsetnot a multiple of--limit--searchwith punctuation, or > 100 chars--status/--category/--priority/--severity/--sort--encoding-idwithout--category encoding;--license/--page-urloutside player/analyticscommentwithout a stampcommentreads the ticket first and sends itsmodifiedAtas the requiredupdatedStamp, so nobody hits the misleading1004 … Check your JSON syntaxthat a missing stamp actually producesA
403now names the organization the request was scoped to and points ataccount organizations list, since the common cause is a tenant org the credential has no ACL grant for.Notes on the endpoints
/v1/support/tickets.bitmovin-open-apimarks/v1/account/zendesk/ticketsas deprecated.support-serviceserves both from one controller (@RequestMapping("/tickets", "/public/tickets")) behind the two gateway routes, so they are the same handlers. Verified equivalent against production before switching: both returntotal=9with the same first ticket for the same tenant org, and the detail endpoint returns the samecaseId/modifiedAt.GET /v1/account/organizations/{id}/sub-organizationsreturns1001 An organization with the given id does not existfor a valid, visible org id, so it is not used;parentIdonGET /v1/account/organizationsis.config list organizationswas moved onto the same shared derivation (its output is unchanged).@bitmovin/api-sdk, so they go through a small REST helper (src/lib/rest.ts) that reuses the CLI's existing credential resolution — API key / OAuth precedence, silent token refresh, andX-Api-Clientidentification stay identical to SDK calls. No hand-rolled config reads; the API key is never printed.Testing
npm run build,npm run lint,npm testall clean — 34 files, 281 tests (+26 new; was 29 files).No POST was made from the test suite — HTTP is mocked throughout (
vi.mockonlib/rest.jsfor commands, stubbedfetchfor the REST client). Live checks were read-only GETs plus the create-refusal path above.Out of scope
Attachments (
POST /tickets/uploadsand a comment'suploads) — documented as "use the dashboard". The rarer create fields (collaborators,businessImpact,streamId, the player/analytics config blobs).account organizations get/create.🤖 Generated with Claude Code