Skip to content

Add support ticket and organization commands - #39

Merged
lukaskroepfl merged 7 commits into
mainfrom
feat/support-tickets-and-organizations
Aug 17, 2026
Merged

Add support ticket and organization commands#39
lukaskroepfl merged 7 commits into
mainfrom
feat/support-tickets-and-organizations

Conversation

@lukaskroepfl

Copy link
Copy Markdown
Member

What

Adds two things the CLI could not do before: listing organizations (including the sub-org hierarchy) and working with support tickets.

bitmovin account organizations list [--parent <org-id>] [--type root|sub]

bitmovin support tickets list    [--organization <org-id>] [--status …] [--category …]
                                 [--priority …] [--severity …] [--search …] [--sort …]
bitmovin support tickets get     <ID> [--organization <org-id>]
bitmovin support tickets create  --category … --body … [--organization <org-id>] [-y]
bitmovin support tickets comment <ID> --body … [--html] [-y]

Sub-org targeting is via --organization, which sets the X-Tenant-Org-Id header, 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

create and comment print 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 --json mode, and without --yes, the command errors out (exit 2) and sends nothing:

$ bitmovin support tickets create --category other --subject "…" --body "…" --organization 25d46556-…
This files a REAL support ticket with Bitmovin support.
Support engineers will see it, and it cannot be withdrawn via the API.

Organization: 25d46556-817e-48f3-b762-91d2b061989d
Payload: { "body": …, "category": "other", "subject": …,
           "organizationId": "25d46556-817e-48f3-b762-91d2b061989d" }
 ›   Error: Creating a support ticket requires confirmation.

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.

organizationId is never a separate flag — it is always pinned to the X-Tenant-Org-Id value, 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:

Input Why it is rejected client-side
--offset not a multiple of --limit the API pages by offset/limit and silently returns an earlier page
--search with punctuation, or > 100 chars the API rejects it
bad --status / --category / --priority / --severity / --sort validated against the real enums
--encoding-id without --category encoding; --license/--page-url outside player/analytics the API gates these by category
comment without a stamp comment reads the ticket first and sends its modifiedAt as the required updatedStamp, so nobody hits the misleading 1004 … Check your JSON syntax that a missing stamp actually produces

A 403 now names the organization the request was scoped to and points at account organizations list, since the common cause is a tenant org the credential has no ACL grant for.

Notes on the endpoints

  • Uses the non-deprecated /v1/support/tickets. bitmovin-open-api marks /v1/account/zendesk/tickets as deprecated. support-service serves 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 return total=9 with the same first ticket for the same tenant org, and the detail endpoint returns the same caseId/modifiedAt.
  • The org hierarchy is derived from the flat listing. GET /v1/account/organizations/{id}/sub-organizations returns 1001 An organization with the given id does not exist for a valid, visible org id, so it is not used; parentId on GET /v1/account/organizations is. config list organizations was moved onto the same shared derivation (its output is unchanged).
  • These endpoints are not in the generated @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, and X-Api-Client identification stay identical to SDK calls. No hand-rolled config reads; the API key is never printed.

Testing

npm run build, npm run lint, npm test all clean — 34 files, 281 tests (+26 new; was 29 files).

No POST was made from the test suite — HTTP is mocked throughout (vi.mock on lib/rest.js for commands, stubbed fetch for the REST client). Live checks were read-only GETs plus the create-refusal path above.

Out of scope

Attachments (POST /tickets/uploads and a comment's uploads) — 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

lukaskroepfl and others added 4 commits August 12, 2026 14:51
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>
@lukaskroepfl
lukaskroepfl requested a review from SInCE August 13, 2026 14:09
@lukaskroepfl

Copy link
Copy Markdown
Member Author

Review context for @SInCE

This 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:

  1. f4e3fc7 — the commands
  2. dfa6d30 — correctness/security/docs fixes
  3. 9245a95 — the architecture refactor (shared tenant scope, one field table, one confirm policy)

Things worth your eye specifically

  • getClient() now takes a tenant-organization override and --organization moved to BaseCommand.tenantOrgFlag. This touches shared plumbing every topic uses. The motivation: the flag previously lived in lib/organizations.ts and only REST-backed commands could honour it, so adding it to an SDK-backed command would have parsed fine and been silently ignored. If you'd rather it were opt-in per command rather than available from BaseCommand, say so — it's a one-line move.
  • lib/tenant.ts exists for a mundane reason: every command test mocks client.js wholesale, so putting resolveTenantOrgId there broke 16 unrelated test files. Its own module also keeps organizations.ts to pure derivation.
  • confirmDestructive() returns a three-way outcome (proceed / declined / unconfirmable) rather than a boolean, so a declined prompt and an unaskable one keep different exit codes. encoding jobs delete and stop currently confirm nothing — this is ready for them to adopt.

Two API behaviours that forced local checks — both verified against support-service, not guessed:

  • A category-mismatched field (--allow-file-access outside --category encoding) is accepted and silently dropped by the API, not rejected. So the ticket looks filed while the data never arrives, and the check has to be client-side. There's a comment saying so; please don't let anyone relax it expecting a loud error.
  • --offset must be 0 or a multiple of --limit: the server computes page = (offset / limit) + 1 with integer division, so a non-multiple offset silently serves an earlier page.

Known follow-ups, deliberately not in this PR

  • getAuthHeaders/requestScope still read config directly. When feature/config-profiles lands, requestScope() is the one place that needs the profile threaded through — I left the shape to match that branch rather than guessing.
  • Attachments (POST /tickets/uploads) unimplemented; the rarer create fields (collaborators, businessImpact, streamId, player/analytics config blobs) have no flags; TICKET_CATEGORIES omits the API's UNKNOWN, so tickets in that state can't be filtered.
  • Ctrl+D at the confirmation prompt neither confirms nor aborts — the promise never settles and the process exits 0. Not a bypass (nothing is sent), but a wrapper could read it as success.

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 getAuthHeaders were originally completely unpinned, because every command test mocks them. No POST was made from the test suite; live checks were read-only GETs plus the create-refusal path.

…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>
@lukaskroepfl

Copy link
Copy Markdown
Member Author

Update: maintainability review landed, and it found a live bug — plus a correction to my earlier comment

The 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 18ec9fb; CI green.

Correction to my previous comment. I wrote that "dropping a mapped field value fails the suite". That was only true for the values translations (REQUEST_TYPES etc.) and organizationId. 11 of the 20 payload keys could be renamed or deleted with the suite green — so the single-source-of-truth table removed the duplication and added no coverage, turning "compiles but drifts" into "silently wrong and unverified". There is now an exhaustive payload assertion.

The live bug: abbreviate(text, 300, 0) printed the entire text while labelling it truncated, because slice(-0) is slice(0). It was in the comment confirmation, so a long support reply emitted head-300, [4700 characters omitted], then all 5000 characters — pushing the PUBLIC comment warning off screen, which is precisely what the preview exists to prevent.

The typing regression: Object.fromEntries widened createTicketFlags() to {[k: string]: Flag}, erasing the 18 flag names from oclif's parsed-flags type. flags['sdk-version'] no longer compiled, and the only thing keeping create.ts building was the as CreateTicketFlags cast — so the drift the table was meant to remove had just moved somewhere less visible. The generator now returns exact per-flag types and the cast is gone entirely, so the flag→payload chain is type-checked again.

Also fixed from that review: requestScope() wasn't adopted by the two organization commands (the drift it exists to prevent already existed in this PR); the paging loop had no iteration bound; sanitization was missing on the comment confirmation's subject and on tickets get's requester/organization; config list organizations nested an orphan sub-org under an unrelated root; 409/network/timeout had no CLI handling; --sort createdAt:desc was sent lowercase and silently ignored; and MAX_BODY_LENGTH bounded only --body-file, not --body.

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 CREATE_TICKET_FIELDS payload assertion is exhaustive on purpose — adding a field means updating that test, which is the point.

Still open, unchanged from my earlier comment: the feature/config-profiles coupling, attachments, the rarer create fields, UNKNOWN category filtering, and Ctrl+D at the prompt. The review also left a set of low-severity items I did not take: test files that no longer sit beside the modules they test, constants re-typed as literals in the docs, and the four-place edit needed to add a list filter. Happy to do any of those if you'd rather they land here.

@SInCE SInCE left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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

  1. get --json leaks attachment download URLs, bypassing the --show-secrets masking (data exposure).
  2. account organizations list declares --organization but 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

  1. sanitizeForTerminal keeps \r, leaving a terminal line-overwrite vector inside its own threat model.
  2. describeTransportFailure misclassifies any TypeError mentioning "network"/"socket" as a transport failure, for every command.
  3. validateSort vs normalizeSort trim mismatch → spaced sort spuriously rejected.
  4. 403 handler names the config org for SDK errors (latent until an SDK command adopts the flag).

Cleanup

  1. resolveBody duplicated between create.ts and comment.ts (already drifting).
  2. Misplaced doc comment above normalizeSort.
  3. Dead defensive full-list re-sort in toOrganizationRows.
  4. limit/offset flag pair re-declared (9th copy) — a shared paginationFlags would fit.

Checked and cleared (no change needed)

  • normalizeEnumFilter not lowercasing is fine — per the code's own note the API uppercases filter values server-side (unlike sort direction, which is why normalizeSort exists).
  • getTicket before the confirm gate in comment.ts is intentional (supplies the collision stamp + latest-comment preview).
  • config list organizations failing hard on a short page is deliberate.
  • buildCreateTicketPayload category gate is correct (oclif boolean flags default to undefined, not false, when absent).

Comment thread src/commands/support/tickets/get.ts
Comment thread src/commands/account/organizations/list.ts
Comment thread src/lib/support-tickets.ts Outdated
Comment thread src/lib/base-command.ts Outdated
Comment thread src/lib/support-tickets.ts
Comment thread src/lib/base-command.ts Outdated
Comment thread src/commands/support/tickets/comment.ts Outdated
Comment thread src/lib/support-tickets.ts
Comment thread src/lib/organizations.ts Outdated
Comment thread src/commands/support/tickets/list.ts Outdated
- 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
@lukaskroepfl

Copy link
Copy Markdown
Member Author

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:

  • Bump vite from 8.0.3 to 8.0.10 #2: dropped --organization from account organizations list rather than honouring it — /account/organizations is scoped by the credential, not by X-Tenant-Org-Id, so honouring it would mean inventing a scope the endpoint does not have. --parent already covers sub-org filtering.
  • Mask secrets in account info by default #6: fixed now rather than left latent — requestScope() memoizes the resolved scope and the 403 branch falls back to it, so the seam is safe before an SDK command adopts the flag.

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.

@lukaskroepfl
lukaskroepfl requested a review from SInCE August 17, 2026 12:03

@SInCE SInCE left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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

  1. Inline capability URLs in comment body/htmlBody are never redacted → --show-secrets bypass in get --json.

Worth fixing

  1. API error text (developerMessage/message) printed raw → ANSI/CR injection on the error path.
  2. Org name column rendered unsanitized in the org-list commands (get.ts sanitizes the same field).
  3. Untrusted response body echoed into developerMessage (to stdout under --json).
  4. Empty/whitespace config org bypasses the empty-string scope guard (flag-only).
  5. Root cause: terminal-safety is per-print-site, not at the outputData/formatTable boundary.

Defense-in-depth / consistency

  1. Unbounded response.text() — memory-exhaustion DoS from a hostile/compromised endpoint.
  2. Attachment url printed unsanitized under --show-secrets.
  3. tickets get --show-secrets prints no exposure warning (account info does).
  4. 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.

Comment thread src/lib/support-tickets.ts
Comment thread src/lib/base-command.ts Outdated
Comment thread src/lib/organizations.ts
Comment thread src/lib/rest.ts
Comment thread src/lib/tenant.ts Outdated
Comment thread src/lib/support-tickets.ts Outdated
Comment thread src/lib/rest.ts
...(options.body !== undefined && {body: JSON.stringify(options.body)}),
});

const text = await response.text();

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Comment thread src/commands/support/tickets/get.ts Outdated
Comment thread src/commands/support/tickets/get.ts
Comment thread src/commands/support/tickets/comment.ts Outdated
- 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
@lukaskroepfl

Copy link
Copy Markdown
Member Author

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; config set <key> "" now refused too), #6 (sanitizeForTerminal moved to lib/sanitize.ts, output.ts sanitizes every rendered cell), #8, #9, #10.

Taken in part:

  • Bump lodash from 4.17.23 to 4.18.1 #1 — the inline-image premise is right, but I am not regex-scrubbing URLs out of comment text: that text is what get exists to show, and the match is unreliable both ways. Fixed the real defect instead — my docstring overclaimed. Scope is now stated explicitly in the code and in the --show-secrets docs row.
  • Fix encoding templates validate to support JSON Schema 2020-12 #4 — kept the excerpt (it is what makes "a gateway answered with HTML" diagnosable) but capped it at 200 chars, sanitized it on print, and sliced-before-trim as you suggested.

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 Content-Length pre-check anyway.

One correction on #6: the boundary fix does not cover #2, #8 and #10 — those write straight to stderr/stdout and never pass through output.ts, so each got its own fix. It closes #3 and makes future rendered fields safe by default, which is the real win.

348 tests pass, lint and tsc clean. Every fix in this round was mutation-checked individually.

@lukaskroepfl
lukaskroepfl requested review from SInCE and hawk23 and removed request for hawk23 August 17, 2026 12:52

@SInCE SInCE left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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-Length pre-check on response.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-links flag; the docstring overclaim itself is fixed.

Nice work — thorough and well-defended throughout.

@lukaskroepfl
lukaskroepfl merged commit c1dd500 into main Aug 17, 2026
3 checks passed
@lukaskroepfl
lukaskroepfl deleted the feat/support-tickets-and-organizations branch August 17, 2026 17:48
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants