Uh oh!
There was an error while loading. Please reload this page.
feat!: migrate resource commands to the dashboard account plane - #201
feat!: migrate resource commands to the dashboard account plane#201nicknisi wants to merge 26 commits into
Conversation
Greptile SummaryThis PR migrates 13 resource commands (
Confidence Score: 5/5
Important Files Changed
|
Uh oh!
There was an error while loading. Please reload this page.
Uh oh!
There was an error while loading. Please reload this page.
Uh oh!
There was an error while loading. Please reload this page.
e22b447 to
fecd0caCompare| if (res.status === 401 || res.status === 403) { | ||
| throw new DashboardGraphqlError( | ||
| `The dashboard GraphQL API rejected this session (HTTP ${res.status}).`, | ||
| 'forbidden', | ||
| res.status, | ||
| ); | ||
| } |
There was a problem hiding this comment.
HTTP 401 misclassified as "capability not enabled"
Both 401 and 403 are mapped to 'forbidden', which surfaces the message "This account-plane capability is not enabled for this team, or the logged-in account is not backed by a WorkOS dashboard team." A 401 from the server means the bearer token was rejected as invalid — the most common real-world trigger being remote session invalidation (admin deactivation, password change, explicit logout from another device). In that case requireCommandToken() sees a locally-valid (not expired) token, issues the request, gets back a 401, and the user is told "capability not enabled" with no hint to re-authenticate. Distinguishing status === 401 with a separate error code — something like 'invalid_session' — and routing it through copy like "Session was invalidated. Run workos auth login to re-authenticate" would make this edge case actionable. Does the WorkOS dashboard GraphQL endpoint ever return HTTP 401 (as opposed to 403) for a rejected bearer token? If the server guarantees 403 for all auth failures, this distinction can be dropped.
Prompt To Fix With AI
This is a comment left during a code review.
Path: src/lib/dashboard-graphql.ts
Line: 123-129
Comment:
**HTTP 401 misclassified as "capability not enabled"**
Both 401 and 403 are mapped to `'forbidden'`, which surfaces the message "This account-plane capability is not enabled for this team, or the logged-in account is not backed by a WorkOS dashboard team." A 401 from the server means the bearer token was rejected as invalid — the most common real-world trigger being remote session invalidation (admin deactivation, password change, explicit logout from another device). In that case `requireCommandToken()` sees a locally-valid (not expired) token, issues the request, gets back a 401, and the user is told "capability not enabled" with no hint to re-authenticate. Distinguishing `status === 401` with a separate error code — something like `'invalid_session'` — and routing it through copy like "Session was invalidated. Run `workos auth login` to re-authenticate" would make this edge case actionable. Does the WorkOS dashboard GraphQL endpoint ever return HTTP 401 (as opposed to 403) for a rejected bearer token? If the server guarantees 403 for all auth failures, this distinction can be dropped.
How can I resolve this? If you propose a fix, please make it concise.getCliAuthClientId() previously ignored the environment despite its doc comment promising an override. Honor WORKOS_CLIENT_ID so the device auth client can be pointed at a local/admin environment for development, falling back to the production client_id default when unset.
Resolve the API base URL through a single chokepoint in lib/api-key.ts (WORKOS_API_URL -> WORKOS_API_BASE_URL alias -> active profile -> default) so the CLI's own commands can target a local or staging API. Derive the LLM gateway and CLI telemetry endpoints from that same base in urls.ts instead of re-reading the env, so one reader honors the alias and active profile. Surface a once-per-command, human-mode indicator when the resolved base URL is non-prod, and report an env override in 'workos env list'. Catalog WORKOS_API_URL and WORKOS_API_BASE_URL in 'workos debug env'. Also fix the getCliAuthClientId docstring (it is env-overridable via WORKOS_CLIENT_ID).
Report the authenticated identity from the dashboard session: the logged-in user, their team, and the current environment. Unlike `auth status` (which only inspects the locally stored token), `whoami` calls the dashboard GraphQL API with the OAuth bearer and reports who the server resolves you as. This is the first command on the account plane (user/team-scoped via the device-flow token) rather than the environment-scoped API key used by REST commands. It POSTs to `<api>/graphql`, queries `me` / `currentTeam` / `currentEnvironment`, and renders both human and JSON output. Read-only. The capability is gated server-side by a per-team flag, so a rejected session surfaces a clear message instead of a generic error. - src/lib/dashboard-graphql.ts: minimal bearer-authenticated GraphQL client - src/commands/whoami.ts: command with human + JSON modes - src/commands/whoami.spec.ts: tests incl. JSON mode and the gated-403 path - registered in src/bin.ts and src/utils/help-json.ts
Phase 1 of catalog-driven CLI commands. Vendors the committed operation catalog from the workos monorepo as a static asset and exposes it through a single loader behind a CatalogSource interface, so command code (Phase 3+) depends only on the return type, never on the snapshot file or a fetch client. Swapping in a live source (Phase 6) is a one-file change. - src/catalog/mcp-catalog.snapshot.json: vendored 357-operation catalog - src/catalog/catalog-types.ts: types mirroring upstream catalog-types.ts, plus a normalized ManagementCatalog return shape - src/catalog/loader.ts: loadManagementCatalog() applies the same feature-flag filter the live MCP applies (353 visible of 357) - scripts/vendor-catalog.ts + catalog:vendor: dev-only snapshot refresh from a local monorepo checkout, fails loudly on missing/empty source Validation: typecheck, build, and full test suite (2127) pass. Review cycle: 1 of 3 (PASS, no critical/high findings).
Ship the first justified catalog-driven command category (Phase 3): the account-plane lifecycle actions a dashboard user performs that the CLI could not do before. All 12 commands run against the dashboard account plane with the user's OAuth bearer (the same gated capability `whoami` uses) and carry a complete two-axis justification entry in the manifest. Commands: - environment create|rename - project create|rename|list - team members|invite|change-role|remove|resend-invite|update|set-mfa Safety posture (per manifest): - team remove is destructive -> confirmDestructive (prompt or --yes) - project create / team change-role / team set-mfa are require-flag -> non-interactive callers must pass --yes (new requireConfirmationFlag guard) Curation overrides every op to a clean WorkOS noun and rewrites the rotten teamProjectsV2 description; no command name, description, help text, or error message references GraphQL. Handlers resolve the executable document (operation text + transitive fragments) from the vendored catalog via a new operation.ts seam and reuse the whoami error taxonomy. The `environment` noun is kept distinct from the existing local-profile `env` command (naming decision per spec deviation #1). Review: 1 cycle; fixed a GraphQL leak in account-plane error messages.
…Is, branding) Adds a `workos authkit` command group on the dashboard account plane: redirect-uris list/set, cors get/set, logout-uris list/set, and branding get. Set operations replace the full list and expose --dry-run for validation, so none are destructive. Each command carries a complete justification manifest entry plus a curation override (enforced by justification:check), and no internal naming surfaces in any user-facing string.
…ture The userland* -> 'user *' overrides were pre-staged for a catalog-driven AuthKit-user category that is not being built: those nouns collide with the existing REST 'user' command (src/commands/user.ts). Record the deferral and why 'userlandUsers' is intentionally kept as the no-graphql-leak.spec.ts fixture.
Extract the refresh orchestration from ensure-auth.ts into a shared refreshIfExpired() core (command-auth.ts) and add requireCommandToken(), the guard every dashboard-plane resource command now uses: valid token passes through, an expired token with a valid refresh token refreshes silently, and a dead session exits 4 with a structured error instead of launching the login flow. - invalid_grant clears credentials; network/server refresh failures keep them and the exit-4 copy says the refresh failed, not "logged out" - auth_required copy names both escape hatches (workos auth login and workos api); when WORKOS_API_KEY is set it states these commands do not accept API keys - new DASHBOARD_ERROR_MESSAGES taxonomy: auth_required (exit 4) vs forbidden (exit 1: capability off for the team / account not team-backed); stale "staging only" copy corrected now that the capability is enabled in production behind a per-team flag - whoami/team/project/environment/authkit retrofitted from copy-pasted requireToken() to the shared guard; whoami's leaking local error handler replaced with reportDashboardError - no-graphql-leak gate extended over the exported error constants and dashboardErrorMessage branches - command-hints-guard: allowlist two pre-existing static occurrences (whoami display label, debug.ts prose) that fail on unmodified HEAD Review: 2 cycles (3 medium findings from cycle 1 fixed; cycle 2 clean).
Give the dashboard plane the environment semantics REST has today. Every environment-scoped dashboard request now carries x-url-environment-id resolved through resolveEnvironmentTarget() (src/lib/environment-target.ts): --environment-id flag -> active profile's stored environmentId -> clientId join against the team's environments -> one-time picker (human mode) -> structured environment_unresolved error. The server's silent production fallback is never reachable from a stale stored ID: mutations pre-validate the effective ID against a fresh team fetch and exit environment_stale before any operation request is issued; reads trust stored state (documented trade-off: nothing is ever written to the wrong environment). - env profiles gain optional environmentId; setProfileEnvironmentId() setter; markEnvironmentClaimed carries the field through - opportunistic healing: any resolution fetch re-joins the active profile's clientId and re-persists a changed ID, so recreated environments self-repair - login stamps the provisioned staging profile via clientId join (best-effort, non-fatal); env add / env switch attempt join-or-picker resolution without ever blocking profile creation or switching - call-site scope split (21 sites): environment-scoped route through the resolver and send its output as operation variable + header (authkit x7, environment create/rename, whoami); team-scoped deliberately send no header (team x7, project x3, plus the resolver's own teamProjectsV2 fetch) - authkit's previously required --environment-id now defaults from the active profile; whoami and environment create gain --environment-id overrides; help-json registry updated to match - error copy names both remedies (--environment-id, workos env switch) and never mentions GraphQL (leak gate still green) Behavioral change: whoami and authkit reads now exit environment_unresolved for non-interactive callers with no resolvable environment instead of silently showing production (rides the release's feat! contract). Review: 1 cycle (PASS; 1 medium + 1 low non-blocking, logged in implementation notes).
…plane
Replace the API-key REST SDK backend for `workos organization` and
`workos user` with catalog-backed dashboard operations using the OAuth
bearer — the same plane (and recipe) as team/project/environment/authkit.
The subcommand surface is unchanged (organization create/update/get/
list/delete; user get/list/update/delete — still no user create); the
backend, auth story, and output shapes are new. This is the release's
first user-visible breaking surface.
BREAKING CHANGE: both commands now require `workos auth login` (API keys
are no longer accepted; --api-key removed) and emit new curated JSON
shapes (top-level resource objects, camelCase, no list_metadata — the
authoritative examples live in each command's spec file). Flag mapping,
old -> new:
- organization list: --domain now maps to server-side search
(name/domain); every subcommand gains --environment-id
- organization delete / user delete: gain --yes (destructive
confirmation: prompt, --yes bypass, non-interactive refusal)
- user list: --organization dropped (no backing filter on this plane);
--email now maps to server-side search
- user update: --email-verified and --password dropped (not supported on
this plane); --email and --locale added; now requires at least one
update flag
Details:
- manifest + curation entries for all 9 ops with full justification
fields; deletes are destructive: true and surface the catalog
confirmation phrases ("cascades to its connections, directories, and
users" / "permanently deletes the end user")
- the userland* curation collision is resolved by replacement: the
userlandUsers/userlandUser/updateUserlandUser/deleteUserlandUser
overrides are now live via the manifest; createUserlandUser and the
invite overrides stay inert (invitations are Phase 4's turf);
userlandUsers remains the leak-spec worked example
- no-graphql-leak gate extended over the help-json registry, data-driven
off manifest nouns so later migration phases inherit coverage
automatically
- command specs pin the environment invariants: resolved target sent as
variable + header, and mutations pre-validate (teamProjectsV2 fetch
asserted to precede every mutation request)
- domain:state positional grammar maps onto domains +
domainsDeveloperVerified (all-verified -> true, all-pending -> false,
mixed -> structured invalid_argument)
Review: 1 cycle (PASS; 1 medium + 4 low advisory findings — the medium is
the flag-mapping documentation above; two testing lows applied, the
normalizeOrder dedup deferred to Phase 4).…nt plane Phase 4 (identity cluster) of the GraphQL resource-command migration. The membership, invitation, and session commands now run catalog-backed dashboard operations with the OAuth bearer instead of the API-key REST SDK, following the Phase-3 organization/user recipe. The subcommand grammar is unchanged; backend and output shapes are new curated shapes (breaking change). Snapshot-driven mapping deviations from the delta table (all loud, none faked): - membership delete is a two-step (resolve the membership by ID, then remove by org+user) because removeMemberFromOrganization is keyed by org+user. - membership list rides two ops: --user (no pagination) via userlandUserOrganizationMemberships, --org via userlandUsersByOrg (identities flattened to the membership shape, full pagination). --org+--user combined and pagination/order flags with --user now error. - invitation list splits env-wide vs by-org; invitation get client-side filters the 100 most recent invitations (no single-invite op exists). - invitation send defaults --expires-in-days to 7 (input requires it). - --role flags now take role IDs (inputs accept roleId only). - --order dropped from invitation list and session list (no backing variable). - session revoke success is detected by payload presence (document selects no __typename). Safety posture: membership delete / invitation revoke / session revoke are destructive (confirmDestructive); membership update / deactivate are require-flag (privilege/access changes, team change-role precedent). Manifest, curation overrides (invite ops re-pointed from the inert user-invite nouns to invitation), and help-json registry updated with no REST/API-key/GraphQL language. Review: 1 cycle, PASS (0 critical, 0 high); 1 medium (help-json require-flag copy) fixed, 2 low deferred.
…t plane Phase 5 (authorization cluster) of the GraphQL resource-command migration. The role, permission, and feature-flag commands now run catalog-backed dashboard operations with the OAuth bearer instead of the API-key REST SDK, following the Phase-3/4 recipe. The subcommand grammar is unchanged; backend and output shapes are new curated shapes (breaking change). The spec's role stop-rule was evaluated and NOT triggered: both sides of REST's environment/organization role split are expressible (createRole's optional organizationId + header-scoped environment; roles vs rolesForOrganization listings; ID-keyed update/delete), so the whole command migrated. Snapshot-driven mapping deviations from the delta table (all loud, none faked): - Role/permission mutations are ID-keyed; the frozen slug grammar resolves via the scope's list operation first (one extra read). - updateRole/updatePermission REQUIRE name and CLEAR an omitted description server-side, so updates read-merge-write the current name/description; role mutation responses carry no permission selection, so the curated shape overlays the effective slugs. - The org role listing includes inherited environment roles (deduped by slug, org role wins); org-scoped mutations refuse a match whose type is not Organization rather than silently mutating it environment-wide. - set-permissions/add-permission/remove-permission ride updateRole's full permission-slug list (slugs verified against the dashboard resolver). - permission list drops --limit/--before/--after/--order (no backing variables; the operation returns the environment's full set). System permissions are refused loudly on update/delete. - Flag operations are project-scoped: each subcommand derives the active environment's project from the team's project list. updateFlagEnvironment requires the full per-environment state and REPLACES target lists, so enable/disable/add-target/remove-target read-merge-write the freshly fetched state. Targets are typed by ID prefix (user_/org_), mirroring the REST target endpoint; flag shapes report the active environment's state. Safety posture per the manifest: role/permission create+update and the permission-assignment trio are require-flag (team change-role precedent); role delete and permission delete are destructive (confirmDestructive). Feature-flag mutations stay ciPolicy allow (reversible config toggles). Also fixes a latent --help --json truncation: the intercept now awaits a stdout drain before process.exit, since the command tree outgrew the 64KiB pipe buffer this phase. Review: 3 cycles (4 high findings fixed in cycle 1 — role permissions overlay + per-subcommand test matrices; 1 coverage gap fixed in cycle 2; PASS cycle 3). Note: committed unsigned — the 1Password SSH signer was locked during this headless run. Re-sign with `git commit --amend -S --no-edit` if desired.
…ectory stays REST (stop-rule)
Phase 6 (org-infrastructure cluster) of the GraphQL resource-command
migration. The event and org-domain commands now run catalog-backed dashboard
operations with the OAuth bearer instead of the API-key REST SDK, following
the Phase-3/4/5 recipe. The subcommand grammar is unchanged; backend and
output shapes are new curated shapes (breaking change).
DIRECTORY STOP-RULE TRIGGERED — the directory command stays REST this phase.
REST `directory list` is environment-wide with an optional --org filter, but
the vendored catalog's only directory listings are org-scoped
(directoriesForOrganization) or per-directory (directorySummary, directoryId
required), and the upstream dashboard schema has no environment-wide
directories query either (the directories resolver exposes only the by-ID
`directory` query; Organization.directories is a field resolver). Migrating
would force --org to become required — a frozen-grammar violation the delta
forbids. Per the contract's whole-command rule (one auth story per command)
the entire command keeps its REST implementation, including the subcommands
whose ops do exist (get/delete/list-users/list-groups). Upstream ask: an
environment-wide directories listing query in the dashboard schema + catalog.
Snapshot-driven mapping deviations from the delta table (all loud, none
faked):
- `org-domain get` does NOT use teamDashboardOrgDomains: that op returns the
domains of the WorkOS organization backing the current TEAM's own dashboard
login (wrong plane). With no single-domain query in the catalog, get is a
client-side filter over one page of the `organizations` op (each row
carries its domains), capped at 100 with loud miss wording — the
invitation-get precedent (and like it, no manifest entry of its own).
- `org-domain create` rides the plural addDomains op with a one-element list;
the dashboard adds the domain ALREADY VERIFIED (REST created it pending and
started DNS verification). Help text says "added as verified" and the
ExistingNonVerifiedDomain variant maps to a domain_pending error.
- `org-domain verify` maps to restartOrganizationDomainVerification, whose
resolver runs the same initiateVerification as REST verify; the instant
manuallyVerifyOrganizationDomain op is deliberately NOT surfaced (grammar
frozen).
- `event list --org` is REMOVED: the vendored environmentEvents document
declares no organization variable (the upstream schema accepts one, so a
future snapshot re-vendor can restore the flag). --events stays required;
range/cursor/limit flags map onto op variables exactly; single-page default
preserved. Curated event shape: { id, event, data, createdAt, updatedAt }.
Safety posture per the manifest: org-domain delete is destructive
(confirmDestructive with hand-written consequence copy — the op carries no
catalog confirmation phrase); create/verify stay ciPolicy allow
(organization create/update precedent for domain writes); event list is a
cheap read.Phase 7 (app-config cluster) of the GraphQL resource-command migration. The
webhook, portal, and config commands now run catalog-backed dashboard
operations with the OAuth bearer instead of the API-key REST plane, following
the Phase-3..6 recipe. The subcommand grammar is unchanged; backend and output
shapes are new curated shapes (breaking change).
Stop-rule for `config homepage-url set` evaluated and NOT triggered:
`defaultAuthkitApplication(environmentId)` resolves the environment's AuthKit
application, then `updateAuthkitApplication` sets its homepage URL — the same
application the REST endpoint dual-wrote (monorepo
userland-settings.service.ts dual-write confirmed). Two manifest entries share
the command noun (membership-list precedent). The mutation's Userland*-named
union variants are discriminated on but never echoed.
Snapshot-driven deviations from REST (all loud, none faked):
- `webhook create` no longer returns the signing secret: the backing op
selects only { id } and no vendored webhook document exposes a secret.
Human output and help text say it is only visible in the WorkOS Dashboard;
curated JSON echoes the inputs ({ webhookEndpoint: { id, url, events } }).
- `portal generate-link` drops --return-url/--success-url (no input
equivalents; strict() rejects) and rejects --intent audit_logs with a
structured error (the setup-link intent enum has no AuditLogs value);
domain_verification and certificate_renewal are supported. expireIntents is
passed scoped to the requested intent — omitted, the server would expire ALL
of the org's active setup links, not just same-intent ones. Expiry copy now
prints the link's server-side expiresAt instead of "5 minutes".
- `config redirect add` / `config cors add` become read-merge-write over the
full-list setters (fetch current, no-op if present, append + set). The
concurrent-editor race window is accepted and noted in help text — the same
trade-off `authkit redirect-uris set` makes. The redirect merge read is one
bounded page (100): a longer list exits with a structured too_many_uris
error pointing at `authkit redirect-uris set` rather than silently dropping
the overflow from the rewritten list.
- `config redirect add` / `cors add` ride the redirectUris/setRedirectUris/
corsConfig/updateCorsConfig ops already owned by the authkit nouns, so they
deliberately have NO manifest entries of their own (OVERRIDES is op-keyed;
invitation-get precedent, documented in the manifest comment).
Safety posture per the manifest: webhook delete is destructive
(confirmDestructive with hand-written consequence copy — the op carries no
catalog confirmation phrase, and the subcommand gains --yes); everything else
is ciPolicy allow (setup-automation precedent). `config` and `authkit`
intentionally both survive with overlapping capability (consolidation
deferred by approved decision). The raw-fetch methods in workos-client.ts are
untouched — Phase 8 owns dead-code removal.
Review: 2 cycles (cycle-1 expireIntents + help-disclosure findings fixed; the
per-subcommand-manifest-entry finding refuted against the op-keyed leak gate
and withdrawn).Phase 8 (cleanup and docs) of the GraphQL resource command migration.
Dead-code sweep of src/lib/workos-client.ts — delete only zero-reference
symbols (whole-src grep, including dynamic imports):
| Symbol | Verdict | Why |
| ----------------------------------------------- | ------- | -------------------------------------------- |
| webhooks.{list,create,delete} + WebhookEndpoint | DELETED | zero refs; webhook command migrated (Phase 7)|
| redirectUris.add | KEPT | seed.ts:290 (out-of-scope workflow command) |
| corsOrigins.add | KEPT | seed.ts:303 |
| homepageUrl.set | KEPT | seed.ts:315 |
| auditLogs.* | KEPT | audit-log.ts (stayed REST) |
| createWorkOSClient / sdk | KEPT | 12 still-REST command importers |
| workos-api.ts (workosRequest, WorkOSApiError) | KEPT | imported by workos-client, api-error-handler |
Docs:
- CLAUDE.md non-TTY auth bullet: dashboard login (with silent token
refresh) is the auth story for resource commands; WORKOS_API_KEY applies
only to `workos api` and the still-REST commands (enumerated); exit-4
semantics unchanged.
- README: two-plane auth split in Resource Management, per-command grammar
refreshed to the migrated surface (flags dropped/added in Phases 3-7),
examples no longer pass --api-key to migrated commands, error sample is
now auth_required, env-var table row scoped.
- help-json registry and command-aliases verified in sync with bin.ts (no
changes needed; zero api-key rows remain on migrated commands).
Note: `directory` remains REST per the Phase 6 stop-rule (no
environment-rooted directories operation in the vendored catalog); the
criterion-1 grep gate passes over the 13 migrated command files.
Review: 1 cycle, PASS (2 non-blocking findings fixed).Three tiers: read-only (default), --mutate (CRUD round-trips scoped to a disposable organization, deleted afterwards), and --config-writes (snapshot-and-restore for redirect URIs and CORS origins). Includes a negative test asserting a bogus --environment-id is refused rather than falling back to the production environment.
… CORS wildcards - getCliAuthClientId() no longer reads WORKOS_CLIENT_ID: that env var is the developer's own application client ID (credential-discovery reads it with that meaning), and overriding the CLI's OAuth client with it made token refresh fail with invalid_grant and clear a valid session. - resolveEnvironmentTarget surfaces a 403 from the environment fetch as a structured forbidden error instead of a generic retry hint. - CORS setters (authkit cors set, config cors add) reject wildcard origins client-side before any request is made.
…ce helpers
Quality pass over the dashboard-plane migration (no behavior change):
- Add src/lib/dashboard-operation.ts: runEnvScopedOperation /
runTeamScopedOperation / executeDashboardOperation compose auth, catalog
lookup, environment targeting, the wire request, and error reporting.
All ~63 hand-threaded handler preambles across 17 command files now route
through the seam; intentional forMutation overrides are preserved.
- Add src/utils/resource-command.ts: normalizeOrder (was copied in 4 files),
printDetailFields (8 copies), printPaginationFooter (14 copies, including
the pre-existing REST list commands).
- Close the mutation result unions (drop the `| { __typename: string }`
catch-all members) so __typename comparisons narrow; deletes ~30 unchecked
casts while keeping every runtime unexpected_result guard.
- Memoize catalog normalization and index getOperation lookups (were rebuilt
and linearly scanned per call).
- Halve keyring IPC per command: refreshIfExpired accepts pre-read
credentials; environment-target reads config once per resolution.
- Dedupe the --environment-id option in bin.ts (61 identical inline
declarations -> one shared constant).
- Drop the redundant REQUIRED_KEYS presence pass in justification.ts (every
field is already covered by its field-level check).
- Remove broken gen:routes / check:coverage npm scripts (pointed at files
deleted with the emulator extraction).
Net -1,214 lines. 2,425 tests, typecheck, build, lint, format, and
justification:check all green.Adds `workos authkit branding set`, which uploads AuthKit logo, icon, and favicon images (light and dark) for an environment: workos authkit branding set --logo ./logo.png --icon ./icon.png This is the only command that sends files. The account-plane branding fields are file-upload scalars, so they need a multipart request — a JSON transport cannot carry bytes, which is why an MCP client cannot set them and a CLI can. No new server surface was required. Notes on the implementation: - Write scope is the server's decision. The command addresses the environment's branding record by id and deliberately does NOT send `environmentId`: the API scopes the write itself based on whether per-environment branding is enabled, and forcing the environment-scoped path would be invisible to AuthKit when it is not. - The multipart envelope carries `null` in the variables for each image and binds the file parts by path. A path that matches nothing is silently ignored, and a surviving `null` means "clear this image" — so the client asserts every declared path resolves to a null slot before sending. - Only the images passed as flags are touched; the rest are left absent rather than nulled. - Files are validated locally first (400 KB cap, supported types) because the server enforces its cap by aborting the upload stream, which otherwise surfaces as a truncated request rather than a message naming the limit. Adds a `--branding-writes` tier to the dashboard-plane smoke script, with a `SMOKE_BRANDING_ENV_ID` override so the one destructive tier can be pointed at a throwaway environment. Verified live: 29/29 pass, with the uploaded assets read back and served by the AuthKit login page.
`src/utils/clack.ts` was deleted in favor of `src/utils/ui.ts`, but these files still imported it. `vi.mock()` paths are plain strings, so typecheck stayed green while the specs were broken at runtime. `ui.select`/`confirm`/`isCancel` are API-compatible with what clack exposed, so this is a module-specifier swap with no behavior change.
`workos authkit branding {get,set}` becomes `workos branding {get,set}`.
Branding is not AuthKit-specific — the same record drives hosted AuthKit
pages and transactional emails — so it reads better as its own noun. The
old path was never released, so nothing is aliased.
`set` now also takes a slot and a file positionally, matching how the rest
of the CLI reads (`role get <slug>`, `organization get <orgId>`):
workos branding set icon ./icon.png
workos branding set logo-dark ./logo-dark.png
Slot names are the flag names, so the two forms agree. The flag form stays
for setting several images at once:
workos branding set --logo ./logo.png --favicon ./favicon.ico
Mixing the forms in one invocation is rejected rather than merged: both
could name the same slot with different files, and silently picking a
winner would upload the wrong image.
Handlers move from `commands/authkit.ts` to `commands/branding.ts`, and the
smoke tier gains live coverage of the positional path (set one image, then
assert only that image moved).Neither appeared in the command overview or the per-command reference. Adds both, including the two forms of `branding set`, the supported image types, and the 400 KB per-image cap. Also corrects a stale comment in authkit.ts: it described `workos config` as API-key-based and REST-backed, but config.ts runs on the dashboard plane like authkit does. The real distinction is add-one-entry versus replace-the-list.
510fce7 to
c852df6Compare
Summary
Resource commands now run on the dashboard account plane using the device-flow OAuth login (
workos auth login), instead of environment-scoped API keys. One login covers the whole CLI; per-environment key management is no longer required for day-to-day resource work.Migrated (13):
organization,user,role,permission,membership,invitation,session,event,feature-flag,org-domain,portal,webhook,configStill API-key/REST (by design):
connection,directory,api-key,audit-log,vault, andworkos api(the deliberate raw-REST escape hatch).directorywas planned to migrate but stayed REST: the account plane has no environment-wide directory listing, and migrating would have forced a new--orgrequirement ontodirectory list.Breaking changes (feat!)
workos auth login;WORKOS_API_KEYno longer applies to them (it still works for the commands listed above). Not logged in → exit 4 with a structuredauth_requirederror naming the escape hatches.team/project), not the public-API REST shapes.--environment-idto override). A stale or unrecognized environment now errors (environment_unresolved) instead of silently targeting production — this also fixes a latent defect where every dashboard-plane request silently hit the team's production environment.webhook createno longer returns the signing secret (dashboard-only);portal generate-linkdrops--return-url/--success-urland theaudit_logsintent;event listrequires--events.Validation
no-graphql-leak.spec.tsextended to the new surface).scripts/smoke-dashboard-plane.sh— read tier 17/17,--mutate44/44 (full CRUD round-trips in a disposable org: organization, org-domain, role+permission, membership incl. deactivate/reactivate, invitation, webhook, portal link),--config-writes23/23 (snapshot-and-restore on redirect URIs and CORS origins). Includes a negative test proving a bogus--environment-idis refused rather than falling back to production.Before merge
Follow-ups (not in this PR)
org-, which users can't see). A pattern-filtered passthrough would keep errors actionable without leaking internals.connection/api-key/audit-log/directoryonce the missing account-plane operations exist.authkit redirect-uris listvsauthkit cors getfor equivalent reads.