feat!: migrate resource commands to the dashboard account plane#201
feat!: migrate resource commands to the dashboard account plane#201nicknisi wants to merge 21 commits into
Conversation
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).
…rmation Phase 2 of catalog-driven CLI commands: the machinery that makes "justify every command" enforceable, layered on the Phase 1 loader. - manifest-types.ts: CommandJustification two-axis rubric (audiences, useCase, load, mutation, destructive, ciPolicy) - manifest.ts: getManifest() curated allowlist (empty until Phase 3) - curation.ts: resolveCommandMeta() maps catalog ops to clean WorkOS nouns/descriptions, hiding GraphQL/userland naming and overriding rotten upstream descriptions - justification.ts: validateManifest() rejects incomplete entries or a mapsTo absent from the catalog; field list kept in lockstep with the type via a satisfies + exhaustiveness guard - confirm.ts: confirmDestructive() mirrors the api command's gate — non-interactive/JSON refusal exits 1 (confirmation_required), active prompt cancel exits 2, --yes bypasses - scripts/check-justification.ts + justification:check script + CI gate Tests cover validation rejects/passes, no-graphql/userland leak in curated metadata, and the exit-code contract. The api confirmation primitive was mirrored rather than extracted (entangled with request-specific state); documented in implementation notes.
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.
Greptile SummaryThis PR migrates 13 resource commands (
Confidence Score: 5/5Safe to merge; all three previously flagged issues are resolved and no new defects were found in the auth, environment-targeting, or CORS-guard paths. The auth and environment-resolution paths are well-structured and their key invariants (no silent production fallback on mutations, correct 403 surfacing, no CORS wildcard pass-through) are all enforced in this revision. The one comment in this review is a documentation accuracy note about the read-path fallback behavior, not a code defect. No files require special attention. The outstanding merge prerequisite (capability flag enabled for all production teams) is tracked in the PR checklist. Important Files Changed
|
… 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.
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.