Skip to content

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

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

feat!: migrate resource commands to the dashboard account plane#201
nicknisi wants to merge 22 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 the API-key REST plane to the OAuth bearer dashboard account plane. The migration is a breaking change in auth model, output shapes, and environment targeting behavior.

  • New infrastructure (command-auth.ts, dashboard-graphql.ts, dashboard-operation.ts, environment-target.ts) provides the shared auth-guard, GraphQL transport, and environment-resolution layer that all migrated commands build on top of.
  • A vendored operation catalog (mcp-catalog.snapshot.json) with a curation layer enforces that no internal naming (GraphQL, userland* prefixes) ever surfaces in user-facing output; this is verified by no-graphql-leak.spec.ts on every migrated command.
  • Commands that remain on the REST plane (connection, directory, api-key, audit-log, vault) are explicitly excluded and retain their API-key auth.

Confidence Score: 5/5

Safe to merge. The migration is well-scoped: REST-plane commands are untouched, migrated commands are fully covered by unit tests and live smoke tests, and the environment-invariant enforcement prevents the production-fallback hazard the PR explicitly set out to fix.

The core infrastructure is carefully designed with correct token-lifecycle semantics, and the no-graphql-leak gate closes a whole class of future regressions automatically. The single finding is in an edge-case error path and does not affect correctness. All previous review threads are addressed.

src/lib/dashboard-graphql.ts — the JSON parse failure error classification (see inline comment).

Important Files Changed

Filename Overview
src/lib/command-auth.ts New auth guard for dashboard-plane commands: correctly handles token refresh, distinguishes transient network failures from dead sessions, and surfaces appropriately distinct error copy for each case.
src/lib/dashboard-graphql.ts New minimal GraphQL transport with 30-second timeout and correct 401/403 classification; JSON parse failure is misclassified as network_error, producing a misleading check-your-connection message when the server actually responded.
src/lib/environment-target.ts New environment-resolution module that enforces the invariant that no dashboard request ever omits the environment header; handles 403, stale IDs, clientId-join healing, picker, and CI/agent modes correctly.
src/lib/dashboard-operation.ts Clean execution layer composing auth, catalog lookup, environment targeting, and error reporting; env-scoped and team-scoped variants well-separated.
src/commands/config.ts Migrated to dashboard plane with read-merge-write pattern; correctly guards against overflow on redirect URI merge, and rejectWildcardOrigins is applied before any network call.
src/commands/authkit.ts New dashboard-plane AuthKit config surface; wildcard origin rejection is in place before any request, dry-run support is correctly wired, and no internal naming leaks.
src/lib/api-key.ts Added getApiBaseUrlSource() with provenance tracking and normalizeApiBaseUrl() with URL validation; precedence chain is correct and tested.
src/catalog/operation.ts Catalog accessor layer with correct fragment stitching; reportDashboardError correctly masks internal GraphQL phrasing and classified errors map to clean user-facing copy.
src/catalog/no-graphql-leak.spec.ts Comprehensive gate ensuring no GraphQL or userland naming surfaces in command metadata, help-json registry, or error copy; data-driven off the manifest so future migrations inherit coverage automatically.
src/commands/organization.ts Migrated to dashboard plane; domain state validation, mixed-state guard, curated output shape, and not_found handling are all present.
src/commands/role.ts Migrated with slug to ID resolution pattern; read-merge-write for permission updates; org-scope guard correctly rejects inherited environment roles from org-scoped mutations.
scripts/smoke-dashboard-plane.sh Comprehensive live smoke test covering reads, mutations, and config writes; unsets WORKOS_API_KEY to confirm migrated commands do not depend on it; includes negative test for bogus --environment-id.
src/lib/ensure-auth.ts Correctly refactored to delegate expiry/refresh core to refreshIfExpired(); distinguishes never-logged-in, transient failure, and dead-session via observable store state rather than flag passing.

Sequence Diagram

sequenceDiagram
    participant CLI as CLI (migrated command)
    participant CA as command-auth.ts
    participant CR as credentials store
    participant TRC as token-refresh-client
    participant ET as environment-target.ts
    participant DG as dashboard-graphql.ts
    participant API as WorkOS API /graphql

    CLI->>CA: requireCommandToken()
    CA->>CR: getCredentials()
    CR-->>CA: creds or null
    alt token expired and refresh token exists
        CA->>TRC: refreshAccessToken(domain, clientId)
        TRC-->>CA: accessToken, refreshToken, expiresAt
        CA->>CR: updateTokens()
    end
    CA-->>CLI: accessToken or exit 4 auth_required

    CLI->>ET: resolveEnvironmentTarget(token, flagValue, forMutation)
    alt forMutation true or no stored env
        ET->>DG: dashboardGraphqlRequest(teamProjectsV2, token)
        DG->>API: POST /graphql Bearer token
        API-->>DG: currentTeam projectsV2
        DG-->>ET: TeamEnvironment array
        ET->>ET: healActiveProfile and validate ID
    end
    ET-->>CLI: environmentId and source

    CLI->>DG: dashboardGraphqlRequest(operation, token, variables, environmentId)
    DG->>API: POST /graphql x-url-environment-id env_xxx
    API-->>DG: data payload
    DG-->>CLI: data
    CLI->>CLI: shape and output
Loading

Reviews (4): Last reviewed commit: "refactor: extract dashboard-operation ex..." | Re-trigger Greptile

Comment thread src/lib/settings.ts
Comment thread src/lib/environment-target.ts Outdated
Comment thread src/commands/authkit.ts
nicknisi added 3 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.
…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.
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