Skip to content

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

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

feat!: migrate resource commands to the dashboard account plane#201
nicknisi wants to merge 25 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.

@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 dashboard account plane, using device-flow OAuth bearer tokens. One login covers all migrated commands; per-environment key management is no longer required for day-to-day resource work.

  • Auth layer: A new requireCommandToken() guard resolves or refreshes the bearer token on every command path, distinguishing transient refresh failures from dead sessions and the never-logged-in case. The refresh core is shared with install flows via refreshIfExpired().
  • Environment targeting: resolveEnvironmentTarget() enforces the invariant that mutations always pre-validate the resolved environment ID, fixing a latent defect where every dashboard-plane request would silently fall back to the team's production environment.
  • Catalog-backed operations: All migrated commands use the new dashboard-graphql.ts client with a no-graphql-leak spec gate ensuring internal naming never surfaces to users.

Confidence Score: 4/5

Safe to merge with one caveat: when a user's session is remotely invalidated while their local token appears valid, the error message points them in the wrong direction.

The auth and environment-targeting logic is well-structured, the previously-flagged issues are all confirmed fixed, and the 2400+ test suite passes. One real defect remains: HTTP 401 responses from the GraphQL endpoint are mapped to the 'capability not enabled' message — a session remotely invalidated by an admin or password change would leave the user confused with no path to self-recovery.

Files Needing Attention: src/lib/dashboard-graphql.ts — the 401 vs 403 distinction in sendDashboardRequest needs a second look before rollout.

Important Files Changed

Filename Overview
src/lib/dashboard-graphql.ts New GraphQL client for the account plane. One concern: HTTP 401 and 403 are both classified as 'forbidden' and produce the same misleading 'capability not enabled' message.
src/lib/command-auth.ts New command-path auth guard correctly separates transient refresh failure from dead session and never-logged-in.
src/lib/environment-target.ts New environment resolver with reads-trust/mutations-pre-validate invariant, correct 403 handling, and opportunistic profile healing.
src/lib/dashboard-operation.ts Clean execution seam with three layers: env-scoped, team-scoped, and already-resolved.
src/commands/config.ts Migrated to dashboard plane with documented READ-MERGE-WRITE and wildcard-origin guard before requireCommandToken().
src/commands/authkit.ts New authkit URI/CORS management commands; rejectWildcardOrigins exported and reused by config.ts.
src/commands/organization.ts Organization commands migrated cleanly with exhausted union-typed mutation results.
src/commands/webhook.ts Migrated to dashboard plane; breaking change (no signing secret at create) is loudly documented.
src/catalog/confirm.ts New confirmation gate correctly separates destructive (prompt) from require-flag (no prompt) operations.
src/catalog/operation.ts Catalog operation accessors with WeakMap-memoized index; reportDashboardError hides internal GraphQL details.
src/lib/ensure-auth.ts Refactored to delegate refresh core to command-auth.ts; three post-refresh states drive correct messages.
src/lib/settings.ts getCliAuthClientId() now reads only from config; WORKOS_CLIENT_ID collision issue confirmed resolved.
src/commands/event.ts Migrated to dashboard plane; --org drop and --events required are documented breaking changes.
src/commands/role.ts Complex migration with slug-to-ID resolution, read-merge-write, and org-scope mutation safety check.
Prompt To Fix All With AI
Fix the following 1 code review issue. Work through them one at a time, proposing concise fixes.

---

### Issue 1 of 1
src/lib/dashboard-graphql.ts:123-129
**HTTP 401 misclassified as "capability not enabled"**

Both 401 and 403 are mapped to `'forbidden'`, which surfaces the message "This account-plane capability is not enabled for this team, or the logged-in account is not backed by a WorkOS dashboard team." A 401 from the server means the bearer token was rejected as invalid — the most common real-world trigger being remote session invalidation (admin deactivation, password change, explicit logout from another device). In that case `requireCommandToken()` sees a locally-valid (not expired) token, issues the request, gets back a 401, and the user is told "capability not enabled" with no hint to re-authenticate. Distinguishing `status === 401` with a separate error code — something like `'invalid_session'` — and routing it through copy like "Session was invalidated. Run `workos auth login` to re-authenticate" would make this edge case actionable.

Reviews (6): Last reviewed commit: "feat!: move branding to a top-level comm..." | 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 24 commits July 24, 2026 21:31
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.
… CORS wildcards

- getCliAuthClientId() no longer reads WORKOS_CLIENT_ID: that env var is
  the developer's own application client ID (credential-discovery reads it
  with that meaning), and overriding the CLI's OAuth client with it made
  token refresh fail with invalid_grant and clear a valid session.
- resolveEnvironmentTarget surfaces a 403 from the environment fetch as a
  structured forbidden error instead of a generic retry hint.
- CORS setters (authkit cors set, config cors add) reject wildcard origins
  client-side before any request is made.
…ce helpers

Quality pass over the dashboard-plane migration (no behavior change):

- Add src/lib/dashboard-operation.ts: runEnvScopedOperation /
  runTeamScopedOperation / executeDashboardOperation compose auth, catalog
  lookup, environment targeting, the wire request, and error reporting.
  All ~63 hand-threaded handler preambles across 17 command files now route
  through the seam; intentional forMutation overrides are preserved.
- Add src/utils/resource-command.ts: normalizeOrder (was copied in 4 files),
  printDetailFields (8 copies), printPaginationFooter (14 copies, including
  the pre-existing REST list commands).
- Close the mutation result unions (drop the `| { __typename: string }`
  catch-all members) so __typename comparisons narrow; deletes ~30 unchecked
  casts while keeping every runtime unexpected_result guard.
- Memoize catalog normalization and index getOperation lookups (were rebuilt
  and linearly scanned per call).
- Halve keyring IPC per command: refreshIfExpired accepts pre-read
  credentials; environment-target reads config once per resolution.
- Dedupe the --environment-id option in bin.ts (61 identical inline
  declarations -> one shared constant).
- Drop the redundant REQUIRED_KEYS presence pass in justification.ts (every
  field is already covered by its field-level check).
- Remove broken gen:routes / check:coverage npm scripts (pointed at files
  deleted with the emulator extraction).

Net -1,214 lines. 2,425 tests, typecheck, build, lint, format, and
justification:check all green.
Adds `workos authkit branding set`, which uploads AuthKit logo, icon, and
favicon images (light and dark) for an environment:

    workos authkit branding set --logo ./logo.png --icon ./icon.png

This is the only command that sends files. The account-plane branding fields
are file-upload scalars, so they need a multipart request — a JSON transport
cannot carry bytes, which is why an MCP client cannot set them and a CLI can.
No new server surface was required.

Notes on the implementation:

- Write scope is the server's decision. The command addresses the
  environment's branding record by id and deliberately does NOT send
  `environmentId`: the API scopes the write itself based on whether
  per-environment branding is enabled, and forcing the environment-scoped
  path would be invisible to AuthKit when it is not.
- The multipart envelope carries `null` in the variables for each image and
  binds the file parts by path. A path that matches nothing is silently
  ignored, and a surviving `null` means "clear this image" — so the client
  asserts every declared path resolves to a null slot before sending.
- Only the images passed as flags are touched; the rest are left absent
  rather than nulled.
- Files are validated locally first (400 KB cap, supported types) because the
  server enforces its cap by aborting the upload stream, which otherwise
  surfaces as a truncated request rather than a message naming the limit.

Adds a `--branding-writes` tier to the dashboard-plane smoke script, with a
`SMOKE_BRANDING_ENV_ID` override so the one destructive tier can be pointed
at a throwaway environment. Verified live: 29/29 pass, with the uploaded
assets read back and served by the AuthKit login page.
`src/utils/clack.ts` was deleted in favor of `src/utils/ui.ts`, but these
files still imported it. `vi.mock()` paths are plain strings, so typecheck
stayed green while the specs were broken at runtime.

`ui.select`/`confirm`/`isCancel` are API-compatible with what clack exposed,
so this is a module-specifier swap with no behavior change.
@nicknisi
nicknisi force-pushed the ideation/graphql-resource-migration branch from e22b447 to fecd0ca Compare July 25, 2026 03:23
`workos authkit branding {get,set}` becomes `workos branding {get,set}`.
Branding is not AuthKit-specific — the same record drives hosted AuthKit
pages and transactional emails — so it reads better as its own noun. The
old path was never released, so nothing is aliased.

`set` now also takes a slot and a file positionally, matching how the rest
of the CLI reads (`role get <slug>`, `organization get <orgId>`):

    workos branding set icon ./icon.png
    workos branding set logo-dark ./logo-dark.png

Slot names are the flag names, so the two forms agree. The flag form stays
for setting several images at once:

    workos branding set --logo ./logo.png --favicon ./favicon.ico

Mixing the forms in one invocation is rejected rather than merged: both
could name the same slot with different files, and silently picking a
winner would upload the wrong image.

Handlers move from `commands/authkit.ts` to `commands/branding.ts`, and the
smoke tier gains live coverage of the positional path (set one image, then
assert only that image moved).
Comment on lines +123 to +129
if (res.status === 401 || res.status === 403) {
throw new DashboardGraphqlError(
`The dashboard GraphQL API rejected this session (HTTP ${res.status}).`,
'forbidden',
res.status,
);
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P1 HTTP 401 misclassified as "capability not enabled"

Both 401 and 403 are mapped to 'forbidden', which surfaces the message "This account-plane capability is not enabled for this team, or the logged-in account is not backed by a WorkOS dashboard team." A 401 from the server means the bearer token was rejected as invalid — the most common real-world trigger being remote session invalidation (admin deactivation, password change, explicit logout from another device). In that case requireCommandToken() sees a locally-valid (not expired) token, issues the request, gets back a 401, and the user is told "capability not enabled" with no hint to re-authenticate. Distinguishing status === 401 with a separate error code — something like 'invalid_session' — and routing it through copy like "Session was invalidated. Run workos auth login to re-authenticate" would make this edge case actionable. Does the WorkOS dashboard GraphQL endpoint ever return HTTP 401 (as opposed to 403) for a rejected bearer token? If the server guarantees 403 for all auth failures, this distinction can be dropped.

Prompt To Fix With AI
This is a comment left during a code review.
Path: src/lib/dashboard-graphql.ts
Line: 123-129

Comment:
**HTTP 401 misclassified as "capability not enabled"**

Both 401 and 403 are mapped to `'forbidden'`, which surfaces the message "This account-plane capability is not enabled for this team, or the logged-in account is not backed by a WorkOS dashboard team." A 401 from the server means the bearer token was rejected as invalid — the most common real-world trigger being remote session invalidation (admin deactivation, password change, explicit logout from another device). In that case `requireCommandToken()` sees a locally-valid (not expired) token, issues the request, gets back a 401, and the user is told "capability not enabled" with no hint to re-authenticate. Distinguishing `status === 401` with a separate error code — something like `'invalid_session'` — and routing it through copy like "Session was invalidated. Run `workos auth login` to re-authenticate" would make this edge case actionable. Does the WorkOS dashboard GraphQL endpoint ever return HTTP 401 (as opposed to 403) for a rejected bearer token? If the server guarantees 403 for all auth failures, this distinction can be dropped.

How can I resolve this? If you propose a fix, please make it concise.

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