Skip to content

feat!: migrate resource commands to the dashboard account plane#201

Open
nicknisi wants to merge 21 commits into
mainfrom
ideation/graphql-resource-migration
Open

feat!: migrate resource commands to the dashboard account plane#201
nicknisi wants to merge 21 commits into
mainfrom
ideation/graphql-resource-migration

Conversation

@nicknisi

Copy link
Copy Markdown
Member

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, config

Still API-key/REST (by design): connection, directory, api-key, audit-log, vault, and workos api (the deliberate raw-REST escape hatch). directory was planned to migrate but stayed REST: the account plane has no environment-wide directory listing, and migrating would have forced a new --org requirement onto directory list.

Breaking changes (feat!)

  • Auth: migrated commands require workos auth login; WORKOS_API_KEY no longer applies to them (it still works for the commands listed above). Not logged in → exit 4 with a structured auth_required error naming the escape hatches.
  • Output shapes: migrated commands emit new curated JSON shapes (consistent with team/project), not the public-API REST shapes.
  • Environment targeting: requests target the CLI's active environment (stored env ID, --environment-id to 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.
  • Token refresh: an expired access token with a valid refresh token now refreshes silently on the command path; only a truly dead session exits 4.
  • Per-command flag divergences are documented in the individual commit messages. Notables: webhook create no longer returns the signing secret (dashboard-only); portal generate-link drops --return-url/--success-url and the audit_logs intent; event list requires --events.

Validation

  • 2400+ unit tests, typecheck, build all green.
  • No migrated command file touches the API-key plane (grep gate in CI spec); no user-visible surface leaks internal API details (no-graphql-leak.spec.ts extended to the new surface).
  • Live smoke against production (sandbox environment): scripts/smoke-dashboard-plane.sh — read tier 17/17, --mutate 44/44 (full CRUD round-trips in a disposable org: organization, org-domain, role+permission, membership incl. deactivate/reactivate, invitation, webhook, portal link), --config-writes 23/23 (snapshot-and-restore on redirect URIs and CORS origins). Includes a negative test proving a bogus --environment-id is refused rather than falling back to production.

Before merge

  • Confirm the dashboard capability flag is enabled for all production teams (headless-dashboard/MCP team) — an unflagged team would see 403s on all 13 migrated commands.

Follow-ups (not in this PR)

  • M2M/client-credentials auth to restore a first-class headless story for migrated commands.
  • Error passthrough: server-side validation messages are currently masked to a generic "could not complete this request" (e.g. org-scoped role slugs must be prefixed org-, which users can't see). A pattern-filtered passthrough would keep errors actionable without leaking internals.
  • Migrate connection/api-key/audit-log/directory once the missing account-plane operations exist.
  • Grammar consistency: authkit redirect-uris list vs authkit cors get for equivalent reads.

nicknisi added 17 commits July 22, 2026 14:22
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.

@devin-ai-integration devin-ai-integration Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

✅ Devin Review: No Issues Found

Devin Review analyzed this PR and found no bugs or issues to report.

Open in Devin Review

@greptile-apps

greptile-apps Bot commented Jul 23, 2026

Copy link
Copy Markdown

Greptile Summary

This PR migrates 13 resource commands (organization, user, role, permission, membership, invitation, session, event, feature-flag, org-domain, portal, webhook, config) from API-key REST to the dashboard account plane using OAuth device-flow tokens. The three issues flagged in prior review threads — WORKOS_CLIENT_ID env-var collision in settings.ts, 403 responses being swallowed as generic network errors in environment-target.ts, and the missing CORS wildcard guard in config.ts — are all correctly addressed in this PR.

  • New auth layer (command-auth.ts): refreshIfExpired / requireCommandToken own the command-path session lifecycle; expired tokens are silently refreshed, transient network failures are distinguished from dead sessions, and failed commands exit with structured auth_required errors rather than opening a browser.
  • Environment targeting (environment-target.ts): mutations pre-validate the resolved environment ID against a live team fetch (preventing silent production fallback), while reads trust stored/flag state. The rejectWildcardOrigins guard covers both the authkit cors set and config cors add paths before any token is obtained.
  • Catalog-backed operations: all 13 migrated commands use the vendored GraphQL catalog via getOperation / resolveExecutableDocument, with reportDashboardError consistently translating wire errors into user-facing copy that never surfaces internal GraphQL terminology.

Confidence Score: 5/5

Safe 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

Filename Overview
src/lib/command-auth.ts New auth guard for dashboard-plane commands. refreshIfExpired covers all credential states correctly and requireCommandToken correctly distinguishes transient vs dead sessions.
src/lib/dashboard-graphql.ts Minimal GraphQL client with correct 401/403 disambiguation, timeout handling, and no internal error message leakage to users.
src/lib/environment-target.ts Environment resolution with correct mutation pre-validation, 403 handling (previously unaddressed), and best-effort healing. Doc comment slightly understates the read-path risk (silent prod fallback, not a server error).
src/commands/authkit.ts New authkit config surface with rejectWildcardOrigins guard correctly placed before requireCommandToken. Redirect/logout URI set operations handle all discriminated union variants.
src/commands/config.ts Migrated config commands with correct read-merge-write pattern and truncation guard at REDIRECT_MERGE_SCAN_LIMIT. rejectWildcardOrigins is called before auth on the CORS add path.
src/lib/settings.ts getCliAuthClientId now returns config.workos.clientId directly without reading WORKOS_CLIENT_ID, resolving the previously flagged env-var collision risk.
src/catalog/operation.ts Clean catalog accessors with never-typed reportDashboardError ensuring definite assignment in all callers. GraphQL error messages are correctly not echoed to users.
src/lib/api-key.ts Refactored to expose getApiBaseUrlSource with URL validation and normalization. The new normalizeApiBaseUrl function validates protocol and trims trailing slashes before returning.
src/commands/organization.ts Full org lifecycle migrated to dashboard plane. Correctly passes environmentId as both variable and header, handles all discriminated union error variants from the server.
scripts/smoke-dashboard-plane.sh Comprehensive smoke harness covering 17 read + 44 mutate + 23 config-write paths. Uses set -u but not set -e; temp-dir setup failures would cascade silently, though this is a dev-only script.

Sequence Diagram

sequenceDiagram
    participant CLI as workos CLI
    participant CA as command-auth.ts
    participant ET as environment-target.ts
    participant GQL as dashboard-graphql.ts
    participant API as WorkOS Account Plane

    CLI->>CA: requireCommandToken()
    CA->>CA: getCredentials() / isTokenExpired()
    alt "token expired & refresh token present"
        CA->>API: refreshAccessToken(domain, clientId)
        API-->>CA: "{ accessToken, expiresAt }"
        CA->>CA: updateTokens()
    end
    CA-->>CLI: accessToken

    CLI->>ET: "resolveEnvironmentTarget(token, { forMutation })"
    alt mutation
        ET->>GQL: dashboardGraphqlRequest(teamProjectsV2)
        GQL->>API: POST /graphql (Bearer token)
        API-->>GQL: "{ currentTeam.projectsV2[].environments[] }"
        GQL-->>ET: TeamEnvironment[]
        ET->>ET: validate environmentId in list
    else read with stored/flag ID
        ET-->>CLI: "{ environmentId, source }"
    end
    ET-->>CLI: "{ environmentId, source }"

    CLI->>GQL: "dashboardGraphqlRequest(operation, { token, environmentId })"
    GQL->>API: POST /graphql (Bearer + x-url-environment-id header)
    API-->>GQL: "{ data } or errors[]"
    GQL-->>CLI: data payload
Loading

Reviews (3): Last reviewed commit: "fix: address review feedback on client-i..." | Re-trigger Greptile

Comment thread src/lib/settings.ts
Comment thread src/lib/environment-target.ts
Comment thread src/commands/authkit.ts
nicknisi added 2 commits July 22, 2026 22:57
… 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.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Development

Successfully merging this pull request may close these issues.

1 participant