Skip to content

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

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

feat!: migrate resource commands to the dashboard account plane#201
nicknisi wants to merge 26 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-integrationdevin-ai-integrationBot 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-appsBot 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 WorkOS dashboard account plane, using the device-flow OAuth session established by workos auth login. Per-environment API-key management is no longer required for day-to-day resource work on these commands.

  • New auth layer: requireCommandToken() in command-auth.ts provides a structured exit-4 path for unauthenticated dashboard commands; silent token refresh is extracted into the shared refreshIfExpired() used by both this guard and the existing ensureAuthenticated() install flow.
  • Environment targeting: resolveEnvironmentTarget() enforces a strict invariant — environment-scoped mutations are pre-validated against the team's live environment list; a stale stored ID exits with environment_stale rather than silently targeting production. Reads use the stored ID directly (accepted trade-off documented in code).
  • Catalog-backed operations: Commands now look up operation documents from a vendored snapshot (mcp-catalog.snapshot.json) rather than hand-writing GraphQL, and a no-graphql-leak.spec.ts gate enforces that no internal field names surface in user-visible strings.

Confidence Score: 5/5

  • This PR is safe to merge once the capability flag is confirmed enabled for all production teams (noted in the PR checklist).
  • The migration is carefully layered: auth, environment targeting, catalog operations, and error translation each have clear ownership and test coverage. The mutation pre-validation invariant (no silent production fallback on stale IDs) is well-enforced and exercised by the negative smoke test. The one speculative concern — whether server validation messages from typed error variants could expose internal terminology — is isolated to error paths and depends entirely on the server's own message content, which the server team controls.
  • The passthrough of result.message from UserlandApplicationValidationFailed in config.ts and URI-validation error messages in authkit.ts are worth a quick check of the server's actual message content; the no-graphql-leak.spec.ts does not currently scan those paths.

Important Files Changed

FilenameOverview
src/lib/command-auth.tsNew auth guard for dashboard-plane commands. refreshIfExpired correctly handles all token states (valid, expired+refresh, invalid_grant, network error); requireCommandToken translates no-session cases into structured exit-4 errors. The WORKOS_API_KEY variant copy is well-differentiated. No sensitive fields are logged. Well-tested in command-auth.spec.ts.
src/lib/dashboard-graphql.tsMinimal GraphQL client for the account plane. Timeout, abort, and error classification (forbidden/http/graphql/network) are all handled. The multipart upload path correctly follows the GraphQL multipart spec with the CSRF header. assertNullPlaceholder prevents silent variable path mismatches that would clear branding assets.
src/lib/environment-target.tsSolid environment resolution with correct mutation pre-validation vs. read fast-path, opportunistic profile healing via clientId join, and distinct forbidden vs. network-error copy. The 403 from fetchTeamEnvironments now correctly surfaces the forbidden message rather than a transient retry hint.
src/lib/dashboard-operation.tsClean composition layer: auth → catalog lookup → environment targeting → wire request → structured error exit. reportDashboardError is typed as never, so TypeScript correctly verifies the Promise<T> return type without a post-catch return statement.
src/commands/config.tsMigrated from REST to dashboard plane. The read-merge-write pattern for redirect and CORS adds is documented with its accepted race window. The scan-cap guard on redirect URIs correctly uses listMetadata.after to refuse a truncated read. The UserlandApplicationValidationFailed.message is passed through to users without the graphql-leak spec covering that path.
src/commands/authkit.tsNew command surface for per-environment AuthKit config. Wildcard-origin guard (rejectWildcardOrigins) is correctly placed before requireCommandToken. Discriminated union error variant messages (result.message) for redirect/logout URI errors are passed through to users without graphql-leak spec coverage.
src/commands/feature-flag.tsMigrated to dashboard plane with project-scoped operations. The resolveProjectId extra round-trip is necessary and catches stale environment IDs implicitly. The read-merge-write for enable/disable/targeting is correct. Target ID prefix validation (user_/org_) is enforced client-side.
src/commands/organization.tsMigration adds domain state validation (verified/pending), enforces the all-same-state constraint via domainsDeveloperVerified, and adds the confirmation gate on delete. The curated output shape is clearly defined and tested.
src/catalog/no-graphql-leak.spec.tsGood coverage of static command metadata and CLI-owned error constants. Gap: does not exercise server-supplied message fields from typed discriminated union error variants (e.g., UserlandApplicationValidationFailed.message in config.ts, URI error messages in authkit.ts), which are passed through to users outside the spec's scan.
src/lib/settings.tsgetCliAuthClientId is now config-only (no env override) with a clear comment explaining the WORKOS_CLIENT_ID collision hazard. getAuthkitDomain keeps its env override via WORKOS_AUTHKIT_DOMAIN which is correctly scoped.
src/commands/webhook.tsMigrated to dashboard plane. The signing-secret removal is explicitly documented in comments and the file-level docstring. Destructive delete is correctly gated by confirmDestructive.
src/lib/api-key.tsNew getApiBaseUrlSource returns provenance alongside the URL (useful for the "→ non-prod" indicator). normalizeApiBaseUrl validates protocol and strips trailing slashes, failing loudly instead of surfacing as an opaque TypeError.
scripts/smoke-dashboard-plane.shComprehensive smoke test covering read (17/17), mutate (44/44), and config-writes (23/23) tiers, including a negative test proving bogus --environment-id is rejected. No TLS bypass or hardcoded secrets found.

Sequence Diagram

sequenceDiagram
participant CLI as CLI Command
participant CA as requireCommandToken()
participant TR as token-refresh-client
participant ET as resolveEnvironmentTarget()
participant DG as dashboardGraphqlRequest()
participant Server as WorkOS Dashboard /graphql
CLI->>CA: requireCommandToken()
alt token still valid
CA-->>CLI: accessToken
else token expired, refresh token present
CA->>TR: refreshAccessToken(domain, clientId)
TR->>Server: POST /oauth2/token (refresh_token grant)
Server-->>TR: new access_token + refresh_token
TR-->>CA: "RefreshResult{success, accessToken}"
CA->>CA: updateTokens()
CA-->>CLI: accessToken
else no session / invalid_grant
CA->>CLI: exitWithAuthRequired(exit 4)
end
CLI->>ET: "resolveEnvironmentTarget(token, {forMutation})"
alt "forMutation=false, stored envId exists"
ET-->>CLI: "{environmentId, source:'profile'} (fast path, no network)"
else "forMutation=true OR no stored envId"
ET->>Server: teamProjectsV2 (team-scoped, no env header)
Server-->>ET: projects + environments list
ET->>ET: healActiveProfile() via clientId join
alt envId not found in team list
ET->>CLI: exitWithError(environment_stale)
else envId valid
ET-->>CLI: "{environmentId, source}"
end
end
CLI->>DG: "dashboardGraphqlRequest(document, {token, environmentId, variables})"
DG->>Server: POST /graphql (Bearer token, x-url-environment-id header)
alt success
Server-->>DG: "{data: T}"
DG-->>CLI: T
else 401/403
Server-->>DG: HTTP 401/403
DG->>CLI: reportDashboardError → exitWithError(forbidden)
else graphql errors
Server-->>DG: "{errors:[...]}"
DG->>CLI: reportDashboardError → exitWithError(graphql_error)
end
Loading

Reviews (8): Last reviewed commit: "docs: document the authkit and branding ..." | Re-trigger Greptile

Comment threadsrc/lib/settings.ts
Comment threadsrc/lib/environment-target.ts Outdated
Comment threadsrc/commands/authkit.ts
@nicknisi
nicknisiforce-pushed the ideation/graphql-resource-migration branch from e22b447 to fecd0caCompareJuly 25, 2026 03:23
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.

P1HTTP 401 misclassified as "capability not enabled"

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

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

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

@nicknisi