From bfb6d30b9309dd31cdf81313ebeca85129b63f27 Mon Sep 17 00:00:00 2001 From: Nick Nisi Date: Mon, 15 Jun 2026 13:12:28 -0500 Subject: [PATCH 01/22] feat: allow overriding CLI auth client ID via WORKOS_CLIENT_ID 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. --- src/lib/settings.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/lib/settings.ts b/src/lib/settings.ts index 233a660c..bc5e211f 100644 --- a/src/lib/settings.ts +++ b/src/lib/settings.ts @@ -58,7 +58,7 @@ export function getConfig(): InstallerConfig { * Get the CLI auth client ID (from config; not env-overridable). */ export function getCliAuthClientId(): string { - return config.workos.clientId; + return process.env.WORKOS_CLIENT_ID || config.workos.clientId; } /** From d6ec31a61e5091986940d5eb0e397b3d0ea34dc2 Mon Sep 17 00:00:00 2001 From: Nick Nisi Date: Mon, 22 Jun 2026 10:58:34 -0500 Subject: [PATCH 02/22] feat: point the CLI at a non-prod API via WORKOS_API_URL 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). --- README.md | 17 ++++---- src/bin.ts | 12 ++++++ src/commands/debug.ts | 7 +++- src/commands/env.spec.ts | 42 ++++++++++++++++++++ src/commands/env.ts | 13 +++++- src/lib/api-key.spec.ts | 86 +++++++++++++++++++++++++++++++++++++++- src/lib/api-key.ts | 63 ++++++++++++++++++++++++++++- src/lib/settings.ts | 3 +- src/utils/output.spec.ts | 51 ++++++++++++++++++++++++ src/utils/output.ts | 21 ++++++++++ src/utils/urls.ts | 19 +++++---- 11 files changed, 314 insertions(+), 20 deletions(-) diff --git a/README.md b/README.md index e51003af..76f2678a 100644 --- a/README.md +++ b/README.md @@ -555,13 +555,16 @@ workos install --api-key sk_test_xxx --client-id client_xxx --no-commit 2>/dev/n ### Environment Variables -| Variable | Effect | -| ------------------------ | --------------------------------------------------------- | -| `WORKOS_API_KEY` | API key for management commands (bypasses stored config) | -| `WORKOS_API_BASE_URL` | Override API base URL (set automatically by `workos dev`) | -| `WORKOS_MODE` | Interaction mode: `human`, `agent`, or `ci` | -| `WORKOS_FORCE_TTY=1` | Force human (non-JSON) **output** mode even when piped | -| `WORKOS_TELEMETRY=false` | Disable telemetry | +| Variable | Effect | +| ------------------------ | ----------------------------------------------------------------- | +| `WORKOS_API_KEY` | API key for management commands (bypasses stored config) | +| `WORKOS_API_URL` | Override the API base URL for all CLI commands (e.g. a local API) | +| `WORKOS_API_BASE_URL` | Accepted alias for `WORKOS_API_URL` (what `workos dev` sets) | +| `WORKOS_MODE` | Interaction mode: `human`, `agent`, or `ci` | +| `WORKOS_FORCE_TTY=1` | Force human (non-JSON) **output** mode even when piped | +| `WORKOS_TELEMETRY=false` | Disable telemetry | + +> `WORKOS_API_URL` controls where the **CLI's own** commands (`workos user`, `workos api`, etc.) send requests — set it to point the CLI at a locally-running API. This is distinct from `workos dev`, which sets API vars for your **app's** dev process so it talks to the in-process emulator. ### Command Discovery diff --git a/src/bin.ts b/src/bin.ts index b159927c..0ec4e4c2 100644 --- a/src/bin.ts +++ b/src/bin.ts @@ -41,8 +41,10 @@ import { isJsonMode, outputJson, outputError, + outputApiBaseUrlIndicator, exitWithError, } from './utils/output.js'; +import { getApiBaseUrlSource } from './lib/api-key.js'; import clack from './utils/clack.js'; import { registerSubcommand } from './utils/register-subcommand.js'; import { installCrashReporter, sanitizeMessage } from './utils/crash-reporter.js'; @@ -298,6 +300,16 @@ async function runCli(): Promise { const commandParts = (argv._ as string[]) || []; commandName = resolveCanonicalName(commandParts); }) + .middleware((argv) => { + // Surface a non-prod API base URL once per command, human mode only. + // Gated on !isJsonMode() so automation/JSON paths skip the config read + // entirely — API commands still validate the URL at call time via + // resolveApiBaseUrl(). Skip the bare root command (`workos` / + // `--help` / `--version`); there is no API call to attribute it to. + // outputApiBaseUrlIndicator no-ops when the base URL is the prod default. + if (isJsonMode() || String(argv._?.[0] ?? '') === '') return; + outputApiBaseUrlIndicator(getApiBaseUrlSource()); + }) .middleware((argv) => { // First-run, stderr-only notice that telemetry is being collected. // Skip while the user is actively managing telemetry, and on the diff --git a/src/commands/debug.ts b/src/commands/debug.ts index 675df5b4..56a3e26b 100644 --- a/src/commands/debug.ts +++ b/src/commands/debug.ts @@ -391,7 +391,12 @@ export const ENV_VAR_CATALOG: { name: string; effect: string }[] = [ // URLs (WORKOS_API_URL is the single base; gateway + telemetry derive from it) { name: 'WORKOS_API_URL', - effect: 'Overrides API base URL; also reroutes the LLM gateway and CLI telemetry endpoints', + effect: + 'Overrides the API base URL for all CLI commands (default: https://api.workos.com); also reroutes the LLM gateway and CLI telemetry endpoints', + }, + { + name: 'WORKOS_API_BASE_URL', + effect: 'Accepted alias for WORKOS_API_URL (what `workos dev` sets)', }, { name: 'WORKOS_DASHBOARD_URL', effect: 'Overrides dashboard URL (default: https://dashboard.workos.com)' }, { name: 'WORKOS_AUTHKIT_DOMAIN', effect: 'Overrides AuthKit domain from settings' }, diff --git a/src/commands/env.spec.ts b/src/commands/env.spec.ts index 30d858e2..fa856fee 100644 --- a/src/commands/env.spec.ts +++ b/src/commands/env.spec.ts @@ -63,6 +63,8 @@ describe('env commands', () => { testDir = mkdtempSync(join(tmpdir(), 'env-cmd-test-')); setInsecureConfigStorage(true); resetInteractionModeForTests(); + delete process.env.WORKOS_API_URL; + delete process.env.WORKOS_API_BASE_URL; vi.clearAllMocks(); }); @@ -270,6 +272,29 @@ describe('env commands', () => { await runEnvAdd({ name: 'prod', apiKey: 'sk_live_abc' }); await expect(runEnvList()).resolves.not.toThrow(); }); + + it('prints an env-var override annotation when WORKOS_API_URL is set', async () => { + process.env.WORKOS_API_URL = 'http://localhost:7777'; + await runEnvAdd({ name: 'prod', apiKey: 'sk_live_abc' }); + const logSpy = vi.spyOn(console, 'log').mockImplementation(() => {}); + await runEnvList(); + const lines = logSpy.mock.calls.map((c) => c.map(String).join(' ')); + expect( + lines.some( + (l) => l.includes('Override:') && l.includes('WORKOS_API_URL') && l.includes('http://localhost:7777'), + ), + ).toBe(true); + logSpy.mockRestore(); + }); + + it('does not print an override annotation when no env var is set', async () => { + await runEnvAdd({ name: 'prod', apiKey: 'sk_live_abc' }); + const logSpy = vi.spyOn(console, 'log').mockImplementation(() => {}); + await runEnvList(); + const lines = logSpy.mock.calls.map((c) => c.map(String).join(' ')); + expect(lines.some((l) => l.includes('Override:'))).toBe(false); + logSpy.mockRestore(); + }); }); describe('runEnvProvision', () => { @@ -503,6 +528,23 @@ describe('env commands', () => { const output = JSON.parse(consoleOutput[0]); expect(output.data).toEqual([]); }); + + it('runEnvList includes an override field when WORKOS_API_URL is set', async () => { + process.env.WORKOS_API_URL = 'http://localhost:7777'; + await runEnvAdd({ name: 'prod', apiKey: 'sk_live_abc' }); + consoleOutput = []; + await runEnvList(); + const output = JSON.parse(consoleOutput[0]); + expect(output.override).toEqual({ baseUrl: 'http://localhost:7777', via: 'WORKOS_API_URL' }); + }); + + it('runEnvList override field is null when no env var is set', async () => { + await runEnvAdd({ name: 'prod', apiKey: 'sk_live_abc' }); + consoleOutput = []; + await runEnvList(); + const output = JSON.parse(consoleOutput[0]); + expect(output.override).toBeNull(); + }); }); describe('command hints route through formatWorkOSCommand (npx vs bare)', () => { diff --git a/src/commands/env.ts b/src/commands/env.ts index 2d600a3f..1cff02da 100644 --- a/src/commands/env.ts +++ b/src/commands/env.ts @@ -2,6 +2,7 @@ import chalk from 'chalk'; import clack from '../utils/clack.js'; import { getConfig, saveConfig, isUnclaimedEnvironment, freshEnvKey } from '../lib/config-store.js'; import type { CliConfig } from '../lib/config-store.js'; +import { getApiBaseUrlSource } from '../lib/api-key.js'; import { outputSuccess, outputJson, exitWithError, isJsonMode } from '../utils/output.js'; import { isAgentMode, isCiMode, isPromptAllowed } from '../utils/interaction-mode.js'; import { missingArgsRecovery } from '../utils/recovery-hints.js'; @@ -296,6 +297,11 @@ export async function runEnvList(): Promise { } const entries = Object.entries(config.environments); + const baseUrlSource = getApiBaseUrlSource(); + // Only an env-var override supersedes the stored profiles below; a profile + // endpoint is already shown in the table, so don't double-report it here. + const override = + baseUrlSource.source === 'env' ? { baseUrl: baseUrlSource.baseUrl, via: baseUrlSource.via } : null; if (isJsonMode()) { const data = entries.map(([key, env]) => ({ @@ -306,7 +312,7 @@ export async function runEnvList(): Promise { hasApiKey: !!env.apiKey, hasClientId: !!env.clientId, })); - outputJson({ data }); + outputJson({ data, override }); return; } @@ -344,4 +350,9 @@ export async function runEnvList(): Promise { console.log(''); console.log(chalk.dim(` Run \`${formatWorkOSCommand('env claim')}\` to keep this environment.`)); } + + if (override) { + console.log(''); + console.log(chalk.yellow(`Override: ${override.via}=${override.baseUrl} `) + chalk.dim('(active for all commands)')); + } } diff --git a/src/lib/api-key.spec.ts b/src/lib/api-key.spec.ts index e8eaa047..9d5e1a91 100644 --- a/src/lib/api-key.spec.ts +++ b/src/lib/api-key.spec.ts @@ -39,7 +39,7 @@ vi.mock('node:os', async (importOriginal) => { }); const { saveConfig, setInsecureConfigStorage, clearConfig } = await import('./config-store.js'); -const { resolveApiKey, resolveOptionalApiKey, resolveApiBaseUrl } = await import('./api-key.js'); +const { resolveApiKey, resolveOptionalApiKey, resolveApiBaseUrl, getApiBaseUrlSource } = await import('./api-key.js'); describe('api-key', () => { const originalEnv = process.env; @@ -49,6 +49,8 @@ describe('api-key', () => { setInsecureConfigStorage(true); process.env = { ...originalEnv }; delete process.env.WORKOS_API_KEY; + delete process.env.WORKOS_API_URL; + delete process.env.WORKOS_API_BASE_URL; }); afterEach(() => { @@ -173,5 +175,87 @@ describe('api-key', () => { }); expect(resolveApiBaseUrl()).toBe('http://localhost:8001'); }); + + it('returns WORKOS_API_URL over a profile endpoint', () => { + process.env.WORKOS_API_URL = 'http://localhost:7777'; + saveConfig({ + activeEnvironment: 'local', + environments: { + local: { name: 'local', type: 'sandbox', apiKey: 'sk_test', endpoint: 'http://localhost:8001' }, + }, + }); + expect(resolveApiBaseUrl()).toBe('http://localhost:7777'); + }); + + it('uses WORKOS_API_BASE_URL as an alias when WORKOS_API_URL is unset', () => { + process.env.WORKOS_API_BASE_URL = 'http://localhost:9999'; + expect(resolveApiBaseUrl()).toBe('http://localhost:9999'); + }); + + it('prefers WORKOS_API_URL over WORKOS_API_BASE_URL when both are set', () => { + process.env.WORKOS_API_URL = 'http://localhost:7777'; + process.env.WORKOS_API_BASE_URL = 'http://localhost:9999'; + expect(resolveApiBaseUrl()).toBe('http://localhost:7777'); + }); + + it('ignores an empty WORKOS_API_URL and falls through to the default', () => { + process.env.WORKOS_API_URL = ''; + expect(resolveApiBaseUrl()).toBe('https://api.workos.com'); + }); + + it('strips a trailing slash from the env override', () => { + process.env.WORKOS_API_URL = 'http://localhost:7777/'; + expect(resolveApiBaseUrl()).toBe('http://localhost:7777'); + }); + + it('exits with a friendly error when WORKOS_API_URL is malformed', () => { + process.env.WORKOS_API_URL = 'not a url'; + expect(() => resolveApiBaseUrl()).toThrow(ExitError); + expect(mockExitWithError).toHaveBeenCalledWith(expect.objectContaining({ code: 'invalid_api_url' })); + }); + + it('exits when WORKOS_API_URL uses an unsupported scheme', () => { + process.env.WORKOS_API_URL = 'ftp://localhost:7777'; + expect(() => resolveApiBaseUrl()).toThrow(ExitError); + expect(mockExitWithError).toHaveBeenCalledWith(expect.objectContaining({ code: 'invalid_api_url' })); + }); + }); + + describe('getApiBaseUrlSource', () => { + it('reports the default source when nothing is configured', () => { + expect(getApiBaseUrlSource()).toEqual({ baseUrl: 'https://api.workos.com', source: 'default' }); + }); + + it('reports the env source and which var was used', () => { + process.env.WORKOS_API_URL = 'http://localhost:7777'; + expect(getApiBaseUrlSource()).toEqual({ + baseUrl: 'http://localhost:7777', + source: 'env', + via: 'WORKOS_API_URL', + }); + }); + + it('reports the alias var name when only WORKOS_API_BASE_URL is set', () => { + process.env.WORKOS_API_BASE_URL = 'http://localhost:9999'; + expect(getApiBaseUrlSource()).toEqual({ + baseUrl: 'http://localhost:9999', + source: 'env', + via: 'WORKOS_API_BASE_URL', + }); + }); + + it('reports the profile source and name', () => { + saveConfig({ + activeEnvironment: 'local', + environments: { + local: { name: 'local', type: 'sandbox', apiKey: 'sk_test', endpoint: 'http://localhost:8001' }, + }, + }); + expect(getApiBaseUrlSource()).toEqual({ + baseUrl: 'http://localhost:8001', + source: 'profile', + via: 'local', + }); + }); }); }); diff --git a/src/lib/api-key.ts b/src/lib/api-key.ts index decdebb4..2ab51858 100644 --- a/src/lib/api-key.ts +++ b/src/lib/api-key.ts @@ -39,7 +39,66 @@ export function resolveOptionalApiKey(options?: ApiKeyOptions): string | undefin return undefined; } -export function resolveApiBaseUrl(): string { +export type ApiBaseUrlSource = 'env' | 'profile' | 'default'; + +export interface ResolvedApiBaseUrl { + baseUrl: string; + source: ApiBaseUrlSource; + /** + * Provenance for display: the env var name when `source === 'env'`, + * or the active environment name when `source === 'profile'`. + */ + via?: string; +} + +/** + * Resolve the API base URL along with where it came from. + * + * Precedence: WORKOS_API_URL → WORKOS_API_BASE_URL (alias) → active profile + * endpoint → default. Env-supplied values are validated and normalized so a + * typo fails loudly instead of surfacing as an opaque `new URL()` TypeError + * deep in a command. + */ +export function getApiBaseUrlSource(): ResolvedApiBaseUrl { + if (process.env.WORKOS_API_URL) { + return { baseUrl: normalizeApiBaseUrl(process.env.WORKOS_API_URL, 'WORKOS_API_URL'), source: 'env', via: 'WORKOS_API_URL' }; + } + if (process.env.WORKOS_API_BASE_URL) { + return { + baseUrl: normalizeApiBaseUrl(process.env.WORKOS_API_BASE_URL, 'WORKOS_API_BASE_URL'), + source: 'env', + via: 'WORKOS_API_BASE_URL', + }; + } + const activeEnv = getActiveEnvironment(); - return activeEnv?.endpoint || DEFAULT_BASE_URL; + if (activeEnv?.endpoint) { + return { baseUrl: activeEnv.endpoint, source: 'profile', via: activeEnv.name }; + } + + return { baseUrl: DEFAULT_BASE_URL, source: 'default' }; +} + +export function resolveApiBaseUrl(): string { + return getApiBaseUrlSource().baseUrl; +} + +function normalizeApiBaseUrl(value: string, varName: string): string { + let parsed: URL; + try { + parsed = new URL(value); + } catch { + exitWithError({ + code: 'invalid_api_url', + message: `${varName} is not a valid URL: "${value}". Example: ${varName}=http://localhost:7777`, + }); + } + if (parsed.protocol !== 'http:' && parsed.protocol !== 'https:') { + exitWithError({ + code: 'invalid_api_url', + message: `${varName} must use http or https (got "${parsed.protocol}"). Example: ${varName}=http://localhost:7777`, + }); + } + // Strip trailing slashes so paths concatenate cleanly (`${base}/users`). + return value.replace(/\/+$/, ''); } diff --git a/src/lib/settings.ts b/src/lib/settings.ts index bc5e211f..c322c432 100644 --- a/src/lib/settings.ts +++ b/src/lib/settings.ts @@ -55,7 +55,8 @@ export function getConfig(): InstallerConfig { } /** - * Get the CLI auth client ID (from config; not env-overridable). + * Get the CLI auth client ID. + * Env var (WORKOS_CLIENT_ID) overrides the config default. */ export function getCliAuthClientId(): string { return process.env.WORKOS_CLIENT_ID || config.workos.clientId; diff --git a/src/utils/output.spec.ts b/src/utils/output.spec.ts index 480b499d..86b0aa5d 100644 --- a/src/utils/output.spec.ts +++ b/src/utils/output.spec.ts @@ -10,6 +10,8 @@ const { outputError, outputSuccess, exitWithError, + formatApiBaseUrlIndicator, + outputApiBaseUrlIndicator, } = await import('./output.js'); const { CliExit } = await import('./cli-exit.js'); @@ -177,6 +179,55 @@ describe('output', () => { }); }); + describe('formatApiBaseUrlIndicator', () => { + it('returns null for the default source (no override)', () => { + expect(formatApiBaseUrlIndicator({ baseUrl: 'https://api.workos.com', source: 'default' })).toBeNull(); + }); + + it('names the env var for an env override', () => { + expect( + formatApiBaseUrlIndicator({ baseUrl: 'http://localhost:7777', source: 'env', via: 'WORKOS_API_URL' }), + ).toBe('→ http://localhost:7777 (WORKOS_API_URL)'); + }); + + it('names the profile for a profile override', () => { + expect(formatApiBaseUrlIndicator({ baseUrl: 'http://localhost:8001', source: 'profile', via: 'local' })).toBe( + '→ http://localhost:8001 (profile "local")', + ); + }); + }); + + describe('outputApiBaseUrlIndicator', () => { + it('writes a dim line to stderr in human mode for an override', () => { + setOutputMode('human'); + const spy = vi.spyOn(console, 'error').mockImplementation(() => {}); + outputApiBaseUrlIndicator({ baseUrl: 'http://localhost:7777', source: 'env', via: 'WORKOS_API_URL' }); + expect(spy).toHaveBeenCalledTimes(1); + expect(spy.mock.calls[0][0]).toContain('http://localhost:7777'); + expect(spy.mock.calls[0][0]).toContain('WORKOS_API_URL'); + spy.mockRestore(); + }); + + it('writes nothing in json mode (keeps stdout/stderr clean for scripting)', () => { + setOutputMode('json'); + const errSpy = vi.spyOn(console, 'error').mockImplementation(() => {}); + const logSpy = vi.spyOn(console, 'log').mockImplementation(() => {}); + outputApiBaseUrlIndicator({ baseUrl: 'http://localhost:7777', source: 'env', via: 'WORKOS_API_URL' }); + expect(errSpy).not.toHaveBeenCalled(); + expect(logSpy).not.toHaveBeenCalled(); + errSpy.mockRestore(); + logSpy.mockRestore(); + }); + + it('writes nothing when the source is the default', () => { + setOutputMode('human'); + const spy = vi.spyOn(console, 'error').mockImplementation(() => {}); + outputApiBaseUrlIndicator({ baseUrl: 'https://api.workos.com', source: 'default' }); + expect(spy).not.toHaveBeenCalled(); + spy.mockRestore(); + }); + }); + describe('exitWithError', () => { it('throws CliExit with exit code 1 for unknown codes', () => { setOutputMode('json'); diff --git a/src/utils/output.ts b/src/utils/output.ts index e5828d35..9360c097 100644 --- a/src/utils/output.ts +++ b/src/utils/output.ts @@ -12,6 +12,7 @@ import { resolveErrorCode } from './exit-codes.js'; import { formatTable, type TableColumn } from './table.js'; import type { RecoveryHints } from './recovery-hints.js'; import type { InteractionModeInfo } from './interaction-mode.js'; +import type { ResolvedApiBaseUrl } from '../lib/api-key.js'; export type OutputMode = 'human' | 'json'; @@ -114,6 +115,26 @@ export function outputError(error: StructuredError): void { } } +/** + * Format the "you are not talking to prod" indicator, or null when the base URL + * is the default. Pure — the caller decides whether/how to emit it. + */ +export function formatApiBaseUrlIndicator(resolved: ResolvedApiBaseUrl): string | null { + if (resolved.source === 'default') return null; + const tag = resolved.source === 'env' ? resolved.via : `profile "${resolved.via}"`; + return `→ ${resolved.baseUrl} (${tag})`; +} + +/** + * Emit the base-URL indicator to stderr in human mode only. Suppressed in JSON + * mode so piped/agent output stays clean. No-op when the base URL is default. + */ +export function outputApiBaseUrlIndicator(resolved: ResolvedApiBaseUrl): void { + if (currentMode === 'json') return; + const line = formatApiBaseUrlIndicator(resolved); + if (line) console.error(chalk.dim(line)); +} + /** Write tabular data — chalk table in human mode, JSON array in json mode. */ export function outputTable(columns: TableColumn[], rows: string[][], rawData?: unknown[]): void { if (currentMode === 'json') { diff --git a/src/utils/urls.ts b/src/utils/urls.ts index b363c8d1..0906f6f8 100644 --- a/src/utils/urls.ts +++ b/src/utils/urls.ts @@ -1,18 +1,23 @@ +import { resolveApiBaseUrl } from '../lib/api-key.js'; + /** * WorkOS service endpoint resolution. Env vars override defaults. * - * WORKOS_API_URL is the single base host; the LLM gateway and CLI telemetry - * endpoints are served under it and derived here. The trailing slash is - * normalized at the source so every derived path stays clean. + * The API base host is resolved once in `lib/api-key.ts` (`resolveApiBaseUrl`: + * the WORKOS_API_URL -> WORKOS_API_BASE_URL -> active profile -> default + * chokepoint). The LLM gateway and CLI telemetry endpoints derive from it here, + * so they always follow the same host as every other CLI request. Do not read + * WORKOS_API_URL directly here -- that reintroduces a second reader that ignores + * the alias and the active profile. */ -export const getWorkOSApiUrl = (): string => - (process.env.WORKOS_API_URL || 'https://api.workos.com').replace(/\/$/, ''); - -export const getWorkOSDashboardUrl = (): string => process.env.WORKOS_DASHBOARD_URL || 'https://dashboard.workos.com'; +export const getWorkOSApiUrl = (): string => resolveApiBaseUrl().replace(/\/+$/, ''); /** LLM gateway endpoint, served under the WorkOS API host. */ export const getLlmGatewayUrl = (): string => `${getWorkOSApiUrl()}/llm-gateway`; /** CLI telemetry endpoint, served under the WorkOS API host. */ export const getTelemetryUrl = (): string => `${getWorkOSApiUrl()}/cli`; + +export const getWorkOSDashboardUrl = (): string => + process.env.WORKOS_DASHBOARD_URL || 'https://dashboard.workos.com'; From d91c70c7a613ca39fb6e06f5d1579e4dc7a1c22c Mon Sep 17 00:00:00 2001 From: Nick Nisi Date: Tue, 23 Jun 2026 12:45:56 -0500 Subject: [PATCH 03/22] feat: add `workos whoami` command 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 `/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 --- src/bin.ts | 11 +++ src/commands/whoami.spec.ts | 127 ++++++++++++++++++++++++++++++++ src/commands/whoami.ts | 139 +++++++++++++++++++++++++++++++++++ src/lib/dashboard-graphql.ts | 119 ++++++++++++++++++++++++++++++ src/utils/help-json.ts | 5 ++ 5 files changed, 401 insertions(+) create mode 100644 src/commands/whoami.spec.ts create mode 100644 src/commands/whoami.ts create mode 100644 src/lib/dashboard-graphql.ts diff --git a/src/bin.ts b/src/bin.ts index 0ec4e4c2..83371d1f 100644 --- a/src/bin.ts +++ b/src/bin.ts @@ -328,6 +328,7 @@ async function runCli(): Promise { if ( [ 'auth', + 'whoami', 'skills', 'doctor', 'env', @@ -412,6 +413,16 @@ async function runCli(): Promise { ); return yargs.demandCommand(1, 'Please specify an auth subcommand').strict(); }) + .command( + 'whoami', + 'Show the authenticated user, team, and environment (dashboard session)', + (yargs) => yargs.options(insecureStorageOption), + async (argv) => { + await applyInsecureStorage(argv.insecureStorage as boolean | undefined); + const { runWhoami } = await import('./commands/whoami.js'); + await runWhoami(); + }, + ) .command('telemetry', 'Manage telemetry collection (opt-out, opt-in, status)', (yargs) => { registerSubcommand( yargs, diff --git a/src/commands/whoami.spec.ts b/src/commands/whoami.spec.ts new file mode 100644 index 00000000..2a6ad81d --- /dev/null +++ b/src/commands/whoami.spec.ts @@ -0,0 +1,127 @@ +import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'; + +const mockGetAccessToken = vi.fn(); +const mockGraphqlRequest = vi.fn(); + +vi.mock('../lib/credentials.js', () => ({ + getAccessToken: () => mockGetAccessToken(), +})); + +// Replace only the request function; keep the real DashboardGraphqlError so the +// command's `instanceof` check matches what the tests throw. +vi.mock('../lib/dashboard-graphql.js', async (importActual) => { + const actual = await importActual(); + return { + ...actual, + dashboardGraphqlRequest: (...args: unknown[]) => mockGraphqlRequest(...args), + }; +}); + +const { setOutputMode } = await import('../utils/output.js'); +const { DashboardGraphqlError } = await import('../lib/dashboard-graphql.js'); +const { CliExit } = await import('../utils/cli-exit.js'); +const { runWhoami } = await import('./whoami.js'); + +function sampleData() { + return { + me: { id: 'usr_dash_1', name: 'Nick Nisi', email: 'nick.nisi@workos.com', workosUserId: 'user_123' }, + currentTeam: { id: 'team_1', name: 'WorkOS', organizationId: 'org_123', productionState: 'Live' }, + currentEnvironment: { id: 'env_1', name: 'Production', clientId: 'client_123', platform: 'Node', sandbox: false }, + }; +} + +describe('whoami command', () => { + let consoleOutput: string[]; + let consoleErrors: string[]; + + beforeEach(() => { + vi.clearAllMocks(); + consoleOutput = []; + consoleErrors = []; + vi.spyOn(console, 'log').mockImplementation((...args: unknown[]) => { + consoleOutput.push(args.map(String).join(' ')); + }); + vi.spyOn(console, 'error').mockImplementation((...args: unknown[]) => { + consoleErrors.push(args.map(String).join(' ')); + }); + }); + + afterEach(() => { + vi.restoreAllMocks(); + }); + + it('exits auth-required (code 4) when not logged in', async () => { + mockGetAccessToken.mockReturnValue(null); + await expect(runWhoami()).rejects.toMatchObject({ name: 'CliExit', exitCode: 4 }); + expect(mockGraphqlRequest).not.toHaveBeenCalled(); + }); + + it('calls the dashboard GraphQL API with the bearer token', async () => { + mockGetAccessToken.mockReturnValue('tok_123'); + mockGraphqlRequest.mockResolvedValue(sampleData()); + await runWhoami(); + expect(mockGraphqlRequest).toHaveBeenCalledWith(expect.stringContaining('workosCliWhoami'), { token: 'tok_123' }); + }); + + it('renders user, team, and environment in human mode', async () => { + mockGetAccessToken.mockReturnValue('tok'); + mockGraphqlRequest.mockResolvedValue(sampleData()); + await runWhoami(); + const out = consoleOutput.join('\n'); + expect(out).toContain('nick.nisi@workos.com'); + expect(out).toContain('WorkOS'); + expect(out).toContain('org_123'); + expect(out).toContain('Production'); + expect(out).toContain('client_123'); + }); + + it('omits the team and environment blocks when absent', async () => { + mockGetAccessToken.mockReturnValue('tok'); + mockGraphqlRequest.mockResolvedValue({ ...sampleData(), currentTeam: null, currentEnvironment: null }); + await runWhoami(); + const out = consoleOutput.join('\n'); + expect(out).toContain('User'); + expect(out).not.toContain('Team'); + expect(out).not.toContain('Environment'); + }); + + it('explains the gated-capability case on a 403', async () => { + mockGetAccessToken.mockReturnValue('tok'); + mockGraphqlRequest.mockRejectedValue( + new DashboardGraphqlError('The dashboard GraphQL API rejected this session (HTTP 403).', 'forbidden', 403), + ); + await expect(runWhoami()).rejects.toBeInstanceOf(CliExit); + const err = consoleErrors.join('\n'); + expect(err).toContain('staging only'); + }); + + describe('JSON output mode', () => { + beforeEach(() => { + setOutputMode('json'); + }); + + afterEach(() => { + setOutputMode('human'); + }); + + it('outputs user/team/environment as JSON', async () => { + mockGetAccessToken.mockReturnValue('tok'); + mockGraphqlRequest.mockResolvedValue(sampleData()); + await runWhoami(); + const output = JSON.parse(consoleOutput[0]); + expect(output.user.email).toBe('nick.nisi@workos.com'); + expect(output.user.workosUserId).toBe('user_123'); + expect(output.team.organizationId).toBe('org_123'); + expect(output.environment.clientId).toBe('client_123'); + }); + + it('preserves null team/environment in JSON', async () => { + mockGetAccessToken.mockReturnValue('tok'); + mockGraphqlRequest.mockResolvedValue({ ...sampleData(), currentTeam: null, currentEnvironment: null }); + await runWhoami(); + const output = JSON.parse(consoleOutput[0]); + expect(output.team).toBeNull(); + expect(output.environment).toBeNull(); + }); + }); +}); diff --git a/src/commands/whoami.ts b/src/commands/whoami.ts new file mode 100644 index 00000000..24c79613 --- /dev/null +++ b/src/commands/whoami.ts @@ -0,0 +1,139 @@ +/** + * `workos whoami` — resolve the caller's identity from the dashboard session. + * + * Unlike `auth status` (which only inspects the locally stored token), this + * calls the dashboard GraphQL API with the OAuth bearer and reports who the + * server says you are: the authenticated user, their team, and the environment + * the session currently acts on. It is read-only and account-scoped. + */ + +import chalk from 'chalk'; +import { getAccessToken } from '../lib/credentials.js'; +import { dashboardGraphqlRequest, DashboardGraphqlError } from '../lib/dashboard-graphql.js'; +import { isJsonMode, outputJson, exitWithError } from '../utils/output.js'; +import { exitWithAuthRequired } from '../utils/exit-codes.js'; +import { formatWorkOSCommand } from '../utils/command-invocation.js'; + +// Every field below is read straight from the bearer guard's request context +// (`context.user` / `context.team` / `context.environment`), so all three root +// fields co-resolve under the CLI token. Selections mirror the dashboard's own +// `dashboardSession` query — no speculative fields. +const WHOAMI_QUERY = `query workosCliWhoami { + me { + id + name + email + workosUserId + } + currentTeam { + id + name + organizationId + productionState + } + currentEnvironment { + id + name + clientId + platform + sandbox + } +}`; + +interface WhoamiData { + me: { + id: string; + name: string | null; + email: string; + workosUserId: string | null; + }; + currentTeam: { + id: string; + name: string | null; + organizationId: string | null; + productionState: string | null; + } | null; + currentEnvironment: { + id: string; + name: string | null; + clientId: string | null; + platform: string | null; + sandbox: boolean | null; + } | null; +} + +export async function runWhoami(): Promise { + const token = getAccessToken(); + if (!token) { + // Read-only by design: a missing/expired token is reported, not silently + // refreshed. Exit 4 follows the CLI's auth-required convention. + exitWithAuthRequired(`Not logged in. Run \`${formatWorkOSCommand('auth login')}\` to authenticate.`); + } + + let data: WhoamiData; + try { + data = await dashboardGraphqlRequest(WHOAMI_QUERY, { token }); + } catch (error) { + if (error instanceof DashboardGraphqlError) { + reportGraphqlError(error); + } + throw error; + } + + if (isJsonMode()) { + outputJson({ + user: data.me, + team: data.currentTeam, + environment: data.currentEnvironment, + }); + return; + } + + renderHuman(data); +} + +function reportGraphqlError(error: DashboardGraphqlError): never { + const message = + error.code === 'forbidden' + ? `${error.message} This requires the dashboard OAuth bearer capability to be enabled for this API host ` + + '(currently staging only) and a token belonging to a WorkOS dashboard account with a team.' + : error.message; + exitWithError({ code: error.code, message }); +} + +function renderHuman(data: WhoamiData): void { + const { me, currentTeam, currentEnvironment } = data; + + console.log(chalk.bold('User')); + console.log(` ${me.name ?? chalk.dim('(no name)')} ${chalk.dim(me.email)}`); + console.log(chalk.dim(` dashboard id: ${me.id}`)); + if (me.workosUserId) { + console.log(chalk.dim(` workos user: ${me.workosUserId}`)); + } + + if (currentTeam) { + console.log(); + console.log(chalk.bold('Team')); + console.log(` ${currentTeam.name ?? chalk.dim('(unnamed)')}`); + if (currentTeam.organizationId) { + console.log(chalk.dim(` organization: ${currentTeam.organizationId}`)); + } + if (currentTeam.productionState) { + console.log(chalk.dim(` production: ${currentTeam.productionState}`)); + } + } + + if (currentEnvironment) { + console.log(); + console.log(chalk.bold('Environment')); + const sandboxTag = currentEnvironment.sandbox ? chalk.dim(' (sandbox)') : ''; + console.log(` ${currentEnvironment.name ?? chalk.dim('(unnamed)')}${sandboxTag}`); + if (currentEnvironment.platform) { + console.log(chalk.dim(` platform: ${currentEnvironment.platform}`)); + } + if (currentEnvironment.clientId) { + console.log(chalk.dim(` client id: ${currentEnvironment.clientId}`)); + } + console.log(chalk.dim(` env id: ${currentEnvironment.id}`)); + } +} diff --git a/src/lib/dashboard-graphql.ts b/src/lib/dashboard-graphql.ts new file mode 100644 index 00000000..b342f129 --- /dev/null +++ b/src/lib/dashboard-graphql.ts @@ -0,0 +1,119 @@ +/** + * Minimal GraphQL client for the WorkOS dashboard API, authenticated with the + * logged-in user's OAuth bearer token. + * + * This is the *account plane*: the device-flow access token acts as the user + * (resolving their team and environment), unlike the environment-scoped API key + * that REST commands use via the WorkOS SDK. The dashboard `/graphql` endpoint + * accepts the bearer through `DashboardOAuthBearerGuard`. + * + * The capability is gated server-side by a feature flag (enabled in staging), + * so a 403 here is the expected outcome wherever the flag is off — callers + * should surface that distinctly rather than as a generic failure. + */ + +import { getWorkOSApiUrl } from '../utils/urls.js'; + +const REQUEST_TIMEOUT_MS = 30_000; + +export type DashboardGraphqlErrorCode = + | 'forbidden' // 401/403: capability disabled, or token not backed by a team + | 'http_error' // other non-2xx + | 'graphql_error' // 200 with an errors[] payload (or no data) + | 'network_error'; // transport failure or timeout + +export class DashboardGraphqlError extends Error { + constructor( + message: string, + readonly code: DashboardGraphqlErrorCode, + readonly status?: number, + ) { + super(message); + this.name = 'DashboardGraphqlError'; + } +} + +interface GraphqlResponseBody { + data?: T | null; + errors?: Array<{ message: string }>; +} + +export interface DashboardGraphqlOptions { + /** The user's OAuth bearer access token. */ + token: string; + variables?: Record; + /** + * Optional environment to operate in. The guard validates it against the + * caller's own team and falls back to the team's production environment when + * unset or unrecognized (sent as the `x-url-environment-id` header). + */ + environmentId?: string; +} + +/** + * Execute a GraphQL operation against `/graphql` with a bearer token. + * + * Resolves to the `data` payload, or throws a {@link DashboardGraphqlError} + * classified by failure mode (forbidden / http / graphql / network). + */ +export async function dashboardGraphqlRequest(query: string, options: DashboardGraphqlOptions): Promise { + const url = `${getWorkOSApiUrl()}/graphql`; + const controller = new AbortController(); + const timeout = setTimeout(() => controller.abort(), REQUEST_TIMEOUT_MS); + + let res: Response; + try { + res = await fetch(url, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + Authorization: `Bearer ${options.token}`, + ...(options.environmentId ? { 'x-url-environment-id': options.environmentId } : {}), + }, + body: JSON.stringify({ query, variables: options.variables ?? {} }), + signal: controller.signal, + }); + } catch (error) { + if (error instanceof DOMException && error.name === 'AbortError') { + throw new DashboardGraphqlError('Request to the dashboard GraphQL API timed out', 'network_error'); + } + throw new DashboardGraphqlError( + `Could not reach the dashboard GraphQL API: ${error instanceof Error ? error.message : String(error)}`, + 'network_error', + ); + } finally { + clearTimeout(timeout); + } + + if (res.status === 401 || res.status === 403) { + throw new DashboardGraphqlError( + `The dashboard GraphQL API rejected this session (HTTP ${res.status}).`, + 'forbidden', + res.status, + ); + } + + if (!res.ok) { + throw new DashboardGraphqlError(`The dashboard GraphQL API returned HTTP ${res.status}.`, 'http_error', res.status); + } + + let body: GraphqlResponseBody; + try { + body = (await res.json()) as GraphqlResponseBody; + } catch (error) { + throw new DashboardGraphqlError( + `Invalid JSON from the dashboard GraphQL API: ${error instanceof Error ? error.message : String(error)}`, + 'network_error', + ); + } + + if (body.errors?.length) { + throw new DashboardGraphqlError(body.errors.map((e) => e.message).join('; '), 'graphql_error'); + } + + if (body.data == null) { + throw new DashboardGraphqlError('The dashboard GraphQL API returned no data.', 'graphql_error'); + } + + return body.data; +} diff --git a/src/utils/help-json.ts b/src/utils/help-json.ts index 41c47f50..9516de0c 100644 --- a/src/utils/help-json.ts +++ b/src/utils/help-json.ts @@ -112,6 +112,11 @@ const commands: CommandSchema[] = [ description: 'Show current authentication status', options: [insecureStorageOpt], }, + { + name: 'whoami', + description: 'Show the authenticated user, team, and environment (dashboard session)', + options: [insecureStorageOpt], + }, { name: 'telemetry', description: 'Manage telemetry collection (opt-out, opt-in, status)', From 14fc444c672802965f728e802ca1fcf0821816ba Mon Sep 17 00:00:00 2001 From: Nick Nisi Date: Thu, 25 Jun 2026 16:00:23 -0500 Subject: [PATCH 04/22] feat: vendor Management catalog snapshot + loader 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). --- package.json | 5 +- scripts/vendor-catalog.ts | 91 + src/catalog/catalog-types.ts | 102 + src/catalog/loader.spec.ts | 91 + src/catalog/loader.ts | 57 + src/catalog/mcp-catalog.snapshot.json | 15513 ++++++++++++++++++++++++ 6 files changed, 15858 insertions(+), 1 deletion(-) create mode 100644 scripts/vendor-catalog.ts create mode 100644 src/catalog/catalog-types.ts create mode 100644 src/catalog/loader.spec.ts create mode 100644 src/catalog/loader.ts create mode 100644 src/catalog/mcp-catalog.snapshot.json diff --git a/package.json b/package.json index 1a904b58..ec9e58d8 100644 --- a/package.json +++ b/package.json @@ -106,7 +106,10 @@ "eval:diff": "tsx tests/evals/index.ts diff", "eval:prune": "tsx tests/evals/index.ts prune", "eval:logs": "tsx tests/evals/index.ts logs", - "eval:show": "tsx tests/evals/index.ts show" + "eval:show": "tsx tests/evals/index.ts show", + "gen:routes": "tsx scripts/gen-routes.ts", + "catalog:vendor": "tsx scripts/vendor-catalog.ts", + "check:coverage": "tsx scripts/check-coverage.ts" }, "author": "WorkOS", "license": "MIT" diff --git a/scripts/vendor-catalog.ts b/scripts/vendor-catalog.ts new file mode 100644 index 00000000..0fe6a3bf --- /dev/null +++ b/scripts/vendor-catalog.ts @@ -0,0 +1,91 @@ +#!/usr/bin/env tsx +/** + * Dev script: refresh the vendored Management catalog snapshot from a local + * `workos` monorepo checkout, so the snapshot does not rot by hand. + * + * Usage: + * pnpm catalog:vendor # default sibling ../workos + * pnpm catalog:vendor --monorepo # explicit monorepo root + * WORKOS_MONOREPO= pnpm catalog:vendor + * + * Resolves /packages/api/src/mcp/generated/mcp-catalog.generated.json, + * validates that it parses and has at least one operation, then writes + * src/catalog/mcp-catalog.snapshot.json. Fails loudly if the source is missing + * or empty so a bad refresh can't silently empty the snapshot. + * + * This is the MVP stand-in for the (deferred) CI drift gate; it requires the + * monorepo locally and is not run in CI. + */ + +import { readFileSync, writeFileSync, existsSync } from 'node:fs'; +import { resolve, dirname, join } from 'node:path'; +import { fileURLToPath } from 'node:url'; + +const SOURCE_RELATIVE = 'packages/api/src/mcp/generated/mcp-catalog.generated.json'; +const SNAPSHOT_RELATIVE = '../src/catalog/mcp-catalog.snapshot.json'; + +interface ParsedArgs { + monorepo: string; +} + +function parseArgs(argv: string[]): ParsedArgs { + const args = argv.slice(2); + let monorepo: string | undefined; + for (let i = 0; i < args.length; i++) { + const arg = args[i]; + if (arg === '--monorepo') { + monorepo = args[i + 1]; + i++; + } else if (arg?.startsWith('--monorepo=')) { + monorepo = arg.slice('--monorepo='.length); + } + } + monorepo ??= process.env.WORKOS_MONOREPO ?? '../workos'; + return { monorepo: resolve(monorepo) }; +} + +function fail(message: string): never { + console.error(`vendor-catalog: ${message}`); + process.exit(1); +} + +function main(): void { + const { monorepo } = parseArgs(process.argv); + const sourcePath = join(monorepo, SOURCE_RELATIVE); + + if (!existsSync(sourcePath)) { + fail( + `source catalog not found at ${sourcePath}\n` + + `Point at your monorepo with --monorepo or WORKOS_MONOREPO=.`, + ); + } + + let raw: string; + try { + raw = readFileSync(sourcePath, 'utf8'); + } catch (error) { + fail(`failed to read ${sourcePath}: ${(error as Error).message}`); + } + + let data: { operations?: Record }; + try { + data = JSON.parse(raw); + } catch (error) { + fail(`source catalog is not valid JSON: ${(error as Error).message}`); + } + + const operationCount = Object.keys(data.operations ?? {}).length; + if (operationCount === 0) { + fail(`source catalog has zero operations — refusing to write an empty snapshot`); + } + + const here = dirname(fileURLToPath(import.meta.url)); + const snapshotPath = resolve(here, SNAPSHOT_RELATIVE); + // Re-serialize from the parsed object for stable, validated formatting. + writeFileSync(snapshotPath, `${JSON.stringify(data, null, 2)}\n`); + + console.log(`vendor-catalog: wrote ${operationCount} operations to ${snapshotPath}`); + console.log(` source: ${sourcePath}`); +} + +main(); diff --git a/src/catalog/catalog-types.ts b/src/catalog/catalog-types.ts new file mode 100644 index 00000000..abda687e --- /dev/null +++ b/src/catalog/catalog-types.ts @@ -0,0 +1,102 @@ +/** + * TypeScript types for the vendored Management operation catalog. + * + * Mirrors the upstream data model in the `workos` monorepo + * (`packages/api/src/mcp/catalog-types.ts`) so `scripts/vendor-catalog.ts` can + * later diff the snapshot structurally. The catalog is generated upstream at + * codegen time from the dashboard's checked-in GraphQL operation documents; we + * vendor a static snapshot (`mcp-catalog.snapshot.json`) and never take a live + * dependency on the schema here. + * + * The GraphQL `document` field is internal and must never be surfaced to users. + */ + +export type OperationKind = 'query' | 'mutation'; + +export interface CatalogVariable { + /** Variable name without the leading `$`, e.g. "input". */ + name: string; + /** SDL-rendered type, e.g. "CreateOrganizationInput!". */ + type: string; + /** NonNull with no default value. */ + required: boolean; + /** SDL-printed default value, when one is declared. */ + defaultValue?: string; +} + +export interface CatalogInputField { + name: string; + /** SDL-rendered type, e.g. "[String!]". */ + type: string; + required: boolean; + defaultValue?: string; + description?: string; +} + +export interface CatalogInputType { + name: string; + kind: 'INPUT_OBJECT' | 'ENUM' | 'SCALAR'; + description?: string; + /** Present for INPUT_OBJECT types. */ + fields?: CatalogInputField[]; + /** Present for ENUM types. */ + enumValues?: string[]; +} + +export interface CatalogOperation { + /** GraphQL operation name — the catalog key and the wire `operationName`. */ + name: string; + kind: OperationKind; + /** Root-field schema description, or a generated fallback. */ + description: string; + /** + * Present when a selected root field is gated behind confirmation: the + * consequence phrase surfaced before a destructive operation runs. Sparse — + * only a handful of operations declare it. + */ + confirmation?: string; + /** + * Present (and `true`) when a selected root field is gated behind a feature + * flag. The live MCP hides these from its index; the loader filters them out + * by default so the CLI's candidate set matches what the live MCP exposes. + */ + featureFlagGated?: true; + /** Schema field names selected at the document root (de-aliased, deduped). */ + rootFields: string[]; + /** Named return types of the root fields, e.g. ["OrganizationsList"]. */ + returnTypes: string[]; + /** print()-normalized operation text, WITHOUT its fragments. Internal — never shown to users. */ + document: string; + /** Transitive fragment dependencies, sorted by name. */ + fragmentNames: string[]; + variables: CatalogVariable[]; +} + +/** + * Raw, on-disk shape of the vendored snapshot — identical to the upstream + * `OperationCatalog`. Operations are keyed by name. The loader normalizes this + * into a {@link ManagementCatalog} before any command code sees it. + */ +export interface RawManagementCatalog { + /** Keyed by operation name. */ + operations: Record; + /** Fragment name -> print()-normalized fragment text. */ + fragments: Record; + /** + * Every named input/enum/custom-scalar type transitively reachable from any + * operation's variables, keyed by type name. + */ + inputTypes: Record; +} + +/** + * The loader's return shape — the seam command code depends on. `operations` is + * a flat array (normalized from the raw Record) so callers can filter and map + * over it without re-deriving the keying. Fragments and input types are carried + * through unchanged. + */ +export interface ManagementCatalog { + operations: CatalogOperation[]; + fragments: Record; + inputTypes: Record; +} diff --git a/src/catalog/loader.spec.ts b/src/catalog/loader.spec.ts new file mode 100644 index 00000000..554ce861 --- /dev/null +++ b/src/catalog/loader.spec.ts @@ -0,0 +1,91 @@ +import { describe, it, expect } from 'vitest'; +import { + loadManagementCatalog, + snapshotSource, + type CatalogSource, +} from './loader.js'; +import type { RawManagementCatalog } from './catalog-types.js'; + +// Visible operation count: 357 in the snapshot minus the 4 feature-flag-gated +// directory mutations the live MCP hides from its index. +const VISIBLE_COUNT = 353; +const TOTAL_COUNT = 357; + +describe('loadManagementCatalog', () => { + it('loads the vendored snapshot as a normalized catalog', () => { + const catalog = loadManagementCatalog(); + expect(Array.isArray(catalog.operations)).toBe(true); + expect(catalog.operations.length).toBeGreaterThan(0); + expect(typeof catalog.fragments).toBe('object'); + expect(typeof catalog.inputTypes).toBe('object'); + }); + + it('returns the visible operation count (feature-flagged filtered out)', () => { + const catalog = loadManagementCatalog(); + expect(catalog.operations.length).toBe(VISIBLE_COUNT); + }); + + it('never returns a feature-flag-gated operation by default', () => { + const catalog = loadManagementCatalog(); + expect(catalog.operations.some((op) => op.featureFlagGated === true)).toBe(false); + }); + + it('returns the full set when includeFeatureFlagged is true', () => { + const catalog = loadManagementCatalog(snapshotSource, { includeFeatureFlagged: true }); + expect(catalog.operations.length).toBe(TOTAL_COUNT); + expect(catalog.operations.some((op) => op.featureFlagGated === true)).toBe(true); + }); + + it('carries fragments and input types through unchanged', () => { + const raw = snapshotSource.load(); + const catalog = loadManagementCatalog(); + expect(catalog.fragments).toBe(raw.fragments); + expect(catalog.inputTypes).toBe(raw.inputTypes); + }); + + it('includes known account-plane operations', () => { + const names = new Set(loadManagementCatalog().operations.map((op) => op.name)); + expect(names.has('createEnvironment')).toBe(true); + expect(names.has('inviteUserToTeam')).toBe(true); + }); + + it('reads through an injected CatalogSource (the swap seam)', () => { + const fakeSource: CatalogSource = { + load(): RawManagementCatalog { + return { + operations: { + keepMe: { + name: 'keepMe', + kind: 'query', + description: 'visible', + rootFields: ['keepMe'], + returnTypes: ['Thing'], + document: 'query keepMe { keepMe }', + fragmentNames: [], + variables: [], + }, + hideMe: { + name: 'hideMe', + kind: 'mutation', + description: 'flagged', + featureFlagGated: true, + rootFields: ['hideMe'], + returnTypes: ['Thing'], + document: 'mutation hideMe { hideMe }', + fragmentNames: [], + variables: [], + }, + }, + fragments: {}, + inputTypes: {}, + }; + }, + }; + + const filtered = loadManagementCatalog(fakeSource); + expect(filtered.operations.map((op) => op.name)).toEqual(['keepMe']); + + const all = loadManagementCatalog(fakeSource, { includeFeatureFlagged: true }); + expect(all.operations.map((op) => op.name).sort()).toEqual(['hideMe', 'keepMe']); + }); +}); diff --git a/src/catalog/loader.ts b/src/catalog/loader.ts new file mode 100644 index 00000000..751a2e5b --- /dev/null +++ b/src/catalog/loader.ts @@ -0,0 +1,57 @@ +import snapshot from './mcp-catalog.snapshot.json' with { type: 'json' }; +import type { + CatalogOperation, + ManagementCatalog, + RawManagementCatalog, +} from './catalog-types.js'; + +/** + * Loads the Management operation catalog behind a single seam. + * + * Command code (Phase 3+) imports {@link loadManagementCatalog} and depends only + * on its {@link ManagementCatalog} return type — never on the snapshot file or a + * fetch client. Swapping the source later (Phase 6 adds a `LiveCatalogSource` + * backed by `GET /mcp/catalog`) is a one-file change here. + */ +export interface CatalogSource { + load(): RawManagementCatalog; +} + +export interface LoadCatalogOptions { + /** + * Include feature-flag-gated operations in the result. Off by default so the + * CLI's candidate set matches what the live MCP exposes (the live MCP hides + * flag-gated operations from its index). Tooling that needs the raw set can + * opt in. + */ + includeFeatureFlagged?: boolean; +} + +/** + * Default source: the vendored static snapshot. Imported as JSON so the bundler + * inlines it (the import is statically resolved at build time). Refresh it with + * `pnpm catalog:vendor`. + */ +export const snapshotSource: CatalogSource = { + load(): RawManagementCatalog { + return snapshot as RawManagementCatalog; + }, +}; + +/** + * Returns the catalog as a normalized {@link ManagementCatalog}: operations are + * flattened from the raw name-keyed Record into an array, and feature-flag-gated + * operations are filtered out unless {@link LoadCatalogOptions.includeFeatureFlagged} + * is set. Fragments and input types are carried through unchanged. + */ +export function loadManagementCatalog( + source: CatalogSource = snapshotSource, + options: LoadCatalogOptions = {}, +): ManagementCatalog { + const raw = source.load(); + const all: CatalogOperation[] = Object.values(raw.operations); + const operations = options.includeFeatureFlagged + ? all + : all.filter((op) => !op.featureFlagGated); + return { operations, fragments: raw.fragments, inputTypes: raw.inputTypes }; +} diff --git a/src/catalog/mcp-catalog.snapshot.json b/src/catalog/mcp-catalog.snapshot.json new file mode 100644 index 00000000..28795557 --- /dev/null +++ b/src/catalog/mcp-catalog.snapshot.json @@ -0,0 +1,15513 @@ +{ + "operations": { + "actionExecution": { + "name": "actionExecution", + "kind": "query", + "description": "Return a single Actions execution by ID", + "rootFields": [ + "actionExecution" + ], + "returnTypes": [ + "ActionExecution" + ], + "document": "query actionExecution($id: String!) {\n actionExecution(id: $id) {\n ...ActionExecution\n }\n}", + "fragmentNames": [ + "ActionExecution" + ], + "variables": [ + { + "name": "id", + "type": "String!", + "required": true + } + ] + }, + "actionExecutions": { + "name": "actionExecutions", + "kind": "query", + "description": "List Actions executions in an environment, optionally filtered by endpoint", + "rootFields": [ + "actionExecutions" + ], + "returnTypes": [ + "ActionExecutionsList" + ], + "document": "query actionExecutions($environmentId: String!, $actionsEndpointIds: [String!], $after: String, $before: String, $limit: Int = 10) {\n actionExecutions(\n environmentId: $environmentId\n actionsEndpointIds: $actionsEndpointIds\n after: $after\n before: $before\n limit: $limit\n ) {\n data {\n ...ActionExecution\n }\n listMetadata {\n after\n before\n }\n }\n}", + "fragmentNames": [ + "ActionExecution" + ], + "variables": [ + { + "name": "environmentId", + "type": "String!", + "required": true + }, + { + "name": "actionsEndpointIds", + "type": "[String!]", + "required": false + }, + { + "name": "after", + "type": "String", + "required": false + }, + { + "name": "before", + "type": "String", + "required": false + }, + { + "name": "limit", + "type": "Int", + "required": false, + "defaultValue": "10" + } + ] + }, + "actionsEndpoint": { + "name": "actionsEndpoint", + "kind": "query", + "description": "Return an environment's Actions endpoint of a given type, if configured", + "rootFields": [ + "actionsEndpoint" + ], + "returnTypes": [ + "ActionsEndpoint" + ], + "document": "query actionsEndpoint($type: ActionsEndpointType!, $environmentId: String!) {\n actionsEndpoint(type: $type, environmentId: $environmentId) {\n ...ActionsEndpoint\n }\n}", + "fragmentNames": [ + "ActionsEndpoint" + ], + "variables": [ + { + "name": "type", + "type": "ActionsEndpointType!", + "required": true + }, + { + "name": "environmentId", + "type": "String!", + "required": true + } + ] + }, + "actionsEndpoints": { + "name": "actionsEndpoints", + "kind": "query", + "description": "List all Actions endpoints configured in an environment", + "rootFields": [ + "actionsEndpoints" + ], + "returnTypes": [ + "ActionsEndpoint" + ], + "document": "query actionsEndpoints($environmentId: String!) {\n actionsEndpoints(environmentId: $environmentId) {\n ...ActionsEndpoint\n }\n}", + "fragmentNames": [ + "ActionsEndpoint" + ], + "variables": [ + { + "name": "environmentId", + "type": "String!", + "required": true + } + ] + }, + "activePortalSetupLinks": { + "name": "activePortalSetupLinks", + "kind": "query", + "description": "List the active Admin Portal setup links for an organization", + "rootFields": [ + "activePortalSetupLinks" + ], + "returnTypes": [ + "PortalSetupLinkList" + ], + "document": "query activePortalSetupLinks($organizationId: String!) {\n activePortalSetupLinks(organizationId: $organizationId) {\n data {\n ...PortalSetupLink\n }\n }\n}", + "fragmentNames": [ + "PortalSetupLink" + ], + "variables": [ + { + "name": "organizationId", + "type": "String!", + "required": true + } + ] + }, + "addAdpConnectionSslCertificate": { + "name": "addAdpConnectionSslCertificate", + "kind": "mutation", + "description": "Add an SSL certificate to an ADP SSO connection and re-verify it", + "rootFields": [ + "addAdpConnectionSslCertificate" + ], + "returnTypes": [ + "AddAdpConnectionSslCertificateResult" + ], + "document": "mutation addAdpConnectionSslCertificate($input: AddAdpConnectionSslCertificateInput!) {\n addAdpConnectionSslCertificate(input: $input) {\n __typename\n ... on AdpConnectionSslCertificateAdded {\n adpConnectionSslCertificate {\n id\n value\n }\n }\n ... on ConnectionNotFound {\n connectionId\n }\n }\n}", + "fragmentNames": [], + "variables": [ + { + "name": "input", + "type": "AddAdpConnectionSslCertificateInput!", + "required": true + } + ] + }, + "addBillingAddress": { + "name": "addBillingAddress", + "kind": "mutation", + "description": "Add a billing address to the current team", + "rootFields": [ + "addBillingAddress" + ], + "returnTypes": [ + "AddBillingAddressResult" + ], + "document": "mutation addBillingAddress($input: AddBillingAddressInput!) {\n addBillingAddress(input: $input) {\n __typename\n ... on BillingAddressAdded {\n team {\n ...CurrentTeam\n }\n }\n }\n}", + "fragmentNames": [ + "CurrentTeam" + ], + "variables": [ + { + "name": "input", + "type": "AddBillingAddressInput!", + "required": true + } + ] + }, + "addDomains": { + "name": "addDomains", + "kind": "mutation", + "description": "Add verified domains to an organization", + "rootFields": [ + "addDomains" + ], + "returnTypes": [ + "AddDomainsResult" + ], + "document": "mutation addDomains($input: AddOrganizationDomainsInput!) {\n addDomains(input: $input) {\n __typename\n ... on DomainsAdded {\n domains {\n id\n state\n domain\n verificationStrategy\n }\n }\n ... on ConsumerDomainForbidden {\n domain\n }\n ... on OrganizationDomainAlreadyInUse {\n domain\n organization {\n name\n }\n }\n ... on ExistingNonVerifiedDomain {\n nonVerifiedDomain {\n state\n domain\n }\n }\n }\n}", + "fragmentNames": [], + "variables": [ + { + "name": "input", + "type": "AddOrganizationDomainsInput!", + "required": true + } + ] + }, + "addOrganizationAdmins": { + "name": "addOrganizationAdmins", + "kind": "mutation", + "description": "Add admin (IT contact) emails to an organization for Admin Portal access", + "rootFields": [ + "addOrganizationAdmins" + ], + "returnTypes": [ + "AddOrganizationAdminsResult" + ], + "document": "mutation addOrganizationAdmins($input: AddOrganizationAdminsInput!) {\n addOrganizationAdmins(input: $input) {\n __typename\n ... on OrganizationAdminsResult {\n organizationAdmins {\n ...OrganizationAdmin\n }\n }\n ... on OrganizationNotFound {\n organizationId\n }\n ... on InvalidEmail {\n email\n }\n ... on TooManyOrganizationAdmins {\n limit\n currentCount\n requestedCount\n }\n }\n}", + "fragmentNames": [ + "OrganizationAdmin" + ], + "variables": [ + { + "name": "input", + "type": "AddOrganizationAdminsInput!", + "required": true + } + ] + }, + "addRadarListEntry": { + "name": "addRadarListEntry", + "kind": "mutation", + "description": "Add an entry to a Radar allow/block list in an environment", + "rootFields": [ + "addRadarListEntry" + ], + "returnTypes": [ + "AddRadarListEntryResult" + ], + "document": "mutation addRadarListEntry($input: AddRadarListEntryInput!) {\n addRadarListEntry(input: $input) {\n __typename\n ... on RadarListEntryOperationSuccess {\n success\n }\n }\n}", + "fragmentNames": [], + "variables": [ + { + "name": "input", + "type": "AddRadarListEntryInput!", + "required": true + } + ] + }, + "addTaxId": { + "name": "addTaxId", + "kind": "mutation", + "description": "Add a tax ID to the current team", + "rootFields": [ + "addTaxId" + ], + "returnTypes": [ + "AddTaxIdResult" + ], + "document": "mutation addTaxId($input: AddTaxIdToTeamInput!) {\n addTaxId(input: $input) {\n __typename\n ... on TaxIdAdded {\n taxId {\n type\n value\n countryCode\n }\n }\n ... on TaxIdInvalid {\n taxId {\n type\n value\n countryCode\n }\n }\n }\n}", + "fragmentNames": [], + "variables": [ + { + "name": "input", + "type": "AddTaxIdToTeamInput!", + "required": true + } + ] + }, + "addUserlandUserToOrg": { + "name": "addUserlandUserToOrg", + "kind": "mutation", + "description": "Add an AuthKit user to an organization, optionally with a role", + "rootFields": [ + "addUserlandUserToOrganization" + ], + "returnTypes": [ + "AddUserlandUserToOrganizationResult" + ], + "document": "mutation addUserlandUserToOrg($input: AddUserlandUserToOrganizationInput!) {\n addUserlandUserToOrganization(input: $input) {\n __typename\n ... on OrganizationNotFound {\n organizationId\n }\n ... on UserlandUserNotFound {\n userlandUserId\n }\n ... on UserlandUserAlreadyInvited {\n userlandUserId\n organizationId\n }\n ... on UserlandUserAddedToOrganization {\n __typename\n }\n }\n}", + "fragmentNames": [], + "variables": [ + { + "name": "input", + "type": "AddUserlandUserToOrganizationInput!", + "required": true + } + ] + }, + "aggregateRadarDetectionIdentifiers": { + "name": "aggregateRadarDetectionIdentifiers", + "kind": "query", + "description": "Return aggregated Radar detection identifier counts for an environment", + "rootFields": [ + "environment" + ], + "returnTypes": [ + "Environment" + ], + "document": "query aggregateRadarDetectionIdentifiers($environmentId: String!, $timeRange: AggregateRadarDetectionsTimeRange) {\n environment(id: $environmentId) {\n aggregateRadarDetectionIdentifiers(timeRange: $timeRange) {\n ipAddresses {\n identifier\n percentage\n totalDetections\n }\n locations {\n identifier\n percentage\n totalDetections\n }\n countries {\n identifier\n percentage\n totalDetections\n }\n users {\n identifier\n percentage\n totalDetections\n }\n domains {\n identifier\n percentage\n totalDetections\n }\n }\n }\n}", + "fragmentNames": [], + "variables": [ + { + "name": "environmentId", + "type": "String!", + "required": true + }, + { + "name": "timeRange", + "type": "AggregateRadarDetectionsTimeRange", + "required": false + } + ] + }, + "aggregateRadarDetections": { + "name": "aggregateRadarDetections", + "kind": "query", + "description": "Return aggregated Radar detection counts for an environment, grouped for charting", + "rootFields": [ + "environment" + ], + "returnTypes": [ + "Environment" + ], + "document": "query aggregateRadarDetections($environmentId: String!, $timeRange: AggregateRadarDetectionsTimeRange) {\n environment(id: $environmentId) {\n aggregateRadarDetections(timeRange: $timeRange) {\n totalDetections\n totalDetectionsDelta\n allowed\n allowedDelta\n blocked\n blockedDelta\n challenged\n challengedDelta\n data {\n timestamp\n total\n allowed\n blocked\n challenged\n breakdownByDetection {\n detection\n total\n allowed\n blocked\n challenged\n }\n }\n detectionTotalsByType {\n detection\n total\n allowed\n blocked\n challenged\n }\n }\n }\n}", + "fragmentNames": [], + "variables": [ + { + "name": "environmentId", + "type": "String!", + "required": true + }, + { + "name": "timeRange", + "type": "AggregateRadarDetectionsTimeRange", + "required": false + } + ] + }, + "allRolesForOrganization": { + "name": "allRolesForOrganization", + "kind": "query", + "description": "List all roles available to an organization, including resource-scoped roles Return the role configuration for an organization Return the role configuration for an environment", + "rootFields": [ + "allRolesForOrganization", + "roleConfigForOrganizationV2", + "roleConfigForEnvironmentV2" + ], + "returnTypes": [ + "RoleList", + "OrganizationRoleConfigResultV2", + "EnvironmentRoleConfig" + ], + "document": "query allRolesForOrganization($organizationId: ID!, $environmentId: ID!) {\n allRolesForOrganization(id: $organizationId) {\n roles {\n ...Role\n permissions {\n id\n slug\n }\n }\n }\n roleConfigForOrganizationV2(id: $organizationId) {\n ... on RoleConfigNotFoundForOrganization {\n organizationId\n }\n ... on OrganizationRoleConfig {\n ...OrganizationRoleConfig\n }\n }\n roleConfigForEnvironmentV2(id: $environmentId) {\n ...EnvironmentRoleConfig\n }\n}", + "fragmentNames": [ + "EnvironmentRoleConfig", + "OrganizationRoleConfig", + "ResourceType", + "Role" + ], + "variables": [ + { + "name": "organizationId", + "type": "ID!", + "required": true + }, + { + "name": "environmentId", + "type": "ID!", + "required": true + } + ] + }, + "apiKeys": { + "name": "apiKeys", + "kind": "query", + "description": "List signing keys for an environment, optionally scoped by application and key state", + "rootFields": [ + "keys" + ], + "returnTypes": [ + "KeyList" + ], + "document": "query apiKeys($environmentId: String!, $applicationId: String) {\n activeKeys: keys(\n environmentId: $environmentId\n applicationId: $applicationId\n scope: Active\n ) {\n data {\n ...Key\n }\n }\n expiredKeys: keys(\n environmentId: $environmentId\n applicationId: $applicationId\n scope: RecentlyExpired\n ) {\n data {\n ...Key\n }\n }\n}", + "fragmentNames": [ + "Key" + ], + "variables": [ + { + "name": "environmentId", + "type": "String!", + "required": true + }, + { + "name": "applicationId", + "type": "String", + "required": false + } + ] + }, + "appBranding": { + "name": "appBranding", + "kind": "query", + "description": "Return the team for the current dashboard session", + "rootFields": [ + "currentTeam" + ], + "returnTypes": [ + "Team" + ], + "document": "query appBranding {\n currentTeam {\n projects: projectsV2 {\n appBranding {\n ...AppBranding\n }\n }\n }\n}", + "fragmentNames": [ + "AppBranding" + ], + "variables": [] + }, + "application": { + "name": "application", + "kind": "query", + "description": "Return a single Connect application (first-party, third-party, or dynamically registered OAuth app) by ID", + "rootFields": [ + "application" + ], + "returnTypes": [ + "Application" + ], + "document": "query application($applicationId: String!) {\n application(id: $applicationId) {\n __typename\n ...Application\n }\n}", + "fragmentNames": [ + "Application" + ], + "variables": [ + { + "name": "applicationId", + "type": "String!", + "required": true + } + ] + }, + "approveWaitlistEntry": { + "name": "approveWaitlistEntry", + "kind": "mutation", + "description": "Approve a waitlist entry and send the resulting user invitation email", + "rootFields": [ + "approveWaitlistEntry" + ], + "returnTypes": [ + "ApproveWaitlistEntryResult" + ], + "document": "mutation approveWaitlistEntry($input: ApproveWaitlistEntryInput!) {\n approveWaitlistEntry(input: $input) {\n ... on WaitlistEntryApproved {\n waitlistEntry {\n id\n email\n state\n approvedAt\n createdAt\n updatedAt\n }\n }\n ... on WaitlistEntryApproveError {\n message\n }\n }\n}", + "fragmentNames": [], + "variables": [ + { + "name": "input", + "type": "ApproveWaitlistEntryInput!", + "required": true + } + ] + }, + "auditLogEvent": { + "name": "auditLogEvent", + "kind": "query", + "description": "Return a single Audit Log event by ID", + "rootFields": [ + "auditLogEvent" + ], + "returnTypes": [ + "AuditLogEvent" + ], + "document": "query auditLogEvent($id: String!) {\n auditLogEvent(id: $id) {\n data\n ...AuditLogEvent\n }\n}", + "fragmentNames": [ + "AuditLogEvent" + ], + "variables": [ + { + "name": "id", + "type": "String!", + "required": true + } + ] + }, + "auditLogEvents": { + "name": "auditLogEvents", + "kind": "query", + "description": "List an organization's audit log events, filtered by date range, actions, actors, and target types", + "rootFields": [ + "auditLogEvents" + ], + "returnTypes": [ + "AuditLogEventList" + ], + "document": "query auditLogEvents($after: String, $before: String, $organizationId: String!, $limit: Int, $actions: [String!], $actorNames: [String!], $actorIds: [String!], $targets: [String!], $targetIds: [String!], $startDate: DateTime, $endDate: DateTime) {\n auditLogEvents(\n after: $after\n before: $before\n organizationId: $organizationId\n limit: $limit\n actions: $actions\n actors: $actorNames\n actorIds: $actorIds\n targets: $targets\n targetIds: $targetIds\n startDate: $startDate\n endDate: $endDate\n ) {\n data {\n ...AuditLogEvent\n }\n listMetadata {\n before\n after\n }\n }\n}", + "fragmentNames": [ + "AuditLogEvent" + ], + "variables": [ + { + "name": "after", + "type": "String", + "required": false + }, + { + "name": "before", + "type": "String", + "required": false + }, + { + "name": "organizationId", + "type": "String!", + "required": true + }, + { + "name": "limit", + "type": "Int", + "required": false + }, + { + "name": "actions", + "type": "[String!]", + "required": false + }, + { + "name": "actorNames", + "type": "[String!]", + "required": false + }, + { + "name": "actorIds", + "type": "[String!]", + "required": false + }, + { + "name": "targets", + "type": "[String!]", + "required": false + }, + { + "name": "targetIds", + "type": "[String!]", + "required": false + }, + { + "name": "startDate", + "type": "DateTime", + "required": false + }, + { + "name": "endDate", + "type": "DateTime", + "required": false + } + ] + }, + "auditLogExport": { + "name": "auditLogExport", + "kind": "query", + "description": "Return a single audit log export by ID, including a signed CSV download URL once it is ready", + "rootFields": [ + "auditLogExport" + ], + "returnTypes": [ + "AuditLogExport" + ], + "document": "query auditLogExport($id: String!) {\n auditLogExport(id: $id) {\n id\n state\n url\n }\n}", + "fragmentNames": [], + "variables": [ + { + "name": "id", + "type": "String!", + "required": true + } + ] + }, + "auditLogRetentionPeriod": { + "name": "auditLogRetentionPeriod", + "kind": "query", + "description": "Return a single organization by ID", + "rootFields": [ + "organization" + ], + "returnTypes": [ + "Organization" + ], + "document": "query auditLogRetentionPeriod($organizationId: String!) {\n organization(id: $organizationId) {\n auditLogTrail {\n retentionPeriodInDays\n }\n }\n}", + "fragmentNames": [], + "variables": [ + { + "name": "organizationId", + "type": "String!", + "required": true + } + ] + }, + "auditLogSchemaEditor": { + "name": "auditLogSchemaEditor", + "kind": "query", + "description": "Return a single audit log validator (action schema) by ID List audit log target types seen in an environment", + "rootFields": [ + "auditLogValidator", + "auditLogTargets" + ], + "returnTypes": [ + "AuditLogValidator", + "AuditLogTargetList" + ], + "document": "query auditLogSchemaEditor($id: String!, $environmentId: String!, $limit: Int = 100) {\n event: auditLogValidator(id: $id) {\n ...AuditLogValidator\n }\n targets: auditLogTargets(limit: $limit, environmentId: $environmentId) {\n data {\n ...AuditLogTarget\n }\n }\n}", + "fragmentNames": [ + "AuditLogTarget", + "AuditLogValidator" + ], + "variables": [ + { + "name": "id", + "type": "String!", + "required": true + }, + { + "name": "environmentId", + "type": "String!", + "required": true + }, + { + "name": "limit", + "type": "Int", + "required": false, + "defaultValue": "100" + } + ] + }, + "auditLogSchemaPreview": { + "name": "auditLogSchemaPreview", + "kind": "query", + "description": "Preview the generated JSON schema for an audit log action and its target types", + "rootFields": [ + "auditLogSchemaPreview" + ], + "returnTypes": [ + "AuditLogSchemaPreview" + ], + "document": "query auditLogSchemaPreview($action: String!, $targets: [String!]!, $environmentId: String!, $schema: JSON) {\n auditLogSchemaPreview(\n action: $action\n targets: $targets\n environmentId: $environmentId\n schema: $schema\n ) {\n schema\n }\n}", + "fragmentNames": [], + "variables": [ + { + "name": "action", + "type": "String!", + "required": true + }, + { + "name": "targets", + "type": "[String!]!", + "required": true + }, + { + "name": "environmentId", + "type": "String!", + "required": true + }, + { + "name": "schema", + "type": "JSON", + "required": false + } + ] + }, + "auditLogStream": { + "name": "auditLogStream", + "kind": "query", + "description": "Return a single audit log stream by ID with its decrypted credentials", + "rootFields": [ + "auditLogStream" + ], + "returnTypes": [ + "AuditLogStream" + ], + "document": "query auditLogStream($id: String!) {\n auditLogStream(id: $id) {\n ...AuditLogStream\n organization {\n id\n name\n }\n }\n}", + "fragmentNames": [ + "AuditLogStream" + ], + "variables": [ + { + "name": "id", + "type": "String!", + "required": true + } + ] + }, + "auditLogTargets": { + "name": "auditLogTargets", + "kind": "query", + "description": "List audit log target types seen in an environment", + "rootFields": [ + "auditLogTargets" + ], + "returnTypes": [ + "AuditLogTargetList" + ], + "document": "query auditLogTargets($after: String, $before: String, $limit: Int, $target: String, $environmentId: String!) {\n auditLogTargets(\n after: $after\n before: $before\n limit: $limit\n target: $target\n environmentId: $environmentId\n ) {\n data {\n ...AuditLogTarget\n }\n listMetadata {\n before\n after\n }\n }\n}", + "fragmentNames": [ + "AuditLogTarget" + ], + "variables": [ + { + "name": "after", + "type": "String", + "required": false + }, + { + "name": "before", + "type": "String", + "required": false + }, + { + "name": "limit", + "type": "Int", + "required": false + }, + { + "name": "target", + "type": "String", + "required": false + }, + { + "name": "environmentId", + "type": "String!", + "required": true + } + ] + }, + "auditLogValidator": { + "name": "auditLogValidator", + "kind": "query", + "description": "Return a single audit log validator (action schema) by ID", + "rootFields": [ + "auditLogValidator" + ], + "returnTypes": [ + "AuditLogValidator" + ], + "document": "query auditLogValidator($id: String!) {\n auditLogValidator(id: $id) {\n ...AuditLogValidator\n }\n}", + "fragmentNames": [ + "AuditLogValidator" + ], + "variables": [ + { + "name": "id", + "type": "String!", + "required": true + } + ] + }, + "auditLogValidators": { + "name": "auditLogValidators", + "kind": "query", + "description": "List an environment's audit log validators, optionally filtered by action", + "rootFields": [ + "auditLogValidators" + ], + "returnTypes": [ + "AuditLogValidatorList" + ], + "document": "query auditLogValidators($after: String, $before: String, $limit: Int, $action: String, $environmentId: String!) {\n auditLogValidators(\n after: $after\n before: $before\n limit: $limit\n action: $action\n environmentId: $environmentId\n ) {\n data {\n ...AuditLogValidator\n }\n listMetadata {\n before\n after\n }\n }\n}", + "fragmentNames": [ + "AuditLogValidator" + ], + "variables": [ + { + "name": "after", + "type": "String", + "required": false + }, + { + "name": "before", + "type": "String", + "required": false + }, + { + "name": "limit", + "type": "Int", + "required": false + }, + { + "name": "action", + "type": "String", + "required": false + }, + { + "name": "environmentId", + "type": "String!", + "required": true + } + ] + }, + "authkitApplication": { + "name": "authkitApplication", + "kind": "query", + "description": "Return a single AuthKit application by ID", + "rootFields": [ + "userlandApplication" + ], + "returnTypes": [ + "UserlandApplication" + ], + "document": "query authkitApplication($id: ID!) {\n userlandApplication(id: $id) {\n ...UserlandApplication\n }\n}", + "fragmentNames": [ + "Key", + "RedirectUri", + "UserlandApplication" + ], + "variables": [ + { + "name": "id", + "type": "ID!", + "required": true + } + ] + }, + "authkitApplications": { + "name": "authkitApplications", + "kind": "query", + "description": "List AuthKit applications in an environment", + "rootFields": [ + "userlandApplications" + ], + "returnTypes": [ + "UserlandApplicationList" + ], + "document": "query authkitApplications($environmentId: String!, $search: String, $order: PaginationOrder, $before: String, $after: String, $limit: Int) {\n userlandApplications(\n environmentId: $environmentId\n search: $search\n order: $order\n before: $before\n after: $after\n limit: $limit\n ) {\n data {\n ...UserlandApplication\n }\n listMetadata {\n before\n after\n }\n }\n}", + "fragmentNames": [ + "Key", + "RedirectUri", + "UserlandApplication" + ], + "variables": [ + { + "name": "environmentId", + "type": "String!", + "required": true + }, + { + "name": "search", + "type": "String", + "required": false + }, + { + "name": "order", + "type": "PaginationOrder", + "required": false + }, + { + "name": "before", + "type": "String", + "required": false + }, + { + "name": "after", + "type": "String", + "required": false + }, + { + "name": "limit", + "type": "Int", + "required": false + } + ] + }, + "authkitOauthResources": { + "name": "authkitOauthResources", + "kind": "query", + "description": "List the AuthKit OAuth resources (API resources) configured for an environment", + "rootFields": [ + "environment" + ], + "returnTypes": [ + "Environment" + ], + "document": "query authkitOauthResources($environmentId: String!) {\n environment(id: $environmentId) {\n authkitOauthResources {\n id\n uri\n }\n }\n}", + "fragmentNames": [], + "variables": [ + { + "name": "environmentId", + "type": "String!", + "required": true + } + ] + }, + "autoMappedCustomAttributes": { + "name": "autoMappedCustomAttributes", + "kind": "query", + "description": "List available auto-mapped custom attributes for an environment, flagging which are enabled", + "rootFields": [ + "autoMappedCustomAttributes" + ], + "returnTypes": [ + "AutoMappedCustomAttribute" + ], + "document": "query autoMappedCustomAttributes($environmentId: String!) {\n autoMappedCustomAttributes(environmentId: $environmentId) {\n ...AutoMappedCustomAttribute\n }\n}", + "fragmentNames": [ + "AutoMappedCustomAttribute" + ], + "variables": [ + { + "name": "environmentId", + "type": "String!", + "required": true + } + ] + }, + "billingFormDetails": { + "name": "billingFormDetails", + "kind": "query", + "description": "Return the team for the current dashboard session", + "rootFields": [ + "currentTeam" + ], + "returnTypes": [ + "Team" + ], + "document": "query billingFormDetails {\n currentTeam {\n stripeBillingData {\n ...StripeBillingData\n }\n billingDetails {\n email\n hasBillingAddress\n }\n }\n}", + "fragmentNames": [ + "StripeBillingData" + ], + "variables": [] + }, + "bindManagedListToEnvironment": { + "name": "bindManagedListToEnvironment", + "kind": "mutation", + "description": "Bind a managed Radar list to an environment for a given block action", + "rootFields": [ + "bindManagedListToEnvironment" + ], + "returnTypes": [ + "BindManagedListToEnvironmentResult" + ], + "document": "mutation bindManagedListToEnvironment($input: BindManagedListToEnvironmentInput!) {\n bindManagedListToEnvironment(input: $input) {\n __typename\n ... on RadarListBindingOperationSuccess {\n success\n }\n }\n}", + "fragmentNames": [], + "variables": [ + { + "name": "input", + "type": "BindManagedListToEnvironmentInput!", + "required": true + } + ] + }, + "CancelStripeConnect": { + "name": "CancelStripeConnect", + "kind": "mutation", + "description": "Disconnect the Stripe Connect integration for an environment", + "rootFields": [ + "cancelStripeConnect" + ], + "returnTypes": [ + "CancelStripeConnectResult" + ], + "document": "mutation CancelStripeConnect($environmentId: String!) {\n cancelStripeConnect(environmentId: $environmentId) {\n __typename\n ... on CancelStripeConnectSuccess {\n environment {\n id\n stripeConnectState\n }\n }\n }\n}", + "fragmentNames": [], + "variables": [ + { + "name": "environmentId", + "type": "String!", + "required": true + } + ] + }, + "changeRole": { + "name": "changeRole", + "kind": "mutation", + "description": "Change a dashboard team member's role", + "rootFields": [ + "changeRole" + ], + "returnTypes": [ + "UsersTeam" + ], + "document": "mutation changeRole($usersOrganizationsId: String!, $role: UsersOrganizationsRole!) {\n changeRole(usersOrganizationsId: $usersOrganizationsId, role: $role) {\n id\n role\n }\n}", + "fragmentNames": [], + "variables": [ + { + "name": "usersOrganizationsId", + "type": "String!", + "required": true + }, + { + "name": "role", + "type": "UsersOrganizationsRole!", + "required": true + } + ] + }, + "CheckEmailSuppression": { + "name": "CheckEmailSuppression", + "kind": "query", + "description": "Check whether an email address is on the suppression list for an environment's email provider", + "rootFields": [ + "checkEmailSuppression" + ], + "returnTypes": [ + "EmailSuppressionCheckResult" + ], + "document": "query CheckEmailSuppression($input: CheckEmailSuppressionInput!) {\n checkEmailSuppression(input: $input) {\n email\n isSuppressed\n suppressions {\n email\n type\n reason\n createdAt\n }\n }\n}", + "fragmentNames": [], + "variables": [ + { + "name": "input", + "type": "CheckEmailSuppressionInput!", + "required": true + } + ] + }, + "configureAzureSentinelLogStream": { + "name": "configureAzureSentinelLogStream", + "kind": "mutation", + "description": "Validate Azure Sentinel credentials and configure an audit log stream to deliver to them", + "rootFields": [ + "configureAzureSentinelLogStream" + ], + "returnTypes": [ + "ConfigureAuditLogStreamResult" + ], + "document": "mutation configureAzureSentinelLogStream($input: ConfigureAzureSentinelLogStreamInput!) {\n configureAzureSentinelLogStream(input: $input) {\n __typename\n ... on AuditLogStreamConfigured {\n __typename\n auditLogStream {\n id\n }\n }\n ... on AuditLogStreamNotFound {\n __typename\n auditLogStreamId\n }\n ... on InvalidAuditLogStreamCredentials {\n __typename\n message\n }\n }\n}", + "fragmentNames": [], + "variables": [ + { + "name": "input", + "type": "ConfigureAzureSentinelLogStreamInput!", + "required": true + } + ] + }, + "configureDatadogActivityStream": { + "name": "configureDatadogActivityStream", + "kind": "mutation", + "description": "Configure an existing webhook endpoint to forward events to Datadog using a validated API key and region", + "rootFields": [ + "configureDatadogWebhookEndpoint" + ], + "returnTypes": [ + "DatadogWebhookEndpointResult" + ], + "document": "mutation configureDatadogActivityStream($id: String!, $apiKey: String!, $region: String!) {\n configureDatadogWebhookEndpoint(id: $id, apiKey: $apiKey, region: $region) {\n __typename\n ... on WebhookEndpoint {\n id\n }\n ... on InvalidDatadogCredentials {\n message\n }\n }\n}", + "fragmentNames": [], + "variables": [ + { + "name": "id", + "type": "String!", + "required": true + }, + { + "name": "apiKey", + "type": "String!", + "required": true + }, + { + "name": "region", + "type": "String!", + "required": true + } + ] + }, + "configureDatadogLogStreamWithRegion": { + "name": "configureDatadogLogStreamWithRegion", + "kind": "mutation", + "description": "Validate Datadog API credentials and region and configure an audit log stream to deliver to them", + "rootFields": [ + "configureDatadogLogStreamWithRegion" + ], + "returnTypes": [ + "ConfigureAuditLogStreamResult" + ], + "document": "mutation configureDatadogLogStreamWithRegion($input: ConfigureDatadogLogStreamWithRegionInput!) {\n configureDatadogLogStreamWithRegion(input: $input) {\n __typename\n ... on AuditLogStreamConfigured {\n __typename\n auditLogStream {\n id\n }\n }\n ... on AuditLogStreamNotFound {\n __typename\n auditLogStreamId\n }\n ... on InvalidAuditLogStreamCredentials {\n __typename\n message\n }\n }\n}", + "fragmentNames": [], + "variables": [ + { + "name": "input", + "type": "ConfigureDatadogLogStreamWithRegionInput!", + "required": true + } + ] + }, + "configureGenericHttpsLogStream": { + "name": "configureGenericHttpsLogStream", + "kind": "mutation", + "description": "Configure an audit log stream to deliver events to a generic HTTPS endpoint", + "rootFields": [ + "configureGenericHttpsLogStream" + ], + "returnTypes": [ + "ConfigureAuditLogStreamResult" + ], + "document": "mutation configureGenericHttpsLogStream($input: ConfigureGenericHttpsLogStreamInput!) {\n configureGenericHttpsLogStream(input: $input) {\n __typename\n ... on AuditLogStreamConfigured {\n __typename\n auditLogStream {\n id\n }\n }\n ... on AuditLogStreamNotFound {\n __typename\n auditLogStreamId\n }\n ... on InvalidAuditLogStreamCredentials {\n __typename\n message\n }\n }\n}", + "fragmentNames": [], + "variables": [ + { + "name": "input", + "type": "ConfigureGenericHttpsLogStreamInput!", + "required": true + } + ] + }, + "configureGoogleCloudStorageLogStream": { + "name": "configureGoogleCloudStorageLogStream", + "kind": "mutation", + "description": "Configure an audit log stream to deliver events to a Google Cloud Storage bucket", + "rootFields": [ + "configureGoogleCloudStorageLogStream" + ], + "returnTypes": [ + "ConfigureAuditLogStreamResult" + ], + "document": "mutation configureGoogleCloudStorageLogStream($input: ConfigureGoogleCloudStorageLogStreamInput!) {\n configureGoogleCloudStorageLogStream(input: $input) {\n __typename\n ... on AuditLogStreamConfigured {\n __typename\n auditLogStream {\n id\n }\n }\n ... on AuditLogStreamNotFound {\n __typename\n auditLogStreamId\n }\n ... on InvalidAuditLogStreamCredentials {\n __typename\n message\n }\n }\n}", + "fragmentNames": [], + "variables": [ + { + "name": "input", + "type": "ConfigureGoogleCloudStorageLogStreamInput!", + "required": true + } + ] + }, + "configureS3LogStream": { + "name": "configureS3LogStream", + "kind": "mutation", + "description": "Configure an audit log stream to deliver events to an Amazon S3 bucket", + "rootFields": [ + "configureS3LogStream" + ], + "returnTypes": [ + "ConfigureAuditLogStreamResult" + ], + "document": "mutation configureS3LogStream($input: ConfigureS3LogStreamInput!) {\n configureS3LogStream(input: $input) {\n __typename\n ... on AuditLogStreamConfigured {\n __typename\n auditLogStream {\n id\n }\n }\n ... on AuditLogStreamNotFound {\n __typename\n auditLogStreamId\n }\n ... on InvalidAuditLogStreamCredentials {\n __typename\n message\n }\n }\n}", + "fragmentNames": [], + "variables": [ + { + "name": "input", + "type": "ConfigureS3LogStreamInput!", + "required": true + } + ] + }, + "configureSplunkLogStream": { + "name": "configureSplunkLogStream", + "kind": "mutation", + "description": "Configure an audit log stream to deliver events to a Splunk destination", + "rootFields": [ + "configureSplunkLogStream" + ], + "returnTypes": [ + "ConfigureAuditLogStreamResult" + ], + "document": "mutation configureSplunkLogStream($input: ConfigureSplunkLogStreamInput!) {\n configureSplunkLogStream(input: $input) {\n __typename\n ... on AuditLogStreamConfigured {\n __typename\n auditLogStream {\n id\n }\n }\n ... on AuditLogStreamNotFound {\n __typename\n auditLogStreamId\n }\n ... on InvalidAuditLogStreamCredentials {\n __typename\n message\n }\n }\n}", + "fragmentNames": [], + "variables": [ + { + "name": "input", + "type": "ConfigureSplunkLogStreamInput!", + "required": true + } + ] + }, + "connection": { + "name": "connection", + "kind": "query", + "description": "Return a single SSO connection by ID", + "rootFields": [ + "connection" + ], + "returnTypes": [ + "ConnectionOrUntypedConnection" + ], + "document": "query connection($id: String!) {\n connection(id: $id) {\n __typename\n ... on Connection {\n ...Connection\n }\n ... on UntypedConnection {\n ...UntypedConnection\n }\n }\n}", + "fragmentNames": [ + "Connection", + "ConnectionAttributeMap", + "SamlRelyingPartyTrust", + "SamlX509Certificate", + "SsoCustomAttributesWithMappings", + "UntypedConnection" + ], + "variables": [ + { + "name": "id", + "type": "String!", + "required": true + } + ] + }, + "connectionGroups": { + "name": "connectionGroups", + "kind": "query", + "description": "List the connection groups for an SSO/Directory connection", + "rootFields": [ + "groupsForConnection" + ], + "returnTypes": [ + "ConnectionGroupsList" + ], + "document": "query connectionGroups($connectionId: String!, $before: String, $after: String, $limit: Int) {\n groupsForConnection(\n connectionId: $connectionId\n before: $before\n after: $after\n limit: $limit\n ) {\n data {\n id\n name\n connectionId\n idpId\n groupRoleMapping {\n id\n stableGroupId\n role {\n id\n slug\n name\n }\n }\n }\n listMetadata {\n before\n after\n }\n }\n}", + "fragmentNames": [], + "variables": [ + { + "name": "connectionId", + "type": "String!", + "required": true + }, + { + "name": "before", + "type": "String", + "required": false + }, + { + "name": "after", + "type": "String", + "required": false + }, + { + "name": "limit", + "type": "Int", + "required": false + } + ] + }, + "connectionsByType": { + "name": "connectionsByType", + "kind": "query", + "description": "List an environment's OAuth credentials filtered by provider type, paginated", + "rootFields": [ + "oauthCredentialsByType" + ], + "returnTypes": [ + "OAuthCredentialList" + ], + "document": "query connectionsByType($environmentId: String!, $type: ConnectionType!) {\n oauthCredentialsByType(environmentId: $environmentId, type: $type) {\n data {\n ...OAuthCredential\n }\n }\n}", + "fragmentNames": [ + "OAuthCredential" + ], + "variables": [ + { + "name": "environmentId", + "type": "String!", + "required": true + }, + { + "name": "type", + "type": "ConnectionType!", + "required": true + } + ] + }, + "ConnectionSession": { + "name": "ConnectionSession", + "kind": "query", + "description": "Return a SAML, OIDC, or OAuth connection sign-in session by ID", + "rootFields": [ + "connectionSession" + ], + "returnTypes": [ + "ConnectionSession" + ], + "document": "query ConnectionSession($id: String!, $createdAt: DateTime) {\n connectionSession(id: $id, createdAt: $createdAt) {\n ...ConnectionSessionData\n }\n}", + "fragmentNames": [ + "ConnectionSessionData", + "ConnectionSessionError", + "ProfileSnapshot", + "SamlSession" + ], + "variables": [ + { + "name": "id", + "type": "String!", + "required": true + }, + { + "name": "createdAt", + "type": "DateTime", + "required": false + } + ] + }, + "connectionsForOrganization": { + "name": "connectionsForOrganization", + "kind": "query", + "description": "List an organization's SSO connections", + "rootFields": [ + "organization" + ], + "returnTypes": [ + "Organization" + ], + "document": "query connectionsForOrganization($before: String, $after: String, $limit: Int, $search: String, $organizationId: String!, $state: ConnectionState, $states: [ConnectionState!], $connectionType: ConnectionType) {\n organization(id: $organizationId) {\n connections(\n before: $before\n after: $after\n limit: $limit\n search: $search\n state: $state\n states: $states\n type: $connectionType\n ) {\n data {\n ...Connection\n }\n listMetadata {\n before\n after\n }\n }\n }\n}", + "fragmentNames": [ + "Connection", + "ConnectionAttributeMap", + "SamlRelyingPartyTrust", + "SamlX509Certificate", + "SsoCustomAttributesWithMappings" + ], + "variables": [ + { + "name": "before", + "type": "String", + "required": false + }, + { + "name": "after", + "type": "String", + "required": false + }, + { + "name": "limit", + "type": "Int", + "required": false + }, + { + "name": "search", + "type": "String", + "required": false + }, + { + "name": "organizationId", + "type": "String!", + "required": true + }, + { + "name": "state", + "type": "ConnectionState", + "required": false + }, + { + "name": "states", + "type": "[ConnectionState!]", + "required": false + }, + { + "name": "connectionType", + "type": "ConnectionType", + "required": false + } + ] + }, + "connectionWithOrganizationDirectories": { + "name": "connectionWithOrganizationDirectories", + "kind": "query", + "description": "Return a single SSO connection by ID", + "rootFields": [ + "connection" + ], + "returnTypes": [ + "ConnectionOrUntypedConnection" + ], + "document": "query connectionWithOrganizationDirectories($id: String!) {\n connection(id: $id) {\n __typename\n ... on Connection {\n ...ConnectionWithOrganizationDirectories\n }\n ... on UntypedConnection {\n ...UntypedConnection\n }\n }\n}", + "fragmentNames": [ + "ConnectionAttributeMap", + "ConnectionWithOrganizationDirectories", + "SamlRelyingPartyTrust", + "SamlX509Certificate", + "SsoCustomAttributesWithMappings", + "UntypedConnection" + ], + "variables": [ + { + "name": "id", + "type": "String!", + "required": true + } + ] + }, + "corsConfig": { + "name": "corsConfig", + "kind": "query", + "description": "Return an environment's AuthKit CORS configuration (allowed web origins)", + "rootFields": [ + "environment" + ], + "returnTypes": [ + "Environment" + ], + "document": "query corsConfig($environmentId: String!) {\n webOrigins: environment(id: $environmentId) {\n webOrigins {\n origins\n }\n }\n}", + "fragmentNames": [], + "variables": [ + { + "name": "environmentId", + "type": "String!", + "required": true + } + ] + }, + "createApplication": { + "name": "createApplication", + "kind": "mutation", + "description": "Create a Connect Application scoped to an organization or the current environment", + "rootFields": [ + "createApplication" + ], + "returnTypes": [ + "CreateApplicationResult" + ], + "document": "mutation createApplication($input: CreateApplicationInput!) {\n createApplication(input: $input) {\n __typename\n ... on OrganizationNotFound {\n organizationId\n }\n ... on ApplicationCreated {\n application {\n ...Application\n }\n }\n }\n}", + "fragmentNames": [ + "Application" + ], + "variables": [ + { + "name": "input", + "type": "CreateApplicationInput!", + "required": true + } + ] + }, + "createAuditLogExport": { + "name": "createAuditLogExport", + "kind": "mutation", + "description": "Create an audit log export for an organization over a date range (max 31 days)", + "rootFields": [ + "createAuditLogExport" + ], + "returnTypes": [ + "CreateAuditLogExportResult" + ], + "document": "mutation createAuditLogExport($input: CreateAuditLogExportInput!) {\n createAuditLogExport(input: $input) {\n __typename\n ... on AuditLogExport {\n id\n state\n url\n }\n ... on NoAuditLogEventsFound {\n message\n }\n ... on InvalidExportDateRange {\n message\n }\n }\n}", + "fragmentNames": [], + "variables": [ + { + "name": "input", + "type": "CreateAuditLogExportInput!", + "required": true + } + ] + }, + "createAuditLogStream": { + "name": "createAuditLogStream", + "kind": "mutation", + "description": "Create an audit log stream for an organization to deliver its audit events to an external destination", + "rootFields": [ + "createAuditLogStream" + ], + "returnTypes": [ + "CreateAuditLogStreamResult" + ], + "document": "mutation createAuditLogStream($input: CreateAuditLogStreamInput!) {\n createAuditLogStream(input: $input) {\n ... on AuditLogStream {\n ...AuditLogStream\n }\n }\n}", + "fragmentNames": [ + "AuditLogStream" + ], + "variables": [ + { + "name": "input", + "type": "CreateAuditLogStreamInput!", + "required": true + } + ] + }, + "createAuditLogValidator": { + "name": "createAuditLogValidator", + "kind": "mutation", + "description": "Create an audit log schema validator for an action and its target types in an environment", + "rootFields": [ + "createAuditLogValidator" + ], + "returnTypes": [ + "CreateAuditLogValidatorResult" + ], + "document": "mutation createAuditLogValidator($input: CreateAuditLogValidatorInput!) {\n createAuditLogValidator(input: $input) {\n __typename\n ... on AuditLogValidatorCreated {\n auditLogValidator {\n id\n }\n }\n ... on AuditLogActionAlreadyExists {\n message\n }\n ... on InvalidAuditLogSchema {\n message\n }\n ... on InvalidAuditLogAction {\n message\n }\n ... on InvalidAuditLogTargets {\n message\n targets\n }\n }\n}", + "fragmentNames": [], + "variables": [ + { + "name": "input", + "type": "CreateAuditLogValidatorInput!", + "required": true + } + ] + }, + "createAuditLogValidatorVersion": { + "name": "createAuditLogValidatorVersion", + "kind": "mutation", + "description": "Create a new schema version for an existing audit log validator", + "rootFields": [ + "createAuditLogValidatorVersion" + ], + "returnTypes": [ + "CreateAuditLogValidatorVersionResult" + ], + "document": "mutation createAuditLogValidatorVersion($input: CreateAuditLogValidatorVersionInput!) {\n createAuditLogValidatorVersion(input: $input) {\n __typename\n ... on AuditLogValidatorVersionCreated {\n auditLogValidatorVersion {\n id\n version\n }\n }\n ... on AuditLogValidatorNotFound {\n auditLogValidatorId\n }\n ... on InvalidAuditLogSchema {\n message\n }\n ... on InvalidAuditLogTargets {\n message\n targets\n }\n }\n}", + "fragmentNames": [], + "variables": [ + { + "name": "input", + "type": "CreateAuditLogValidatorVersionInput!", + "required": true + } + ] + }, + "createAuthkitApplication": { + "name": "createAuthkitApplication", + "kind": "mutation", + "description": "Create an AuthKit application in an environment with its login, homepage, and sign-up URLs", + "rootFields": [ + "createUserlandApplication" + ], + "returnTypes": [ + "CreateUserlandApplicationResult" + ], + "document": "mutation createAuthkitApplication($input: CreateUserlandApplicationInput!) {\n createUserlandApplication(input: $input) {\n __typename\n ... on UserlandApplicationCreated {\n userlandApplication {\n ...UserlandApplication\n }\n }\n ... on UserlandApplicationValidationFailed {\n message\n }\n ... on EnvironmentNotFound {\n __typename\n }\n }\n}", + "fragmentNames": [ + "Key", + "RedirectUri", + "UserlandApplication" + ], + "variables": [ + { + "name": "input", + "type": "CreateUserlandApplicationInput!", + "required": true + } + ] + }, + "createConnectionGroupWithRoleMapping": { + "name": "createConnectionGroupWithRoleMapping", + "kind": "mutation", + "description": "Create an SSO connection group and map it to a role for role assignment", + "rootFields": [ + "createConnectionGroupWithRoleMapping" + ], + "returnTypes": [ + "CreateConnectionGroupWithRoleMappingResult" + ], + "document": "mutation createConnectionGroupWithRoleMapping($input: CreateConnectionGroupWithRoleMappingInput!) {\n createConnectionGroupWithRoleMapping(input: $input) {\n __typename\n ... on ConnectionNotFound {\n connectionId\n }\n ... on ConnectionGroupAlreadyExists {\n connectionId\n idpId\n }\n ... on RoleNotFound {\n roleId\n }\n ... on ConnectionGroupWithRoleMappingCreated {\n connectionGroup {\n id\n }\n groupRoleMapping {\n id\n }\n }\n }\n}", + "fragmentNames": [], + "variables": [ + { + "name": "input", + "type": "CreateConnectionGroupWithRoleMappingInput!", + "required": true + } + ] + }, + "createCustomDataProvider": { + "name": "createCustomDataProvider", + "kind": "mutation", + "description": "Create a custom data provider integration in an environment for Connected Apps / data sync", + "rootFields": [ + "createCustomDataProvider" + ], + "returnTypes": [ + "CustomDataProviderPayload" + ], + "document": "mutation createCustomDataProvider($input: CreateCustomDataProviderInput!) {\n createCustomDataProvider(input: $input) {\n __typename\n customDataProvider {\n __typename\n id\n slug\n name\n description\n authorizationUrl\n tokenUrl\n refreshTokenUrl\n pkceEnabled\n requestScopeSeparator\n scopesRequired\n clientSecretRequired\n tokenBodyContentType\n authenticateVia\n iconSlug\n environmentId\n additionalAuthorizationParameters {\n key\n value\n }\n createdAt\n updatedAt\n }\n }\n}", + "fragmentNames": [], + "variables": [ + { + "name": "input", + "type": "CreateCustomDataProviderInput!", + "required": true + } + ] + }, + "createCustomDomain": { + "name": "createCustomDomain", + "kind": "mutation", + "description": "Configure a custom domain (Auth API, Admin Portal, or email) for an environment", + "rootFields": [ + "createCustomDomain" + ], + "returnTypes": [ + "CreateCustomDomainResult" + ], + "document": "mutation createCustomDomain($input: CreateCustomDomainInput!) {\n createCustomDomain(input: $input) {\n ... on InvalidCustomDomain {\n __typename\n domain\n }\n ... on DomainTypeAlreadyExists {\n __typename\n domain\n type\n }\n ... on CustomDomainCreated {\n __typename\n customDomain {\n ...CustomDomain\n }\n }\n ... on EnvironmentNotFound {\n __typename\n environmentId\n }\n ... on DomainUnavailable {\n __typename\n domain\n }\n }\n}", + "fragmentNames": [ + "CustomDomain" + ], + "variables": [ + { + "name": "input", + "type": "CreateCustomDomainInput!", + "required": true + } + ] + }, + "createCustomEmailDomain": { + "name": "createCustomEmailDomain", + "kind": "mutation", + "description": "Set up a custom sender email domain for a project and start its DNS verification", + "rootFields": [ + "createCustomEmailDomain" + ], + "returnTypes": [ + "CreateCustomEmailDomainResult" + ], + "document": "mutation createCustomEmailDomain($input: CreateCustomEmailDomainInput!) {\n createCustomEmailDomain(input: $input) {\n ... on InvalidCustomEmailDomain {\n __typename\n domain\n }\n ... on CustomEmailDomainAlreadyExistForProject {\n __typename\n projectId\n }\n ... on CustomEmailDomainCreated {\n __typename\n customEmailDomain {\n ...CustomEmailDomain\n }\n }\n }\n}", + "fragmentNames": [ + "CustomEmailDomain" + ], + "variables": [ + { + "name": "input", + "type": "CreateCustomEmailDomainInput!", + "required": true + } + ] + }, + "createCustomEmailProvider": { + "name": "createCustomEmailProvider", + "kind": "mutation", + "description": "Configure a custom email provider with sending credentials for an environment", + "rootFields": [ + "createCustomEmailProvider" + ], + "returnTypes": [ + "CreateCustomEmailProviderResult" + ], + "document": "mutation createCustomEmailProvider($input: CreateCustomEmailProviderInput!) {\n createCustomEmailProvider(input: $input) {\n __typename\n ... on CustomEmailProviderCreated {\n customEmailProvider {\n __typename\n id\n environmentId\n providerType\n status\n defaultSender\n defaultReplyTo\n }\n }\n ... on CustomEmailProviderEmailAddressDomainNotVerifiedErrorList {\n failedVerificationErrors {\n emailAddressField\n domain\n }\n }\n ... on CustomEmailProviderInputError {\n type\n message\n }\n ... on CustomEmailProviderSenderDomainNotVerifiedError {\n domain\n }\n ... on MailgunCustomEmailProviderDomainNotVerifiedError {\n domain\n }\n }\n}", + "fragmentNames": [], + "variables": [ + { + "name": "input", + "type": "CreateCustomEmailProviderInput!", + "required": true + } + ] + }, + "createCustomMappedCustomAttribute": { + "name": "createCustomMappedCustomAttribute", + "kind": "mutation", + "description": "Create a custom-mapped custom attribute definition in an environment", + "rootFields": [ + "createCustomMappedCustomAttribute" + ], + "returnTypes": [ + "CreateCustomMappedCustomAttributeResult" + ], + "document": "mutation createCustomMappedCustomAttribute($input: CreateCustomMappedCustomAttributeInput!) {\n createCustomMappedCustomAttribute(input: $input) {\n __typename\n ... on CustomAttributeInvalid {\n reason\n }\n ... on CustomMappedCustomAttributeCreated {\n customAttribute {\n ...CustomMappedCustomAttribute\n }\n }\n }\n}", + "fragmentNames": [ + "CustomMappedCustomAttribute" + ], + "variables": [ + { + "name": "input", + "type": "CreateCustomMappedCustomAttributeInput!", + "required": true + } + ] + }, + "createDatadogActivityStream": { + "name": "createDatadogActivityStream", + "kind": "mutation", + "description": "Create a webhook endpoint that forwards events to Datadog for an environment after validating credentials", + "rootFields": [ + "createDatadogWebhookEndpoint" + ], + "returnTypes": [ + "DatadogWebhookEndpointResult" + ], + "document": "mutation createDatadogActivityStream($environmentId: String!, $apiKey: String!, $region: String!) {\n createDatadogWebhookEndpoint(\n environmentId: $environmentId\n apiKey: $apiKey\n region: $region\n ) {\n __typename\n ... on WebhookEndpoint {\n __typename\n id\n }\n ... on InvalidDatadogCredentials {\n __typename\n message\n }\n }\n}", + "fragmentNames": [], + "variables": [ + { + "name": "environmentId", + "type": "String!", + "required": true + }, + { + "name": "apiKey", + "type": "String!", + "required": true + }, + { + "name": "region", + "type": "String!", + "required": true + } + ] + }, + "createDirectory": { + "name": "createDirectory", + "kind": "mutation", + "description": "Create a Directory Sync connection for an organization", + "featureFlagGated": true, + "rootFields": [ + "createDirectory" + ], + "returnTypes": [ + "Directory" + ], + "document": "mutation createDirectory($organizationId: String!, $name: String, $type: DirectoryType!, $domain: String) {\n createDirectory(\n organization_id: $organizationId\n name: $name\n type: $type\n domain: $domain\n ) {\n id\n }\n}", + "fragmentNames": [], + "variables": [ + { + "name": "organizationId", + "type": "String!", + "required": true + }, + { + "name": "name", + "type": "String", + "required": false + }, + { + "name": "type", + "type": "DirectoryType!", + "required": true + }, + { + "name": "domain", + "type": "String", + "required": false + } + ] + }, + "CreateDirectoryConfig": { + "name": "CreateDirectoryConfig", + "kind": "mutation", + "description": "Create a Directory Sync configuration (group sync settings) for a directory", + "rootFields": [ + "createDirectoryConfigDashboard" + ], + "returnTypes": [ + "CreateDirectoryConfigDashboardResult" + ], + "document": "mutation CreateDirectoryConfig($input: CreateDirectoryConfigInput!) {\n createDirectoryConfigDashboard(input: $input) {\n __typename\n ... on DirectoryConfigCreatedDashboard {\n directoryConfig {\n ...DirectoryConfig\n }\n }\n ... on DirectoryConfigAlreadyExistsDashboard {\n directoryConfigId\n }\n }\n}", + "fragmentNames": [ + "DirectoryConfig" + ], + "variables": [ + { + "name": "input", + "type": "CreateDirectoryConfigInput!", + "required": true + } + ] + }, + "createDirectoryToken": { + "name": "createDirectoryToken", + "kind": "mutation", + "description": "Create a SCIM bearer token for a Directory Sync directory", + "featureFlagGated": true, + "rootFields": [ + "createDirectoryToken" + ], + "returnTypes": [ + "CreateDirectoryTokenResult" + ], + "document": "mutation createDirectoryToken($input: CreateDirectoryTokenInput!) {\n createDirectoryToken(input: $input) {\n __typename\n ... on DirectoryTokenCreated {\n plaintextToken\n directoryToken {\n id\n tokenSuffix\n createdAt\n lastUsedAt\n }\n }\n ... on DirectoryTokenOperationError {\n code\n message\n }\n ... on DirectoryNotFound {\n directoryId\n }\n }\n}", + "fragmentNames": [], + "variables": [ + { + "name": "input", + "type": "CreateDirectoryTokenInput!", + "required": true + } + ] + }, + "createEnvironment": { + "name": "createEnvironment", + "kind": "mutation", + "description": "Create a new sandbox or production environment within the current project", + "rootFields": [ + "createEnvironment" + ], + "returnTypes": [ + "CreateEnvironmentResponse" + ], + "document": "mutation createEnvironment($input: CreateEnvironmentInput!) {\n createEnvironment(input: $input) {\n environment {\n id\n name\n sandbox\n }\n }\n}", + "fragmentNames": [], + "variables": [ + { + "name": "input", + "type": "CreateEnvironmentInput!", + "required": true + } + ] + }, + "createFlag": { + "name": "createFlag", + "kind": "mutation", + "description": "Create a feature flag in a project", + "rootFields": [ + "createFlag" + ], + "returnTypes": [ + "CreateFlagResult" + ], + "document": "mutation createFlag($input: CreateFlagInput!) {\n createFlag(input: $input) {\n __typename\n ... on ProjectNotFound {\n projectId\n }\n ... on FlagAlreadyExists {\n slug\n }\n ... on FlagSlugInvalid {\n slug\n }\n ... on FlagCreated {\n flag {\n ...Flag\n }\n }\n }\n}", + "fragmentNames": [ + "Flag" + ], + "variables": [ + { + "name": "input", + "type": "CreateFlagInput!", + "required": true + } + ] + }, + "createOAuthCredentials": { + "name": "createOAuthCredentials", + "kind": "mutation", + "description": "Create OAuth provider credentials (social login) for an environment", + "rootFields": [ + "createOauthCredentials" + ], + "returnTypes": [ + "CreateOauthCredentialsPayload" + ], + "document": "mutation createOAuthCredentials($input: CreateOauthCredentialsInput!) {\n createOauthCredentials(input: $input) {\n oauthCredentials {\n ...OAuthCredential\n }\n }\n}", + "fragmentNames": [ + "OAuthCredential" + ], + "variables": [ + { + "name": "input", + "type": "CreateOauthCredentialsInput!", + "required": true + } + ] + }, + "createOrganization": { + "name": "createOrganization", + "kind": "mutation", + "description": "Create an organization in an environment", + "rootFields": [ + "createOrganization" + ], + "returnTypes": [ + "CreateOrganizationResult" + ], + "document": "mutation createOrganization($input: CreateOrganizationInput!) {\n createOrganization(input: $input) {\n __typename\n ... on OrganizationCreated {\n organization {\n id\n name\n domains {\n ...OrganizationDomain\n }\n }\n }\n ... on EnvironmentNotFound {\n environmentId\n }\n ... on OrganizationDomainAlreadyInUse {\n domain\n organization {\n id\n name\n }\n }\n ... on ConsumerDomainForbidden {\n domain\n }\n ... on ExternalIDAlreadyUsed {\n externalId\n }\n }\n}", + "fragmentNames": [ + "OrganizationDomain" + ], + "variables": [ + { + "name": "input", + "type": "CreateOrganizationInput!", + "required": true + } + ] + }, + "createOrganizationIdpAttributesConfig": { + "name": "createOrganizationIdpAttributesConfig", + "kind": "mutation", + "description": "Create an organization-level IdP attribute mapping config for SSO and Directory Sync", + "rootFields": [ + "createOrganizationIdpAttributesConfig" + ], + "returnTypes": [ + "CreateOrganizationIdpAttributesConfigResult" + ], + "document": "mutation createOrganizationIdpAttributesConfig($input: CreateOrganizationIdpAttributesConfigInput!) {\n createOrganizationIdpAttributesConfig(input: $input) {\n __typename\n ... on OrganizationIdpAttributesConfigCreated {\n organizationIdpAttributesConfig {\n id\n ssoCustomAttributeMappingEnabled\n dsyncCustomAttributeMappingEnabled\n }\n }\n }\n}", + "fragmentNames": [], + "variables": [ + { + "name": "input", + "type": "CreateOrganizationIdpAttributesConfigInput!", + "required": true + } + ] + }, + "createOrganizationRoleConfig": { + "name": "createOrganizationRoleConfig", + "kind": "mutation", + "description": "Create role assignment configuration (default role, priority, SSO/dsync assignment) for an organization", + "rootFields": [ + "createOrganizationRoleConfig" + ], + "returnTypes": [ + "CreateOrganizationRoleConfigResult" + ], + "document": "mutation createOrganizationRoleConfig($input: CreateOrganizationRoleConfigInput!) {\n createOrganizationRoleConfig(input: $input) {\n __typename\n ... on OrganizationRoleConfigCreated {\n organizationRoleConfig {\n id\n ssoRoleAssignmentEnabled\n dsyncRoleAssignmentEnabled\n }\n }\n }\n}", + "fragmentNames": [], + "variables": [ + { + "name": "input", + "type": "CreateOrganizationRoleConfigInput!", + "required": true + } + ] + }, + "createPermission": { + "name": "createPermission", + "kind": "mutation", + "description": "Create an RBAC permission in the current environment", + "rootFields": [ + "createPermission" + ], + "returnTypes": [ + "CreatePermissionResult" + ], + "document": "mutation createPermission($input: CreatePermissionInput!) {\n createPermission(input: $input) {\n __typename\n ... on EnvironmentNotFound {\n environmentId\n }\n ... on PermissionAlreadyExists {\n slug\n }\n ... on PermissionSlugInvalid {\n slug\n }\n ... on PermissionCreated {\n permission {\n ...Permission\n }\n }\n }\n}", + "fragmentNames": [ + "Permission", + "ResourceType" + ], + "variables": [ + { + "name": "input", + "type": "CreatePermissionInput!", + "required": true + } + ] + }, + "createProjectWithNewEnvironments": { + "name": "createProjectWithNewEnvironments", + "kind": "mutation", + "description": "Create a project provisioned with fresh environments — a staging environment, and a production environment unless opted out", + "rootFields": [ + "createProjectWithNewEnvironments" + ], + "returnTypes": [ + "CreateProjectWithNewEnvironmentsResult" + ], + "document": "mutation createProjectWithNewEnvironments($input: CreateProjectWithNewEnvironmentsInput!) {\n createProjectWithNewEnvironments(input: $input) {\n __typename\n ... on ProjectCreated {\n project {\n id\n name\n }\n }\n ... on ProjectNameAlreadyUsed {\n name\n }\n }\n}", + "fragmentNames": [], + "variables": [ + { + "name": "input", + "type": "CreateProjectWithNewEnvironmentsInput!", + "required": true + } + ] + }, + "createResourceType": { + "name": "createResourceType", + "kind": "mutation", + "description": "Create an FGA authorization resource type in the current environment with a name, slug, and parent types", + "rootFields": [ + "createResourceType" + ], + "returnTypes": [ + "CreateAuthorizationResourceTypeResult" + ], + "document": "mutation createResourceType($input: CreateAuthorizationResourceTypeInput!) {\n createResourceType(input: $input) {\n __typename\n ... on AuthorizationResourceTypeCreated {\n __typename\n resourceType {\n ...ResourceType\n }\n }\n ... on AuthorizationResourceTypeSlugConflict {\n __typename\n slug\n }\n ... on EnvironmentNotFound {\n __typename\n environmentId\n }\n }\n}", + "fragmentNames": [ + "ResourceType" + ], + "variables": [ + { + "name": "input", + "type": "CreateAuthorizationResourceTypeInput!", + "required": true + } + ] + }, + "createRole": { + "name": "createRole", + "kind": "mutation", + "description": "Create a role with permissions, either environment-wide or scoped to a specific organization", + "rootFields": [ + "createRole" + ], + "returnTypes": [ + "CreateRoleResult" + ], + "document": "mutation createRole($input: CreateRoleInput!) {\n createRole(input: $input) {\n __typename\n ... on EnvironmentNotFound {\n environmentId\n }\n ... on RoleAlreadyExists {\n slug\n }\n ... on RoleCreated {\n role {\n ...Role\n }\n roleConfig {\n ...RoleConfig\n }\n }\n }\n}", + "fragmentNames": [ + "ResourceType", + "Role", + "RoleConfig" + ], + "variables": [ + { + "name": "input", + "type": "CreateRoleInput!", + "required": true + } + ] + }, + "createUserlandUser": { + "name": "createUserlandUser", + "kind": "mutation", + "description": "Create an AuthKit end user in an environment with email and optional name and password", + "rootFields": [ + "createUserlandUser" + ], + "returnTypes": [ + "CreateUserlandUserResult" + ], + "document": "mutation createUserlandUser($input: CreateUserlandUserInput!) {\n createUserlandUser(input: $input) {\n __typename\n ... on EnvironmentNotFound {\n __typename\n }\n ... on PasswordAuthDisabled {\n __typename\n }\n ... on UserlandUserValidationFailed {\n __typename\n errors {\n __typename\n ... on UserlandUserPasswordMissingCharacterType {\n characterType\n symbols\n }\n }\n }\n ... on UserlandUserCreated {\n userlandUser {\n id\n }\n }\n ... on ExternalIDAlreadyUsed {\n externalId\n }\n }\n}", + "fragmentNames": [], + "variables": [ + { + "name": "input", + "type": "CreateUserlandUserInput!", + "required": true + } + ] + }, + "createUserlandUserInvite": { + "name": "createUserlandUserInvite", + "kind": "mutation", + "description": "Send an AuthKit invitation email to a user, optionally joining them to an organization with a role", + "rootFields": [ + "createUserlandUserInvite" + ], + "returnTypes": [ + "CreateUserlandUserInviteResult" + ], + "document": "mutation createUserlandUserInvite($input: CreateUserlandUserInviteInput!) {\n createUserlandUserInvite(input: $input) {\n __typename\n ... on EnvironmentNotFound {\n __typename\n }\n ... on OrganizationNotFound {\n __typename\n }\n ... on UserlandUserNotFound {\n __typename\n }\n ... on CreateUserlandUserInviteExpiresInDaysTooLong {\n __typename\n }\n ... on CreateUserlandUserInviteExpiresInDaysTooShort {\n __typename\n }\n ... on CreateUserlandUserInviteUserAlreadyExists {\n __typename\n }\n ... on CreateUserlandUserInviteUserAlreadyOrganizationMember {\n __typename\n }\n ... on CreateUserlandUserInviteInvalidInviteeEmail {\n __typename\n }\n ... on CreateUserlandUserInviteEmailAlreadyInvitedToEnvironment {\n __typename\n }\n ... on CreateUserlandUserInviteEmailAlreadyInvitedToOrganization {\n __typename\n }\n ... on CreateUserlandUserInviteInvalidRole {\n __typename\n }\n ... on UserlandUserInviteCreated {\n userlandUserInvite {\n id\n }\n }\n }\n}", + "fragmentNames": [], + "variables": [ + { + "name": "input", + "type": "CreateUserlandUserInviteInput!", + "required": true + } + ] + }, + "createWebhookEndpoint": { + "name": "createWebhookEndpoint", + "kind": "mutation", + "description": "Create a webhook endpoint in an environment subscribed to the given events", + "rootFields": [ + "createWebhookEndpoint" + ], + "returnTypes": [ + "WebhookEndpoint" + ], + "document": "mutation createWebhookEndpoint($endpointUrl: String!, $environmentId: String!, $events: [String!]!) {\n createWebhookEndpoint(\n endpointUrl: $endpointUrl\n environmentId: $environmentId\n events: $events\n ) {\n id\n }\n}", + "fragmentNames": [], + "variables": [ + { + "name": "endpointUrl", + "type": "String!", + "required": true + }, + { + "name": "environmentId", + "type": "String!", + "required": true + }, + { + "name": "events", + "type": "[String!]!", + "required": true + } + ] + }, + "customAttributes": { + "name": "customAttributes", + "kind": "query", + "description": "List available auto-mapped custom attributes for an environment, flagging which are enabled List custom-mapped Directory Sync custom attributes in the current environment", + "rootFields": [ + "autoMappedCustomAttributes", + "customMappedCustomAttributes" + ], + "returnTypes": [ + "AutoMappedCustomAttribute", + "CustomMappedCustomAttributeList" + ], + "document": "query customAttributes($environmentId: String!) {\n autoMappedCustomAttributes(environmentId: $environmentId) {\n ...AutoMappedCustomAttribute\n }\n customMappedCustomAttributes {\n data {\n ...CustomMappedCustomAttribute\n }\n }\n}", + "fragmentNames": [ + "AutoMappedCustomAttribute", + "CustomMappedCustomAttribute" + ], + "variables": [ + { + "name": "environmentId", + "type": "String!", + "required": true + } + ] + }, + "customDomain": { + "name": "customDomain", + "kind": "query", + "description": "Return the self-serve custom domain of a given type configured for an environment", + "rootFields": [ + "customDomain" + ], + "returnTypes": [ + "CustomDomain" + ], + "document": "query customDomain($environmentId: String!, $type: CustomDomainType!) {\n customDomain(environmentId: $environmentId, type: $type) {\n ...CustomDomain\n }\n}", + "fragmentNames": [ + "CustomDomain" + ], + "variables": [ + { + "name": "environmentId", + "type": "String!", + "required": true + }, + { + "name": "type", + "type": "CustomDomainType!", + "required": true + } + ] + }, + "customEmailDomain": { + "name": "customEmailDomain", + "kind": "query", + "description": "Return the project's custom email domain along with the default fallback email domains", + "rootFields": [ + "customEmailDomain" + ], + "returnTypes": [ + "CustomEmailDomainQueryResult" + ], + "document": "query customEmailDomain {\n customEmailDomain {\n customEmailDomain {\n ...CustomEmailDomain\n }\n defaultAdminPortalEmailDomain\n sandboxUserlandEmailDomainEmailDomain\n defaultProductionEmailDomain\n defaultSandboxEmailDomain\n }\n}", + "fragmentNames": [ + "CustomEmailDomain" + ], + "variables": [] + }, + "customEmailProviders": { + "name": "customEmailProviders", + "kind": "query", + "description": "List the custom email providers configured for an environment", + "rootFields": [ + "customEmailProviders" + ], + "returnTypes": [ + "CustomEmailProvider" + ], + "document": "query customEmailProviders($environmentId: String!) {\n customEmailProviders(environmentId: $environmentId) {\n __typename\n id\n environmentId\n status\n defaultSender\n defaultReplyTo\n providerType\n ... on AwsSesCustomEmailProvider {\n awsSesRegion\n }\n ... on MailgunCustomEmailProvider {\n mailgunRegion\n mailgunDomain\n }\n }\n}", + "fragmentNames": [], + "variables": [ + { + "name": "environmentId", + "type": "String!", + "required": true + } + ] + }, + "customMappedCustomAttributes": { + "name": "customMappedCustomAttributes", + "kind": "query", + "description": "List custom-mapped Directory Sync custom attributes in the current environment", + "rootFields": [ + "customMappedCustomAttributes" + ], + "returnTypes": [ + "CustomMappedCustomAttributeList" + ], + "document": "query customMappedCustomAttributes {\n customMappedCustomAttributes {\n data {\n ...CustomMappedCustomAttribute\n }\n }\n}", + "fragmentNames": [ + "CustomMappedCustomAttribute" + ], + "variables": [] + }, + "dashboardSession": { + "name": "dashboardSession", + "kind": "query", + "description": "Return whether the current team signed up with a consumer email domain", + "rootFields": [ + "currentEnvironment", + "me", + "currentTeam" + ], + "returnTypes": [ + "Environment", + "User", + "Team" + ], + "document": "query dashboardSession {\n currentEnvironment {\n id\n name\n logoUrl\n sandbox\n clientId\n platform\n resourceTypeCount\n authkitDomains {\n id\n domain\n }\n customAuthDomain {\n domain\n selfServe\n }\n customAuthDomainScope\n customAdminPortalDomain {\n domain\n selfServe\n }\n customAdminPortalDomainScope\n expiredConnectionCertificates {\n connectionId\n expiryDate\n organizationId\n organizationName\n }\n expiringConnectionCertificates {\n connectionId\n expiryDate\n organizationId\n organizationName\n }\n projectV2 {\n id\n name\n environments {\n id\n name\n logoUrl\n sandbox\n clientId\n platform\n customAuthDomainScope\n customAdminPortalDomainScope\n }\n }\n }\n me {\n id\n firstName\n lastName\n name\n email\n workosUserId\n impersonator {\n email\n firstName\n lastName\n }\n impersonationReason\n users_teams {\n id\n role\n state\n isManagedByDirectory\n complianceTermsAcceptedAt\n isInvitationExpired\n user {\n id\n name\n email\n isMfaConfigured\n }\n team {\n id\n }\n }\n }\n currentTeam {\n id\n name\n domain\n createdAt\n organizationId\n onboarding {\n __typename\n ... on TeamOnboarding {\n nextStep\n }\n }\n isMfaRequired\n isUsingConsumerDomain\n productionState\n userManagementActivated\n mcpServerOptions {\n isEnabled\n withProduction\n withMutations\n }\n entitlements {\n id\n featureId\n }\n }\n}", + "fragmentNames": [], + "variables": [] + }, + "dashboardTaxId": { + "name": "dashboardTaxId", + "kind": "query", + "description": "Return the team for the current dashboard session", + "rootFields": [ + "currentTeam" + ], + "returnTypes": [ + "Team" + ], + "document": "query dashboardTaxId {\n currentTeam {\n billingDetails {\n taxId {\n countryCode\n type\n value\n }\n }\n }\n}", + "fragmentNames": [], + "variables": [] + }, + "dataIntegrations": { + "name": "dataIntegrations", + "kind": "query", + "description": "List the Pipes data integrations configured in an environment", + "rootFields": [ + "environment" + ], + "returnTypes": [ + "Environment" + ], + "document": "query dataIntegrations($environmentId: String!, $after: String, $before: String, $credentialsType: DataIntegrationCredentialsType, $enabled: Boolean, $excludeRequested: Boolean, $limit: Int = 50, $order: PaginationOrder, $organizationId: ID, $search: String) {\n environment(id: $environmentId) {\n __typename\n id\n dataIntegrations(\n after: $after\n before: $before\n credentialsType: $credentialsType\n enabled: $enabled\n excludeRequested: $excludeRequested\n limit: $limit\n order: $order\n organizationId: $organizationId\n search: $search\n ) {\n __typename\n data {\n __typename\n id\n type\n slug\n name\n enabled\n credentialsType\n state\n authMethods\n scopes\n organizationsCount\n usersCount\n organizations(limit: 10, dataIntegrationStatus: Active) {\n __typename\n data {\n __typename\n id\n name\n }\n }\n }\n listMetadata {\n __typename\n after\n before\n }\n }\n }\n}", + "fragmentNames": [], + "variables": [ + { + "name": "environmentId", + "type": "String!", + "required": true + }, + { + "name": "after", + "type": "String", + "required": false + }, + { + "name": "before", + "type": "String", + "required": false + }, + { + "name": "credentialsType", + "type": "DataIntegrationCredentialsType", + "required": false + }, + { + "name": "enabled", + "type": "Boolean", + "required": false + }, + { + "name": "excludeRequested", + "type": "Boolean", + "required": false + }, + { + "name": "limit", + "type": "Int", + "required": false, + "defaultValue": "50" + }, + { + "name": "order", + "type": "PaginationOrder", + "required": false + }, + { + "name": "organizationId", + "type": "ID", + "required": false + }, + { + "name": "search", + "type": "String", + "required": false + } + ] + }, + "dataProviderIntegration": { + "name": "dataProviderIntegration", + "kind": "query", + "description": "Return a Pipes data provider integration in the current environment by id", + "rootFields": [ + "dataProviderIntegration" + ], + "returnTypes": [ + "DataProviderIntegration" + ], + "document": "query dataProviderIntegration($id: ID!) {\n dataProviderIntegration(id: $id) {\n __typename\n id\n providerSlug\n name\n description\n credentialsType\n clientId\n redactedClientSecret\n authMethods\n enabled\n state\n scopes\n redirectUri\n customRedirectUri\n createdAt\n updatedAt\n usersCount\n organizationsCount\n organizations(limit: 10, dataIntegrationStatus: Active) {\n __typename\n data {\n __typename\n id\n name\n }\n }\n orgOverrides {\n data {\n id\n enabled\n scopes\n organization {\n id\n name\n }\n }\n }\n }\n}", + "fragmentNames": [], + "variables": [ + { + "name": "id", + "type": "ID!", + "required": true + } + ] + }, + "dataProviderIntegrationForSlug": { + "name": "dataProviderIntegrationForSlug", + "kind": "query", + "description": "Return (creating if needed) an environment's data provider integration for a given provider slug", + "rootFields": [ + "dataProviderIntegrationForSlug" + ], + "returnTypes": [ + "DataProviderIntegrationResult" + ], + "document": "query dataProviderIntegrationForSlug($environmentId: ID!, $providerSlug: String!) {\n dataProviderIntegrationForSlug(\n environmentId: $environmentId\n providerSlug: $providerSlug\n ) {\n __typename\n ... on DataProviderIntegration {\n id\n name\n description\n credentialsType\n clientId\n enabled\n state\n redirectUri\n customRedirectUri\n }\n ... on DataProviderNotFound {\n providerSlug\n }\n }\n}", + "fragmentNames": [], + "variables": [ + { + "name": "environmentId", + "type": "ID!", + "required": true + }, + { + "name": "providerSlug", + "type": "String!", + "required": true + } + ] + }, + "dataProviderIntegrationOrganizations": { + "name": "dataProviderIntegrationOrganizations", + "kind": "query", + "description": "Return a Pipes data provider integration in the current environment by id", + "rootFields": [ + "dataProviderIntegration" + ], + "returnTypes": [ + "DataProviderIntegration" + ], + "document": "query dataProviderIntegrationOrganizations($id: ID!, $limit: Int!, $after: String, $before: String, $search: String, $dataIntegrationStatus: DataIntegrationStatus, $hasScopesOverride: Boolean) {\n dataProviderIntegration(id: $id) {\n id\n providerSlug\n scopes\n organizations(\n limit: $limit\n after: $after\n before: $before\n search: $search\n dataIntegrationStatus: $dataIntegrationStatus\n hasScopesOverride: $hasScopesOverride\n ) {\n data {\n id\n name\n dataProviderIntegrations {\n data {\n id\n providerSlug\n usersCount\n lastAccessed\n orgOverride {\n id\n enabled\n scopes\n }\n }\n }\n }\n listMetadata {\n after\n before\n }\n }\n }\n}", + "fragmentNames": [], + "variables": [ + { + "name": "id", + "type": "ID!", + "required": true + }, + { + "name": "limit", + "type": "Int!", + "required": true + }, + { + "name": "after", + "type": "String", + "required": false + }, + { + "name": "before", + "type": "String", + "required": false + }, + { + "name": "search", + "type": "String", + "required": false + }, + { + "name": "dataIntegrationStatus", + "type": "DataIntegrationStatus", + "required": false + }, + { + "name": "hasScopesOverride", + "type": "Boolean", + "required": false + } + ] + }, + "dataProviderIntegrations": { + "name": "dataProviderIntegrations", + "kind": "query", + "description": "List the Pipes data provider integrations available in an environment", + "rootFields": [ + "environment" + ], + "returnTypes": [ + "Environment" + ], + "document": "query dataProviderIntegrations($environmentId: String!, $after: String, $before: String, $credentialsType: DataIntegrationCredentialsType, $enabled: Boolean, $excludeRequested: Boolean, $limit: Int = 50, $order: PaginationOrder, $organizationId: ID, $search: String) {\n environment(id: $environmentId) {\n __typename\n id\n dataProviderIntegrations(\n after: $after\n before: $before\n credentialsType: $credentialsType\n enabled: $enabled\n excludeRequested: $excludeRequested\n limit: $limit\n order: $order\n organizationId: $organizationId\n search: $search\n ) {\n __typename\n data {\n __typename\n id\n providerSlug\n name\n enabled\n credentialsType\n state\n authMethods\n scopes\n organizationsCount\n usersCount\n organizations(limit: 10, dataIntegrationStatus: Active) {\n __typename\n data {\n __typename\n id\n name\n }\n }\n }\n listMetadata {\n __typename\n after\n before\n }\n }\n }\n}", + "fragmentNames": [], + "variables": [ + { + "name": "environmentId", + "type": "String!", + "required": true + }, + { + "name": "after", + "type": "String", + "required": false + }, + { + "name": "before", + "type": "String", + "required": false + }, + { + "name": "credentialsType", + "type": "DataIntegrationCredentialsType", + "required": false + }, + { + "name": "enabled", + "type": "Boolean", + "required": false + }, + { + "name": "excludeRequested", + "type": "Boolean", + "required": false + }, + { + "name": "limit", + "type": "Int", + "required": false, + "defaultValue": "50" + }, + { + "name": "order", + "type": "PaginationOrder", + "required": false + }, + { + "name": "organizationId", + "type": "ID", + "required": false + }, + { + "name": "search", + "type": "String", + "required": false + } + ] + }, + "dataProviders": { + "name": "dataProviders", + "kind": "query", + "description": "List available data providers (Pipes integration types) for the current environment", + "rootFields": [ + "dataProviders" + ], + "returnTypes": [ + "DataProviderConfig" + ], + "document": "query dataProviders {\n dataProviders {\n id\n slug\n name\n integrationType\n description\n shortDescription\n state\n showScopes\n sharedScopesDescription\n scopesDescription\n oauthDocsExternalLink\n scopesDocsExternalLink\n iconSlug\n iconUrl\n iconDarkUrl\n sharedCredentialsAvailable\n environmentId\n authorizationUrl\n tokenUrl\n pkceEnabled\n requestScopeSeparator\n tokenBodyContentType\n authenticateVia\n authMethods\n additionalAuthorizationParameters {\n key\n value\n }\n scopes {\n slug\n shared\n }\n }\n}", + "fragmentNames": [], + "variables": [] + }, + "deactivateOrganizationMembership": { + "name": "deactivateOrganizationMembership", + "kind": "mutation", + "description": "Deactivate a user's organization membership so they lose access to that organization", + "rootFields": [ + "deactivateUserlandUserOrganizationMembership" + ], + "returnTypes": [ + "DeactivateUserlandUserOrganizationMembershipResult" + ], + "document": "mutation deactivateOrganizationMembership($input: DeactivateUserlandUserOrganizationMembershipInput!) {\n deactivateUserlandUserOrganizationMembership(input: $input) {\n __typename\n ... on UserlandUserOrganizationMembershipNotFound {\n __typename\n message\n }\n ... on UserlandUserOrganizationMembershipDeactivated {\n __typename\n }\n }\n}", + "fragmentNames": [], + "variables": [ + { + "name": "input", + "type": "DeactivateUserlandUserOrganizationMembershipInput!", + "required": true + } + ] + }, + "DeauthorizeStripe": { + "name": "DeauthorizeStripe", + "kind": "mutation", + "description": "Disconnect the Stripe Connect integration from an environment", + "rootFields": [ + "deauthorizeStripe" + ], + "returnTypes": [ + "DeauthorizeStripeResult" + ], + "document": "mutation DeauthorizeStripe($environmentId: String!) {\n deauthorizeStripe(environmentId: $environmentId) {\n __typename\n ... on DeauthorizeStripeSuccess {\n environment {\n id\n stripeConnectState\n }\n }\n ... on StripeError {\n errorMessage\n }\n }\n}", + "fragmentNames": [], + "variables": [ + { + "name": "environmentId", + "type": "String!", + "required": true + } + ] + }, + "defaultAuthkitApplication": { + "name": "defaultAuthkitApplication", + "kind": "query", + "description": "Return the default AuthKit application for an environment", + "rootFields": [ + "defaultUserlandApplication" + ], + "returnTypes": [ + "UserlandApplication" + ], + "document": "query defaultAuthkitApplication($environmentId: String!) {\n defaultUserlandApplication(environmentId: $environmentId) {\n ...UserlandApplication\n }\n}", + "fragmentNames": [ + "Key", + "RedirectUri", + "UserlandApplication" + ], + "variables": [ + { + "name": "environmentId", + "type": "String!", + "required": true + } + ] + }, + "defaultRedirectUri": { + "name": "defaultRedirectUri", + "kind": "query", + "description": "Return the default redirect URI for the environment's default AuthKit application", + "rootFields": [ + "defaultRedirectUri" + ], + "returnTypes": [ + "RedirectURI" + ], + "document": "query defaultRedirectUri($environmentId: String!) {\n defaultRedirectUri(environmentId: $environmentId) {\n ...RedirectUri\n }\n}", + "fragmentNames": [ + "RedirectUri" + ], + "variables": [ + { + "name": "environmentId", + "type": "String!", + "required": true + } + ] + }, + "deleteActionsEndpoint": { + "name": "deleteActionsEndpoint", + "kind": "mutation", + "description": "Delete an Actions endpoint so it no longer receives action execution callbacks", + "rootFields": [ + "deleteActionsEndpoint" + ], + "returnTypes": [ + "DeleteActionsEndpointResult" + ], + "document": "mutation deleteActionsEndpoint($input: DeleteActionsEndpointInput!) {\n deleteActionsEndpoint(input: $input) {\n __typename\n ... on ActionsEndpointDeleted {\n actionsEndpointId\n }\n }\n}", + "fragmentNames": [], + "variables": [ + { + "name": "input", + "type": "DeleteActionsEndpointInput!", + "required": true + } + ] + }, + "deleteAdpConnectionSslCertificate": { + "name": "deleteAdpConnectionSslCertificate", + "kind": "mutation", + "description": "Delete an SSL certificate from an ADP directory connection", + "rootFields": [ + "deleteAdpConnectionSslCertificate" + ], + "returnTypes": [ + "DeleteAdpConnectionSslCertificateResult" + ], + "document": "mutation deleteAdpConnectionSslCertificate($input: DeleteAdpConnectionSslCertificateInput!) {\n deleteAdpConnectionSslCertificate(input: $input) {\n __typename\n ... on AdpConnectionSslCertificateDeleted {\n certificateId\n }\n ... on AdpConnectionSslCertificateNotFound {\n certificateId\n }\n }\n}", + "fragmentNames": [], + "variables": [ + { + "name": "input", + "type": "DeleteAdpConnectionSslCertificateInput!", + "required": true + } + ] + }, + "deleteApiKey": { + "name": "deleteApiKey", + "kind": "mutation", + "description": "Permanently delete an API key, immediately revoking access for anyone using it", + "rootFields": [ + "deleteApiKey" + ], + "returnTypes": [ + "DeleteApiKeyPayload" + ], + "document": "mutation deleteApiKey($input: DeleteApiKeyInput!) {\n deleteApiKey(input: $input) {\n apiKey {\n id\n name\n organizationId\n obfuscatedValue\n }\n }\n}", + "fragmentNames": [], + "variables": [ + { + "name": "input", + "type": "DeleteApiKeyInput!", + "required": true + } + ] + }, + "deleteApplication": { + "name": "deleteApplication", + "kind": "mutation", + "description": "Permanently delete a Connect Application and its credentials", + "confirmation": "permanently deletes the application and its credentials", + "rootFields": [ + "deleteApplication" + ], + "returnTypes": [ + "DeleteApplicationResult" + ], + "document": "mutation deleteApplication($input: DeleteApplicationInput!) {\n deleteApplication(input: $input) {\n __typename\n ... on ApplicationDeleted {\n id\n }\n ... on ApplicationNotFound {\n applicationId\n }\n }\n}", + "fragmentNames": [], + "variables": [ + { + "name": "input", + "type": "DeleteApplicationInput!", + "required": true + } + ] + }, + "deleteApplicationCredential": { + "name": "deleteApplicationCredential", + "kind": "mutation", + "description": "Delete a credential from a Connect Application so it can no longer authenticate", + "rootFields": [ + "deleteApplicationCredential" + ], + "returnTypes": [ + "DeleteApplicationCredentialResult" + ], + "document": "mutation deleteApplicationCredential($input: DeleteApplicationCredentialInput!) {\n deleteApplicationCredential(input: $input) {\n __typename\n ... on ApplicationCredentialDeleted {\n id\n }\n }\n}", + "fragmentNames": [], + "variables": [ + { + "name": "input", + "type": "DeleteApplicationCredentialInput!", + "required": true + } + ] + }, + "deleteAuditLogStream": { + "name": "deleteAuditLogStream", + "kind": "mutation", + "description": "Delete an audit log stream so events stop being forwarded to its destination", + "rootFields": [ + "deleteAuditLogStream" + ], + "returnTypes": [ + "DeleteAuditLogStreamResult" + ], + "document": "mutation deleteAuditLogStream($input: DeleteAuditLogStreamInput!) {\n deleteAuditLogStream(input: $input) {\n __typename\n ... on AuditLogStreamDeleted {\n auditLogStream {\n id\n }\n }\n ... on AuditLogStreamNotFound {\n auditLogStreamId\n }\n }\n}", + "fragmentNames": [], + "variables": [ + { + "name": "input", + "type": "DeleteAuditLogStreamInput!", + "required": true + } + ] + }, + "deleteAuditLogValidator": { + "name": "deleteAuditLogValidator", + "kind": "mutation", + "description": "Delete an audit log schema validator", + "rootFields": [ + "deleteAuditLogValidator" + ], + "returnTypes": [ + "DeleteAuditLogValidatorResult" + ], + "document": "mutation deleteAuditLogValidator($input: DeleteAuditLogValidatorInput!) {\n deleteAuditLogValidator(input: $input) {\n __typename\n ... on AuditLogValidatorDeleted {\n auditLogValidator {\n id\n }\n }\n ... on AuditLogValidatorNotFound {\n auditLogValidatorId\n }\n }\n}", + "fragmentNames": [], + "variables": [ + { + "name": "input", + "type": "DeleteAuditLogValidatorInput!", + "required": true + } + ] + }, + "deleteAuditLogValidatorVersion": { + "name": "deleteAuditLogValidatorVersion", + "kind": "mutation", + "description": "Delete a specific version of an audit log schema validator", + "rootFields": [ + "deleteAuditLogValidatorVersion" + ], + "returnTypes": [ + "DeleteAuditLogValidatorVersionResult" + ], + "document": "mutation deleteAuditLogValidatorVersion($input: DeleteAuditLogValidatorVersionInput!) {\n deleteAuditLogValidatorVersion(input: $input) {\n __typename\n ... on AuditLogValidatorVersionDeleted {\n auditLogValidatorVersion {\n id\n version\n }\n }\n ... on AuditLogValidatorVersionNotFound {\n auditLogValidatorVersionId\n }\n }\n}", + "fragmentNames": [], + "variables": [ + { + "name": "input", + "type": "DeleteAuditLogValidatorVersionInput!", + "required": true + } + ] + }, + "deleteAuthenticationFactor": { + "name": "deleteAuthenticationFactor", + "kind": "mutation", + "description": "Delete an MFA authentication factor so it can no longer be used to verify the user", + "rootFields": [ + "deleteAuthenticationFactor" + ], + "returnTypes": [ + "DeleteAuthenticationFactorResult" + ], + "document": "mutation deleteAuthenticationFactor($id: ID!) {\n deleteAuthenticationFactor(id: $id) {\n __typename\n ... on AuthenticationFactorNotFound {\n id\n }\n ... on AuthenticationFactorDeleted {\n id\n }\n }\n}", + "fragmentNames": [], + "variables": [ + { + "name": "id", + "type": "ID!", + "required": true + } + ] + }, + "deleteAuthkitApplication": { + "name": "deleteAuthkitApplication", + "kind": "mutation", + "description": "Permanently delete an AuthKit application and its API keys", + "confirmation": "permanently deletes the AuthKit application and its API keys", + "rootFields": [ + "deleteUserlandApplication" + ], + "returnTypes": [ + "DeleteUserlandApplicationResult" + ], + "document": "mutation deleteAuthkitApplication($input: DeleteUserlandApplicationInput!) {\n deleteUserlandApplication(input: $input) {\n __typename\n ... on UserlandApplicationDeleted {\n applicationId\n }\n ... on UserlandApplicationNotFound {\n applicationId\n }\n ... on UserlandApplicationCannotDeleteDefault {\n message\n }\n }\n}", + "fragmentNames": [], + "variables": [ + { + "name": "input", + "type": "DeleteUserlandApplicationInput!", + "required": true + } + ] + }, + "deleteConnectionGroup": { + "name": "deleteConnectionGroup", + "kind": "mutation", + "description": "Delete a connection group mapping an IdP group to a WorkOS role", + "rootFields": [ + "deleteConnectionGroup" + ], + "returnTypes": [ + "DeleteConnectionGroupResult" + ], + "document": "mutation deleteConnectionGroup($input: DeleteConnectionGroupInput!) {\n deleteConnectionGroup(input: $input) {\n __typename\n ... on ConnectionGroupNotFound {\n connectionGroupId\n }\n ... on ConnectionGroupDeleted {\n connectionGroupId\n }\n }\n}", + "fragmentNames": [], + "variables": [ + { + "name": "input", + "type": "DeleteConnectionGroupInput!", + "required": true + } + ] + }, + "deleteCustomDataProvider": { + "name": "deleteCustomDataProvider", + "kind": "mutation", + "description": "Delete a custom Pipes data provider in the current environment (fails if an integration still uses it)", + "rootFields": [ + "deleteCustomDataProvider" + ], + "returnTypes": [ + "CustomDataProviderDeleted" + ], + "document": "mutation deleteCustomDataProvider($input: DeleteCustomDataProviderInput!) {\n deleteCustomDataProvider(input: $input) {\n __typename\n id\n }\n}", + "fragmentNames": [], + "variables": [ + { + "name": "input", + "type": "DeleteCustomDataProviderInput!", + "required": true + } + ] + }, + "deleteCustomEmailDomain": { + "name": "deleteCustomEmailDomain", + "kind": "mutation", + "description": "Delete the environment's custom email sending domain and its SendGrid configuration", + "rootFields": [ + "deleteCustomEmailDomain" + ], + "returnTypes": [ + "DeleteCustomEmailDomainResult" + ], + "document": "mutation deleteCustomEmailDomain {\n deleteCustomEmailDomain {\n ... on DomainDeletionSuccess {\n __typename\n id\n }\n }\n}", + "fragmentNames": [], + "variables": [] + }, + "deleteCustomEmailProvider": { + "name": "deleteCustomEmailProvider", + "kind": "mutation", + "description": "Delete a custom email provider configuration", + "rootFields": [ + "deleteCustomEmailProvider" + ], + "returnTypes": [ + "DeleteCustomEmailProviderResult" + ], + "document": "mutation deleteCustomEmailProvider($input: DeleteCustomEmailProviderInput!) {\n deleteCustomEmailProvider(input: $input) {\n __typename\n ... on CustomEmailProviderDeleted {\n customEmailProviderId\n }\n ... on CustomEmailProviderNotFound {\n customEmailProviderId\n }\n }\n}", + "fragmentNames": [], + "variables": [ + { + "name": "input", + "type": "DeleteCustomEmailProviderInput!", + "required": true + } + ] + }, + "deleteCustomMappedCustomAttribute": { + "name": "deleteCustomMappedCustomAttribute", + "kind": "mutation", + "description": "Delete a custom-mapped Directory Sync custom attribute in the environment", + "rootFields": [ + "deleteCustomMappedCustomAttribute" + ], + "returnTypes": [ + "DeleteCustomMappedCustomAttributeResult" + ], + "document": "mutation deleteCustomMappedCustomAttribute($input: DeleteCustomMappedCustomAttributeInput!) {\n deleteCustomMappedCustomAttribute(input: $input) {\n __typename\n ... on CustomAttributeNotFound {\n customAttributeId\n }\n ... on CustomMappedCustomAttributeDeleted {\n customAttribute {\n ...CustomMappedCustomAttribute\n }\n }\n }\n}", + "fragmentNames": [ + "CustomMappedCustomAttribute" + ], + "variables": [ + { + "name": "input", + "type": "DeleteCustomMappedCustomAttributeInput!", + "required": true + } + ] + }, + "deleteDataInstallation": { + "name": "deleteDataInstallation", + "kind": "mutation", + "description": "Delete a Pipes data installation in the current environment", + "rootFields": [ + "deleteDataInstallation" + ], + "returnTypes": [ + "DeleteDataInstallationResult" + ], + "document": "mutation deleteDataInstallation($input: DeleteDataInstallationInput!) {\n deleteDataInstallation(input: $input) {\n __typename\n ... on DataInstallationDeleted {\n id\n }\n ... on DataInstallationNotFound {\n dataInstallationId\n }\n }\n}", + "fragmentNames": [], + "variables": [ + { + "name": "input", + "type": "DeleteDataInstallationInput!", + "required": true + } + ] + }, + "deleteDataIntegration": { + "name": "deleteDataIntegration", + "kind": "mutation", + "description": "Delete a Pipes data integration in the current environment", + "rootFields": [ + "deleteDataIntegration" + ], + "returnTypes": [ + "DeleteDataIntegrationResult" + ], + "document": "mutation deleteDataIntegration($input: DeleteDataIntegrationInput!) {\n deleteDataIntegration(input: $input) {\n __typename\n ... on DataIntegrationDeleted {\n id\n }\n ... on DataIntegrationNotFound {\n dataIntegrationId\n }\n }\n}", + "fragmentNames": [], + "variables": [ + { + "name": "input", + "type": "DeleteDataIntegrationInput!", + "required": true + } + ] + }, + "deleteDirectory": { + "name": "deleteDirectory", + "kind": "mutation", + "description": "Permanently delete a Directory Sync connection and its synced users and groups", + "confirmation": "permanently deletes the directory connection and its synced state", + "featureFlagGated": true, + "rootFields": [ + "deleteDirectory" + ], + "returnTypes": [ + "DeleteDirectoryPayload" + ], + "document": "mutation deleteDirectory($input: DeleteDirectoryInput!) {\n deleteDirectory(input: $input) {\n directory {\n id\n }\n }\n}", + "fragmentNames": [], + "variables": [ + { + "name": "input", + "type": "DeleteDirectoryInput!", + "required": true + } + ] + }, + "deleteDirectoryToken": { + "name": "deleteDirectoryToken", + "kind": "mutation", + "description": "Delete a SCIM bearer token for a Directory Sync directory", + "featureFlagGated": true, + "rootFields": [ + "deleteDirectoryToken" + ], + "returnTypes": [ + "DeleteDirectoryTokenResult" + ], + "document": "mutation deleteDirectoryToken($input: DeleteDirectoryTokenInput!) {\n deleteDirectoryToken(input: $input) {\n __typename\n ... on DirectoryTokenDeleted {\n id\n }\n ... on DirectoryTokenOperationError {\n code\n message\n }\n ... on DirectoryNotFound {\n directoryId\n }\n }\n}", + "fragmentNames": [], + "variables": [ + { + "name": "input", + "type": "DeleteDirectoryTokenInput!", + "required": true + } + ] + }, + "deleteFlag": { + "name": "deleteFlag", + "kind": "mutation", + "description": "Delete a feature flag from the current environment", + "rootFields": [ + "deleteFlag" + ], + "returnTypes": [ + "DeleteFlagResult" + ], + "document": "mutation deleteFlag($input: DeleteFlagInput!) {\n deleteFlag(input: $input) {\n __typename\n ... on ProjectNotFound {\n projectId\n }\n ... on FlagNotFound {\n flagId\n }\n ... on FlagDeleted {\n flagId\n }\n }\n}", + "fragmentNames": [], + "variables": [ + { + "name": "input", + "type": "DeleteFlagInput!", + "required": true + } + ] + }, + "deleteJwtTemplate": { + "name": "deleteJwtTemplate", + "kind": "mutation", + "description": "Delete a JWT template", + "rootFields": [ + "deleteJwtTemplate" + ], + "returnTypes": [ + "DeleteJwtTemplateResult" + ], + "document": "mutation deleteJwtTemplate($input: DeleteJwtTemplateInput!) {\n deleteJwtTemplate(input: $input) {\n __typename\n ... on JwtTemplateDeleted {\n jwtTemplateId\n }\n }\n}", + "fragmentNames": [], + "variables": [ + { + "name": "input", + "type": "DeleteJwtTemplateInput!", + "required": true + } + ] + }, + "deleteOrganization": { + "name": "deleteOrganization", + "kind": "mutation", + "description": "Permanently delete an organization (queued asynchronously) in the current environment", + "confirmation": "permanently deletes the organization and cascades to its connections, directories, and users", + "rootFields": [ + "deleteOrganization" + ], + "returnTypes": [ + "DeleteOrganizationPayload" + ], + "document": "mutation deleteOrganization($input: DeleteOrganizationInput!) {\n deleteOrganization(input: $input) {\n organization {\n id\n }\n }\n}", + "fragmentNames": [], + "variables": [ + { + "name": "input", + "type": "DeleteOrganizationInput!", + "required": true + } + ] + }, + "deleteOrganizationAdminMembership": { + "name": "deleteOrganizationAdminMembership", + "kind": "mutation", + "description": "Remove an admin user's membership from a specific organization", + "rootFields": [ + "deleteOrganizationAdminMembership" + ], + "returnTypes": [ + "DeleteOrganizationAdminMembershipResult" + ], + "document": "mutation deleteOrganizationAdminMembership($input: DeleteOrganizationAdminMembershipInput!) {\n deleteOrganizationAdminMembership(input: $input) {\n __typename\n ... on OrganizationAdminMembershipDeleted {\n organizationAdminId\n }\n ... on OrganizationAdminNotFound {\n organizationAdminId\n organizationId\n }\n ... on OrganizationNotFound {\n organizationId\n }\n }\n}", + "fragmentNames": [], + "variables": [ + { + "name": "input", + "type": "DeleteOrganizationAdminMembershipInput!", + "required": true + } + ] + }, + "deleteOrganizationDomain": { + "name": "deleteOrganizationDomain", + "kind": "mutation", + "description": "Delete a domain from an organization", + "rootFields": [ + "deleteOrganizationDomain" + ], + "returnTypes": [ + "OrganizationDomain" + ], + "document": "mutation deleteOrganizationDomain($id: String!) {\n deleteOrganizationDomain(id: $id) {\n id\n }\n}", + "fragmentNames": [], + "variables": [ + { + "name": "id", + "type": "String!", + "required": true + } + ] + }, + "deletePermission": { + "name": "deletePermission", + "kind": "mutation", + "description": "Delete a custom RBAC permission from the environment (system permissions cannot be deleted)", + "rootFields": [ + "deletePermission" + ], + "returnTypes": [ + "DeletePermissionResult" + ], + "document": "mutation deletePermission($input: DeletePermissionInput!) {\n deletePermission(input: $input) {\n __typename\n ... on EnvironmentNotFound {\n environmentId\n }\n ... on PermissionNotFound {\n permissionId\n }\n ... on PermissionDeleted {\n permissionId\n }\n }\n}", + "fragmentNames": [], + "variables": [ + { + "name": "input", + "type": "DeletePermissionInput!", + "required": true + } + ] + }, + "deleteResourceType": { + "name": "deleteResourceType", + "kind": "mutation", + "description": "Delete an FGA authorization resource type from the environment", + "rootFields": [ + "deleteResourceType" + ], + "returnTypes": [ + "DeleteAuthorizationResourceTypeResult" + ], + "document": "mutation deleteResourceType($input: DeleteAuthorizationResourceTypeInput!) {\n deleteResourceType(input: $input) {\n __typename\n ... on AuthorizationResourceTypeDeleted {\n __typename\n resourceTypeId\n }\n ... on AuthorizationResourceTypeNotFound {\n __typename\n resourceTypeId\n }\n }\n}", + "fragmentNames": [], + "variables": [ + { + "name": "input", + "type": "DeleteAuthorizationResourceTypeInput!", + "required": true + } + ] + }, + "deleteRole": { + "name": "deleteRole", + "kind": "mutation", + "description": "Delete a role, optionally reassigning members to a replacement default role", + "rootFields": [ + "deleteRole" + ], + "returnTypes": [ + "DeleteRoleResult" + ], + "document": "mutation deleteRole($input: DeleteRoleInput!) {\n deleteRole(input: $input) {\n __typename\n ... on EnvironmentNotFound {\n environmentId\n }\n ... on RoleNotFound {\n roleId\n }\n ... on RoleDeleted {\n roleConfig {\n id\n defaultRole\n rolePriorityOrder\n }\n }\n }\n}", + "fragmentNames": [], + "variables": [ + { + "name": "input", + "type": "DeleteRoleInput!", + "required": true + } + ] + }, + "deleteSamlX509Certificate": { + "name": "deleteSamlX509Certificate", + "kind": "mutation", + "description": "Delete a SAML X.509 certificate from an SSO connection and re-verify the connection", + "rootFields": [ + "deleteSamlX509Certificate" + ], + "returnTypes": [ + "DeleteSamlX509CertificateResult" + ], + "document": "mutation deleteSamlX509Certificate($input: DeleteSamlX509CertificateInput!) {\n deleteSamlX509Certificate(input: $input) {\n ... on SamlX509CertificateDeleted {\n samlX509Certificate {\n id\n }\n }\n ... on SamlX509CertificateNotFound {\n samlX509CertificateId\n }\n }\n}", + "fragmentNames": [], + "variables": [ + { + "name": "input", + "type": "DeleteSamlX509CertificateInput!", + "required": true + } + ] + }, + "deleteUserlandUser": { + "name": "deleteUserlandUser", + "kind": "mutation", + "description": "Permanently delete an AuthKit end user", + "confirmation": "permanently deletes the end user", + "rootFields": [ + "deleteUserlandUser" + ], + "returnTypes": [ + "DeleteUserlandUserResult" + ], + "document": "mutation deleteUserlandUser($input: DeleteUserlandUserInput!) {\n deleteUserlandUser(input: $input) {\n __typename\n ... on UserlandUserNotFound {\n __typename\n }\n ... on UserlandUserDeleted {\n __typename\n }\n }\n}", + "fragmentNames": [], + "variables": [ + { + "name": "input", + "type": "DeleteUserlandUserInput!", + "required": true + } + ] + }, + "deleteWaitlistEntry": { + "name": "deleteWaitlistEntry", + "kind": "mutation", + "description": "Delete a waitlist entry from the environment", + "rootFields": [ + "deleteWaitlistEntry" + ], + "returnTypes": [ + "Boolean" + ], + "document": "mutation deleteWaitlistEntry($input: DeleteWaitlistEntryInput!) {\n deleteWaitlistEntry(input: $input)\n}", + "fragmentNames": [], + "variables": [ + { + "name": "input", + "type": "DeleteWaitlistEntryInput!", + "required": true + } + ] + }, + "deleteWebhookEndpoint": { + "name": "deleteWebhookEndpoint", + "kind": "mutation", + "description": "Delete a webhook endpoint so it stops receiving events", + "rootFields": [ + "deleteWebhookEndpoint" + ], + "returnTypes": [ + "String" + ], + "document": "mutation deleteWebhookEndpoint($id: String!) {\n deleteWebhookEndpoint(id: $id)\n}", + "fragmentNames": [], + "variables": [ + { + "name": "id", + "type": "String!", + "required": true + } + ] + }, + "denyWaitlistEntry": { + "name": "denyWaitlistEntry", + "kind": "mutation", + "description": "Deny a pending waitlist entry", + "rootFields": [ + "denyWaitlistEntry" + ], + "returnTypes": [ + "DenyWaitlistEntryResult" + ], + "document": "mutation denyWaitlistEntry($input: DenyWaitlistEntryInput!) {\n denyWaitlistEntry(input: $input) {\n ... on WaitlistEntryDenied {\n waitlistEntry {\n id\n email\n state\n approvedAt\n createdAt\n updatedAt\n }\n }\n ... on WaitlistEntryDenyError {\n message\n }\n }\n}", + "fragmentNames": [], + "variables": [ + { + "name": "input", + "type": "DenyWaitlistEntryInput!", + "required": true + } + ] + }, + "DeprovisionPlatform": { + "name": "DeprovisionPlatform", + "kind": "mutation", + "description": "Deprovision a platform integration from the current team", + "rootFields": [ + "deprovisionPlatform" + ], + "returnTypes": [ + "DeprovisionPlatformResult" + ], + "document": "mutation DeprovisionPlatform($input: DeprovisionPlatformInput!) {\n deprovisionPlatform(input: $input) {\n __typename\n ... on PlatformDeprovisioned {\n teamId\n platform\n }\n ... on TeamPlatformNotFound {\n teamId\n platform\n }\n }\n}", + "fragmentNames": [], + "variables": [ + { + "name": "input", + "type": "DeprovisionPlatformInput!", + "required": true + } + ] + }, + "descendantTypesForResourceType": { + "name": "descendantTypesForResourceType", + "kind": "query", + "description": "List the descendant resource types of an FGA authorization resource type", + "rootFields": [ + "descendantTypesForResourceType" + ], + "returnTypes": [ + "AuthorizationResourceTypeList" + ], + "document": "query descendantTypesForResourceType($resourceTypeId: ID!, $loadTopologies: Boolean = false) {\n descendantTypesForResourceType(\n resourceTypeId: $resourceTypeId\n loadTopologies: $loadTopologies\n ) {\n resourceTypes {\n id\n name\n slug\n description\n childTypeIds\n parentTypeIds\n }\n }\n}", + "fragmentNames": [], + "variables": [ + { + "name": "resourceTypeId", + "type": "ID!", + "required": true + }, + { + "name": "loadTopologies", + "type": "Boolean", + "required": false, + "defaultValue": "false" + } + ] + }, + "directoriesForOrganization": { + "name": "directoriesForOrganization", + "kind": "query", + "description": "List an organization's Directory Sync connections", + "rootFields": [ + "organization" + ], + "returnTypes": [ + "Organization" + ], + "document": "query directoriesForOrganization($organizationId: String!, $before: String, $after: String, $limit: Int, $search: String) {\n organization(id: $organizationId) {\n directories(before: $before, after: $after, limit: $limit, search: $search) {\n data {\n __typename\n ... on Directory {\n id\n type\n name\n state\n createdAt\n }\n ... on UntypedDirectory {\n ...UntypedDirectory\n }\n }\n listMetadata {\n before\n after\n }\n }\n }\n}", + "fragmentNames": [ + "UntypedDirectory" + ], + "variables": [ + { + "name": "organizationId", + "type": "String!", + "required": true + }, + { + "name": "before", + "type": "String", + "required": false + }, + { + "name": "after", + "type": "String", + "required": false + }, + { + "name": "limit", + "type": "Int", + "required": false + }, + { + "name": "search", + "type": "String", + "required": false + } + ] + }, + "directory": { + "name": "directory", + "kind": "query", + "description": "Return a Directory Sync connection by ID", + "rootFields": [ + "directory" + ], + "returnTypes": [ + "DirectoryOrUntypedDirectory" + ], + "document": "query directory($id: String!, $includeLastFinalizedDirectorySyncRun: Boolean = false) {\n directory(id: $id) {\n __typename\n ... on Directory {\n ...Directory\n lastFinalizedDirectorySyncRun @include(if: $includeLastFinalizedDirectorySyncRun) {\n ...DirectorySyncRunWithInternalErrors\n }\n }\n ... on UntypedDirectory {\n ...UntypedDirectory\n }\n }\n}", + "fragmentNames": [ + "BambooHrDirectory", + "BreatheHrDirectory", + "CezanneHrDirectory", + "Directory", + "DirectoryAttributeMap", + "DirectoryConfig", + "DirectorySyncRunWithInternalErrors", + "FourthHrDirectory", + "GoogleWorkspaceDirectory", + "HibobDirectory", + "OrganizationDomain", + "PeopleHrDirectory", + "PersonioDirectory", + "RipplingDirectory", + "SftpDirectory", + "UntypedDirectory", + "WorkdayDirectory" + ], + "variables": [ + { + "name": "id", + "type": "String!", + "required": true + }, + { + "name": "includeLastFinalizedDirectorySyncRun", + "type": "Boolean", + "required": false, + "defaultValue": "false" + } + ] + }, + "directoryCustomAttributeMappings": { + "name": "directoryCustomAttributeMappings", + "kind": "query", + "description": "List custom attribute mappings configured for a directory", + "rootFields": [ + "directoryCustomAttributeMappings" + ], + "returnTypes": [ + "DirectoryCustomAttributeMappingList" + ], + "document": "query directoryCustomAttributeMappings($directoryId: String!) {\n directoryCustomAttributeMappings(directoryId: $directoryId) {\n data {\n ...DirectoryCustomAttributeMappings\n }\n }\n}", + "fragmentNames": [ + "DirectoryCustomAttributeMappings" + ], + "variables": [ + { + "name": "directoryId", + "type": "String!", + "required": true + } + ] + }, + "directoryCustomAttributes": { + "name": "directoryCustomAttributes", + "kind": "query", + "description": "List the Directory Sync custom attribute mappings configured for an environment", + "rootFields": [ + "directoryCustomAttributes" + ], + "returnTypes": [ + "DirectoryCustomAttributeList" + ], + "document": "query directoryCustomAttributes($environmentId: String!) {\n directoryCustomAttributes(environmentId: $environmentId) {\n data {\n ...DirectoryCustomAttribute\n }\n }\n}", + "fragmentNames": [ + "DirectoryCustomAttribute" + ], + "variables": [ + { + "name": "environmentId", + "type": "String!", + "required": true + } + ] + }, + "directoryEvents": { + "name": "directoryEvents", + "kind": "query", + "description": "List recent directory sync events for a Directory Sync connection", + "rootFields": [ + "directory" + ], + "returnTypes": [ + "DirectoryOrUntypedDirectory" + ], + "document": "query directoryEvents($directoryId: String!, $directoryGroupId: String, $directoryUserId: String, $rangeStart: DateTime, $rangeEnd: DateTime, $names: [String!], $search: String, $before: String, $after: String, $limit: Int) {\n directory(id: $directoryId) {\n __typename\n ... on Directory {\n events(\n directoryGroupId: $directoryGroupId\n directoryUserId: $directoryUserId\n rangeStart: $rangeStart\n rangeEnd: $rangeEnd\n names: $names\n search: $search\n before: $before\n after: $after\n limit: $limit\n ) {\n data {\n id\n name\n data\n createdAt\n updatedAt\n }\n listMetadata {\n before\n after\n }\n }\n }\n }\n}", + "fragmentNames": [], + "variables": [ + { + "name": "directoryId", + "type": "String!", + "required": true + }, + { + "name": "directoryGroupId", + "type": "String", + "required": false + }, + { + "name": "directoryUserId", + "type": "String", + "required": false + }, + { + "name": "rangeStart", + "type": "DateTime", + "required": false + }, + { + "name": "rangeEnd", + "type": "DateTime", + "required": false + }, + { + "name": "names", + "type": "[String!]", + "required": false + }, + { + "name": "search", + "type": "String", + "required": false + }, + { + "name": "before", + "type": "String", + "required": false + }, + { + "name": "after", + "type": "String", + "required": false + }, + { + "name": "limit", + "type": "Int", + "required": false + } + ] + }, + "DirectoryGroupConfigsSummaryDashboard": { + "name": "DirectoryGroupConfigsSummaryDashboard", + "kind": "query", + "description": "Return the total and currently-syncing group counts for a directory", + "rootFields": [ + "directoryGroupConfigsSummaryDashboard" + ], + "returnTypes": [ + "DirectoryGroupConfigSummary" + ], + "document": "query DirectoryGroupConfigsSummaryDashboard($directoryId: String!) {\n directoryGroupConfigsSummaryDashboard(id: $directoryId) {\n numberOfGroupsInDirectory\n numberOfSyncingGroupsInDirectory\n }\n}", + "fragmentNames": [], + "variables": [ + { + "name": "directoryId", + "type": "String!", + "required": true + } + ] + }, + "directoryGroups": { + "name": "directoryGroups", + "kind": "query", + "description": "List the groups synced by a Directory Sync connection", + "rootFields": [ + "directory" + ], + "returnTypes": [ + "DirectoryOrUntypedDirectory" + ], + "document": "query directoryGroups($directoryId: String!, $before: String, $after: String, $limit: Int, $search: String) {\n directory(id: $directoryId) {\n __typename\n ... on Directory {\n directoryGroups(before: $before, after: $after, limit: $limit, search: $search) {\n data {\n ...DirectoryGroupWithRoleMapping\n }\n listMetadata {\n before\n after\n }\n }\n }\n }\n}", + "fragmentNames": [ + "DirectoryGroupWithRoleMapping" + ], + "variables": [ + { + "name": "directoryId", + "type": "String!", + "required": true + }, + { + "name": "before", + "type": "String", + "required": false + }, + { + "name": "after", + "type": "String", + "required": false + }, + { + "name": "limit", + "type": "Int", + "required": false + }, + { + "name": "search", + "type": "String", + "required": false + } + ] + }, + "directoryGroupsWithRoleMappings": { + "name": "directoryGroupsWithRoleMappings", + "kind": "query", + "description": "List the groups synced into a Directory Sync directory, filterable by name search, member count, and role", + "rootFields": [ + "groupsForDirectory" + ], + "returnTypes": [ + "DirectoryGroupsList" + ], + "document": "query directoryGroupsWithRoleMappings($directoryId: String!, $after: String, $before: String, $limit: Int) {\n groupsForDirectory(\n directoryId: $directoryId\n after: $after\n before: $before\n limit: $limit\n ) {\n __typename\n data {\n ...DirectoryGroupWithRoleMapping\n }\n listMetadata {\n before\n after\n }\n }\n}", + "fragmentNames": [ + "DirectoryGroupWithRoleMapping" + ], + "variables": [ + { + "name": "directoryId", + "type": "String!", + "required": true + }, + { + "name": "after", + "type": "String", + "required": false + }, + { + "name": "before", + "type": "String", + "required": false + }, + { + "name": "limit", + "type": "Int", + "required": false + } + ] + }, + "directoryLastFinalizedDirectorySyncRun": { + "name": "directoryLastFinalizedDirectorySyncRun", + "kind": "query", + "description": "Return a Directory Sync connection by ID", + "rootFields": [ + "directory" + ], + "returnTypes": [ + "DirectoryOrUntypedDirectory" + ], + "document": "query directoryLastFinalizedDirectorySyncRun($directoryId: String!) {\n directory(id: $directoryId) {\n __typename\n ... on Directory {\n lastFinalizedDirectorySyncRun {\n ...DirectorySyncRunWithInternalErrors\n }\n }\n }\n}", + "fragmentNames": [ + "DirectorySyncRunWithInternalErrors" + ], + "variables": [ + { + "name": "directoryId", + "type": "String!", + "required": true + } + ] + }, + "directorySummary": { + "name": "directorySummary", + "kind": "query", + "description": "Return a summary of a Directory Sync connection (counts and status)", + "rootFields": [ + "directory" + ], + "returnTypes": [ + "DirectoryOrUntypedDirectory" + ], + "document": "query directorySummary($directoryId: String!, $limit: Int = 1, $state: DirectorySyncRunState) {\n directory(id: $directoryId) {\n __typename\n ... on Directory {\n ...DirectorySummary\n }\n }\n}", + "fragmentNames": [ + "DirectorySummary", + "DirectorySyncRun" + ], + "variables": [ + { + "name": "directoryId", + "type": "String!", + "required": true + }, + { + "name": "limit", + "type": "Int", + "required": false, + "defaultValue": "1" + }, + { + "name": "state", + "type": "DirectorySyncRunState", + "required": false + } + ] + }, + "directoryUser": { + "name": "directoryUser", + "kind": "query", + "description": "Return a single Directory Sync user by ID within a directory and environment", + "rootFields": [ + "directoryUser" + ], + "returnTypes": [ + "DirectoryUser" + ], + "document": "query directoryUser($id: String!, $directoryId: String!, $environmentId: String!) {\n directoryUser(id: $id, directoryId: $directoryId, environmentId: $environmentId) {\n ...DirectoryUserWithRole\n json\n }\n}", + "fragmentNames": [ + "DirectoryUserWithRole" + ], + "variables": [ + { + "name": "id", + "type": "String!", + "required": true + }, + { + "name": "directoryId", + "type": "String!", + "required": true + }, + { + "name": "environmentId", + "type": "String!", + "required": true + } + ] + }, + "directoryUsers": { + "name": "directoryUsers", + "kind": "query", + "description": "List the users synced by a Directory Sync connection", + "rootFields": [ + "directory" + ], + "returnTypes": [ + "DirectoryOrUntypedDirectory" + ], + "document": "query directoryUsers($directoryId: String!, $before: String, $after: String, $limit: Int, $search: String, $group: String, $role: String, $groups: [String!], $roles: [String!], $state: DirectoryUserState) {\n directory(id: $directoryId) {\n __typename\n ... on Directory {\n directoryUsers(\n before: $before\n after: $after\n limit: $limit\n search: $search\n group: $group\n role: $role\n groups: $groups\n roles: $roles\n state: $state\n ) {\n data {\n ...DirectoryUserWithRole\n }\n listMetadata {\n before\n after\n }\n }\n }\n }\n}", + "fragmentNames": [ + "DirectoryUserWithRole" + ], + "variables": [ + { + "name": "directoryId", + "type": "String!", + "required": true + }, + { + "name": "before", + "type": "String", + "required": false + }, + { + "name": "after", + "type": "String", + "required": false + }, + { + "name": "limit", + "type": "Int", + "required": false + }, + { + "name": "search", + "type": "String", + "required": false + }, + { + "name": "group", + "type": "String", + "required": false + }, + { + "name": "role", + "type": "String", + "required": false + }, + { + "name": "groups", + "type": "[String!]", + "required": false + }, + { + "name": "roles", + "type": "[String!]", + "required": false + }, + { + "name": "state", + "type": "DirectoryUserState", + "required": false + } + ] + }, + "directoryUsersCount": { + "name": "directoryUsersCount", + "kind": "query", + "description": "Return the count of users synced by a Directory Sync connection", + "rootFields": [ + "directory" + ], + "returnTypes": [ + "DirectoryOrUntypedDirectory" + ], + "document": "query directoryUsersCount($directoryId: String!, $search: String, $group: String, $role: String, $groups: [String!], $roles: [String!], $state: DirectoryUserState) {\n directory(id: $directoryId) {\n __typename\n ... on Directory {\n directoryUsersCount(\n search: $search\n group: $group\n role: $role\n groups: $groups\n roles: $roles\n state: $state\n )\n }\n }\n}", + "fragmentNames": [], + "variables": [ + { + "name": "directoryId", + "type": "String!", + "required": true + }, + { + "name": "search", + "type": "String", + "required": false + }, + { + "name": "group", + "type": "String", + "required": false + }, + { + "name": "role", + "type": "String", + "required": false + }, + { + "name": "groups", + "type": "[String!]", + "required": false + }, + { + "name": "roles", + "type": "[String!]", + "required": false + }, + { + "name": "state", + "type": "DirectoryUserState", + "required": false + } + ] + }, + "directoryWithOrganizationConnections": { + "name": "directoryWithOrganizationConnections", + "kind": "query", + "description": "Return a summary of a Directory Sync connection (counts and status)", + "rootFields": [ + "directory" + ], + "returnTypes": [ + "DirectoryOrUntypedDirectory" + ], + "document": "query directoryWithOrganizationConnections($id: String!, $includeLastFinalizedDirectorySyncRun: Boolean = false) {\n directory(id: $id) {\n __typename\n ... on Directory {\n ...DirectoryWithOrganizationConnections\n lastFinalizedDirectorySyncRun @include(if: $includeLastFinalizedDirectorySyncRun) {\n ...DirectorySyncRunWithInternalErrors\n }\n }\n ... on UntypedDirectory {\n ...UntypedDirectory\n }\n }\n}", + "fragmentNames": [ + "AttributeSchema", + "BambooHrDirectory", + "BreatheHrDirectory", + "CezanneHrDirectory", + "DirectoryAttributeMap", + "DirectoryConfig", + "DirectorySyncRunWithInternalErrors", + "DirectoryWithOrganizationConnections", + "FourthHrDirectory", + "GoogleWorkspaceDirectory", + "HibobDirectory", + "OrganizationDomain", + "PeopleHrDirectory", + "PersonioDirectory", + "RipplingDirectory", + "SftpDirectory", + "UntypedDirectory", + "WorkdayDirectory" + ], + "variables": [ + { + "name": "id", + "type": "String!", + "required": true + }, + { + "name": "includeLastFinalizedDirectorySyncRun", + "type": "Boolean", + "required": false, + "defaultValue": "false" + } + ] + }, + "DisconnectSlack": { + "name": "DisconnectSlack", + "kind": "mutation", + "description": "Remove the Slack AuthKit add-on integration from an environment", + "rootFields": [ + "disconnectSlack" + ], + "returnTypes": [ + "DisconnectSlackResult" + ], + "document": "mutation DisconnectSlack($environmentId: ID!) {\n disconnectSlack(environmentId: $environmentId) {\n success\n }\n}", + "fragmentNames": [], + "variables": [ + { + "name": "environmentId", + "type": "ID!", + "required": true + } + ] + }, + "emailNotificationPreferences": { + "name": "emailNotificationPreferences", + "kind": "query", + "description": "Return the current dashboard user's email notification subscribe/unsubscribe preferences", + "rootFields": [ + "emailNotificationPreferences" + ], + "returnTypes": [ + "EmailNotificationPreferences" + ], + "document": "query emailNotificationPreferences {\n emailNotificationPreferences {\n preferences {\n category\n preference\n }\n }\n}", + "fragmentNames": [], + "variables": [] + }, + "environmentAppBranding": { + "name": "environmentAppBranding", + "kind": "query", + "description": "Return an environment's AuthKit branding", + "rootFields": [ + "environment" + ], + "returnTypes": [ + "Environment" + ], + "document": "query environmentAppBranding($environmentId: String!) {\n environment(id: $environmentId) {\n appBranding {\n ...AppBranding\n }\n }\n}", + "fragmentNames": [ + "AppBranding" + ], + "variables": [ + { + "name": "environmentId", + "type": "String!", + "required": true + } + ] + }, + "environmentApplications": { + "name": "environmentApplications", + "kind": "query", + "description": "List the Connect applications in an environment", + "rootFields": [ + "environment" + ], + "returnTypes": [ + "Environment" + ], + "document": "query environmentApplications($environmentId: String!, $search: String, $before: String, $after: String, $limit: Int, $type: ApplicationIntegrationType, $registration: ApplicationRegistration) {\n environment(id: $environmentId) {\n applications(\n search: $search\n before: $before\n after: $after\n limit: $limit\n type: $type\n registration: $registration\n ) {\n data {\n ...Application\n }\n listMetadata {\n before\n after\n }\n }\n }\n}", + "fragmentNames": [ + "Application" + ], + "variables": [ + { + "name": "environmentId", + "type": "String!", + "required": true + }, + { + "name": "search", + "type": "String", + "required": false + }, + { + "name": "before", + "type": "String", + "required": false + }, + { + "name": "after", + "type": "String", + "required": false + }, + { + "name": "limit", + "type": "Int", + "required": false + }, + { + "name": "type", + "type": "ApplicationIntegrationType", + "required": false + }, + { + "name": "registration", + "type": "ApplicationRegistration", + "required": false + } + ] + }, + "environmentEvents": { + "name": "environmentEvents", + "kind": "query", + "description": "List recent domain events for an environment", + "rootFields": [ + "environment" + ], + "returnTypes": [ + "Environment" + ], + "document": "query environmentEvents($environmentId: String!, $names: [String!], $directoryId: String, $directoryGroupId: String, $directoryUserId: String, $userId: String, $rangeStart: DateTime, $rangeEnd: DateTime, $search: String, $before: String, $after: String, $limit: Int) {\n environment(id: $environmentId) {\n events(\n directoryId: $directoryId\n names: $names\n directoryGroupId: $directoryGroupId\n directoryUserId: $directoryUserId\n userId: $userId\n rangeStart: $rangeStart\n rangeEnd: $rangeEnd\n search: $search\n before: $before\n after: $after\n limit: $limit\n ) {\n data {\n id\n name\n data\n context\n createdAt\n updatedAt\n metadata\n }\n listMetadata {\n before\n after\n }\n }\n }\n}", + "fragmentNames": [], + "variables": [ + { + "name": "environmentId", + "type": "String!", + "required": true + }, + { + "name": "names", + "type": "[String!]", + "required": false + }, + { + "name": "directoryId", + "type": "String", + "required": false + }, + { + "name": "directoryGroupId", + "type": "String", + "required": false + }, + { + "name": "directoryUserId", + "type": "String", + "required": false + }, + { + "name": "userId", + "type": "String", + "required": false + }, + { + "name": "rangeStart", + "type": "DateTime", + "required": false + }, + { + "name": "rangeEnd", + "type": "DateTime", + "required": false + }, + { + "name": "search", + "type": "String", + "required": false + }, + { + "name": "before", + "type": "String", + "required": false + }, + { + "name": "after", + "type": "String", + "required": false + }, + { + "name": "limit", + "type": "Int", + "required": false + } + ] + }, + "environmentSentEmails": { + "name": "environmentSentEmails", + "kind": "query", + "description": "List emails AuthKit has sent in an environment", + "rootFields": [ + "environment" + ], + "returnTypes": [ + "Environment" + ], + "document": "query environmentSentEmails($environmentId: String!, $before: String, $after: String, $limit: Int, $order: PaginationOrder) {\n environment(id: $environmentId) {\n sentEmails(before: $before, after: $after, limit: $limit, order: $order) {\n data {\n id\n to\n status\n subject\n timestamp\n events {\n type\n subType\n message\n timestamp\n }\n }\n listMetadata {\n before\n after\n }\n }\n }\n}", + "fragmentNames": [], + "variables": [ + { + "name": "environmentId", + "type": "String!", + "required": true + }, + { + "name": "before", + "type": "String", + "required": false + }, + { + "name": "after", + "type": "String", + "required": false + }, + { + "name": "limit", + "type": "Int", + "required": false + }, + { + "name": "order", + "type": "PaginationOrder", + "required": false + } + ] + }, + "event": { + "name": "event", + "kind": "query", + "description": "Return a single domain event in an environment by ID", + "rootFields": [ + "environment" + ], + "returnTypes": [ + "Environment" + ], + "document": "query event($environmentId: String!, $eventId: String!) {\n environment(id: $environmentId) {\n event(id: $eventId) {\n ...Event\n }\n }\n}", + "fragmentNames": [ + "Event" + ], + "variables": [ + { + "name": "environmentId", + "type": "String!", + "required": true + }, + { + "name": "eventId", + "type": "String!", + "required": true + } + ] + }, + "eventWithWebhooks": { + "name": "eventWithWebhooks", + "kind": "query", + "description": "Return a single domain event in an environment by ID", + "rootFields": [ + "environment" + ], + "returnTypes": [ + "Environment" + ], + "document": "query eventWithWebhooks($environmentId: String!, $eventId: String!) {\n environment(id: $environmentId) {\n event(id: $eventId) {\n ...Event\n webhooks {\n data {\n id\n state\n deliverAfter\n attempt\n event\n metadata\n webhookEndpointId\n }\n }\n }\n }\n}", + "fragmentNames": [ + "Event" + ], + "variables": [ + { + "name": "environmentId", + "type": "String!", + "required": true + }, + { + "name": "eventId", + "type": "String!", + "required": true + } + ] + }, + "expireApiKey": { + "name": "expireApiKey", + "kind": "mutation", + "description": "Set an expiration time on an API key so it stops working after that point", + "rootFields": [ + "expireApiKey" + ], + "returnTypes": [ + "ExpireApiKeyPayload" + ], + "document": "mutation expireApiKey($input: ExpireApiKeyInput!) {\n expireApiKey(input: $input) {\n apiKey {\n id\n name\n organizationId\n obfuscatedValue\n lastUsedAt\n createdAt\n expiresAt\n }\n }\n}", + "fragmentNames": [], + "variables": [ + { + "name": "input", + "type": "ExpireApiKeyInput!", + "required": true + } + ] + }, + "expirePortalSetupLinks": { + "name": "expirePortalSetupLinks", + "kind": "mutation", + "description": "Expire an organization's active Admin Portal setup links", + "rootFields": [ + "expirePortalSetupLinks" + ], + "returnTypes": [ + "ExpirePortalSetupLinksResult" + ], + "document": "mutation expirePortalSetupLinks($input: ExpirePortalSetupLinksInput!) {\n expirePortalSetupLinks(input: $input) {\n __typename\n ... on PortalSetupLinksExpired {\n organization {\n id\n }\n }\n ... on OrganizationNotFound {\n organizationId\n }\n }\n}", + "fragmentNames": [], + "variables": [ + { + "name": "input", + "type": "ExpirePortalSetupLinksInput!", + "required": true + } + ] + }, + "flag": { + "name": "flag", + "kind": "query", + "description": "Return a feature flag by its ID", + "rootFields": [ + "flag" + ], + "returnTypes": [ + "Flag" + ], + "document": "query flag($id: ID!) {\n flag(id: $id) {\n ...Flag\n }\n}", + "fragmentNames": [ + "Flag" + ], + "variables": [ + { + "name": "id", + "type": "ID!", + "required": true + } + ] + }, + "flagBySlug": { + "name": "flagBySlug", + "kind": "query", + "description": "Return a feature flag by slug within a project", + "rootFields": [ + "flagBySlug" + ], + "returnTypes": [ + "Flag" + ], + "document": "query flagBySlug($projectId: String!, $slug: String!) {\n flagBySlug(projectId: $projectId, slug: $slug) {\n ...Flag\n }\n}", + "fragmentNames": [ + "Flag" + ], + "variables": [ + { + "name": "projectId", + "type": "String!", + "required": true + }, + { + "name": "slug", + "type": "String!", + "required": true + } + ] + }, + "flags": { + "name": "flags", + "kind": "query", + "description": "List the feature flags in a project", + "rootFields": [ + "flagsForProject" + ], + "returnTypes": [ + "FlagList" + ], + "document": "query flags($projectId: String!, $status: String, $search: String, $tags: [String!], $rangeStart: DateTime, $rangeEnd: DateTime, $before: String, $after: String, $limit: Int, $order: PaginationOrder) {\n flagsForProject(\n projectId: $projectId\n status: $status\n search: $search\n tags: $tags\n rangeStart: $rangeStart\n rangeEnd: $rangeEnd\n before: $before\n after: $after\n limit: $limit\n order: $order\n ) {\n data {\n ...FlagListItem\n }\n listMetadata {\n before\n after\n }\n }\n}", + "fragmentNames": [ + "FlagListItem" + ], + "variables": [ + { + "name": "projectId", + "type": "String!", + "required": true + }, + { + "name": "status", + "type": "String", + "required": false + }, + { + "name": "search", + "type": "String", + "required": false + }, + { + "name": "tags", + "type": "[String!]", + "required": false + }, + { + "name": "rangeStart", + "type": "DateTime", + "required": false + }, + { + "name": "rangeEnd", + "type": "DateTime", + "required": false + }, + { + "name": "before", + "type": "String", + "required": false + }, + { + "name": "after", + "type": "String", + "required": false + }, + { + "name": "limit", + "type": "Int", + "required": false + }, + { + "name": "order", + "type": "PaginationOrder", + "required": false + } + ] + }, + "generatePortalSetupLink": { + "name": "generatePortalSetupLink", + "kind": "mutation", + "description": "Generate an Admin Portal setup link for an organization, expiring prior links of the same intent", + "rootFields": [ + "generatePortalSetupLink" + ], + "returnTypes": [ + "GeneratePortalSetupLinkResult" + ], + "document": "mutation generatePortalSetupLink($input: GeneratePortalSetupLinkInput!) {\n generatePortalSetupLink(input: $input) {\n __typename\n ... on PortalSetupLinkGenerated {\n portalSetupLink {\n ...PortalSetupLink\n }\n }\n ... on OrganizationNotFound {\n organizationId\n }\n }\n}", + "fragmentNames": [ + "PortalSetupLink" + ], + "variables": [ + { + "name": "input", + "type": "GeneratePortalSetupLinkInput!", + "required": true + } + ] + }, + "generateSharedSlackChannel": { + "name": "generateSharedSlackChannel", + "kind": "mutation", + "description": "Create a shared Slack support channel for the current team", + "rootFields": [ + "generateSharedSlackChannel" + ], + "returnTypes": [ + "GenerateSharedSlackChannelResult" + ], + "document": "mutation generateSharedSlackChannel {\n generateSharedSlackChannel {\n __typename\n ... on SharedSlackChannelGenerated {\n slackChannel {\n ...SlackChannel\n }\n }\n }\n}", + "fragmentNames": [ + "SlackChannel" + ], + "variables": [] + }, + "GenerateSlackInstallUrl": { + "name": "GenerateSlackInstallUrl", + "kind": "mutation", + "description": "Return a Slack install URL to connect the AuthKit add-on for an environment", + "rootFields": [ + "generateSlackInstallUrl" + ], + "returnTypes": [ + "SlackInstallUrl" + ], + "document": "mutation GenerateSlackInstallUrl($environmentId: ID!) {\n generateSlackInstallUrl(environmentId: $environmentId) {\n url\n }\n}", + "fragmentNames": [], + "variables": [ + { + "name": "environmentId", + "type": "ID!", + "required": true + } + ] + }, + "getDatadogActivityStream": { + "name": "getDatadogActivityStream", + "kind": "query", + "description": "Return the Datadog activity-stream webhook endpoint configured for an environment", + "rootFields": [ + "datadogWebhookEndpoint" + ], + "returnTypes": [ + "DatadogWebhookEndpoint" + ], + "document": "query getDatadogActivityStream($environmentId: String!) {\n datadogWebhookEndpoint(environmentId: $environmentId) {\n id\n region\n healthy\n }\n}", + "fragmentNames": [], + "variables": [ + { + "name": "environmentId", + "type": "String!", + "required": true + } + ] + }, + "hasEnvironmentUsedAllowProfilesOutsideOrganization": { + "name": "hasEnvironmentUsedAllowProfilesOutsideOrganization", + "kind": "query", + "description": "Return whether an environment has ever used the SSO 'allow profiles outside organization' setting", + "rootFields": [ + "environment" + ], + "returnTypes": [ + "Environment" + ], + "document": "query hasEnvironmentUsedAllowProfilesOutsideOrganization($environmentId: String!) {\n environment(id: $environmentId) {\n hasUsedAllowProfilesOutsideOrganization\n }\n}", + "fragmentNames": [], + "variables": [ + { + "name": "environmentId", + "type": "String!", + "required": true + } + ] + }, + "idpAttributes": { + "name": "idpAttributes", + "kind": "query", + "description": "List the identity-provider attributes resolved for an environment's IdP attribute configuration", + "rootFields": [ + "idpAttributes" + ], + "returnTypes": [ + "IdpAttribute" + ], + "document": "query idpAttributes($environmentId: String!) {\n idpAttributes(environmentId: $environmentId) {\n ...IdpAttribute\n }\n}", + "fragmentNames": [ + "IdpAttribute" + ], + "variables": [ + { + "name": "environmentId", + "type": "String!", + "required": true + } + ] + }, + "idpAttributesConfigForEnvironment": { + "name": "idpAttributesConfigForEnvironment", + "kind": "query", + "description": "Return the environment-level IdP attribute mapping configuration", + "rootFields": [ + "idpAttributesConfigForEnvironment" + ], + "returnTypes": [ + "EnvironmentIdpAttributesConfig" + ], + "document": "query idpAttributesConfigForEnvironment($id: ID!) {\n idpAttributesConfigForEnvironment(id: $id) {\n ...EnvironmentIdpAttributesConfig\n }\n}", + "fragmentNames": [ + "EnvironmentIdpAttributesConfig" + ], + "variables": [ + { + "name": "id", + "type": "ID!", + "required": true + } + ] + }, + "idpAttributesConfigsForOrganization": { + "name": "idpAttributesConfigsForOrganization", + "kind": "query", + "description": "Return an organization's IdP attribute mapping configuration, or a not-found result if none exists Return the environment-level IdP attribute mapping configuration", + "rootFields": [ + "idpAttributesConfigForOrganization", + "idpAttributesConfigForEnvironment" + ], + "returnTypes": [ + "OrganizationIdpAttributesConfigResult", + "EnvironmentIdpAttributesConfig" + ], + "document": "query idpAttributesConfigsForOrganization($organizationId: ID!, $environmentId: ID!) {\n idpAttributesConfigForOrganization(id: $organizationId) {\n ... on OrganizationIdpAttributesConfigNotFound {\n organizationId\n }\n ... on OrganizationIdpAttributesConfig {\n ...OrganizationIdpAttributesConfig\n }\n }\n idpAttributesConfigForEnvironment(id: $environmentId) {\n ...EnvironmentIdpAttributesConfig\n }\n}", + "fragmentNames": [ + "EnvironmentIdpAttributesConfig", + "OrganizationIdpAttributesConfig" + ], + "variables": [ + { + "name": "organizationId", + "type": "ID!", + "required": true + }, + { + "name": "environmentId", + "type": "ID!", + "required": true + } + ] + }, + "initiateLoginUrl": { + "name": "initiateLoginUrl", + "kind": "query", + "description": "Return the IdP-initiated SSO login URL configured for an environment", + "rootFields": [ + "environment" + ], + "returnTypes": [ + "Environment" + ], + "document": "query initiateLoginUrl($environmentId: String!) {\n environment(id: $environmentId) {\n initiateLoginUrl\n }\n}", + "fragmentNames": [], + "variables": [ + { + "name": "environmentId", + "type": "String!", + "required": true + } + ] + }, + "inviteAdminReplyToEmail": { + "name": "inviteAdminReplyToEmail", + "kind": "query", + "description": "Return the team for the current dashboard session", + "rootFields": [ + "currentTeam" + ], + "returnTypes": [ + "Team" + ], + "document": "query inviteAdminReplyToEmail {\n currentTeam {\n projects: projectsV2 {\n appBranding {\n inviteAdminReplyToEmail\n }\n }\n }\n}", + "fragmentNames": [], + "variables": [] + }, + "inviteToSharedSlackChannel": { + "name": "inviteToSharedSlackChannel", + "kind": "mutation", + "description": "Send the current dashboard team an invite to its shared WorkOS support Slack channel", + "rootFields": [ + "inviteToSharedSlackChannel" + ], + "returnTypes": [ + "InviteToSharedSlackChannelResult" + ], + "document": "mutation inviteToSharedSlackChannel {\n inviteToSharedSlackChannel {\n __typename\n ... on SlackChannelInviteSent {\n slackChannel {\n id\n }\n }\n ... on SlackChannelNotReadyForInvites {\n slackChannel {\n id\n }\n }\n ... on SlackChannelForTeamNotFound {\n teamId\n }\n }\n}", + "fragmentNames": [], + "variables": [] + }, + "inviteUserToTeam": { + "name": "inviteUserToTeam", + "kind": "mutation", + "description": "Invite a user by email to join the current WorkOS dashboard team with a given role", + "rootFields": [ + "inviteUserToTeam" + ], + "returnTypes": [ + "InviteUserToTeamResult" + ], + "document": "mutation inviteUserToTeam($input: InviteUserToTeamInput!) {\n inviteUserToTeam(input: $input) {\n __typename\n ... on UserInvitedToTeam {\n invitedMember {\n id\n role\n state\n user {\n id\n email\n name\n }\n }\n }\n ... on UserAlreadyBelongsToCurrentTeam {\n email\n }\n ... on UserAlreadyBelongsToAnotherTeam {\n email\n }\n }\n}", + "fragmentNames": [], + "variables": [ + { + "name": "input", + "type": "InviteUserToTeamInput!", + "required": true + } + ] + }, + "isUsingConsumerDomain": { + "name": "isUsingConsumerDomain", + "kind": "query", + "description": "Return whether the current team signed up with a consumer email domain", + "rootFields": [ + "currentTeam" + ], + "returnTypes": [ + "Team" + ], + "document": "query isUsingConsumerDomain {\n currentTeam {\n isUsingConsumerDomain\n }\n}", + "fragmentNames": [], + "variables": [] + }, + "jitProvisioningConfiguration": { + "name": "jitProvisioningConfiguration", + "kind": "query", + "description": "Return a single organization by ID", + "rootFields": [ + "organization" + ], + "returnTypes": [ + "Organization" + ], + "document": "query jitProvisioningConfiguration($organizationId: String!) {\n organization(id: $organizationId) {\n isSsoJitProvisioningEnabled\n domains {\n ...OrganizationDomain\n }\n }\n}", + "fragmentNames": [ + "OrganizationDomain" + ], + "variables": [ + { + "name": "organizationId", + "type": "String!", + "required": true + } + ] + }, + "jwtTemplate": { + "name": "jwtTemplate", + "kind": "query", + "description": "Return the JWT template configured for an environment, if one exists Return the JWT template context schema available for an environment", + "rootFields": [ + "jwtTemplate", + "contextSchema" + ], + "returnTypes": [ + "JwtTemplate", + "ContextSchemaResult" + ], + "document": "query jwtTemplate($environmentId: String!) {\n jwtTemplate(environmentId: $environmentId) {\n ...JwtTemplate\n }\n contextSchema(environmentId: $environmentId) {\n result\n }\n}", + "fragmentNames": [ + "JwtTemplate" + ], + "variables": [ + { + "name": "environmentId", + "type": "String!", + "required": true + } + ] + }, + "jwtTemplateContent": { + "name": "jwtTemplateContent", + "kind": "query", + "description": "Return the JWT template configured for an environment, if one exists", + "rootFields": [ + "jwtTemplate" + ], + "returnTypes": [ + "JwtTemplate" + ], + "document": "query jwtTemplateContent($environmentId: String!) {\n jwtTemplate(environmentId: $environmentId) {\n ...JwtTemplate\n }\n}", + "fragmentNames": [ + "JwtTemplate" + ], + "variables": [ + { + "name": "environmentId", + "type": "String!", + "required": true + } + ] + }, + "logoutUris": { + "name": "logoutUris", + "kind": "query", + "description": "List the configured logout URIs for an environment", + "rootFields": [ + "logoutUris" + ], + "returnTypes": [ + "LogoutUriList" + ], + "document": "query logoutUris($environmentId: String!, $limit: Int = 100) {\n logoutUris(environmentId: $environmentId, limit: $limit) {\n data {\n id\n uri\n isDefault\n }\n }\n}", + "fragmentNames": [], + "variables": [ + { + "name": "environmentId", + "type": "String!", + "required": true + }, + { + "name": "limit", + "type": "Int", + "required": false, + "defaultValue": "100" + } + ] + }, + "logStreamsForOrganization": { + "name": "logStreamsForOrganization", + "kind": "query", + "description": "Return an organization's audit log stream configuration", + "rootFields": [ + "organization" + ], + "returnTypes": [ + "Organization" + ], + "document": "query logStreamsForOrganization($organizationId: String!) {\n organization(id: $organizationId) {\n auditLogStream {\n ...AuditLogStream\n }\n }\n}", + "fragmentNames": [ + "AuditLogStream" + ], + "variables": [ + { + "name": "organizationId", + "type": "String!", + "required": true + } + ] + }, + "managedListBinding": { + "name": "managedListBinding", + "kind": "query", + "description": "Return how a Radar managed list is bound to an environment, or null if not bound", + "rootFields": [ + "managedListBinding" + ], + "returnTypes": [ + "ManagedListBinding" + ], + "document": "query managedListBinding($listId: String!) {\n managedListBinding(listId: $listId) {\n action\n }\n}", + "fragmentNames": [], + "variables": [ + { + "name": "listId", + "type": "String!", + "required": true + } + ] + }, + "manuallyVerifyOrganizationDomain": { + "name": "manuallyVerifyOrganizationDomain", + "kind": "mutation", + "description": "Manually mark an organization domain as verified, bypassing automatic verification", + "rootFields": [ + "manuallyVerifyOrganizationDomain" + ], + "returnTypes": [ + "OrganizationDomain" + ], + "document": "mutation manuallyVerifyOrganizationDomain($id: String!) {\n manuallyVerifyOrganizationDomain(id: $id) {\n ...OrganizationDomainWithDomainCapture\n }\n}", + "fragmentNames": [ + "OrganizationDomain", + "OrganizationDomainWithDomainCapture" + ], + "variables": [ + { + "name": "id", + "type": "String!", + "required": true + } + ] + }, + "membershipCountForRole": { + "name": "membershipCountForRole", + "kind": "query", + "description": "Return the number of unique users assigned a given role", + "rootFields": [ + "membershipCountForRole" + ], + "returnTypes": [ + "MembershipCountForRoleResult" + ], + "document": "query membershipCountForRole($id: ID!) {\n membershipCountForRole(id: $id) {\n ... on RoleNotFound {\n roleId\n }\n ... on MembershipCountForRole {\n membershipCount\n }\n }\n}", + "fragmentNames": [], + "variables": [ + { + "name": "id", + "type": "ID!", + "required": true + } + ] + }, + "notifications": { + "name": "notifications", + "kind": "query", + "description": "List dashboard notifications for an environment", + "rootFields": [ + "environment" + ], + "returnTypes": [ + "Environment" + ], + "document": "query notifications($after: String, $before: String, $environmentId: String!, $limit: Int = 10, $order: PaginationOrder, $state: NotificationState, $type: NotificationType) {\n environment(id: $environmentId) {\n notifications(\n after: $after\n before: $before\n limit: $limit\n order: $order\n state: $state\n type: $type\n ) {\n data {\n connectionId\n createdAt\n directoryId\n environmentId\n groupId\n id\n message\n organizationId\n state\n title\n type\n updatedAt\n metadata\n }\n listMetadata {\n before\n after\n }\n }\n }\n}", + "fragmentNames": [], + "variables": [ + { + "name": "after", + "type": "String", + "required": false + }, + { + "name": "before", + "type": "String", + "required": false + }, + { + "name": "environmentId", + "type": "String!", + "required": true + }, + { + "name": "limit", + "type": "Int", + "required": false, + "defaultValue": "10" + }, + { + "name": "order", + "type": "PaginationOrder", + "required": false + }, + { + "name": "state", + "type": "NotificationState", + "required": false + }, + { + "name": "type", + "type": "NotificationType", + "required": false + } + ] + }, + "oidcJwtSigningKeyPairs": { + "name": "oidcJwtSigningKeyPairs", + "kind": "query", + "description": "Return the active OIDC JWT signing key pair for an SSO connection", + "rootFields": [ + "oidcJwtSigningKeyPair" + ], + "returnTypes": [ + "OidcJwtSigningKeyPair" + ], + "document": "query oidcJwtSigningKeyPairs($connectionId: String!) {\n oidcJwtSigningKeyPair(connectionId: $connectionId) {\n id\n publicKeyCert\n notAfter\n notBefore\n createdAt\n updatedAt\n }\n}", + "fragmentNames": [], + "variables": [ + { + "name": "connectionId", + "type": "String!", + "required": true + } + ] + }, + "oidcSessionEmails": { + "name": "oidcSessionEmails", + "kind": "query", + "description": "Search the end-user emails seen in a connection's OIDC sign-in sessions (typeahead)", + "rootFields": [ + "connection" + ], + "returnTypes": [ + "ConnectionOrUntypedConnection" + ], + "document": "query oidcSessionEmails($connectionId: String!, $search: String!, $limit: Int, $createdAtGte: DateTime, $createdAtLte: DateTime) {\n connection(id: $connectionId) {\n __typename\n ... on Connection {\n oidcSessionEmails(\n search: $search\n limit: $limit\n createdAtGte: $createdAtGte\n createdAtLte: $createdAtLte\n )\n }\n }\n}", + "fragmentNames": [], + "variables": [ + { + "name": "connectionId", + "type": "String!", + "required": true + }, + { + "name": "search", + "type": "String!", + "required": true + }, + { + "name": "limit", + "type": "Int", + "required": false + }, + { + "name": "createdAtGte", + "type": "DateTime", + "required": false + }, + { + "name": "createdAtLte", + "type": "DateTime", + "required": false + } + ] + }, + "oidcSessions": { + "name": "oidcSessions", + "kind": "query", + "description": "List a connection's OIDC sign-in sessions, filterable by email, state, initiator, and date range", + "rootFields": [ + "connection" + ], + "returnTypes": [ + "ConnectionOrUntypedConnection" + ], + "document": "query oidcSessions($connectionId: String!, $before: String, $after: String, $search: String, $emails: [String!], $state: OidcSessionState, $states: [OidcSessionState!], $createdAtGte: DateTime, $createdAtLte: DateTime, $initiator: OidcSessionInitiator, $limit: Int) {\n connection(id: $connectionId) {\n __typename\n ... on Connection {\n oidcSessions(\n before: $before\n after: $after\n search: $search\n emails: $emails\n state: $state\n states: $states\n createdAtGte: $createdAtGte\n createdAtLte: $createdAtLte\n initiator: $initiator\n limit: $limit\n ) {\n data {\n ...ConnectionSessionData\n }\n listMetadata {\n before\n after\n }\n }\n }\n }\n}", + "fragmentNames": [ + "ConnectionSessionData", + "ConnectionSessionError", + "ProfileSnapshot", + "SamlSession" + ], + "variables": [ + { + "name": "connectionId", + "type": "String!", + "required": true + }, + { + "name": "before", + "type": "String", + "required": false + }, + { + "name": "after", + "type": "String", + "required": false + }, + { + "name": "search", + "type": "String", + "required": false + }, + { + "name": "emails", + "type": "[String!]", + "required": false + }, + { + "name": "state", + "type": "OidcSessionState", + "required": false + }, + { + "name": "states", + "type": "[OidcSessionState!]", + "required": false + }, + { + "name": "createdAtGte", + "type": "DateTime", + "required": false + }, + { + "name": "createdAtLte", + "type": "DateTime", + "required": false + }, + { + "name": "initiator", + "type": "OidcSessionInitiator", + "required": false + }, + { + "name": "limit", + "type": "Int", + "required": false + } + ] + }, + "orderedDirectoryGroupConfigs": { + "name": "orderedDirectoryGroupConfigs", + "kind": "query", + "description": "Return a Directory Sync connection's group-sync configuration, in order", + "rootFields": [ + "directory" + ], + "returnTypes": [ + "DirectoryOrUntypedDirectory" + ], + "document": "query orderedDirectoryGroupConfigs($directoryId: String!, $after: String, $before: String, $filterGroupsWithMembers: Boolean, $filterSynced: Boolean, $limit: Int, $order: PaginationOrder, $search: String) {\n directory(id: $directoryId) {\n __typename\n ... on Directory {\n orderedDirectoryGroupConfigs(\n after: $after\n before: $before\n filterGroupsWithMembers: $filterGroupsWithMembers\n filterSynced: $filterSynced\n limit: $limit\n order: $order\n search: $search\n ) {\n __typename\n ... on OrderedDirectoryGroupConfigsList {\n data {\n ...DirectoryGroupConfig\n }\n listMetadata {\n before\n after\n }\n }\n ... on InvalidDirectoryGroupConfigCursor {\n cursor\n }\n }\n }\n }\n}", + "fragmentNames": [ + "DirectoryGroupConfig" + ], + "variables": [ + { + "name": "directoryId", + "type": "String!", + "required": true + }, + { + "name": "after", + "type": "String", + "required": false + }, + { + "name": "before", + "type": "String", + "required": false + }, + { + "name": "filterGroupsWithMembers", + "type": "Boolean", + "required": false + }, + { + "name": "filterSynced", + "type": "Boolean", + "required": false + }, + { + "name": "limit", + "type": "Int", + "required": false + }, + { + "name": "order", + "type": "PaginationOrder", + "required": false + }, + { + "name": "search", + "type": "String", + "required": false + } + ] + }, + "organization": { + "name": "organization", + "kind": "query", + "description": "Return a single organization by ID", + "rootFields": [ + "organization" + ], + "returnTypes": [ + "Organization" + ], + "document": "query organization($id: String!) {\n organization(id: $id) {\n ...Organization\n }\n}", + "fragmentNames": [ + "AuditLogTrail", + "Organization", + "OrganizationDomain", + "OrganizationDomainWithDomainCapture" + ], + "variables": [ + { + "name": "id", + "type": "String!", + "required": true + } + ] + }, + "organizationAdminCollectionEnabled": { + "name": "organizationAdminCollectionEnabled", + "kind": "query", + "description": "Return an environment by its ID", + "rootFields": [ + "environment" + ], + "returnTypes": [ + "Environment" + ], + "document": "query organizationAdminCollectionEnabled($environmentId: String!) {\n environment(id: $environmentId) {\n id\n organizationAdminCollectionEnabled\n }\n}", + "fragmentNames": [], + "variables": [ + { + "name": "environmentId", + "type": "String!", + "required": true + } + ] + }, + "organizationAdmins": { + "name": "organizationAdmins", + "kind": "query", + "description": "List the admin (IT contact) accounts for an organization", + "rootFields": [ + "organizationAdmins" + ], + "returnTypes": [ + "OrganizationAdmin" + ], + "document": "query organizationAdmins($organizationId: String!) {\n organizationAdmins(organizationId: $organizationId) {\n ...OrganizationAdmin\n }\n}", + "fragmentNames": [ + "OrganizationAdmin" + ], + "variables": [ + { + "name": "organizationId", + "type": "String!", + "required": true + } + ] + }, + "organizationApiKeys": { + "name": "organizationApiKeys", + "kind": "query", + "description": "List an organization's API keys", + "rootFields": [ + "organization" + ], + "returnTypes": [ + "Organization" + ], + "document": "query organizationApiKeys($id: String!, $before: String, $after: String, $limit: Int, $search: String, $createdAtRange: ApiKeysDateRangeInput, $lastUsedAtRange: ApiKeysDateRangeInput, $hasExpiration: Boolean, $expiresAtRange: ApiKeysDateRangeInput) {\n organization(id: $id) {\n id\n apiKeys(\n before: $before\n after: $after\n limit: $limit\n search: $search\n createdAtRange: $createdAtRange\n lastUsedAtRange: $lastUsedAtRange\n hasExpiration: $hasExpiration\n expiresAtRange: $expiresAtRange\n ) {\n data {\n id\n name\n obfuscatedValue\n lastUsedAt\n createdAt\n expiresAt\n }\n listMetadata {\n before\n after\n }\n }\n }\n}", + "fragmentNames": [], + "variables": [ + { + "name": "id", + "type": "String!", + "required": true + }, + { + "name": "before", + "type": "String", + "required": false + }, + { + "name": "after", + "type": "String", + "required": false + }, + { + "name": "limit", + "type": "Int", + "required": false + }, + { + "name": "search", + "type": "String", + "required": false + }, + { + "name": "createdAtRange", + "type": "ApiKeysDateRangeInput", + "required": false + }, + { + "name": "lastUsedAtRange", + "type": "ApiKeysDateRangeInput", + "required": false + }, + { + "name": "hasExpiration", + "type": "Boolean", + "required": false + }, + { + "name": "expiresAtRange", + "type": "ApiKeysDateRangeInput", + "required": false + } + ] + }, + "organizationCount": { + "name": "organizationCount", + "kind": "query", + "description": "Return the number of organizations in an environment", + "rootFields": [ + "organizationCount" + ], + "returnTypes": [ + "Int" + ], + "document": "query organizationCount($environmentId: String!) {\n organizationCount(environmentId: $environmentId)\n}", + "fragmentNames": [], + "variables": [ + { + "name": "environmentId", + "type": "String!", + "required": true + } + ] + }, + "organizationDataProviderIntegrations": { + "name": "organizationDataProviderIntegrations", + "kind": "query", + "description": "List the Pipes data provider integrations enabled for an organization", + "rootFields": [ + "organization" + ], + "returnTypes": [ + "Organization" + ], + "document": "query organizationDataProviderIntegrations($organizationId: String!, $limit: Int, $before: String, $after: String, $search: String, $dataIntegrationStatus: DataIntegrationStatus, $hasScopesOverride: Boolean) {\n organization(id: $organizationId) {\n id\n dataProviderIntegrations(\n limit: $limit\n before: $before\n after: $after\n search: $search\n dataIntegrationStatus: $dataIntegrationStatus\n hasScopesOverride: $hasScopesOverride\n ) {\n data {\n id\n providerSlug\n name\n scopes\n usersCount\n lastAccessed\n orgOverride {\n id\n enabled\n scopes\n }\n provider {\n slug\n name\n iconSlug\n iconUrl\n iconDarkUrl\n }\n }\n listMetadata {\n before\n after\n }\n }\n }\n}", + "fragmentNames": [], + "variables": [ + { + "name": "organizationId", + "type": "String!", + "required": true + }, + { + "name": "limit", + "type": "Int", + "required": false + }, + { + "name": "before", + "type": "String", + "required": false + }, + { + "name": "after", + "type": "String", + "required": false + }, + { + "name": "search", + "type": "String", + "required": false + }, + { + "name": "dataIntegrationStatus", + "type": "DataIntegrationStatus", + "required": false + }, + { + "name": "hasScopesOverride", + "type": "Boolean", + "required": false + } + ] + }, + "organizations": { + "name": "organizations", + "kind": "query", + "description": "List organizations in an environment, with filtering, search, and pagination", + "rootFields": [ + "organizations" + ], + "returnTypes": [ + "OrganizationList" + ], + "document": "query organizations($after: String, $before: String, $connectionState: OrganizationConnectionState, $connectionType: ConnectionType, $directoryState: OrganizationDirectoryState, $auditLogStreamType: AuditLogStreamType, $auditLogStreamState: AuditLogStreamState, $expiringCertificateWindow: DateTime, $directoryType: DirectoryType, $environmentId: String!, $hasDataIntegrations: Boolean, $hasOrganizationAdmin: Boolean, $limit: Int = 10, $rangeEnd: DateTime, $rangeStart: DateTime, $search: String, $sortField: OrganizationSortField, $order: PaginationOrder, $usersCountMaximum: Int, $usersCountMinimum: Int) {\n organizations(\n after: $after\n before: $before\n connectionState: $connectionState\n connectionType: $connectionType\n directoryState: $directoryState\n directoryType: $directoryType\n auditLogStreamType: $auditLogStreamType\n auditLogStreamState: $auditLogStreamState\n expiringCertificateWindow: $expiringCertificateWindow\n environmentId: $environmentId\n hasDataIntegrations: $hasDataIntegrations\n hasOrganizationAdmin: $hasOrganizationAdmin\n limit: $limit\n rangeEnd: $rangeEnd\n rangeStart: $rangeStart\n search: $search\n sortField: $sortField\n order: $order\n usersCountMaximum: $usersCountMaximum\n usersCountMinimum: $usersCountMinimum\n ) {\n data {\n ...Organization\n }\n listMetadata {\n before\n after\n }\n }\n}", + "fragmentNames": [ + "AuditLogTrail", + "Organization", + "OrganizationDomain", + "OrganizationDomainWithDomainCapture" + ], + "variables": [ + { + "name": "after", + "type": "String", + "required": false + }, + { + "name": "before", + "type": "String", + "required": false + }, + { + "name": "connectionState", + "type": "OrganizationConnectionState", + "required": false + }, + { + "name": "connectionType", + "type": "ConnectionType", + "required": false + }, + { + "name": "directoryState", + "type": "OrganizationDirectoryState", + "required": false + }, + { + "name": "auditLogStreamType", + "type": "AuditLogStreamType", + "required": false + }, + { + "name": "auditLogStreamState", + "type": "AuditLogStreamState", + "required": false + }, + { + "name": "expiringCertificateWindow", + "type": "DateTime", + "required": false + }, + { + "name": "directoryType", + "type": "DirectoryType", + "required": false + }, + { + "name": "environmentId", + "type": "String!", + "required": true + }, + { + "name": "hasDataIntegrations", + "type": "Boolean", + "required": false + }, + { + "name": "hasOrganizationAdmin", + "type": "Boolean", + "required": false + }, + { + "name": "limit", + "type": "Int", + "required": false, + "defaultValue": "10" + }, + { + "name": "rangeEnd", + "type": "DateTime", + "required": false + }, + { + "name": "rangeStart", + "type": "DateTime", + "required": false + }, + { + "name": "search", + "type": "String", + "required": false + }, + { + "name": "sortField", + "type": "OrganizationSortField", + "required": false + }, + { + "name": "order", + "type": "PaginationOrder", + "required": false + }, + { + "name": "usersCountMaximum", + "type": "Int", + "required": false + }, + { + "name": "usersCountMinimum", + "type": "Int", + "required": false + } + ] + }, + "organizationsForSelect": { + "name": "organizationsForSelect", + "kind": "query", + "description": "List organizations in an environment, with filtering, search, and pagination", + "rootFields": [ + "organizations" + ], + "returnTypes": [ + "OrganizationList" + ], + "document": "query organizationsForSelect($after: String, $before: String, $environmentId: String!, $limit: Int = 10, $search: String, $sortField: OrganizationSortField, $order: PaginationOrder) {\n organizations(\n after: $after\n before: $before\n environmentId: $environmentId\n limit: $limit\n search: $search\n sortField: $sortField\n order: $order\n ) {\n data {\n id\n name\n }\n listMetadata {\n before\n after\n }\n }\n}", + "fragmentNames": [], + "variables": [ + { + "name": "after", + "type": "String", + "required": false + }, + { + "name": "before", + "type": "String", + "required": false + }, + { + "name": "environmentId", + "type": "String!", + "required": true + }, + { + "name": "limit", + "type": "Int", + "required": false, + "defaultValue": "10" + }, + { + "name": "search", + "type": "String", + "required": false + }, + { + "name": "sortField", + "type": "OrganizationSortField", + "required": false + }, + { + "name": "order", + "type": "PaginationOrder", + "required": false + } + ] + }, + "organizationUserlandSettings": { + "name": "organizationUserlandSettings", + "kind": "query", + "description": "Return a single organization by ID", + "rootFields": [ + "organization" + ], + "returnTypes": [ + "Organization" + ], + "document": "query organizationUserlandSettings($id: String!) {\n organization(id: $id) {\n isAppleOauthEnabled\n isBitbucketOauthEnabled\n isDiscordOauthEnabled\n isGithubOauthEnabled\n isGitLabOauthEnabled\n isGoogleOauthEnabled\n isIntuitOauthEnabled\n isLinkedInOauthEnabled\n isMicrosoftOauthEnabled\n isSalesforceOauthEnabled\n isSlackOauthEnabled\n isVercelMarketplaceOauthEnabled\n isVercelOauthEnabled\n isXeroOauthEnabled\n isMagicAuthEnabled\n isPasswordAuthEnabled\n isPasskeyAuthEnabled\n domainMfaRequired\n domainSsoRequired\n nonDomainSsoRequired\n nonDomainMfaRequired\n isDomainJitProvisioningEnabled\n }\n}", + "fragmentNames": [], + "variables": [ + { + "name": "id", + "type": "String!", + "required": true + } + ] + }, + "permissions": { + "name": "permissions", + "kind": "query", + "description": "List all permissions defined in an environment", + "rootFields": [ + "permissionsForEnvironment" + ], + "returnTypes": [ + "PermissionList" + ], + "document": "query permissions($id: ID!) {\n permissionsForEnvironment(id: $id) {\n permissions {\n ...Permission\n }\n }\n}", + "fragmentNames": [ + "Permission", + "ResourceType" + ], + "variables": [ + { + "name": "id", + "type": "ID!", + "required": true + } + ] + }, + "portalSettings": { + "name": "portalSettings", + "kind": "query", + "description": "Return the Admin Portal settings (branding and enabled features) for an environment", + "rootFields": [ + "environment" + ], + "returnTypes": [ + "Environment" + ], + "document": "query portalSettings($environmentId: String!) {\n environment(id: $environmentId) {\n portalSettings {\n ...PortalSettings\n }\n }\n}", + "fragmentNames": [ + "PortalSettings" + ], + "variables": [ + { + "name": "environmentId", + "type": "String!", + "required": true + } + ] + }, + "portalSetupLinkEmailSentEvents": { + "name": "portalSetupLinkEmailSentEvents", + "kind": "query", + "description": "List the email-sent events for an Admin Portal setup link", + "rootFields": [ + "portalSetupLinkEmailSentEvents" + ], + "returnTypes": [ + "PortalSetupLinkEvent" + ], + "document": "query portalSetupLinkEmailSentEvents($portalSetupLinkId: String!) {\n portalSetupLinkEmailSentEvents(portalSetupLinkId: $portalSetupLinkId) {\n id\n portalSetupLinkId\n action {\n type\n organizationAdminEmail\n }\n }\n}", + "fragmentNames": [], + "variables": [ + { + "name": "portalSetupLinkId", + "type": "String!", + "required": true + } + ] + }, + "previewJwtTemplate": { + "name": "previewJwtTemplate", + "kind": "mutation", + "description": "Render a preview of a JWT template's claims for a given user and organization", + "rootFields": [ + "previewJwtTemplate" + ], + "returnTypes": [ + "PreviewJwtTemplateResult" + ], + "document": "mutation previewJwtTemplate($input: PreviewJwtTemplateInput!) {\n previewJwtTemplate(input: $input) {\n __typename\n ... on JwtTemplateRenderingError {\n error\n }\n ... on JwtTemplateRenderingResult {\n renderedTemplate\n }\n }\n}", + "fragmentNames": [], + "variables": [ + { + "name": "input", + "type": "PreviewJwtTemplateInput!", + "required": true + } + ] + }, + "profileByEmail": { + "name": "profileByEmail", + "kind": "query", + "description": "Return an SSO profile matching an email within a connection", + "rootFields": [ + "profileByEmail" + ], + "returnTypes": [ + "Profile" + ], + "document": "query profileByEmail($input: ProfileByEmailInput!) {\n profileByEmail(input: $input) {\n ...Profile\n }\n}", + "fragmentNames": [ + "Profile" + ], + "variables": [ + { + "name": "input", + "type": "ProfileByEmailInput!", + "required": true + } + ] + }, + "profiles": { + "name": "profiles", + "kind": "query", + "description": "List the SSO profiles (authenticated identities) seen for a connection", + "rootFields": [ + "connection" + ], + "returnTypes": [ + "ConnectionOrUntypedConnection" + ], + "document": "query profiles($connectionId: String!, $before: String, $after: String, $search: String, $limit: Int) {\n connection(id: $connectionId) {\n __typename\n ... on Connection {\n profiles(before: $before, after: $after, search: $search, limit: $limit) {\n data {\n ...Profile\n }\n listMetadata {\n before\n after\n }\n }\n }\n }\n}", + "fragmentNames": [ + "Profile" + ], + "variables": [ + { + "name": "connectionId", + "type": "String!", + "required": true + }, + { + "name": "before", + "type": "String", + "required": false + }, + { + "name": "after", + "type": "String", + "required": false + }, + { + "name": "search", + "type": "String", + "required": false + }, + { + "name": "limit", + "type": "Int", + "required": false + } + ] + }, + "radarDetection": { + "name": "radarDetection", + "kind": "query", + "description": "Return a single Radar detection in an environment by ID", + "rootFields": [ + "environment" + ], + "returnTypes": [ + "Environment" + ], + "document": "query radarDetection($environmentId: String!, $detectionId: String!) {\n environment(id: $environmentId) {\n radarDetection(id: $detectionId) {\n ...RadarDetection\n }\n }\n}", + "fragmentNames": [ + "RadarDetection" + ], + "variables": [ + { + "name": "environmentId", + "type": "String!", + "required": true + }, + { + "name": "detectionId", + "type": "String!", + "required": true + } + ] + }, + "radarDetections": { + "name": "radarDetections", + "kind": "query", + "description": "List Radar detections in an environment", + "rootFields": [ + "environment" + ], + "returnTypes": [ + "Environment" + ], + "document": "query radarDetections($environmentId: String!, $controls: [RadarDetectionControl!], $actions: [RadarDetectionAction!], $user: String, $ipAddress: String, $locationCountry: String, $rangeStart: DateTime, $rangeEnd: DateTime, $before: String, $after: String, $limit: Int, $order: PaginationOrder) {\n environment(id: $environmentId) {\n radarDetections(\n controls: $controls\n actions: $actions\n user: $user\n ipAddress: $ipAddress\n locationCountry: $locationCountry\n rangeStart: $rangeStart\n rangeEnd: $rangeEnd\n before: $before\n after: $after\n limit: $limit\n order: $order\n ) {\n data {\n ...RadarDetection\n }\n listMetadata {\n before\n after\n }\n }\n }\n}", + "fragmentNames": [ + "RadarDetection" + ], + "variables": [ + { + "name": "environmentId", + "type": "String!", + "required": true + }, + { + "name": "controls", + "type": "[RadarDetectionControl!]", + "required": false + }, + { + "name": "actions", + "type": "[RadarDetectionAction!]", + "required": false + }, + { + "name": "user", + "type": "String", + "required": false + }, + { + "name": "ipAddress", + "type": "String", + "required": false + }, + { + "name": "locationCountry", + "type": "String", + "required": false + }, + { + "name": "rangeStart", + "type": "DateTime", + "required": false + }, + { + "name": "rangeEnd", + "type": "DateTime", + "required": false + }, + { + "name": "before", + "type": "String", + "required": false + }, + { + "name": "after", + "type": "String", + "required": false + }, + { + "name": "limit", + "type": "Int", + "required": false + }, + { + "name": "order", + "type": "PaginationOrder", + "required": false + } + ] + }, + "radarListDetails": { + "name": "radarListDetails", + "kind": "query", + "description": "Return the entry count for a Radar allow/block list in an environment", + "rootFields": [ + "radarListDetails" + ], + "returnTypes": [ + "RadarListDetails" + ], + "document": "query radarListDetails($type: RadarBlocklistType!, $action: RadarListAction!, $environmentId: String) {\n radarListDetails(type: $type, action: $action, environmentId: $environmentId) {\n entryCount\n }\n}", + "fragmentNames": [], + "variables": [ + { + "name": "type", + "type": "RadarBlocklistType!", + "required": true + }, + { + "name": "action", + "type": "RadarListAction!", + "required": true + }, + { + "name": "environmentId", + "type": "String", + "required": false + } + ] + }, + "radarListEntries": { + "name": "radarListEntries", + "kind": "query", + "description": "List entries in a Radar allow/block list for an environment, paginated", + "rootFields": [ + "radarListEntries" + ], + "returnTypes": [ + "RadarListEntryList" + ], + "document": "query radarListEntries($type: RadarBlocklistType!, $action: RadarListAction!, $limit: Int, $after: String, $before: String, $search: String, $order: PaginationOrder, $environmentId: String) {\n radarListEntries(\n type: $type\n action: $action\n limit: $limit\n after: $after\n before: $before\n search: $search\n order: $order\n environmentId: $environmentId\n ) {\n data {\n ...RadarListEntry\n }\n listMetadata {\n after\n before\n }\n }\n}", + "fragmentNames": [ + "RadarListEntry" + ], + "variables": [ + { + "name": "type", + "type": "RadarBlocklistType!", + "required": true + }, + { + "name": "action", + "type": "RadarListAction!", + "required": true + }, + { + "name": "limit", + "type": "Int", + "required": false + }, + { + "name": "after", + "type": "String", + "required": false + }, + { + "name": "before", + "type": "String", + "required": false + }, + { + "name": "search", + "type": "String", + "required": false + }, + { + "name": "order", + "type": "PaginationOrder", + "required": false + }, + { + "name": "environmentId", + "type": "String", + "required": false + } + ] + }, + "radarMode": { + "name": "radarMode", + "kind": "query", + "description": "Return an environment's Radar configuration (enforcement mode, rules, and thresholds)", + "rootFields": [ + "environment" + ], + "returnTypes": [ + "Environment" + ], + "document": "query radarMode($environmentId: String!) {\n environment(id: $environmentId) {\n radarSettings {\n radarMode\n enabledAt\n }\n }\n}", + "fragmentNames": [], + "variables": [ + { + "name": "environmentId", + "type": "String!", + "required": true + } + ] + }, + "radarSettings": { + "name": "radarSettings", + "kind": "query", + "description": "Return an environment's Radar configuration (enforcement mode, rules, and thresholds)", + "rootFields": [ + "environment" + ], + "returnTypes": [ + "Environment" + ], + "document": "query radarSettings($environmentId: String!) {\n environment(id: $environmentId) {\n radarSettings {\n smsChallengesEnabled\n domainProtectionsEnabled\n configuration {\n botDetection {\n enabled\n decision\n notifyAdmin\n }\n bruteForceAttack {\n enabled\n decision\n notifyAdmin\n }\n credentialStuffing {\n enabled\n decision\n notifyAdmin\n }\n fasterThanPossibleTravel {\n enabled\n decision\n notifyAdmin\n notifyUser\n }\n repeatSignUp {\n enabled\n decision\n notifyAdmin\n }\n staleAccount {\n enabled\n notifyAdmin\n notifyUser\n }\n unrecognizedDevice {\n enabled\n notifyAdmin\n notifyUser\n }\n }\n }\n }\n}", + "fragmentNames": [], + "variables": [ + { + "name": "environmentId", + "type": "String!", + "required": true + } + ] + }, + "reactivateOrganizationMembership": { + "name": "reactivateOrganizationMembership", + "kind": "mutation", + "description": "Reactivate an inactive user's organization membership", + "rootFields": [ + "reactivateUserlandUserOrganizationMembership" + ], + "returnTypes": [ + "ReactivateUserlandUserOrganizationMembershipResult" + ], + "document": "mutation reactivateOrganizationMembership($input: ReactivateUserlandUserOrganizationMembershipInput!) {\n reactivateUserlandUserOrganizationMembership(input: $input) {\n __typename\n ... on UserlandUserOrganizationMembershipNotFound {\n __typename\n message\n }\n ... on UserlandUserOrganizationMembershipReactivated {\n __typename\n }\n }\n}", + "fragmentNames": [], + "variables": [ + { + "name": "input", + "type": "ReactivateUserlandUserOrganizationMembershipInput!", + "required": true + } + ] + }, + "redirectUris": { + "name": "redirectUris", + "kind": "query", + "description": "List the redirect URIs configured for an environment, paginated", + "rootFields": [ + "redirectUris" + ], + "returnTypes": [ + "RedirectUriList" + ], + "document": "query redirectUris($environmentId: String!, $before: String, $after: String, $limit: Int = 100) {\n redirectUris(\n environmentId: $environmentId\n before: $before\n after: $after\n limit: $limit\n ) {\n data {\n ...RedirectUri\n }\n listMetadata {\n before\n after\n }\n }\n}", + "fragmentNames": [ + "RedirectUri" + ], + "variables": [ + { + "name": "environmentId", + "type": "String!", + "required": true + }, + { + "name": "before", + "type": "String", + "required": false + }, + { + "name": "after", + "type": "String", + "required": false + }, + { + "name": "limit", + "type": "Int", + "required": false, + "defaultValue": "100" + } + ] + }, + "refreshCustomDomain": { + "name": "refreshCustomDomain", + "kind": "mutation", + "description": "Re-check DNS and refresh the verification status of a custom domain", + "rootFields": [ + "refreshCustomDomain" + ], + "returnTypes": [ + "RefreshCustomDomainResult" + ], + "document": "mutation refreshCustomDomain($id: String!) {\n refreshCustomDomain(id: $id) {\n ... on CustomDomainNotFound {\n __typename\n customDomainId\n }\n ... on CustomDomainRefreshed {\n __typename\n customDomain {\n ...CustomDomain\n }\n }\n }\n}", + "fragmentNames": [ + "CustomDomain" + ], + "variables": [ + { + "name": "id", + "type": "String!", + "required": true + } + ] + }, + "removeCustomDomain": { + "name": "removeCustomDomain", + "kind": "mutation", + "description": "Remove the custom AuthKit/email domain configured for an environment's project", + "rootFields": [ + "removeCustomDomain" + ], + "returnTypes": [ + "RemoveCustomDomainResult" + ], + "document": "mutation removeCustomDomain($input: RemoveCustomDomainInput!) {\n removeCustomDomain(input: $input) {\n updatedEnvironments {\n id\n name\n customAuthDomain {\n domain\n selfServe\n }\n customAdminPortalDomain {\n domain\n selfServe\n }\n authkitDomains {\n id\n domain\n }\n }\n }\n}", + "fragmentNames": [], + "variables": [ + { + "name": "input", + "type": "RemoveCustomDomainInput!", + "required": true + } + ] + }, + "RemoveEmailSuppression": { + "name": "RemoveEmailSuppression", + "kind": "mutation", + "description": "Remove an email address from the environment's email-provider suppression (bounce) list", + "rootFields": [ + "removeEmailSuppression" + ], + "returnTypes": [ + "RemoveEmailSuppressionResult" + ], + "document": "mutation RemoveEmailSuppression($input: RemoveEmailSuppressionInput!) {\n removeEmailSuppression(input: $input) {\n __typename\n ... on EmailSuppressionRemovalSuccess {\n email\n }\n ... on EnvironmentNotFoundForSuppression {\n environmentId\n }\n }\n}", + "fragmentNames": [], + "variables": [ + { + "name": "input", + "type": "RemoveEmailSuppressionInput!", + "required": true + } + ] + }, + "removeMemberFromOrganization": { + "name": "removeMemberFromOrganization", + "kind": "mutation", + "description": "Remove an AuthKit user's membership in an organization", + "rootFields": [ + "removeUserlandUserFromOrganization" + ], + "returnTypes": [ + "RemoveUserlandUserFromOrganizationResult" + ], + "document": "mutation removeMemberFromOrganization($input: RemoveUserlandUserFromOrganizationInput!) {\n removeUserlandUserFromOrganization(input: $input) {\n __typename\n ... on OrganizationNotFound {\n __typename\n organizationId\n }\n ... on UserlandUserNotFound {\n __typename\n userlandUserId\n }\n ... on UserlandUserOrganizationMembershipNotFound {\n __typename\n message\n }\n ... on UserlandUserRemovedFromOrganization {\n __typename\n }\n }\n}", + "fragmentNames": [], + "variables": [ + { + "name": "input", + "type": "RemoveUserlandUserFromOrganizationInput!", + "required": true + } + ] + }, + "removeRadarListEntry": { + "name": "removeRadarListEntry", + "kind": "mutation", + "description": "Remove an entry from a Radar allow/block list in the current environment", + "rootFields": [ + "removeRadarListEntry" + ], + "returnTypes": [ + "RemoveRadarListEntryResult" + ], + "document": "mutation removeRadarListEntry($input: RemoveRadarListEntryInput!) {\n removeRadarListEntry(input: $input) {\n __typename\n ... on RadarListEntryOperationSuccess {\n success\n }\n }\n}", + "fragmentNames": [], + "variables": [ + { + "name": "input", + "type": "RemoveRadarListEntryInput!", + "required": true + } + ] + }, + "removeUserFromTeam": { + "name": "removeUserFromTeam", + "kind": "mutation", + "description": "Remove a member from the WorkOS dashboard team unless their access is directory-managed", + "rootFields": [ + "removeUserFromTeam" + ], + "returnTypes": [ + "String" + ], + "document": "mutation removeUserFromTeam($usersOrganizationsId: String!) {\n removeUserFromTeam(usersOrganizationsId: $usersOrganizationsId)\n}", + "fragmentNames": [], + "variables": [ + { + "name": "usersOrganizationsId", + "type": "String!", + "required": true + } + ] + }, + "renameEnvironment": { + "name": "renameEnvironment", + "kind": "mutation", + "description": "Rename an environment, enforcing length limits and uniqueness within its project", + "rootFields": [ + "renameEnvironment" + ], + "returnTypes": [ + "RenameEnvironmentPayload" + ], + "document": "mutation renameEnvironment($input: RenameEnvironmentInput!) {\n renameEnvironment(input: $input) {\n environment {\n id\n name\n }\n }\n}", + "fragmentNames": [], + "variables": [ + { + "name": "input", + "type": "RenameEnvironmentInput!", + "required": true + } + ] + }, + "renameProject": { + "name": "renameProject", + "kind": "mutation", + "description": "Rename a project, enforcing that its name is unique within the team", + "rootFields": [ + "renameProject" + ], + "returnTypes": [ + "RenameProjectResult" + ], + "document": "mutation renameProject($input: RenameProjectInput!) {\n renameProject(input: $input) {\n __typename\n ... on ProjectRenamed {\n project {\n id\n name\n }\n }\n ... on ProjectNameAlreadyUsed {\n name\n }\n ... on ProjectNotFound {\n projectId\n }\n }\n}", + "fragmentNames": [], + "variables": [ + { + "name": "input", + "type": "RenameProjectInput!", + "required": true + } + ] + }, + "requestCustomDataIntegration": { + "name": "requestCustomDataIntegration", + "kind": "mutation", + "description": "Request a not-yet-supported custom data provider integration for an environment", + "rootFields": [ + "requestCustomDataIntegration" + ], + "returnTypes": [ + "RequestCustomDataIntegrationResult" + ], + "document": "mutation requestCustomDataIntegration($input: RequestCustomDataIntegrationInput!) {\n requestCustomDataIntegration(input: $input) {\n __typename\n ... on CustomDataIntegrationRequested {\n success\n }\n }\n}", + "fragmentNames": [], + "variables": [ + { + "name": "input", + "type": "RequestCustomDataIntegrationInput!", + "required": true + } + ] + }, + "requestDataProvider": { + "name": "requestDataProvider", + "kind": "mutation", + "description": "Create a requested-state data provider integration for an environment by provider slug", + "rootFields": [ + "requestDataProvider" + ], + "returnTypes": [ + "RequestDataProviderResult" + ], + "document": "mutation requestDataProvider($input: RequestDataProviderInput!) {\n requestDataProvider(input: $input) {\n __typename\n ... on DataProviderRequested {\n dataProviderIntegration {\n __typename\n id\n name\n state\n }\n }\n ... on DataProviderNotFound {\n providerSlug\n }\n }\n}", + "fragmentNames": [], + "variables": [ + { + "name": "input", + "type": "RequestDataProviderInput!", + "required": true + } + ] + }, + "resendDashboardInvite": { + "name": "resendDashboardInvite", + "kind": "mutation", + "description": "Resend the invitation email for an expired WorkOS dashboard team invite", + "rootFields": [ + "resendDashboardInvite" + ], + "returnTypes": [ + "ResendDashboardInviteResult" + ], + "document": "mutation resendDashboardInvite($input: ResendDashboardInviteInput!) {\n resendDashboardInvite(input: $input) {\n __typename\n ... on DashboardInviteNotFound {\n __typename\n }\n ... on DashboardInviteNotExpired {\n __typename\n }\n ... on DashboardInviteResent {\n resentInvitation\n }\n }\n}", + "fragmentNames": [], + "variables": [ + { + "name": "input", + "type": "ResendDashboardInviteInput!", + "required": true + } + ] + }, + "resendUserlandUserInvite": { + "name": "resendUserlandUserInvite", + "kind": "mutation", + "description": "Resend the invitation email for a pending AuthKit user invite", + "rootFields": [ + "resendUserlandUserInvite" + ], + "returnTypes": [ + "ResendUserlandUserInviteResult" + ], + "document": "mutation resendUserlandUserInvite($input: ResendUserlandUserInviteInput!) {\n resendUserlandUserInvite(input: $input) {\n __typename\n ... on UserlandUserInviteNotFound {\n __typename\n }\n ... on UserlandUserInviteNotPending {\n __typename\n }\n ... on UserlandUserInviteResent {\n userlandUserInvite {\n id\n }\n }\n }\n}", + "fragmentNames": [], + "variables": [ + { + "name": "input", + "type": "ResendUserlandUserInviteInput!", + "required": true + } + ] + }, + "resendWebhookEvent": { + "name": "resendWebhookEvent", + "kind": "mutation", + "description": "Re-deliver a previously sent webhook event to its endpoint", + "rootFields": [ + "resendWebhookEvent" + ], + "returnTypes": [ + "ResendWebhookEventResult" + ], + "document": "mutation resendWebhookEvent($input: ResendWebhookEventInput!) {\n resendWebhookEvent(input: $input) {\n __typename\n ... on WebhookEventNotFound {\n webhookId\n }\n ... on ResendNotAllowedForEnvironment {\n environmentId\n }\n ... on ResendWebhookEventSuccessful {\n webhookId\n }\n ... on ResendWebhookEventFailed {\n webhookId\n }\n }\n}", + "fragmentNames": [], + "variables": [ + { + "name": "input", + "type": "ResendWebhookEventInput!", + "required": true + } + ] + }, + "resetOauthCredentials": { + "name": "resetOauthCredentials", + "kind": "mutation", + "description": "Reset (regenerate) an environment's OAuth provider credentials for AuthKit social login", + "rootFields": [ + "resetOauthCredentials" + ], + "returnTypes": [ + "ResetOauthCredentialsPayload" + ], + "document": "mutation resetOauthCredentials($input: ResetOauthCredentialsInput!) {\n resetOauthCredentials(input: $input) {\n __typename\n oauthCredentials {\n id\n }\n }\n}", + "fragmentNames": [], + "variables": [ + { + "name": "input", + "type": "ResetOauthCredentialsInput!", + "required": true + } + ] + }, + "resetOrganizationIdpAttributesConfig": { + "name": "resetOrganizationIdpAttributesConfig", + "kind": "mutation", + "description": "Reset an organization's IdP attribute mapping config back to environment defaults", + "rootFields": [ + "resetOrganizationIdpAttributesConfig" + ], + "returnTypes": [ + "ResetOrganizationIdpAttributesConfigResult" + ], + "document": "mutation resetOrganizationIdpAttributesConfig($input: ResetOrganizationIdpAttributesConfigInput!) {\n resetOrganizationIdpAttributesConfig(input: $input) {\n __typename\n ... on OrganizationIdpAttributesConfigReset {\n organizationIdpAttributesConfig {\n id\n ssoCustomAttributeMappingEnabled\n dsyncCustomAttributeMappingEnabled\n }\n }\n }\n}", + "fragmentNames": [], + "variables": [ + { + "name": "input", + "type": "ResetOrganizationIdpAttributesConfigInput!", + "required": true + } + ] + }, + "resetOrganizationRoleConfig": { + "name": "resetOrganizationRoleConfig", + "kind": "mutation", + "description": "Reset selected properties of an organization's role config back to environment defaults", + "rootFields": [ + "resetOrganizationRoleConfig" + ], + "returnTypes": [ + "ResetOrganizationRoleConfigResult" + ], + "document": "mutation resetOrganizationRoleConfig($input: ResetOrganizationRoleConfigInput!) {\n resetOrganizationRoleConfig(input: $input) {\n __typename\n ... on OrganizationRoleConfigReset {\n organizationRoleConfig {\n id\n ssoRoleAssignmentEnabled\n dsyncRoleAssignmentEnabled\n }\n }\n }\n}", + "fragmentNames": [], + "variables": [ + { + "name": "input", + "type": "ResetOrganizationRoleConfigInput!", + "required": true + } + ] + }, + "resetUserlandUserAuthenticationFactors": { + "name": "resetUserlandUserAuthenticationFactors", + "kind": "mutation", + "description": "Delete an AuthKit user's enrolled MFA second factors (deprecated; use resetUserlandUserSecondFactors) DEPRECATED: Please use `resetUserlandUserSecondFactors` instead.", + "rootFields": [ + "resetUserlandUserAuthenticationFactors" + ], + "returnTypes": [ + "ResetUserlandUserAuthenticationFactorsResult" + ], + "document": "mutation resetUserlandUserAuthenticationFactors($input: ResetUserlandUserAuthenticationFactorsInput!) {\n resetUserlandUserAuthenticationFactors(input: $input) {\n __typename\n ... on UserlandUserNotFound {\n userlandUserId\n }\n ... on UserlandUserAuthenticationFactorsReset {\n __typename\n }\n }\n}", + "fragmentNames": [], + "variables": [ + { + "name": "input", + "type": "ResetUserlandUserAuthenticationFactorsInput!", + "required": true + } + ] + }, + "resourceRoleAssignmentsForOrganizationMembership": { + "name": "resourceRoleAssignmentsForOrganizationMembership", + "kind": "query", + "description": "List FGA resource-scoped role assignments granted to an organization membership", + "rootFields": [ + "resourceRoleAssignmentsForOrganizationMembership" + ], + "returnTypes": [ + "AuthorizationResourceRoleAssignmentList" + ], + "document": "query resourceRoleAssignmentsForOrganizationMembership($organizationMembershipId: String!, $limit: Int, $after: String, $before: String) {\n resourceRoleAssignmentsForOrganizationMembership(\n organizationMembershipId: $organizationMembershipId\n limit: $limit\n after: $after\n before: $before\n ) {\n data {\n ...AuthorizationResourceRoleAssignment\n }\n listMetadata {\n before\n after\n }\n }\n}", + "fragmentNames": [ + "AuthorizationResourceRoleAssignment" + ], + "variables": [ + { + "name": "organizationMembershipId", + "type": "String!", + "required": true + }, + { + "name": "limit", + "type": "Int", + "required": false + }, + { + "name": "after", + "type": "String", + "required": false + }, + { + "name": "before", + "type": "String", + "required": false + } + ] + }, + "resourceTypesForEnvironment": { + "name": "resourceTypesForEnvironment", + "kind": "query", + "description": "List FGA authorization resource types defined in an environment, optionally with their hierarchy topologies", + "rootFields": [ + "resourceTypesForEnvironment" + ], + "returnTypes": [ + "AuthorizationResourceTypeList" + ], + "document": "query resourceTypesForEnvironment($id: ID!, $loadTopologies: Boolean = true) {\n resourceTypesForEnvironment(id: $id, loadTopologies: $loadTopologies) {\n resourceTypes {\n id\n name\n slug\n description\n childTypeIds\n parentTypeIds\n }\n }\n}", + "fragmentNames": [], + "variables": [ + { + "name": "id", + "type": "ID!", + "required": true + }, + { + "name": "loadTopologies", + "type": "Boolean", + "required": false, + "defaultValue": "true" + } + ] + }, + "restartOrganizationDomainVerification": { + "name": "restartOrganizationDomainVerification", + "kind": "mutation", + "description": "Restart DNS verification for an organization domain by issuing a fresh verification token", + "rootFields": [ + "restartOrganizationDomainVerification" + ], + "returnTypes": [ + "OrganizationDomain" + ], + "document": "mutation restartOrganizationDomainVerification($id: String!) {\n restartOrganizationDomainVerification(id: $id) {\n ...OrganizationDomainWithDomainCapture\n }\n}", + "fragmentNames": [ + "OrganizationDomain", + "OrganizationDomainWithDomainCapture" + ], + "variables": [ + { + "name": "id", + "type": "String!", + "required": true + } + ] + }, + "revokeUserlandSession": { + "name": "revokeUserlandSession", + "kind": "mutation", + "description": "Revoke an AuthKit user session so it can no longer be used to authenticate", + "rootFields": [ + "revokeUserlandSession" + ], + "returnTypes": [ + "RevokeUserlandSessionResult" + ], + "document": "mutation revokeUserlandSession($input: RevokeUserlandSessionInput!) {\n revokeUserlandSession(input: $input) {\n ... on RevokedUserlandSession {\n sessionId\n }\n }\n}", + "fragmentNames": [], + "variables": [ + { + "name": "input", + "type": "RevokeUserlandSessionInput!", + "required": true + } + ] + }, + "revokeUserlandUserInvite": { + "name": "revokeUserlandUserInvite", + "kind": "mutation", + "description": "Revoke a pending AuthKit user invitation", + "rootFields": [ + "revokeUserlandUserInvite" + ], + "returnTypes": [ + "RevokeUserlandUserInviteResult" + ], + "document": "mutation revokeUserlandUserInvite($input: RevokeUserlandUserInviteInput!) {\n revokeUserlandUserInvite(input: $input) {\n __typename\n ... on UserlandUserInviteNotFound {\n __typename\n }\n ... on UserlandUserInviteNotPending {\n __typename\n }\n ... on UserlandUserInviteRevoked {\n userlandUserInvite {\n id\n }\n }\n }\n}", + "fragmentNames": [], + "variables": [ + { + "name": "input", + "type": "RevokeUserlandUserInviteInput!", + "required": true + } + ] + }, + "revokeUserlandUserSessions": { + "name": "revokeUserlandUserSessions", + "kind": "mutation", + "description": "Revoke all active AuthKit user sessions for a user so they can no longer be used to authenticate", + "confirmation": "revokes all active AuthKit sessions for the user", + "rootFields": [ + "revokeUserlandUserSessions" + ], + "returnTypes": [ + "RevokeUserlandUserSessionsResult" + ], + "document": "mutation revokeUserlandUserSessions($input: RevokeUserlandUserSessionsInput!) {\n revokeUserlandUserSessions(input: $input) {\n __typename\n ... on RevokedUserlandUserSessions {\n sessionCount\n }\n ... on UserlandUserNotFound {\n userlandUserId\n }\n }\n}", + "fragmentNames": [], + "variables": [ + { + "name": "input", + "type": "RevokeUserlandUserSessionsInput!", + "required": true + } + ] + }, + "roleConfigsForOrganization": { + "name": "roleConfigsForOrganization", + "kind": "query", + "description": "Return the role configuration for an organization Return the role configuration for an environment", + "rootFields": [ + "roleConfigForOrganizationV2", + "roleConfigForEnvironmentV2" + ], + "returnTypes": [ + "OrganizationRoleConfigResultV2", + "EnvironmentRoleConfig" + ], + "document": "query roleConfigsForOrganization($organizationId: ID!, $environmentId: ID!) {\n roleConfigForOrganizationV2(id: $organizationId) {\n ... on RoleConfigNotFoundForOrganization {\n organizationId\n }\n ... on OrganizationRoleConfig {\n ...OrganizationRoleConfig\n }\n }\n roleConfigForEnvironmentV2(id: $environmentId) {\n ...EnvironmentRoleConfig\n }\n}", + "fragmentNames": [ + "EnvironmentRoleConfig", + "OrganizationRoleConfig" + ], + "variables": [ + { + "name": "organizationId", + "type": "ID!", + "required": true + }, + { + "name": "environmentId", + "type": "ID!", + "required": true + } + ] + }, + "roles": { + "name": "roles", + "kind": "query", + "description": "List roles defined in an environment, scoped to organization roles or all resource types Return the role configuration for an environment", + "rootFields": [ + "rolesForEnvironment", + "roleConfigForEnvironmentV2" + ], + "returnTypes": [ + "RoleList", + "EnvironmentRoleConfig" + ], + "document": "query roles($id: ID!, $resourceScope: String) {\n rolesForEnvironment(id: $id, resourceScope: $resourceScope) {\n roles {\n ...Role\n defaultForOrganizationsCount\n permissions {\n id\n slug\n }\n }\n }\n roleConfigForEnvironmentV2(id: $id) {\n ...EnvironmentRoleConfig\n }\n}", + "fragmentNames": [ + "EnvironmentRoleConfig", + "ResourceType", + "Role" + ], + "variables": [ + { + "name": "id", + "type": "ID!", + "required": true + }, + { + "name": "resourceScope", + "type": "String", + "required": false + } + ] + }, + "rolesForOrganization": { + "name": "rolesForOrganization", + "kind": "query", + "description": "List organization-scoped roles assignable to memberships of an organization Return the role configuration for an organization Return the role configuration for an environment", + "rootFields": [ + "rolesForOrganization", + "roleConfigForOrganizationV2", + "roleConfigForEnvironmentV2" + ], + "returnTypes": [ + "RoleList", + "OrganizationRoleConfigResultV2", + "EnvironmentRoleConfig" + ], + "document": "query rolesForOrganization($organizationId: ID!, $environmentId: ID!) {\n rolesForOrganization(id: $organizationId) {\n roles {\n ...Role\n permissions {\n id\n slug\n }\n }\n }\n roleConfigForOrganizationV2(id: $organizationId) {\n ... on RoleConfigNotFoundForOrganization {\n organizationId\n }\n ... on OrganizationRoleConfig {\n ...OrganizationRoleConfig\n }\n }\n roleConfigForEnvironmentV2(id: $environmentId) {\n ...EnvironmentRoleConfig\n }\n}", + "fragmentNames": [ + "EnvironmentRoleConfig", + "OrganizationRoleConfig", + "ResourceType", + "Role" + ], + "variables": [ + { + "name": "organizationId", + "type": "ID!", + "required": true + }, + { + "name": "environmentId", + "type": "ID!", + "required": true + } + ] + }, + "samlSessionEmails": { + "name": "samlSessionEmails", + "kind": "query", + "description": "Search the end-user emails seen in a connection's SAML sign-in sessions (typeahead)", + "rootFields": [ + "connection" + ], + "returnTypes": [ + "ConnectionOrUntypedConnection" + ], + "document": "query samlSessionEmails($connectionId: String!, $search: String!, $limit: Int, $createdAtGte: DateTime, $createdAtLte: DateTime) {\n connection(id: $connectionId) {\n __typename\n ... on Connection {\n samlSessionEmails(\n search: $search\n limit: $limit\n createdAtGte: $createdAtGte\n createdAtLte: $createdAtLte\n )\n }\n }\n}", + "fragmentNames": [], + "variables": [ + { + "name": "connectionId", + "type": "String!", + "required": true + }, + { + "name": "search", + "type": "String!", + "required": true + }, + { + "name": "limit", + "type": "Int", + "required": false + }, + { + "name": "createdAtGte", + "type": "DateTime", + "required": false + }, + { + "name": "createdAtLte", + "type": "DateTime", + "required": false + } + ] + }, + "samlSessions": { + "name": "samlSessions", + "kind": "query", + "description": "List a connection's SAML sign-in sessions, filterable by email, state, initiator, and date range", + "rootFields": [ + "connection" + ], + "returnTypes": [ + "ConnectionOrUntypedConnection" + ], + "document": "query samlSessions($connectionId: String!, $before: String, $after: String, $search: String, $emails: [String!], $state: SAMLSessionState, $states: [SAMLSessionState!], $createdAtGte: DateTime, $createdAtLte: DateTime, $initiator: SamlSessionInitiator, $limit: Int) {\n connection(id: $connectionId) {\n __typename\n ... on Connection {\n samlSessions(\n before: $before\n after: $after\n search: $search\n emails: $emails\n state: $state\n states: $states\n createdAtGte: $createdAtGte\n createdAtLte: $createdAtLte\n initiator: $initiator\n limit: $limit\n ) {\n data {\n ...ConnectionSessionData\n }\n listMetadata {\n before\n after\n }\n }\n }\n }\n}", + "fragmentNames": [ + "ConnectionSessionData", + "ConnectionSessionError", + "ProfileSnapshot", + "SamlSession" + ], + "variables": [ + { + "name": "connectionId", + "type": "String!", + "required": true + }, + { + "name": "before", + "type": "String", + "required": false + }, + { + "name": "after", + "type": "String", + "required": false + }, + { + "name": "search", + "type": "String", + "required": false + }, + { + "name": "emails", + "type": "[String!]", + "required": false + }, + { + "name": "state", + "type": "SAMLSessionState", + "required": false + }, + { + "name": "states", + "type": "[SAMLSessionState!]", + "required": false + }, + { + "name": "createdAtGte", + "type": "DateTime", + "required": false + }, + { + "name": "createdAtLte", + "type": "DateTime", + "required": false + }, + { + "name": "initiator", + "type": "SamlSessionInitiator", + "required": false + }, + { + "name": "limit", + "type": "Int", + "required": false + } + ] + }, + "searchUserlandUserIdentities": { + "name": "searchUserlandUserIdentities", + "kind": "query", + "description": "Return a single AuthKit end user by ID", + "rootFields": [ + "userlandUser" + ], + "returnTypes": [ + "UserlandUser" + ], + "document": "query searchUserlandUserIdentities($id: ID!, $search: String, $status: [String!], $limit: Int) {\n userlandUser(id: $id) {\n identities(search: $search, status: $status, limit: $limit) {\n data {\n ...UserlandIdentity\n }\n }\n }\n}", + "fragmentNames": [ + "UserlandIdentity" + ], + "variables": [ + { + "name": "id", + "type": "ID!", + "required": true + }, + { + "name": "search", + "type": "String", + "required": false + }, + { + "name": "status", + "type": "[String!]", + "required": false + }, + { + "name": "limit", + "type": "Int", + "required": false + } + ] + }, + "seededTestIdpConnection": { + "name": "seededTestIdpConnection", + "kind": "query", + "description": "Return the seeded test IdP SSO connection for an environment, if one exists", + "rootFields": [ + "environment" + ], + "returnTypes": [ + "Environment" + ], + "document": "query seededTestIdpConnection($environmentId: String!) {\n environment(id: $environmentId) {\n seededTestIdpConnection {\n ...Connection\n organization {\n id\n domains {\n domain\n state\n }\n }\n }\n }\n}", + "fragmentNames": [ + "Connection", + "ConnectionAttributeMap", + "SamlRelyingPartyTrust", + "SamlX509Certificate", + "SsoCustomAttributesWithMappings" + ], + "variables": [ + { + "name": "environmentId", + "type": "String!", + "required": true + } + ] + }, + "sendSetupLinksEmails": { + "name": "sendSetupLinksEmails", + "kind": "mutation", + "description": "Send Admin Portal setup-link invitation emails to organization admins for the given intent", + "rootFields": [ + "sendSetupLinkEmails" + ], + "returnTypes": [ + "SendSetupLinkEmailsResult" + ], + "document": "mutation sendSetupLinksEmails($input: SendSetupLinkEmailsInput!) {\n sendSetupLinkEmails(input: $input) {\n __typename\n ... on OrganizationNotFound {\n organizationId\n }\n ... on SetupLinkEmailsSent {\n organizationAdmins {\n ...OrganizationAdmin\n }\n }\n ... on MissingIntent {\n intents\n }\n ... on InvalidEmail {\n email\n }\n ... on SetupLinkNotFound {\n intent\n organizationId\n }\n }\n}", + "fragmentNames": [ + "OrganizationAdmin" + ], + "variables": [ + { + "name": "input", + "type": "SendSetupLinkEmailsInput!", + "required": true + } + ] + }, + "sendTestEmail": { + "name": "sendTestEmail", + "kind": "mutation", + "description": "Send a test email to the signed-in user to verify an environment's email provider configuration", + "rootFields": [ + "sendTestEmail" + ], + "returnTypes": [ + "SendTestEmailResult" + ], + "document": "mutation sendTestEmail($input: SendTestEmailInput!) {\n sendTestEmail(input: $input) {\n ... on SendTestEmailSuccess {\n __typename\n success\n }\n ... on SendTestEmailSendError {\n __typename\n error\n }\n ... on SendTestEmailRateLimitedError {\n __typename\n retryAfterSeconds\n }\n }\n}", + "fragmentNames": [], + "variables": [ + { + "name": "input", + "type": "SendTestEmailInput!", + "required": true + } + ] + }, + "sendTestWebhook": { + "name": "sendTestWebhook", + "kind": "mutation", + "description": "Send a test event to a webhook endpoint to verify delivery (Sandbox environments only)", + "rootFields": [ + "testWebhookEndpoint" + ], + "returnTypes": [ + "Webhook" + ], + "document": "mutation sendTestWebhook($webhookEndpointId: String!, $event: WebhookEventFixtures!) {\n testWebhookEndpoint(id: $webhookEndpointId, event: $event) {\n id\n state\n metadata\n requestBody\n responseBody\n responseStatusCode\n }\n}", + "fragmentNames": [], + "variables": [ + { + "name": "webhookEndpointId", + "type": "String!", + "required": true + }, + { + "name": "event", + "type": "WebhookEventFixtures!", + "required": true + } + ] + }, + "setApplicationPermissions": { + "name": "setApplicationPermissions", + "kind": "mutation", + "description": "Replace the set of permission scopes assigned to a Connect application", + "rootFields": [ + "setApplicationPermissions" + ], + "returnTypes": [ + "SetApplicationPermissionsResult" + ], + "document": "mutation setApplicationPermissions($input: SetApplicationPermissionsInput!) {\n setApplicationPermissions(input: $input) {\n __typename\n ... on SetApplicationPermissions {\n application {\n id\n }\n }\n }\n}", + "fragmentNames": [], + "variables": [ + { + "name": "input", + "type": "SetApplicationPermissionsInput!", + "required": true + } + ] + }, + "setAuthkitApplicationLogoutUris": { + "name": "setAuthkitApplicationLogoutUris", + "kind": "mutation", + "description": "Set the full list of allowed logout URIs for an AuthKit application (supports dry-run validation)", + "rootFields": [ + "setUserlandApplicationLogoutUris" + ], + "returnTypes": [ + "SetUserlandApplicationLogoutUrisResult" + ], + "document": "mutation setAuthkitApplicationLogoutUris($input: SetUserlandApplicationLogoutUrisInput!) {\n setUserlandApplicationLogoutUris(input: $input) {\n __typename\n ... on LogoutUrisSet {\n logoutUris {\n id\n uri\n isDefault\n }\n }\n ... on UserlandApplicationNotFound {\n applicationId\n }\n ... on InvalidLogoutUriError {\n message\n uri\n }\n ... on InvalidWildcardLogoutUri {\n message\n uri\n }\n ... on MultipleDefaultUrisError {\n message\n }\n }\n}", + "fragmentNames": [], + "variables": [ + { + "name": "input", + "type": "SetUserlandApplicationLogoutUrisInput!", + "required": true + } + ] + }, + "setAuthkitApplicationRedirectUris": { + "name": "setAuthkitApplicationRedirectUris", + "kind": "mutation", + "description": "Set the full list of allowed redirect URIs for an AuthKit application (supports dry-run validation)", + "rootFields": [ + "setUserlandApplicationRedirectUris" + ], + "returnTypes": [ + "SetUserlandApplicationRedirectUrisResult" + ], + "document": "mutation setAuthkitApplicationRedirectUris($input: SetUserlandApplicationRedirectUrisInput!) {\n setUserlandApplicationRedirectUris(input: $input) {\n __typename\n ... on RedirectUrisSet {\n redirectUris {\n id\n uri\n isDefault\n }\n }\n ... on UserlandApplicationNotFound {\n applicationId\n }\n ... on InvalidRedirectUriError {\n message\n uri\n }\n ... on InvalidWildcardRedirectUri {\n message\n uri\n }\n }\n}", + "fragmentNames": [], + "variables": [ + { + "name": "input", + "type": "SetUserlandApplicationRedirectUrisInput!", + "required": true + } + ] + }, + "setAuthkitApplicationWebOrigins": { + "name": "setAuthkitApplicationWebOrigins", + "kind": "mutation", + "description": "Set the full list of allowed web origins (CORS) for an AuthKit application (supports dry-run)", + "rootFields": [ + "setUserlandApplicationWebOrigins" + ], + "returnTypes": [ + "SetUserlandApplicationWebOriginsResult" + ], + "document": "mutation setAuthkitApplicationWebOrigins($input: SetUserlandApplicationWebOriginsInput!) {\n setUserlandApplicationWebOrigins(input: $input) {\n __typename\n ... on WebOriginsSet {\n origins\n }\n ... on UserlandApplicationNotFound {\n applicationId\n }\n ... on InvalidWebOriginProtocol {\n message\n uri\n }\n ... on InvalidWildcardWebOrigin {\n message\n uri\n }\n ... on MalformedWebOrigin {\n message\n uri\n }\n ... on WildcardNotAllowedInEnvironment {\n message\n uri\n }\n }\n}", + "fragmentNames": [], + "variables": [ + { + "name": "input", + "type": "SetUserlandApplicationWebOriginsInput!", + "required": true + } + ] + }, + "setAuthkitOauthResources": { + "name": "setAuthkitOauthResources", + "kind": "mutation", + "description": "Replace the AuthKit OAuth protected-resource URIs configured for an environment", + "rootFields": [ + "setAuthkitOauthResources" + ], + "returnTypes": [ + "SetAuthkitOauthResourcesResult" + ], + "document": "mutation setAuthkitOauthResources($input: SetAuthkitOauthResourcesInput!) {\n setAuthkitOauthResources(input: $input) {\n __typename\n ... on AuthkitOauthResourcesSet {\n authkitOauthResources {\n id\n uri\n }\n }\n ... on AuthkitOauthResourceNotFoundError {\n id\n message\n }\n ... on InvalidAuthkitOauthResourceError {\n message\n uri\n }\n }\n}", + "fragmentNames": [], + "variables": [ + { + "name": "input", + "type": "SetAuthkitOauthResourcesInput!", + "required": true + } + ] + }, + "setAutoMappedCustomAttributes": { + "name": "setAutoMappedCustomAttributes", + "kind": "mutation", + "description": "Enable the set of auto-mapped Directory Sync custom attributes for an environment", + "rootFields": [ + "setAutoMappedCustomAttributes" + ], + "returnTypes": [ + "SetAutoMappedCustomAttributesResult" + ], + "document": "mutation setAutoMappedCustomAttributes($input: SetAutoMappedCustomAttributesInput!) {\n setAutoMappedCustomAttributes(input: $input) {\n __typename\n ... on ActiveAutoMappedCustomAttributes {\n data {\n name\n description\n attributeKey\n active\n }\n }\n ... on CustomAttributeInvalid {\n reason\n }\n }\n}", + "fragmentNames": [], + "variables": [ + { + "name": "input", + "type": "SetAutoMappedCustomAttributesInput!", + "required": true + } + ] + }, + "setDirectoryCustomAttributeMappings": { + "name": "setDirectoryCustomAttributeMappings", + "kind": "mutation", + "description": "Set how a Directory Sync directory's IdP attributes map onto custom attributes, then resync users", + "rootFields": [ + "setDirectoryCustomAttributeMappings" + ], + "returnTypes": [ + "SetDirectoryCustomAttributeMappingsResult" + ], + "document": "mutation setDirectoryCustomAttributeMappings($input: SetDirectoryCustomAttributeMappingsInput!) {\n setDirectoryCustomAttributeMappings(input: $input) {\n __typename\n ... on DirectoryCustomAttributeMappingsSet {\n mappings {\n id\n attribute\n directory {\n id\n }\n customAttribute {\n id\n }\n }\n }\n ... on DirectoryNotFound {\n directoryId\n }\n ... on DirectoryCustomAttributeNotFound {\n directoryCustomAttributeId\n }\n }\n}", + "fragmentNames": [], + "variables": [ + { + "name": "input", + "type": "SetDirectoryCustomAttributeMappingsInput!", + "required": true + } + ] + }, + "setEmailNotificationPreferences": { + "name": "setEmailNotificationPreferences", + "kind": "mutation", + "description": "Update the current dashboard user's email notification subscription preferences by category", + "rootFields": [ + "setEmailNotificationPreferences" + ], + "returnTypes": [ + "SetEmailNotificationPreferencesResult" + ], + "document": "mutation setEmailNotificationPreferences($input: SetEmailNotificationPreferencesInput!) {\n setEmailNotificationPreferences(input: $input) {\n __typename\n ... on EmailNotificationPreferences {\n preferences {\n category\n preference\n }\n }\n }\n}", + "fragmentNames": [], + "variables": [ + { + "name": "input", + "type": "SetEmailNotificationPreferencesInput!", + "required": true + } + ] + }, + "setIdpAttributes": { + "name": "setIdpAttributes", + "kind": "mutation", + "description": "Set the environment's IdP attribute configuration, marking each attribute enabled and/or required", + "rootFields": [ + "setIdpAttributes" + ], + "returnTypes": [ + "SetIdpAttributesResult" + ], + "document": "mutation setIdpAttributes($input: SetIdpAttributesInput!) {\n setIdpAttributes(input: $input) {\n __typename\n ... on IdpAttributesUpdated {\n attributes {\n ...IdpAttribute\n }\n }\n ... on IdpAttributeInvalid {\n reason\n }\n }\n}", + "fragmentNames": [ + "IdpAttribute" + ], + "variables": [ + { + "name": "input", + "type": "SetIdpAttributesInput!", + "required": true + } + ] + }, + "setLogoutUris": { + "name": "setLogoutUris", + "kind": "mutation", + "description": "Set the full list of allowed logout URIs for an environment (supports dry-run validation)", + "rootFields": [ + "setLogoutUris" + ], + "returnTypes": [ + "SetLogoutUrisResult" + ], + "document": "mutation setLogoutUris($input: SetLogoutUrisInput!) {\n setLogoutUris(input: $input) {\n __typename\n ... on LogoutUrisSet {\n logoutUris {\n uri\n isDefault\n id\n }\n }\n ... on InvalidLogoutUriError {\n message\n uri\n }\n ... on InvalidWildcardLogoutUri {\n message\n uri\n }\n }\n}", + "fragmentNames": [], + "variables": [ + { + "name": "input", + "type": "SetLogoutUrisInput!", + "required": true + } + ] + }, + "setPermissionsEnabledForApiKeys": { + "name": "setPermissionsEnabledForApiKeys", + "kind": "mutation", + "description": "Set which permissions are grantable to API keys in an environment", + "rootFields": [ + "setPermissionsEnabledForApiKeys" + ], + "returnTypes": [ + "SetPermissionsEnabledForApiKeysResult" + ], + "document": "mutation setPermissionsEnabledForApiKeys($environmentId: ID!, $permissionIds: [ID!]!) {\n setPermissionsEnabledForApiKeys(\n environmentId: $environmentId\n permissionIds: $permissionIds\n ) {\n __typename\n ... on PermissionsEnabledForApiKeysSet {\n environment {\n id\n }\n }\n }\n}", + "fragmentNames": [], + "variables": [ + { + "name": "environmentId", + "type": "ID!", + "required": true + }, + { + "name": "permissionIds", + "type": "[ID!]!", + "required": true + } + ] + }, + "setPermissionsEnabledForUserApiKeys": { + "name": "setPermissionsEnabledForUserApiKeys", + "kind": "mutation", + "description": "Set which permissions are grantable to user API keys in an environment", + "rootFields": [ + "setPermissionsEnabledForUserApiKeys" + ], + "returnTypes": [ + "SetPermissionsEnabledForUserApiKeysResult" + ], + "document": "mutation setPermissionsEnabledForUserApiKeys($environmentId: ID!, $permissionIds: [ID!]!) {\n setPermissionsEnabledForUserApiKeys(\n environmentId: $environmentId\n permissionIds: $permissionIds\n ) {\n __typename\n ... on PermissionsEnabledForUserApiKeysSet {\n environment {\n id\n }\n }\n }\n}", + "fragmentNames": [], + "variables": [ + { + "name": "environmentId", + "type": "ID!", + "required": true + }, + { + "name": "permissionIds", + "type": "[ID!]!", + "required": true + } + ] + }, + "setRedirectUris": { + "name": "setRedirectUris", + "kind": "mutation", + "description": "Set the full list of allowed redirect URIs for an environment or application (supports dry-run)", + "rootFields": [ + "setRedirectUris" + ], + "returnTypes": [ + "SetRedirectUrisResult" + ], + "document": "mutation setRedirectUris($input: SetRedirectUrisInput!) {\n setRedirectUris(input: $input) {\n __typename\n ... on RedirectUrisSet {\n redirectUris {\n uri\n isDefault\n id\n }\n }\n ... on InvalidRedirectUriError {\n message\n uri\n }\n ... on InvalidWildcardRedirectUri {\n message\n uri\n }\n }\n}", + "fragmentNames": [], + "variables": [ + { + "name": "input", + "type": "SetRedirectUrisInput!", + "required": true + } + ] + }, + "setSsoAttributeMappings": { + "name": "setSsoAttributeMappings", + "kind": "mutation", + "description": "Update a connection's SAML/SSO attribute map and reconcile affected user profiles Set how an SSO connection's IdP attributes map onto custom attributes", + "rootFields": [ + "updateAttributeMapV2", + "setSsoCustomAttributeMappings" + ], + "returnTypes": [ + "AttributeMap", + "SetSsoAttributeMappingsResult" + ], + "document": "mutation setSsoAttributeMappings($standardAttributeMapInput: UpdateAttributeMapInput!, $customAttributesInput: SetSsoCustomAttributeMappingsInput!) {\n updateAttributeMapV2(input: $standardAttributeMapInput) {\n ...ConnectionAttributeMap\n }\n setSsoCustomAttributeMappings(input: $customAttributesInput) {\n __typename\n ... on SsoCustomAttributeMappingsSet {\n customAttributeMappings {\n id\n attribute\n }\n }\n ... on ConnectionNotFound {\n connectionId\n }\n ... on CustomAttributeNotFound {\n customAttributeId\n }\n }\n}", + "fragmentNames": [ + "ConnectionAttributeMap" + ], + "variables": [ + { + "name": "standardAttributeMapInput", + "type": "UpdateAttributeMapInput!", + "required": true + }, + { + "name": "customAttributesInput", + "type": "SetSsoCustomAttributeMappingsInput!", + "required": true + } + ] + }, + "slackChannels": { + "name": "slackChannels", + "kind": "query", + "description": "List the Slack channels connected to the current team", + "rootFields": [ + "currentTeam" + ], + "returnTypes": [ + "Team" + ], + "document": "query slackChannels {\n currentTeam {\n slackChannels {\n ...SlackChannel\n }\n unassociatedSlackChannels {\n id\n }\n }\n}", + "fragmentNames": [ + "SlackChannel" + ], + "variables": [] + }, + "SlackConfigStatus": { + "name": "SlackConfigStatus", + "kind": "query", + "description": "Return the Slack AuthKit add-on connection status for an environment", + "rootFields": [ + "slackConfigStatus" + ], + "returnTypes": [ + "SlackConfigStatus" + ], + "document": "query SlackConfigStatus($environmentId: ID!) {\n slackConfigStatus(environmentId: $environmentId) {\n isConnected\n teamName\n channelName\n channelId\n }\n}", + "fragmentNames": [], + "variables": [ + { + "name": "environmentId", + "type": "ID!", + "required": true + } + ] + }, + "stripeBillingData": { + "name": "stripeBillingData", + "kind": "query", + "description": "Return the team for the current dashboard session", + "rootFields": [ + "currentTeam" + ], + "returnTypes": [ + "Team" + ], + "document": "query stripeBillingData {\n currentTeam {\n stripeBillingData {\n ...StripeBillingData\n }\n }\n}", + "fragmentNames": [ + "StripeBillingData" + ], + "variables": [] + }, + "stripeConnect": { + "name": "stripeConnect", + "kind": "query", + "description": "Return an environment by its ID", + "rootFields": [ + "environment" + ], + "returnTypes": [ + "Environment" + ], + "document": "query stripeConnect($environmentId: String!) {\n environment(id: $environmentId) {\n stripeAccountId\n stripeConnectState\n stripeConnectStateUpdatedAt\n stripeEntitlementsEnabled\n stripeSeatSyncEnabled\n }\n}", + "fragmentNames": [], + "variables": [ + { + "name": "environmentId", + "type": "String!", + "required": true + } + ] + }, + "tags": { + "name": "tags", + "kind": "query", + "description": "List tags within a feature-flags project, optionally filtered by name", + "rootFields": [ + "tags" + ], + "returnTypes": [ + "TagList" + ], + "document": "query tags($projectId: String!, $name: String, $limit: Int, $after: String, $before: String) {\n tags(\n projectId: $projectId\n name: $name\n limit: $limit\n after: $after\n before: $before\n ) {\n data {\n id\n projectId\n name\n createdAt\n updatedAt\n }\n listMetadata {\n before\n after\n }\n }\n}", + "fragmentNames": [], + "variables": [ + { + "name": "projectId", + "type": "String!", + "required": true + }, + { + "name": "name", + "type": "String", + "required": false + }, + { + "name": "limit", + "type": "Int", + "required": false + }, + { + "name": "after", + "type": "String", + "required": false + }, + { + "name": "before", + "type": "String", + "required": false + } + ] + }, + "teamBillingData": { + "name": "teamBillingData", + "kind": "query", + "description": "Return the current team's billing overview (Orb and Stripe data, billing details, entitlements)", + "rootFields": [ + "currentTeam" + ], + "returnTypes": [ + "Team" + ], + "document": "query teamBillingData {\n currentTeam {\n orbBillingData {\n currentPeriodStart\n currentPeriodEnd\n planName\n upcomingInvoice {\n total\n lineItems {\n name\n quantity\n total\n unitAmount\n type\n total\n quantity\n tiers {\n from\n to\n name\n total\n name\n quantity\n unitAmount\n }\n }\n }\n }\n stripeBillingData {\n lastInvoiceSummary {\n id\n total\n pdfUrl\n }\n }\n billingDetails {\n connections {\n billableConnectionCount\n }\n directories {\n billableDirectoryCount\n }\n auditLogs {\n billable30DayRention\n billable90DayRetention\n billable365DayRetention\n billableStreams\n }\n }\n entitlements {\n id\n featureId\n endedAt\n }\n }\n}", + "fragmentNames": [], + "variables": [] + }, + "teamBillingInvoice": { + "name": "teamBillingInvoice", + "kind": "query", + "description": "Return a single billing invoice for the current team by ID", + "rootFields": [ + "currentTeam" + ], + "returnTypes": [ + "Team" + ], + "document": "query teamBillingInvoice($invoiceId: String!) {\n currentTeam {\n billingInvoice(invoiceId: $invoiceId) {\n __typename\n ... on DetailedBillingInvoice {\n id\n billingPeriodStart\n connectionsCount\n tax\n totalDiscount\n appliedBalance\n total\n planKey\n pdfUrl\n receiptPdfUrl\n paidAt\n paymentStatus\n items {\n id\n count\n description\n total\n unitPrice\n featureKey\n }\n }\n ... on InvoiceNotFound {\n invoiceId\n }\n ... on LegacyBillingInvoice {\n id\n billingPeriodStart\n receiptPdfUrl\n pdfUrl\n planKey\n tax\n totalDiscount\n appliedBalance\n total\n paidAt\n paymentStatus\n }\n }\n }\n}", + "fragmentNames": [], + "variables": [ + { + "name": "invoiceId", + "type": "String!", + "required": true + } + ] + }, + "teamBillingPaymentHistory": { + "name": "teamBillingPaymentHistory", + "kind": "query", + "description": "Return the current team's billing payment history", + "rootFields": [ + "currentTeam" + ], + "returnTypes": [ + "Team" + ], + "document": "query teamBillingPaymentHistory {\n currentTeam {\n paymentHistory {\n items {\n ...BillingPaymentHistoryItem\n }\n }\n }\n}", + "fragmentNames": [ + "BillingPaymentHistoryItem" + ], + "variables": [] + }, + "teamDashboardOrgDomains": { + "name": "teamDashboardOrgDomains", + "kind": "query", + "description": "List the WorkOS organization domains for the current team", + "rootFields": [ + "currentTeam" + ], + "returnTypes": [ + "Team" + ], + "document": "query teamDashboardOrgDomains {\n currentTeam {\n workOsOrganizationDomains {\n id\n domain\n state\n }\n }\n}", + "fragmentNames": [], + "variables": [] + }, + "teamDashboardSso": { + "name": "teamDashboardSso", + "kind": "query", + "description": "Return the SSO connection the current team uses to log in to the WorkOS dashboard", + "rootFields": [ + "currentTeam" + ], + "returnTypes": [ + "Team" + ], + "document": "query teamDashboardSso {\n currentTeam {\n connection {\n ...TeamConnection\n }\n }\n}", + "fragmentNames": [ + "TeamConnection" + ], + "variables": [] + }, + "TeamDeletionCheck": { + "name": "TeamDeletionCheck", + "kind": "query", + "description": "Return whether the current team can be deleted, with its production state", + "rootFields": [ + "currentTeam" + ], + "returnTypes": [ + "Team" + ], + "document": "query TeamDeletionCheck {\n currentTeam {\n id\n name\n productionState\n canDeleteTeam {\n canDelete\n hasOnlyCustomDomainCharges\n }\n }\n}", + "fragmentNames": [], + "variables": [] + }, + "teamMemberships": { + "name": "teamMemberships", + "kind": "query", + "description": "List the members of the current team", + "rootFields": [ + "currentTeam" + ], + "returnTypes": [ + "Team" + ], + "document": "query teamMemberships {\n currentTeam {\n memberships: users_teams {\n ...TeamMembership\n }\n }\n}", + "fragmentNames": [ + "TeamMembership" + ], + "variables": [] + }, + "TeamPlatforms": { + "name": "TeamPlatforms", + "kind": "query", + "description": "List the third-party platforms provisioned for the current team", + "rootFields": [ + "teamPlatforms" + ], + "returnTypes": [ + "TeamPlatform" + ], + "document": "query TeamPlatforms {\n teamPlatforms {\n ...TeamPlatform\n }\n}", + "fragmentNames": [ + "TeamPlatform" + ], + "variables": [] + }, + "teamProjectsV2": { + "name": "teamProjectsV2", + "kind": "query", + "description": "Return the team for the current dashboard session", + "rootFields": [ + "currentTeam" + ], + "returnTypes": [ + "Team" + ], + "document": "query teamProjectsV2 {\n currentTeam {\n id\n projectsV2 {\n id\n name\n environments {\n ...Environment\n }\n }\n }\n}", + "fragmentNames": [ + "Environment" + ], + "variables": [] + }, + "testActionEndpoint": { + "name": "testActionEndpoint", + "kind": "mutation", + "description": "Trigger a test execution of an environment's configured Action endpoint with a sample context payload", + "rootFields": [ + "testActionEndpoint" + ], + "returnTypes": [ + "TestActionEndpointResult" + ], + "document": "mutation testActionEndpoint($input: TestActionEndpointInput!) {\n testActionEndpoint(input: $input) {\n __typename\n ... on ActionExecution {\n ...ActionExecution\n }\n ... on InvalidContext {\n error\n }\n ... on UnsupportedType {\n type\n }\n ... on NoEndpointConfigured {\n type\n }\n }\n}", + "fragmentNames": [ + "ActionExecution" + ], + "variables": [ + { + "name": "input", + "type": "TestActionEndpointInput!", + "required": true + } + ] + }, + "unbindManagedListFromEnvironment": { + "name": "unbindManagedListFromEnvironment", + "kind": "mutation", + "description": "Unbind a Radar managed list from an environment so its action no longer applies", + "rootFields": [ + "unbindManagedListFromEnvironment" + ], + "returnTypes": [ + "UnbindManagedListFromEnvironmentResult" + ], + "document": "mutation unbindManagedListFromEnvironment($input: UnbindManagedListFromEnvironmentInput!) {\n unbindManagedListFromEnvironment(input: $input) {\n __typename\n ... on RadarListBindingOperationSuccess {\n success\n }\n }\n}", + "fragmentNames": [], + "variables": [ + { + "name": "input", + "type": "UnbindManagedListFromEnvironmentInput!", + "required": true + } + ] + }, + "updateAppBranding": { + "name": "updateAppBranding", + "kind": "mutation", + "description": "Update an application's branding assets such as light and dark logos and theme settings", + "rootFields": [ + "updateAppBranding" + ], + "returnTypes": [ + "UpdateAppBrandingResult" + ], + "document": "mutation updateAppBranding($input: UpdateAppBrandingInput!) {\n updateAppBranding(input: $input) {\n __typename\n ... on AppBrandingUpdated {\n appBranding {\n ...AppBranding\n }\n }\n ... on AppBrandingUploadAssetsError {\n errorMessage\n }\n }\n}", + "fragmentNames": [ + "AppBranding" + ], + "variables": [ + { + "name": "input", + "type": "UpdateAppBrandingInput!", + "required": true + } + ] + }, + "updateApplication": { + "name": "updateApplication", + "kind": "mutation", + "description": "Update a Connect application's name, description, and logo images", + "rootFields": [ + "updateApplication" + ], + "returnTypes": [ + "UpdateApplicationResult" + ], + "document": "mutation updateApplication($input: UpdateApplicationInput!) {\n updateApplication(input: $input) {\n __typename\n ... on ApplicationNotFound {\n applicationId\n }\n ... on ApplicationAssetUploadError {\n message\n }\n ... on ApplicationUpdated {\n application {\n ...Application\n }\n }\n }\n}", + "fragmentNames": [ + "Application" + ], + "variables": [ + { + "name": "input", + "type": "UpdateApplicationInput!", + "required": true + } + ] + }, + "updateAuthkitApplication": { + "name": "updateAuthkitApplication", + "kind": "mutation", + "description": "Update an AuthKit application's URLs, session timeouts, and token expiry settings", + "rootFields": [ + "updateUserlandApplication" + ], + "returnTypes": [ + "UpdateUserlandApplicationResult" + ], + "document": "mutation updateAuthkitApplication($input: UpdateUserlandApplicationInput!) {\n updateUserlandApplication(input: $input) {\n __typename\n ... on UserlandApplicationUpdated {\n userlandApplication {\n ...UserlandApplication\n }\n }\n ... on UserlandApplicationNotFound {\n applicationId\n }\n ... on UserlandApplicationValidationFailed {\n message\n }\n }\n}", + "fragmentNames": [ + "Key", + "RedirectUri", + "UserlandApplication" + ], + "variables": [ + { + "name": "input", + "type": "UpdateUserlandApplicationInput!", + "required": true + } + ] + }, + "updateConnection": { + "name": "updateConnection", + "kind": "mutation", + "description": "Update an SSO connection's settings such as SAML/OIDC configuration and name", + "rootFields": [ + "updateConnection" + ], + "returnTypes": [ + "UpdateConnectionResult" + ], + "document": "mutation updateConnection($input: UpdateConnectionInput!) {\n updateConnection(input: $input) {\n __typename\n ... on ConnectionUpdated {\n connection {\n id\n state\n name\n type\n oidc_client_id\n oidc_discovery_endpoint\n saml_acs_url\n saml_entity_id\n saml_idp_url\n samlX509Certificates {\n id\n value\n }\n }\n }\n ... on ConnectionNotFound {\n connectionId\n }\n }\n}", + "fragmentNames": [], + "variables": [ + { + "name": "input", + "type": "UpdateConnectionInput!", + "required": true + } + ] + }, + "updateConnectionFromDiscoveryEndpoint": { + "name": "updateConnectionFromDiscoveryEndpoint", + "kind": "mutation", + "description": "Update an OIDC SSO connection by fetching configuration from its discovery endpoint", + "rootFields": [ + "updateConnectionFromDiscoveryEndpoint" + ], + "returnTypes": [ + "UpdateConnectionFromDiscoveryEndpointResult" + ], + "document": "mutation updateConnectionFromDiscoveryEndpoint($input: UpdateConnectionFromDiscoveryEndpointInput!) {\n updateConnectionFromDiscoveryEndpoint(input: $input) {\n __typename\n ... on ConnectionUpdatedFromDiscoveryEndpoint {\n connection {\n id\n state\n name\n type\n oidc_client_id\n oidc_discovery_endpoint\n oidc_token_endpoint_auth_method\n oidc_pkce\n oidc_id_token_signed_response_alg\n saml_acs_url\n saml_entity_id\n saml_idp_url\n saml_idp_metadata_url\n samlX509Certificates {\n ...SamlX509Certificate\n }\n }\n }\n ... on ConnectionNotFound {\n connectionId\n }\n ... on OidcDiscoveryValidationFailed {\n errors {\n tag\n severity\n helpMessage\n reason\n }\n }\n }\n}", + "fragmentNames": [ + "SamlX509Certificate" + ], + "variables": [ + { + "name": "input", + "type": "UpdateConnectionFromDiscoveryEndpointInput!", + "required": true + } + ] + }, + "updateConnectionFromMetadataUrl": { + "name": "updateConnectionFromMetadataUrl", + "kind": "mutation", + "description": "Update a SAML SSO connection from the identity provider's metadata URL", + "rootFields": [ + "updateConnectionFromMetadataUrl" + ], + "returnTypes": [ + "UpdateConnectionFromMetadataUrlResult" + ], + "document": "mutation updateConnectionFromMetadataUrl($input: UpdateConnectionFromMetadataUrlInput!) {\n updateConnectionFromMetadataUrl(input: $input) {\n __typename\n ... on ConnectionUpdatedFromMetadataUrl {\n connection {\n id\n state\n name\n type\n oidc_client_id\n oidc_discovery_endpoint\n saml_acs_url\n saml_entity_id\n saml_idp_url\n saml_idp_metadata_url\n samlX509Certificates {\n ...SamlX509Certificate\n }\n }\n }\n ... on ConnectionNotFound {\n connectionId\n }\n ... on MetadataFetchFailed {\n reason\n }\n ... on InvalidSamlX509Certificate {\n validationError\n }\n }\n}", + "fragmentNames": [ + "SamlX509Certificate" + ], + "variables": [ + { + "name": "input", + "type": "UpdateConnectionFromMetadataUrlInput!", + "required": true + } + ] + }, + "updateConnectionFromMetadataXml": { + "name": "updateConnectionFromMetadataXml", + "kind": "mutation", + "description": "Update a SAML SSO connection from pasted identity provider metadata XML", + "rootFields": [ + "updateConnectionFromMetadataXml" + ], + "returnTypes": [ + "UpdateConnectionFromMetadataXmlResult" + ], + "document": "mutation updateConnectionFromMetadataXml($input: UpdateConnectionFromMetadataXmlInput!) {\n updateConnectionFromMetadataXml(input: $input) {\n __typename\n ... on ConnectionUpdatedFromMetadataXml {\n connection {\n id\n state\n name\n type\n oidc_client_id\n oidc_discovery_endpoint\n saml_acs_url\n saml_entity_id\n saml_idp_url\n saml_idp_metadata_url\n samlX509Certificates {\n ...SamlX509Certificate\n }\n }\n }\n ... on ConnectionNotFound {\n connectionId\n }\n ... on MetadataParseFailed {\n reason\n }\n ... on InvalidSamlX509Certificate {\n validationError\n }\n }\n}", + "fragmentNames": [ + "SamlX509Certificate" + ], + "variables": [ + { + "name": "input", + "type": "UpdateConnectionFromMetadataXmlInput!", + "required": true + } + ] + }, + "updateConnectionGroupWithRoleMapping": { + "name": "updateConnectionGroupWithRoleMapping", + "kind": "mutation", + "description": "Rename a connection group Create or replace group-to-role mappings for an SSO connection Delete directory group-to-role mappings by ID in the current environment", + "rootFields": [ + "updateConnectionGroup", + "upsertGroupRoleMappingsForConnection", + "deleteGroupRoleMappings" + ], + "returnTypes": [ + "UpdateConnectionGroupResult", + "UpsertConnectionGroupRoleMappingsResult", + "DeleteGroupRoleMappingsResult" + ], + "document": "mutation updateConnectionGroupWithRoleMapping($updateConnectionGroupInput: UpdateConnectionGroupInput!, $updateGroupRoleMappingInput: UpsertConnectionGroupRoleMappingsInput!, $deleteGroupRoleMappingInput: DeleteGroupRoleMappingsInput!) {\n updateConnectionGroup(input: $updateConnectionGroupInput) {\n __typename\n ... on ConnectionGroupNotFound {\n connectionGroupId\n }\n ... on ConnectionGroupUpdated {\n connectionGroup {\n connectionId\n idpId\n name\n }\n }\n }\n upsertGroupRoleMappingsForConnection(input: $updateGroupRoleMappingInput) {\n __typename\n ... on EnvironmentNotFound {\n environmentId\n }\n ... on ConnectionNotFound {\n connectionId\n }\n ... on GroupRoleMappingsUpserted {\n groupRoleMappings {\n data {\n id\n stableGroupId\n entityId\n roleId\n }\n }\n }\n }\n deleteGroupRoleMappings(input: $deleteGroupRoleMappingInput) {\n __typename\n ... on EnvironmentNotFound {\n environmentId\n }\n ... on GroupRoleMappingsDeleted {\n groupRoleMappingIds\n }\n }\n}", + "fragmentNames": [], + "variables": [ + { + "name": "updateConnectionGroupInput", + "type": "UpdateConnectionGroupInput!", + "required": true + }, + { + "name": "updateGroupRoleMappingInput", + "type": "UpsertConnectionGroupRoleMappingsInput!", + "required": true + }, + { + "name": "deleteGroupRoleMappingInput", + "type": "DeleteGroupRoleMappingsInput!", + "required": true + } + ] + }, + "updateCorsConfig": { + "name": "updateCorsConfig", + "kind": "mutation", + "description": "Set the full list of allowed web origins (CORS) for an environment (supports dry-run validation)", + "rootFields": [ + "setWebOrigins" + ], + "returnTypes": [ + "SetWebOriginsResult" + ], + "document": "mutation updateCorsConfig($environmentId: String!, $origins: [String!]!, $dryRun: Boolean) {\n setWebOrigins(environmentId: $environmentId, origins: $origins, dryRun: $dryRun) {\n __typename\n ... on WebOriginsSet {\n origins\n }\n ... on MalformedWebOrigin {\n message\n uri\n }\n ... on InvalidWebOriginProtocol {\n message\n uri\n }\n ... on InvalidWildcardWebOrigin {\n message\n uri\n }\n ... on WildcardNotAllowedInEnvironment {\n message\n uri\n }\n }\n}", + "fragmentNames": [], + "variables": [ + { + "name": "environmentId", + "type": "String!", + "required": true + }, + { + "name": "origins", + "type": "[String!]!", + "required": true + }, + { + "name": "dryRun", + "type": "Boolean", + "required": false + } + ] + }, + "updateCustomDataProvider": { + "name": "updateCustomDataProvider", + "kind": "mutation", + "description": "Update a custom Pipes data provider's settings such as slug in the current environment", + "rootFields": [ + "updateCustomDataProvider" + ], + "returnTypes": [ + "CustomDataProviderPayload" + ], + "document": "mutation updateCustomDataProvider($input: UpdateCustomDataProviderInput!) {\n updateCustomDataProvider(input: $input) {\n __typename\n customDataProvider {\n __typename\n id\n slug\n name\n description\n authorizationUrl\n tokenUrl\n refreshTokenUrl\n pkceEnabled\n requestScopeSeparator\n scopesRequired\n clientSecretRequired\n tokenBodyContentType\n authenticateVia\n iconSlug\n environmentId\n additionalAuthorizationParameters {\n key\n value\n }\n createdAt\n updatedAt\n }\n }\n}", + "fragmentNames": [], + "variables": [ + { + "name": "input", + "type": "UpdateCustomDataProviderInput!", + "required": true + } + ] + }, + "updateCustomEmailProvider": { + "name": "updateCustomEmailProvider", + "kind": "mutation", + "description": "Update a custom email provider's sender, reply-to, and credentials (e.g. Amazon SES)", + "rootFields": [ + "updateCustomEmailProvider" + ], + "returnTypes": [ + "UpdateCustomEmailProviderResult" + ], + "document": "mutation updateCustomEmailProvider($input: UpdateCustomEmailProviderInput!) {\n updateCustomEmailProvider(input: $input) {\n __typename\n ... on CustomEmailProviderUpdated {\n customEmailProvider {\n __typename\n id\n defaultSender\n defaultReplyTo\n providerType\n ... on AwsSesCustomEmailProvider {\n awsSesRegion\n }\n }\n }\n ... on CustomEmailProviderNotFound {\n customEmailProviderId\n }\n ... on CustomEmailProviderEmailAddressDomainNotVerifiedErrorList {\n failedVerificationErrors {\n emailAddressField\n domain\n }\n }\n ... on CustomEmailProviderInputError {\n type\n message\n }\n ... on CustomEmailProviderSenderDomainNotVerifiedError {\n domain\n }\n ... on MailgunCustomEmailProviderDomainNotVerifiedError {\n domain\n }\n }\n}", + "fragmentNames": [], + "variables": [ + { + "name": "input", + "type": "UpdateCustomEmailProviderInput!", + "required": true + } + ] + }, + "updateCustomMappedCustomAttribute": { + "name": "updateCustomMappedCustomAttribute", + "kind": "mutation", + "description": "Update the description of a custom-mapped custom attribute", + "rootFields": [ + "updateCustomMappedCustomAttribute" + ], + "returnTypes": [ + "UpdateCustomMappedCustomAttributeResult" + ], + "document": "mutation updateCustomMappedCustomAttribute($input: UpdateCustomMappedCustomAttributeInput!) {\n updateCustomMappedCustomAttribute(input: $input) {\n __typename\n ... on CustomAttributeInvalid {\n __typename\n reason\n }\n ... on CustomAttributeNotFound {\n __typename\n customAttributeId\n }\n ... on CustomMappedCustomAttributeUpdated {\n __typename\n customAttribute {\n ...CustomMappedCustomAttribute\n }\n }\n }\n}", + "fragmentNames": [ + "CustomMappedCustomAttribute" + ], + "variables": [ + { + "name": "input", + "type": "UpdateCustomMappedCustomAttributeInput!", + "required": true + } + ] + }, + "updateDataProviderIntegration": { + "name": "updateDataProviderIntegration", + "kind": "mutation", + "description": "Update a Pipes data provider integration's slug and OAuth credentials in the environment", + "rootFields": [ + "updateDataProviderIntegration" + ], + "returnTypes": [ + "UpdateDataProviderIntegrationResult" + ], + "document": "mutation updateDataProviderIntegration($input: UpdateDataIntegrationInput!) {\n updateDataProviderIntegration(input: $input) {\n __typename\n ... on DataProviderIntegrationUpdated {\n dataProviderIntegration {\n __typename\n id\n providerSlug\n enabled\n scopes\n authMethods\n }\n }\n ... on DataIntegrationNotFound {\n dataIntegrationId\n }\n ... on InvalidDataIntegrationCredentialsType {\n message\n }\n }\n}", + "fragmentNames": [], + "variables": [ + { + "name": "input", + "type": "UpdateDataIntegrationInput!", + "required": true + } + ] + }, + "updateDirectory": { + "name": "updateDirectory", + "kind": "mutation", + "description": "Update a Directory Sync connection's settings and provider credentials for an organization", + "rootFields": [ + "updateDirectory" + ], + "returnTypes": [ + "UpdateDirectoryResult" + ], + "document": "mutation updateDirectory($input: UpdateDirectoryInput!) {\n updateDirectory(input: $input) {\n __typename\n ... on DirectoryUpdated {\n directory {\n ...Directory\n }\n }\n ... on DirectoryNotFound {\n directoryId\n }\n ... on DirectoryProviderErrors {\n errors {\n message\n }\n }\n ... on InvalidSshKey {\n message\n }\n }\n}", + "fragmentNames": [ + "BambooHrDirectory", + "BreatheHrDirectory", + "CezanneHrDirectory", + "Directory", + "DirectoryAttributeMap", + "DirectoryConfig", + "FourthHrDirectory", + "GoogleWorkspaceDirectory", + "HibobDirectory", + "OrganizationDomain", + "PeopleHrDirectory", + "PersonioDirectory", + "RipplingDirectory", + "SftpDirectory", + "WorkdayDirectory" + ], + "variables": [ + { + "name": "input", + "type": "UpdateDirectoryInput!", + "required": true + } + ] + }, + "updateDirectoryAttributeMap": { + "name": "updateDirectoryAttributeMap", + "kind": "mutation", + "description": "Update a Directory Sync attribute map controlling how IdP user attributes are mapped", + "rootFields": [ + "updateDirectoryAttributeMap" + ], + "returnTypes": [ + "DirectoryAttributeMap" + ], + "document": "mutation updateDirectoryAttributeMap($directory_attribute_map_id: String!, $emails_attribute: String, $external_id_attribute: String, $first_name_attribute: String, $group_name_attribute: String, $last_name_attribute: String, $job_title_attribute: String, $username_attribute: String) {\n updateDirectoryAttributeMap(\n directory_attribute_map_id: $directory_attribute_map_id\n emails_attribute: $emails_attribute\n external_id_attribute: $external_id_attribute\n first_name_attribute: $first_name_attribute\n group_name_attribute: $group_name_attribute\n last_name_attribute: $last_name_attribute\n job_title_attribute: $job_title_attribute\n username_attribute: $username_attribute\n ) {\n ...DirectoryAttributeMap\n }\n}", + "fragmentNames": [ + "DirectoryAttributeMap" + ], + "variables": [ + { + "name": "directory_attribute_map_id", + "type": "String!", + "required": true + }, + { + "name": "emails_attribute", + "type": "String", + "required": false + }, + { + "name": "external_id_attribute", + "type": "String", + "required": false + }, + { + "name": "first_name_attribute", + "type": "String", + "required": false + }, + { + "name": "group_name_attribute", + "type": "String", + "required": false + }, + { + "name": "last_name_attribute", + "type": "String", + "required": false + }, + { + "name": "job_title_attribute", + "type": "String", + "required": false + }, + { + "name": "username_attribute", + "type": "String", + "required": false + } + ] + }, + "UpdateDirectoryConfig": { + "name": "UpdateDirectoryConfig", + "kind": "mutation", + "description": "Update a directory's group-sync configuration in the current environment", + "rootFields": [ + "updateDirectoryConfigDashboard" + ], + "returnTypes": [ + "DirectoryConfig" + ], + "document": "mutation UpdateDirectoryConfig($input: UpdateDirectoryConfigInput!) {\n updateDirectoryConfigDashboard(input: $input) {\n ...DirectoryConfig\n }\n}", + "fragmentNames": [ + "DirectoryConfig" + ], + "variables": [ + { + "name": "input", + "type": "UpdateDirectoryConfigInput!", + "required": true + } + ] + }, + "updateEnvironmentLocalization": { + "name": "updateEnvironmentLocalization", + "kind": "mutation", + "description": "Update an environment's default locale and whether localization is enabled", + "rootFields": [ + "updateEnvironmentLocalization" + ], + "returnTypes": [ + "UpdateEnvironmentLocalizationPayload" + ], + "document": "mutation updateEnvironmentLocalization($input: UpdateEnvironmentLocalizationInput!) {\n updateEnvironmentLocalization(input: $input) {\n environment {\n localizationEnabled\n defaultLocale\n }\n }\n}", + "fragmentNames": [], + "variables": [ + { + "name": "input", + "type": "UpdateEnvironmentLocalizationInput!", + "required": true + } + ] + }, + "updateEnvironmentOrganizationAdminCollectionEnabled": { + "name": "updateEnvironmentOrganizationAdminCollectionEnabled", + "kind": "mutation", + "description": "Toggle whether an environment collects organization admin contacts", + "rootFields": [ + "updateEnvironmentOrganizationAdminCollectionEnabled" + ], + "returnTypes": [ + "UpdateEnvironmentOrganizationAdminCollectionEnabledPayload" + ], + "document": "mutation updateEnvironmentOrganizationAdminCollectionEnabled($input: UpdateEnvironmentOrganizationAdminCollectionEnabledInput!) {\n updateEnvironmentOrganizationAdminCollectionEnabled(input: $input) {\n environment {\n id\n organizationAdminCollectionEnabled\n }\n }\n}", + "fragmentNames": [], + "variables": [ + { + "name": "input", + "type": "UpdateEnvironmentOrganizationAdminCollectionEnabledInput!", + "required": true + } + ] + }, + "updateEnvironmentSsoSigninConsent": { + "name": "updateEnvironmentSsoSigninConsent", + "kind": "mutation", + "description": "Toggle whether an environment requires SSO sign-in consent", + "rootFields": [ + "updateEnvironmentSsoSigninConsent" + ], + "returnTypes": [ + "UpdateEnvironmentSsoSigninConsentPayload" + ], + "document": "mutation updateEnvironmentSsoSigninConsent($input: UpdateEnvironmentSsoSigninConsentInput!) {\n updateEnvironmentSsoSigninConsent(input: $input) {\n environment {\n id\n ssoSignInConsentEnabled\n }\n }\n}", + "fragmentNames": [], + "variables": [ + { + "name": "input", + "type": "UpdateEnvironmentSsoSigninConsentInput!", + "required": true + } + ] + }, + "updateFlag": { + "name": "updateFlag", + "kind": "mutation", + "description": "Update a feature flag's name, description, owner, and tags", + "rootFields": [ + "updateFlag" + ], + "returnTypes": [ + "UpdateFlagResult" + ], + "document": "mutation updateFlag($input: UpdateFlagInput!) {\n updateFlag(input: $input) {\n __typename\n ... on FlagNotFound {\n __typename\n flagId\n }\n ... on FlagUpdated {\n __typename\n flag {\n ...Flag\n }\n }\n }\n}", + "fragmentNames": [ + "Flag" + ], + "variables": [ + { + "name": "input", + "type": "UpdateFlagInput!", + "required": true + } + ] + }, + "updateFlagEnvironment": { + "name": "updateFlagEnvironment", + "kind": "mutation", + "description": "Update a feature flag's per-environment state, access type, and enabled organizations and users", + "rootFields": [ + "updateFlagEnvironment" + ], + "returnTypes": [ + "UpdateFlagEnvironmentResult" + ], + "document": "mutation updateFlagEnvironment($input: UpdateFlagEnvironmentInput!) {\n updateFlagEnvironment(input: $input) {\n __typename\n ... on FlagEnvironmentNotFound {\n __typename\n flagEnvironmentId\n }\n ... on FlagEnvironmentUpdated {\n __typename\n flagEnvironment {\n ...FlagEnvironment\n }\n }\n }\n}", + "fragmentNames": [ + "FlagEnvironment" + ], + "variables": [ + { + "name": "input", + "type": "UpdateFlagEnvironmentInput!", + "required": true + } + ] + }, + "updateIdpAttributesConfig": { + "name": "updateIdpAttributesConfig", + "kind": "mutation", + "description": "Update whether SSO and Directory Sync custom attribute mapping are enabled in the IdP attributes config", + "rootFields": [ + "updateIdpAttributesConfig" + ], + "returnTypes": [ + "UpdateIdpAttributesConfigResult" + ], + "document": "mutation updateIdpAttributesConfig($input: UpdateIdpAttributesConfigInput!) {\n updateIdpAttributesConfig(input: $input) {\n __typename\n ... on IdpAttributesConfigNotFound {\n __typename\n idpAttributesConfigId\n }\n ... on IdpAttributesConfigUpdated {\n __typename\n idpAttributesConfigId\n }\n }\n}", + "fragmentNames": [], + "variables": [ + { + "name": "input", + "type": "UpdateIdpAttributesConfigInput!", + "required": true + } + ] + }, + "UpdateInitiateLoginUrl": { + "name": "UpdateInitiateLoginUrl", + "kind": "mutation", + "description": "Update the initiate-login URL on an environment's default AuthKit application", + "rootFields": [ + "updateInitiateLoginUrl" + ], + "returnTypes": [ + "UpdateInitiateLoginUrlResult" + ], + "document": "mutation UpdateInitiateLoginUrl($environmentId: String!, $url: String!) {\n updateInitiateLoginUrl(environmentId: $environmentId, url: $url) {\n __typename\n ... on InitiateLoginUrlUpdated {\n url\n }\n ... on EnvironmentNotFound {\n environmentId\n }\n }\n}", + "fragmentNames": [], + "variables": [ + { + "name": "environmentId", + "type": "String!", + "required": true + }, + { + "name": "url", + "type": "String!", + "required": true + } + ] + }, + "updateJitProvisioningConfiguration": { + "name": "updateJitProvisioningConfiguration", + "kind": "mutation", + "description": "Update an organization's name, external ID, metadata, and related settings", + "rootFields": [ + "updateOrganization" + ], + "returnTypes": [ + "UpdateOrganizationResult" + ], + "document": "mutation updateJitProvisioningConfiguration($organizationId: ID!, $isSsoJitProvisioningEnabled: Boolean) {\n updateOrganization(\n id: $organizationId\n isSsoJitProvisioningEnabled: $isSsoJitProvisioningEnabled\n ) {\n ... on UpdateOrganizationPayload {\n organization {\n __typename\n }\n }\n }\n}", + "fragmentNames": [], + "variables": [ + { + "name": "organizationId", + "type": "ID!", + "required": true + }, + { + "name": "isSsoJitProvisioningEnabled", + "type": "Boolean", + "required": false + } + ] + }, + "updateMyself": { + "name": "updateMyself", + "kind": "mutation", + "description": "Update the signed-in dashboard user's own first and last name", + "rootFields": [ + "updateMyself" + ], + "returnTypes": [ + "UpdateMyselfResult" + ], + "document": "mutation updateMyself($input: UpdateMyselfInput!) {\n updateMyself(input: $input) {\n __typename\n ... on MyselfUpdated {\n me {\n ...AuthedUser\n }\n }\n }\n}", + "fragmentNames": [ + "AuthedUser", + "TeamMembership" + ], + "variables": [ + { + "name": "input", + "type": "UpdateMyselfInput!", + "required": true + } + ] + }, + "updateNotification": { + "name": "updateNotification", + "kind": "mutation", + "description": "Update a dashboard notification's state, such as marking it read", + "rootFields": [ + "updateNotification" + ], + "returnTypes": [ + "Notification" + ], + "document": "mutation updateNotification($id: ID!, $state: NotificationState) {\n updateNotification(id: $id, state: $state) {\n __typename\n }\n}", + "fragmentNames": [], + "variables": [ + { + "name": "id", + "type": "ID!", + "required": true + }, + { + "name": "state", + "type": "NotificationState", + "required": false + } + ] + }, + "updateOauthCredentials": { + "name": "updateOauthCredentials", + "kind": "mutation", + "description": "Update a social OAuth provider's client credentials and toggle whether it is enabled for AuthKit sign-in", + "rootFields": [ + "updateOauthCredentials" + ], + "returnTypes": [ + "UpdateOauthCredentialsResult" + ], + "document": "mutation updateOauthCredentials($input: UpdateOauthCredentialsInput!) {\n updateOauthCredentials(input: $input) {\n __typename\n ... on OauthCredentialsUpdated {\n credentials {\n ...OAuthCredential\n }\n }\n ... on OauthCredentialsNotFound {\n environmentId\n }\n ... on InvalidOauthCredentials {\n errors {\n field\n message\n }\n }\n }\n}", + "fragmentNames": [ + "OAuthCredential" + ], + "variables": [ + { + "name": "input", + "type": "UpdateOauthCredentialsInput!", + "required": true + } + ] + }, + "updateOrganization": { + "name": "updateOrganization", + "kind": "mutation", + "description": "Update an organization's name, external ID, metadata, and related settings", + "rootFields": [ + "updateOrganization" + ], + "returnTypes": [ + "UpdateOrganizationResult" + ], + "document": "mutation updateOrganization($id: ID!, $name: String, $domains: [String!], $domainsDeveloperVerified: Boolean, $allowProfilesOutsideOrganization: Boolean, $recreateDomainsWithDifferentState: Boolean, $externalId: String, $metadata: JSON) {\n updateOrganization(\n id: $id\n name: $name\n domains: $domains\n domainsDeveloperVerified: $domainsDeveloperVerified\n allowProfilesOutsideOrganization: $allowProfilesOutsideOrganization\n recreateDomainsWithDifferentState: $recreateDomainsWithDifferentState\n externalId: $externalId\n metadata: $metadata\n ) {\n __typename\n ... on UpdateOrganizationPayload {\n organization {\n id\n name\n domains {\n id\n domain\n state\n }\n }\n }\n ... on ConsumerDomainForbidden {\n domain\n }\n ... on ExternalIDAlreadyUsed {\n externalId\n }\n }\n}", + "fragmentNames": [], + "variables": [ + { + "name": "id", + "type": "ID!", + "required": true + }, + { + "name": "name", + "type": "String", + "required": false + }, + { + "name": "domains", + "type": "[String!]", + "required": false + }, + { + "name": "domainsDeveloperVerified", + "type": "Boolean", + "required": false + }, + { + "name": "allowProfilesOutsideOrganization", + "type": "Boolean", + "required": false + }, + { + "name": "recreateDomainsWithDifferentState", + "type": "Boolean", + "required": false + }, + { + "name": "externalId", + "type": "String", + "required": false + }, + { + "name": "metadata", + "type": "JSON", + "required": false + } + ] + }, + "updateOrganizationUserlandSettings": { + "name": "updateOrganizationUserlandSettings", + "kind": "mutation", + "description": "Update an organization's name, external ID, metadata, and related settings", + "rootFields": [ + "updateOrganization" + ], + "returnTypes": [ + "UpdateOrganizationResult" + ], + "document": "mutation updateOrganizationUserlandSettings($organizationId: ID!, $isAppleOauthEnabled: Boolean, $isBitbucketOauthEnabled: Boolean, $isDiscordOauthEnabled: Boolean, $isGithubOauthEnabled: Boolean, $isGitLabOauthEnabled: Boolean, $isGoogleOauthEnabled: Boolean, $isIntuitOauthEnabled: Boolean, $isLinkedInOauthEnabled: Boolean, $isMicrosoftOauthEnabled: Boolean, $isSalesforceOauthEnabled: Boolean, $isSlackOauthEnabled: Boolean, $isVercelMarketplaceOauthEnabled: Boolean, $isVercelOauthEnabled: Boolean, $isXeroOauthEnabled: Boolean, $isMagicAuthEnabled: Boolean, $isPasswordAuthEnabled: Boolean, $isPasskeyAuthEnabled: Boolean, $domainMfaRequired: Boolean, $domainSsoRequired: Boolean, $nonDomainSsoRequired: Boolean, $nonDomainMfaRequired: Boolean, $setDomainCaptureEnabledDomains: [ID!], $isDomainJitProvisioningEnabled: Boolean) {\n updateOrganization(\n id: $organizationId\n isAppleOauthEnabled: $isAppleOauthEnabled\n isBitbucketOauthEnabled: $isBitbucketOauthEnabled\n isDiscordOauthEnabled: $isDiscordOauthEnabled\n isGithubOauthEnabled: $isGithubOauthEnabled\n isGitLabOauthEnabled: $isGitLabOauthEnabled\n isGoogleOauthEnabled: $isGoogleOauthEnabled\n isIntuitOauthEnabled: $isIntuitOauthEnabled\n isLinkedInOauthEnabled: $isLinkedInOauthEnabled\n isMicrosoftOauthEnabled: $isMicrosoftOauthEnabled\n isSalesforceOauthEnabled: $isSalesforceOauthEnabled\n isSlackOauthEnabled: $isSlackOauthEnabled\n isVercelMarketplaceOauthEnabled: $isVercelMarketplaceOauthEnabled\n isVercelOauthEnabled: $isVercelOauthEnabled\n isXeroOauthEnabled: $isXeroOauthEnabled\n isMagicAuthEnabled: $isMagicAuthEnabled\n isPasswordAuthEnabled: $isPasswordAuthEnabled\n isPasskeyAuthEnabled: $isPasskeyAuthEnabled\n domainMfaRequired: $domainMfaRequired\n domainSsoRequired: $domainSsoRequired\n nonDomainSsoRequired: $nonDomainSsoRequired\n nonDomainMfaRequired: $nonDomainMfaRequired\n setDomainCaptureEnabledDomains: $setDomainCaptureEnabledDomains\n isDomainJitProvisioningEnabled: $isDomainJitProvisioningEnabled\n ) {\n ... on UpdateOrganizationPayload {\n organization {\n __typename\n }\n }\n }\n}", + "fragmentNames": [], + "variables": [ + { + "name": "organizationId", + "type": "ID!", + "required": true + }, + { + "name": "isAppleOauthEnabled", + "type": "Boolean", + "required": false + }, + { + "name": "isBitbucketOauthEnabled", + "type": "Boolean", + "required": false + }, + { + "name": "isDiscordOauthEnabled", + "type": "Boolean", + "required": false + }, + { + "name": "isGithubOauthEnabled", + "type": "Boolean", + "required": false + }, + { + "name": "isGitLabOauthEnabled", + "type": "Boolean", + "required": false + }, + { + "name": "isGoogleOauthEnabled", + "type": "Boolean", + "required": false + }, + { + "name": "isIntuitOauthEnabled", + "type": "Boolean", + "required": false + }, + { + "name": "isLinkedInOauthEnabled", + "type": "Boolean", + "required": false + }, + { + "name": "isMicrosoftOauthEnabled", + "type": "Boolean", + "required": false + }, + { + "name": "isSalesforceOauthEnabled", + "type": "Boolean", + "required": false + }, + { + "name": "isSlackOauthEnabled", + "type": "Boolean", + "required": false + }, + { + "name": "isVercelMarketplaceOauthEnabled", + "type": "Boolean", + "required": false + }, + { + "name": "isVercelOauthEnabled", + "type": "Boolean", + "required": false + }, + { + "name": "isXeroOauthEnabled", + "type": "Boolean", + "required": false + }, + { + "name": "isMagicAuthEnabled", + "type": "Boolean", + "required": false + }, + { + "name": "isPasswordAuthEnabled", + "type": "Boolean", + "required": false + }, + { + "name": "isPasskeyAuthEnabled", + "type": "Boolean", + "required": false + }, + { + "name": "domainMfaRequired", + "type": "Boolean", + "required": false + }, + { + "name": "domainSsoRequired", + "type": "Boolean", + "required": false + }, + { + "name": "nonDomainSsoRequired", + "type": "Boolean", + "required": false + }, + { + "name": "nonDomainMfaRequired", + "type": "Boolean", + "required": false + }, + { + "name": "setDomainCaptureEnabledDomains", + "type": "[ID!]", + "required": false + }, + { + "name": "isDomainJitProvisioningEnabled", + "type": "Boolean", + "required": false + } + ] + }, + "updatePermission": { + "name": "updatePermission", + "kind": "mutation", + "description": "Update a custom permission's name and description (system permissions are immutable)", + "rootFields": [ + "updatePermission" + ], + "returnTypes": [ + "UpdatePermissionResult" + ], + "document": "mutation updatePermission($input: UpdatePermissionInput!) {\n updatePermission(input: $input) {\n __typename\n ... on PermissionNotFound {\n __typename\n permissionId\n }\n ... on PermissionUpdated {\n __typename\n permission {\n ...Permission\n }\n }\n }\n}", + "fragmentNames": [ + "Permission", + "ResourceType" + ], + "variables": [ + { + "name": "input", + "type": "UpdatePermissionInput!", + "required": true + } + ] + }, + "updatePortalSettingsV2": { + "name": "updatePortalSettingsV2", + "kind": "mutation", + "description": "Update an environment's Admin Portal default success/redirect links", + "rootFields": [ + "updatePortalSettingsV2" + ], + "returnTypes": [ + "UpdatePortalSettingsResult" + ], + "document": "mutation updatePortalSettingsV2($input: UpdatePortalSettingsInput!) {\n updatePortalSettingsV2(input: $input) {\n __typename\n ... on PortalSettingsUpdated {\n portalSettings {\n ...PortalSettings\n }\n }\n ... on InvalidRedirectUri {\n validationError\n invalidFields\n }\n }\n}", + "fragmentNames": [ + "PortalSettings" + ], + "variables": [ + { + "name": "input", + "type": "UpdatePortalSettingsInput!", + "required": true + } + ] + }, + "updateRadarSettings": { + "name": "updateRadarSettings", + "kind": "mutation", + "description": "Update an environment's Radar bot/fraud detection rules and challenge settings", + "rootFields": [ + "updateRadarSettings" + ], + "returnTypes": [ + "UpdateRadarSettingsResult" + ], + "document": "mutation updateRadarSettings($input: UpdateRadarSettingsInput!) {\n updateRadarSettings(input: $input) {\n __typename\n }\n}", + "fragmentNames": [], + "variables": [ + { + "name": "input", + "type": "UpdateRadarSettingsInput!", + "required": true + } + ] + }, + "updateRadarSettingsMode": { + "name": "updateRadarSettingsMode", + "kind": "mutation", + "description": "Set an environment's Radar mode to off, log-only, or enforce", + "rootFields": [ + "updateRadarSettingsMode" + ], + "returnTypes": [ + "UpdateRadarSettingsResult" + ], + "document": "mutation updateRadarSettingsMode($input: UpdateRadarModeInput!) {\n updateRadarSettingsMode(input: $input) {\n __typename\n ... on RadarSettingsUpdated {\n radarSettings {\n id\n radarMode\n }\n }\n }\n}", + "fragmentNames": [], + "variables": [ + { + "name": "input", + "type": "UpdateRadarModeInput!", + "required": true + } + ] + }, + "updateResourceType": { + "name": "updateResourceType", + "kind": "mutation", + "description": "Update an FGA resource type's name, description, and relationships in the environment", + "rootFields": [ + "updateResourceType" + ], + "returnTypes": [ + "UpdateAuthorizationResourceTypeResult" + ], + "document": "mutation updateResourceType($input: UpdateAuthorizationResourceTypeInput!) {\n updateResourceType(input: $input) {\n __typename\n ... on AuthorizationResourceTypeNotFound {\n __typename\n resourceTypeId\n }\n ... on AuthorizationResourceTypeUpdateNotValid {\n __typename\n message\n }\n ... on AuthorizationResourceTypeUpdated {\n __typename\n resourceType {\n ...ResourceType\n }\n }\n }\n}", + "fragmentNames": [ + "ResourceType" + ], + "variables": [ + { + "name": "input", + "type": "UpdateAuthorizationResourceTypeInput!", + "required": true + } + ] + }, + "updateRole": { + "name": "updateRole", + "kind": "mutation", + "description": "Update a role's name, description, and assigned permissions in the environment", + "rootFields": [ + "updateRole" + ], + "returnTypes": [ + "UpdateRoleResult" + ], + "document": "mutation updateRole($input: UpdateRoleInput!) {\n updateRole(input: $input) {\n __typename\n ... on RoleNotFound {\n __typename\n roleId\n }\n ... on RoleUpdated {\n __typename\n role {\n ...Role\n }\n }\n }\n}", + "fragmentNames": [ + "ResourceType", + "Role" + ], + "variables": [ + { + "name": "input", + "type": "UpdateRoleInput!", + "required": true + } + ] + }, + "updateRoleConfig": { + "name": "updateRoleConfig", + "kind": "mutation", + "description": "Update an environment or organization role config's default role, priority order, and SSO/dsync assignment toggles", + "rootFields": [ + "updateRoleConfig" + ], + "returnTypes": [ + "UpdateRoleConfigResult" + ], + "document": "mutation updateRoleConfig($input: UpdateRoleConfigInput!) {\n updateRoleConfig(input: $input) {\n __typename\n ... on RoleConfigNotFound {\n __typename\n roleConfigId\n }\n ... on RoleConfigUpdated {\n __typename\n roleConfig {\n ...RoleConfig\n }\n }\n }\n}", + "fragmentNames": [ + "RoleConfig" + ], + "variables": [ + { + "name": "input", + "type": "UpdateRoleConfigInput!", + "required": true + } + ] + }, + "updateRoleOnOrganizationMembership": { + "name": "updateRoleOnOrganizationMembership", + "kind": "mutation", + "description": "Change the role(s) assigned to an AuthKit user's organization membership", + "rootFields": [ + "updateRoleOnOrganizationMembership" + ], + "returnTypes": [ + "UpdateRoleOnOrganizationMembershipResult" + ], + "document": "mutation updateRoleOnOrganizationMembership($input: UpdateRoleOnOrganizationMembershipInput!) {\n updateRoleOnOrganizationMembership(input: $input) {\n __typename\n ... on RoleNotFound {\n __typename\n roleId\n }\n ... on UserlandUserOrganizationMembershipNotFound {\n __typename\n message\n }\n ... on RoleOnOrganizationMembershipUpdated {\n __typename\n organizationMembership {\n ...UserlandUserOrganizationMembership\n }\n }\n }\n}", + "fragmentNames": [ + "UserlandUserOrganizationMembership" + ], + "variables": [ + { + "name": "input", + "type": "UpdateRoleOnOrganizationMembershipInput!", + "required": true + } + ] + }, + "UpdateStripeConfiguration": { + "name": "UpdateStripeConfiguration", + "kind": "mutation", + "description": "Toggle Stripe entitlements and seat-sync for an environment's Stripe Connect integration", + "rootFields": [ + "updateStripeConfiguration" + ], + "returnTypes": [ + "UpdateStripeConfigurationSuccess" + ], + "document": "mutation UpdateStripeConfiguration($environmentId: String!, $stripeEntitlementsEnabled: Boolean!, $stripeSeatSyncEnabled: Boolean!) {\n updateStripeConfiguration(\n environmentId: $environmentId\n stripeEntitlementsEnabled: $stripeEntitlementsEnabled\n stripeSeatSyncEnabled: $stripeSeatSyncEnabled\n ) {\n environment {\n stripeEntitlementsEnabled\n stripeSeatSyncEnabled\n }\n }\n}", + "fragmentNames": [], + "variables": [ + { + "name": "environmentId", + "type": "String!", + "required": true + }, + { + "name": "stripeEntitlementsEnabled", + "type": "Boolean!", + "required": true + }, + { + "name": "stripeSeatSyncEnabled", + "type": "Boolean!", + "required": true + } + ] + }, + "updateSyncForDirectoryGroups": { + "name": "updateSyncForDirectoryGroups", + "kind": "mutation", + "description": "Update which directory groups are synced by toggling group configs on a directory's allow list", + "rootFields": [ + "updateDirectoryGroupConfigsAllowList" + ], + "returnTypes": [ + "UpdateDirectoryGroupConfigsAllowListResult" + ], + "document": "mutation updateSyncForDirectoryGroups($input: UpdateDirectoryGroupConfigsAllowListInput!) {\n updateDirectoryGroupConfigsAllowList(input: $input) {\n __typename\n ... on UpdatedAllowList {\n directoryGroupConfigs {\n ...DirectoryGroupConfig\n }\n }\n ... on MissingDirectoryGroupsConfigsToUpdate {\n directoryId\n }\n ... on DirectoryNotFound {\n directoryId\n }\n }\n}", + "fragmentNames": [ + "DirectoryGroupConfig" + ], + "variables": [ + { + "name": "input", + "type": "UpdateDirectoryGroupConfigsAllowListInput!", + "required": true + } + ] + }, + "updateTeamDetails": { + "name": "updateTeamDetails", + "kind": "mutation", + "description": "Rename the current WorkOS dashboard team/account", + "rootFields": [ + "updateTeamDetails" + ], + "returnTypes": [ + "UpdateTeamDetailsResult" + ], + "document": "mutation updateTeamDetails($input: UpdateTeamDetailsInput!) {\n updateTeamDetails(input: $input) {\n __typename\n ... on TeamDetailsUpdated {\n team {\n ...CurrentTeam\n }\n }\n ... on InvalidTeamName {\n team {\n id\n }\n }\n }\n}", + "fragmentNames": [ + "CurrentTeam" + ], + "variables": [ + { + "name": "input", + "type": "UpdateTeamDetailsInput!", + "required": true + } + ] + }, + "updateTeamMfaRequirement": { + "name": "updateTeamMfaRequirement", + "kind": "mutation", + "description": "Set whether MFA is required for the current WorkOS dashboard team", + "rootFields": [ + "updateTeamMfaRequirement" + ], + "returnTypes": [ + "UpdateTeamMfaRequirementResult" + ], + "document": "mutation updateTeamMfaRequirement($input: UpdateTeamMfaRequirementInput!) {\n updateTeamMfaRequirement(input: $input) {\n __typename\n ... on TeamMfaRequirementUpdated {\n team {\n ...CurrentTeam\n }\n }\n }\n}", + "fragmentNames": [ + "CurrentTeam" + ], + "variables": [ + { + "name": "input", + "type": "UpdateTeamMfaRequirementInput!", + "required": true + } + ] + }, + "updateUserlandEmailSettings": { + "name": "updateUserlandEmailSettings", + "kind": "mutation", + "description": "Toggle which AuthKit transactional emails WorkOS sends for an environment", + "rootFields": [ + "updateUserlandEmailSettings" + ], + "returnTypes": [ + "UpdateUserlandEmailSettingsResult" + ], + "document": "mutation updateUserlandEmailSettings($input: UpdateUserlandEmailSettingsInput!) {\n updateUserlandEmailSettings(input: $input) {\n __typename\n }\n}", + "fragmentNames": [], + "variables": [ + { + "name": "input", + "type": "UpdateUserlandEmailSettingsInput!", + "required": true + } + ] + }, + "updateUserlandExternalLoginUri": { + "name": "updateUserlandExternalLoginUri", + "kind": "mutation", + "description": "Set the environment's AuthKit external login URI where unauthenticated users are redirected", + "rootFields": [ + "updateUserlandExternalLoginUri" + ], + "returnTypes": [ + "UpdateUserlandExternalLoginUriResult" + ], + "document": "mutation updateUserlandExternalLoginUri($input: UpdateUserlandExternalLoginUriInput!) {\n updateUserlandExternalLoginUri(input: $input) {\n __typename\n ... on UserlandExternalLoginUriUpdated {\n userlandSettings {\n externalLoginUri\n }\n }\n ... on InvalidExternalLoginUri {\n externalLoginUri\n }\n ... on InvalidExternalLoginUriProtocol {\n protocol\n }\n ... on ExternalLoginUriNotAllowed {\n reason\n }\n ... on EnvironmentNotFound {\n environmentId\n }\n }\n}", + "fragmentNames": [], + "variables": [ + { + "name": "input", + "type": "UpdateUserlandExternalLoginUriInput!", + "required": true + } + ] + }, + "updateUserlandImpersonationSettings": { + "name": "updateUserlandImpersonationSettings", + "kind": "mutation", + "description": "Enable or disable AuthKit user impersonation for an environment, revoking active impersonated sessions when disabled", + "rootFields": [ + "updateUserlandImpersonationSettings" + ], + "returnTypes": [ + "UpdateUserlandImpersonationSettingsResult" + ], + "document": "mutation updateUserlandImpersonationSettings($input: UpdateUserlandImpersonationSettingsInput!) {\n updateUserlandImpersonationSettings(input: $input) {\n __typename\n }\n}", + "fragmentNames": [], + "variables": [ + { + "name": "input", + "type": "UpdateUserlandImpersonationSettingsInput!", + "required": true + } + ] + }, + "updateUserlandSettings": { + "name": "updateUserlandSettings", + "kind": "mutation", + "description": "Update an environment's AuthKit authentication settings: auth methods, password policy, MFA, sessions, and signup", + "rootFields": [ + "updateUserlandSettings" + ], + "returnTypes": [ + "UpdateUserlandSettingsResult" + ], + "document": "mutation updateUserlandSettings($input: UpdateUserlandSettingsInput!) {\n updateUserlandSettings(input: $input) {\n __typename\n }\n}", + "fragmentNames": [], + "variables": [ + { + "name": "input", + "type": "UpdateUserlandSettingsInput!", + "required": true + } + ] + }, + "updateUserlandUser": { + "name": "updateUserlandUser", + "kind": "mutation", + "description": "Update an AuthKit end user's name, email, locale, metadata, or external ID", + "rootFields": [ + "updateUserlandUser" + ], + "returnTypes": [ + "UpdateUserlandUserResult" + ], + "document": "mutation updateUserlandUser($input: UpdateUserlandUserInput!) {\n updateUserlandUser(input: $input) {\n __typename\n ... on UserlandUserNotFound {\n __typename\n }\n ... on UserlandUserUpdated {\n userlandUser {\n id\n email\n firstName\n lastName\n }\n }\n ... on UserlandUserChangeEmailError {\n reason\n }\n ... on ExternalIDAlreadyUsed {\n externalId\n }\n }\n}", + "fragmentNames": [], + "variables": [ + { + "name": "input", + "type": "UpdateUserlandUserInput!", + "required": true + } + ] + }, + "updateWebhookEndpoint": { + "name": "updateWebhookEndpoint", + "kind": "mutation", + "description": "Update a webhook endpoint's URL, subscribed events, and active/inactive state", + "rootFields": [ + "updateWebhookEndpoint" + ], + "returnTypes": [ + "WebhookEndpoint" + ], + "document": "mutation updateWebhookEndpoint($webhookEndpointId: String!, $endpointUrl: String, $state: WebhookEndpointState, $events: [String!]) {\n updateWebhookEndpoint(\n webhookEndpointId: $webhookEndpointId\n endpointUrl: $endpointUrl\n state: $state\n events: $events\n ) {\n id\n }\n}", + "fragmentNames": [], + "variables": [ + { + "name": "webhookEndpointId", + "type": "String!", + "required": true + }, + { + "name": "endpointUrl", + "type": "String", + "required": false + }, + { + "name": "state", + "type": "WebhookEndpointState", + "required": false + }, + { + "name": "events", + "type": "[String!]", + "required": false + } + ] + }, + "upsertActionsEndpoint": { + "name": "upsertActionsEndpoint", + "kind": "mutation", + "description": "Create or update the Actions endpoint of a given type for an environment, setting its URL, fail-open, and state", + "rootFields": [ + "upsertActionsEndpoint" + ], + "returnTypes": [ + "UpsertActionsEndpointResult" + ], + "document": "mutation upsertActionsEndpoint($input: UpsertActionsEndpointInput!) {\n upsertActionsEndpoint(input: $input) {\n __typename\n ... on ActionsEndpointUpserted {\n actionsEndpoint {\n id\n type\n endpointUrl\n failOpen\n state\n }\n }\n }\n}", + "fragmentNames": [], + "variables": [ + { + "name": "input", + "type": "UpsertActionsEndpointInput!", + "required": true + } + ] + }, + "upsertAndDeleteGroupRoleMappings": { + "name": "upsertAndDeleteGroupRoleMappings", + "kind": "mutation", + "description": "Create or replace directory group-to-role mappings for a Directory Sync directory Delete directory group-to-role mappings by ID in the current environment", + "rootFields": [ + "upsertGroupRoleMappings", + "deleteGroupRoleMappings" + ], + "returnTypes": [ + "UpsertDirectoryGroupRoleMappingsResult", + "DeleteGroupRoleMappingsResult" + ], + "document": "mutation upsertAndDeleteGroupRoleMappings($upsertInput: UpsertDirectoryGroupRoleMappingsInput!, $deleteInput: DeleteGroupRoleMappingsInput!) {\n upsertGroupRoleMappings(input: $upsertInput) {\n __typename\n ... on EnvironmentNotFound {\n environmentId\n }\n ... on GroupRoleMappingsUpserted {\n groupRoleMappings {\n data {\n id\n stableGroupId\n entityId\n roleId\n }\n }\n }\n }\n deleteGroupRoleMappings(input: $deleteInput) {\n __typename\n ... on EnvironmentNotFound {\n environmentId\n }\n ... on GroupRoleMappingsDeleted {\n groupRoleMappingIds\n }\n }\n}", + "fragmentNames": [], + "variables": [ + { + "name": "upsertInput", + "type": "UpsertDirectoryGroupRoleMappingsInput!", + "required": true + }, + { + "name": "deleteInput", + "type": "DeleteGroupRoleMappingsInput!", + "required": true + } + ] + }, + "upsertJwtTemplate": { + "name": "upsertJwtTemplate", + "kind": "mutation", + "description": "Create or update the JWT access-token template for an environment", + "rootFields": [ + "upsertJwtTemplate" + ], + "returnTypes": [ + "UpsertJwtTemplateResult" + ], + "document": "mutation upsertJwtTemplate($input: UpsertJwtTemplateInput!) {\n upsertJwtTemplate(input: $input) {\n __typename\n ... on JwtTemplateUpserted {\n jwtTemplate {\n ...JwtTemplate\n }\n }\n }\n}", + "fragmentNames": [ + "JwtTemplate" + ], + "variables": [ + { + "name": "input", + "type": "UpsertJwtTemplateInput!", + "required": true + } + ] + }, + "userlandEmailSettings": { + "name": "userlandEmailSettings", + "kind": "query", + "description": "Return an environment's AuthKit email settings (sender and customization)", + "rootFields": [ + "environment" + ], + "returnTypes": [ + "Environment" + ], + "document": "query userlandEmailSettings($input: String!) {\n environment(id: $input) {\n userlandEmailSettings {\n isEmailVerificationEmailEnabled\n isInvitationEmailEnabled\n isMagicAuthEmailEnabled\n isResetPasswordEmailEnabled\n isWaitlistConfirmationEmailEnabled\n }\n }\n}", + "fragmentNames": [], + "variables": [ + { + "name": "input", + "type": "String!", + "required": true + } + ] + }, + "userlandIdentities": { + "name": "userlandIdentities", + "kind": "query", + "description": "Return a single AuthKit end user by ID", + "rootFields": [ + "userlandUser" + ], + "returnTypes": [ + "UserlandUser" + ], + "document": "query userlandIdentities($userId: ID!, $before: String, $after: String, $limit: Int) {\n userlandUser(id: $userId) {\n id\n identities(before: $before, after: $after, limit: $limit) {\n data {\n id\n createdAt\n updatedAt\n organization {\n id\n name\n domains {\n ...OrganizationDomain\n }\n }\n }\n listMetadata {\n before\n after\n }\n }\n }\n}", + "fragmentNames": [ + "OrganizationDomain" + ], + "variables": [ + { + "name": "userId", + "type": "ID!", + "required": true + }, + { + "name": "before", + "type": "String", + "required": false + }, + { + "name": "after", + "type": "String", + "required": false + }, + { + "name": "limit", + "type": "Int", + "required": false + } + ] + }, + "userlandSessions": { + "name": "userlandSessions", + "kind": "query", + "description": "List an AuthKit end user's authentication sessions", + "rootFields": [ + "userlandUser" + ], + "returnTypes": [ + "UserlandUser" + ], + "document": "query userlandSessions($userId: ID!, $before: String, $after: String, $limit: Int, $states: [UserlandSessionStateFilter!]) {\n userlandUser(id: $userId) {\n id\n sessions(before: $before, after: $after, limit: $limit, states: $states) {\n data {\n ...UserlandSession\n }\n listMetadata {\n before\n after\n }\n }\n }\n}", + "fragmentNames": [ + "UserlandSession" + ], + "variables": [ + { + "name": "userId", + "type": "ID!", + "required": true + }, + { + "name": "before", + "type": "String", + "required": false + }, + { + "name": "after", + "type": "String", + "required": false + }, + { + "name": "limit", + "type": "Int", + "required": false + }, + { + "name": "states", + "type": "[UserlandSessionStateFilter!]", + "required": false + } + ] + }, + "userlandSettings": { + "name": "userlandSettings", + "kind": "query", + "description": "Return an environment's AuthKit settings (localization, default locale, SSO sign-in consent)", + "rootFields": [ + "environment" + ], + "returnTypes": [ + "Environment" + ], + "document": "query userlandSettings($input: String!) {\n environment(id: $input) {\n localizationEnabled\n defaultLocale\n ssoSignInConsentEnabled\n userlandSettings {\n allowSignUp\n isWaitlistEnabled\n isAuthkitEnabled\n isAuthkitIdpInitiatedSsoEnabled\n isAuthkitDynamicClientRegistrationEnabled\n isAuthkitClientIdMetadataDocumentEnabled\n isEmailVerificationRequired\n isAppleOauthEnabled\n isBitbucketOauthEnabled\n isDiscordOauthEnabled\n isGithubOauthEnabled\n isGitLabOauthEnabled\n isIntuitOauthEnabled\n isGoogleOauthEnabled\n isLinkedInOauthEnabled\n isMicrosoftOauthEnabled\n isSalesforceOauthEnabled\n isSlackOauthEnabled\n isVercelMarketplaceOauthEnabled\n isVercelOauthEnabled\n isXeroOauthEnabled\n isMagicAuthEnabled\n isPasswordAuthEnabled\n isPasswordLowercaseRequired\n isPasswordNumberRequired\n isPasswordPwnedRequired\n isPasswordSymbolRequired\n isPasswordUppercaseRequired\n isPasskeyAuthEnabled\n isPasskeyProgressiveEnrollmentEnabled\n isImpersonationEnabled\n isSsoEnabled\n mfaEnabled\n passwordMinimumLength\n isPasswordHistoryEnabled\n passwordHistorySize\n passwordMinimumStrength\n invitationExpiry\n signUpUrl\n externalLoginUri\n }\n }\n}", + "fragmentNames": [], + "variables": [ + { + "name": "input", + "type": "String!", + "required": true + } + ] + }, + "userlandUser": { + "name": "userlandUser", + "kind": "query", + "description": "Return a single AuthKit end user by ID", + "rootFields": [ + "userlandUser" + ], + "returnTypes": [ + "UserlandUser" + ], + "document": "query userlandUser($id: ID!) {\n userlandUser(id: $id) {\n ...UserlandUser\n ...UserlandUserAuthenticationFactors\n }\n}", + "fragmentNames": [ + "UserlandIdentity", + "UserlandUser", + "UserlandUserAuthenticationFactors", + "UserlandUserWithoutIdentities" + ], + "variables": [ + { + "name": "id", + "type": "ID!", + "required": true + } + ] + }, + "userlandUserApiKeys": { + "name": "userlandUserApiKeys", + "kind": "query", + "description": "List an AuthKit end user's API keys", + "rootFields": [ + "userlandUser" + ], + "returnTypes": [ + "UserlandUser" + ], + "document": "query userlandUserApiKeys($id: ID!, $before: String, $after: String, $limit: Int, $search: String, $createdAtRange: ApiKeysDateRangeInput, $lastUsedAtRange: ApiKeysDateRangeInput, $hasExpiration: Boolean, $expiresAtRange: ApiKeysDateRangeInput) {\n userlandUser(id: $id) {\n id\n apiKeys(\n before: $before\n after: $after\n limit: $limit\n search: $search\n createdAtRange: $createdAtRange\n lastUsedAtRange: $lastUsedAtRange\n hasExpiration: $hasExpiration\n expiresAtRange: $expiresAtRange\n ) {\n data {\n id\n name\n organizationId\n obfuscatedValue\n lastUsedAt\n createdAt\n expiresAt\n }\n listMetadata {\n before\n after\n }\n }\n }\n}", + "fragmentNames": [], + "variables": [ + { + "name": "id", + "type": "ID!", + "required": true + }, + { + "name": "before", + "type": "String", + "required": false + }, + { + "name": "after", + "type": "String", + "required": false + }, + { + "name": "limit", + "type": "Int", + "required": false + }, + { + "name": "search", + "type": "String", + "required": false + }, + { + "name": "createdAtRange", + "type": "ApiKeysDateRangeInput", + "required": false + }, + { + "name": "lastUsedAtRange", + "type": "ApiKeysDateRangeInput", + "required": false + }, + { + "name": "hasExpiration", + "type": "Boolean", + "required": false + }, + { + "name": "expiresAtRange", + "type": "ApiKeysDateRangeInput", + "required": false + } + ] + }, + "userlandUserCount": { + "name": "userlandUserCount", + "kind": "query", + "description": "Return the total number of AuthKit end users in an environment", + "rootFields": [ + "usersCount" + ], + "returnTypes": [ + "Int" + ], + "document": "query userlandUserCount($environmentId: String!) {\n usersCount(environmentId: $environmentId)\n}", + "fragmentNames": [], + "variables": [ + { + "name": "environmentId", + "type": "String!", + "required": true + } + ] + }, + "userlandUserDataInstallations": { + "name": "userlandUserDataInstallations", + "kind": "query", + "description": "List an AuthKit end user's Pipes data installations", + "rootFields": [ + "userlandUser" + ], + "returnTypes": [ + "UserlandUser" + ], + "document": "query userlandUserDataInstallations($id: ID!) {\n userlandUser(id: $id) {\n id\n firstName\n lastName\n dataInstallations {\n ...DataInstallation\n }\n }\n}", + "fragmentNames": [ + "DataInstallation" + ], + "variables": [ + { + "name": "id", + "type": "ID!", + "required": true + } + ] + }, + "userlandUserDataInstallationsV2": { + "name": "userlandUserDataInstallationsV2", + "kind": "query", + "description": "List an AuthKit end user's Pipes data installations", + "rootFields": [ + "userlandUser" + ], + "returnTypes": [ + "UserlandUser" + ], + "document": "query userlandUserDataInstallationsV2($id: ID!) {\n userlandUser(id: $id) {\n id\n firstName\n lastName\n dataInstallations {\n id\n state\n createdAt\n organizationId\n organization {\n id\n name\n }\n dataProviderIntegration {\n id\n providerSlug\n name\n }\n }\n }\n}", + "fragmentNames": [], + "variables": [ + { + "name": "id", + "type": "ID!", + "required": true + } + ] + }, + "userlandUserInvites": { + "name": "userlandUserInvites", + "kind": "query", + "description": "List AuthKit user invitations in an environment", + "rootFields": [ + "userlandUserInvites" + ], + "returnTypes": [ + "UserlandUserInviteList" + ], + "document": "query userlandUserInvites($environmentId: String!, $search: String, $before: String, $after: String, $limit: Int) {\n userlandUserInvites(\n environmentId: $environmentId\n search: $search\n before: $before\n after: $after\n limit: $limit\n ) {\n data {\n ...UserlandUserInvite\n organization {\n id\n name\n }\n }\n listMetadata {\n before\n after\n }\n }\n}", + "fragmentNames": [ + "UserlandUserInvite" + ], + "variables": [ + { + "name": "environmentId", + "type": "String!", + "required": true + }, + { + "name": "search", + "type": "String", + "required": false + }, + { + "name": "before", + "type": "String", + "required": false + }, + { + "name": "after", + "type": "String", + "required": false + }, + { + "name": "limit", + "type": "Int", + "required": false + } + ] + }, + "userlandUserInvitesByOrg": { + "name": "userlandUserInvitesByOrg", + "kind": "query", + "description": "List an organization's pending AuthKit user invitations", + "rootFields": [ + "organization" + ], + "returnTypes": [ + "Organization" + ], + "document": "query userlandUserInvitesByOrg($organizationId: String!, $search: String, $limit: Int, $before: String, $after: String) {\n organization(id: $organizationId) {\n userlandUserInvites(\n search: $search\n limit: $limit\n before: $before\n after: $after\n ) {\n data {\n ...UserlandUserInvite\n }\n listMetadata {\n before\n after\n }\n }\n }\n}", + "fragmentNames": [ + "UserlandUserInvite" + ], + "variables": [ + { + "name": "organizationId", + "type": "String!", + "required": true + }, + { + "name": "search", + "type": "String", + "required": false + }, + { + "name": "limit", + "type": "Int", + "required": false + }, + { + "name": "before", + "type": "String", + "required": false + }, + { + "name": "after", + "type": "String", + "required": false + } + ] + }, + "userlandUserLastSessionForProvider": { + "name": "userlandUserLastSessionForProvider", + "kind": "query", + "description": "List an AuthKit end user's authentication sessions", + "rootFields": [ + "userlandUser" + ], + "returnTypes": [ + "UserlandUser" + ], + "document": "query userlandUserLastSessionForProvider($id: ID!, $provider: SessionProvider!) {\n userlandUser(id: $id) {\n sessions(provider: $provider, limit: 1) {\n data {\n ...UserlandSession\n }\n }\n }\n}", + "fragmentNames": [ + "UserlandSession" + ], + "variables": [ + { + "name": "id", + "type": "ID!", + "required": true + }, + { + "name": "provider", + "type": "SessionProvider!", + "required": true + } + ] + }, + "userlandUserOrganizationMembership": { + "name": "userlandUserOrganizationMembership", + "kind": "query", + "description": "Return a single AuthKit user's organization membership by ID, including its roles", + "rootFields": [ + "userlandUserOrganizationMembership" + ], + "returnTypes": [ + "UserlandUserOrganizationMembership" + ], + "document": "query userlandUserOrganizationMembership($id: String!) {\n userlandUserOrganizationMembership(id: $id) {\n ...UserlandUserOrganizationMembership\n }\n}", + "fragmentNames": [ + "UserlandUserOrganizationMembership" + ], + "variables": [ + { + "name": "id", + "type": "String!", + "required": true + } + ] + }, + "userlandUserOrganizationMemberships": { + "name": "userlandUserOrganizationMemberships", + "kind": "query", + "description": "List all organization memberships for a given AuthKit end user, including their roles", + "rootFields": [ + "userlandUserOrganizationMemberships" + ], + "returnTypes": [ + "UserlandUserOrganizationMembershipList" + ], + "document": "query userlandUserOrganizationMemberships($userlandUserId: String!) {\n userlandUserOrganizationMemberships(id: $userlandUserId) {\n organizationMemberships {\n ...UserlandUserOrganizationMembership\n }\n }\n}", + "fragmentNames": [ + "UserlandUserOrganizationMembership" + ], + "variables": [ + { + "name": "userlandUserId", + "type": "String!", + "required": true + } + ] + }, + "userlandUserOrganizationMembershipsByUserIds": { + "name": "userlandUserOrganizationMembershipsByUserIds", + "kind": "query", + "description": "List AuthKit organization memberships (with roles) for specific user IDs within one organization", + "rootFields": [ + "userlandUserOrganizationMembershipsByUserIds" + ], + "returnTypes": [ + "UserlandUserOrganizationMembershipList" + ], + "document": "query userlandUserOrganizationMembershipsByUserIds($input: UserlandUserOrganizationMembershipsByUserIdsArgs!) {\n userlandUserOrganizationMembershipsByUserIds(input: $input) {\n organizationMemberships {\n ...UserlandUserOrganizationMembership\n }\n }\n}", + "fragmentNames": [ + "UserlandUserOrganizationMembership" + ], + "variables": [ + { + "name": "input", + "type": "UserlandUserOrganizationMembershipsByUserIdsArgs!", + "required": true + } + ] + }, + "userlandUsers": { + "name": "userlandUsers", + "kind": "query", + "description": "List AuthKit end users in an environment, with search, sorting, and role/status filters", + "rootFields": [ + "userlandUsers" + ], + "returnTypes": [ + "UserlandUserList" + ], + "document": "query userlandUsers($environmentId: String!, $search: String, $sessionStatus: UserlandUserSessionStatus, $dataIntegrationId: String, $sortField: UserlandUserSortField, $order: PaginationOrder, $before: String, $after: String, $limit: Int, $status: [String!]) {\n userlandUsers(\n environmentId: $environmentId\n search: $search\n sessionStatus: $sessionStatus\n dataIntegrationId: $dataIntegrationId\n sortField: $sortField\n order: $order\n before: $before\n after: $after\n limit: $limit\n ) {\n data {\n ...UserlandUserWithoutIdentities\n identities(status: $status) {\n data {\n ...UserlandIdentity\n }\n }\n }\n listMetadata {\n before\n after\n }\n }\n}", + "fragmentNames": [ + "UserlandIdentity", + "UserlandUserWithoutIdentities" + ], + "variables": [ + { + "name": "environmentId", + "type": "String!", + "required": true + }, + { + "name": "search", + "type": "String", + "required": false + }, + { + "name": "sessionStatus", + "type": "UserlandUserSessionStatus", + "required": false + }, + { + "name": "dataIntegrationId", + "type": "String", + "required": false + }, + { + "name": "sortField", + "type": "UserlandUserSortField", + "required": false + }, + { + "name": "order", + "type": "PaginationOrder", + "required": false + }, + { + "name": "before", + "type": "String", + "required": false + }, + { + "name": "after", + "type": "String", + "required": false + }, + { + "name": "limit", + "type": "Int", + "required": false + }, + { + "name": "status", + "type": "[String!]", + "required": false + } + ] + }, + "userlandUsersByOrg": { + "name": "userlandUsersByOrg", + "kind": "query", + "description": "List the AuthKit end users in an organization", + "rootFields": [ + "organization" + ], + "returnTypes": [ + "Organization" + ], + "document": "query userlandUsersByOrg($organizationId: String!, $search: String, $limit: Int, $status: [String!], $before: String, $after: String, $sortField: UserlandUserSortField, $order: PaginationOrder, $authenticationMethods: [UserlandUserAuthenticationMethod!], $roleIds: [String!], $dataIntegrationId: String) {\n organization(id: $organizationId) {\n userlandUsers(\n search: $search\n organizationMembershipStatus: $status\n limit: $limit\n before: $before\n after: $after\n sortField: $sortField\n order: $order\n authenticationMethods: $authenticationMethods\n roleIds: $roleIds\n dataIntegrationId: $dataIntegrationId\n ) {\n data {\n ...UserlandUserWithoutIdentities\n ...UserlandUserAuthenticationFactors\n identities(status: $status, organizationId: $organizationId) {\n data {\n ...UserlandIdentity\n }\n }\n }\n listMetadata {\n before\n after\n }\n }\n }\n}", + "fragmentNames": [ + "UserlandIdentity", + "UserlandUserAuthenticationFactors", + "UserlandUserWithoutIdentities" + ], + "variables": [ + { + "name": "organizationId", + "type": "String!", + "required": true + }, + { + "name": "search", + "type": "String", + "required": false + }, + { + "name": "limit", + "type": "Int", + "required": false + }, + { + "name": "status", + "type": "[String!]", + "required": false + }, + { + "name": "before", + "type": "String", + "required": false + }, + { + "name": "after", + "type": "String", + "required": false + }, + { + "name": "sortField", + "type": "UserlandUserSortField", + "required": false + }, + { + "name": "order", + "type": "PaginationOrder", + "required": false + }, + { + "name": "authenticationMethods", + "type": "[UserlandUserAuthenticationMethod!]", + "required": false + }, + { + "name": "roleIds", + "type": "[String!]", + "required": false + }, + { + "name": "dataIntegrationId", + "type": "String", + "required": false + } + ] + }, + "userlandUsersForSelect": { + "name": "userlandUsersForSelect", + "kind": "query", + "description": "List AuthKit end users in an environment, with search, sorting, and role/status filters", + "rootFields": [ + "userlandUsers" + ], + "returnTypes": [ + "UserlandUserList" + ], + "document": "query userlandUsersForSelect($after: String, $before: String, $environmentId: String!, $limit: Int = 10, $search: String, $sortField: UserlandUserSortField, $order: PaginationOrder) {\n userlandUsers(\n after: $after\n before: $before\n environmentId: $environmentId\n limit: $limit\n search: $search\n sortField: $sortField\n order: $order\n ) {\n data {\n id\n email\n firstName\n lastName\n }\n listMetadata {\n before\n after\n }\n }\n}", + "fragmentNames": [], + "variables": [ + { + "name": "after", + "type": "String", + "required": false + }, + { + "name": "before", + "type": "String", + "required": false + }, + { + "name": "environmentId", + "type": "String!", + "required": true + }, + { + "name": "limit", + "type": "Int", + "required": false, + "defaultValue": "10" + }, + { + "name": "search", + "type": "String", + "required": false + }, + { + "name": "sortField", + "type": "UserlandUserSortField", + "required": false + }, + { + "name": "order", + "type": "PaginationOrder", + "required": false + } + ] + }, + "userlandUserWithOrganizationMembership": { + "name": "userlandUserWithOrganizationMembership", + "kind": "query", + "description": "Return an AuthKit end user with a single organization membership and identity", + "rootFields": [ + "userlandUser" + ], + "returnTypes": [ + "UserlandUser" + ], + "document": "query userlandUserWithOrganizationMembership($id: ID!, $organizationMembershipId: String!) {\n userlandUser(id: $id) {\n ...UserlandUserWithoutIdentities\n identity(organizationMembershipId: $organizationMembershipId) {\n ...UserlandIdentity\n }\n }\n}", + "fragmentNames": [ + "UserlandIdentity", + "UserlandUserWithoutIdentities" + ], + "variables": [ + { + "name": "id", + "type": "ID!", + "required": true + }, + { + "name": "organizationMembershipId", + "type": "String!", + "required": true + } + ] + }, + "userlandUserWithOrganizationMemberships": { + "name": "userlandUserWithOrganizationMemberships", + "kind": "query", + "description": "Return a single AuthKit end user by ID", + "rootFields": [ + "userlandUser" + ], + "returnTypes": [ + "UserlandUser" + ], + "document": "query userlandUserWithOrganizationMemberships($id: ID!, $status: [String!], $limit: Int) {\n userlandUser(id: $id) {\n ...UserlandUserWithoutIdentities\n hasPendingCompromisedPasswordRemediation\n ...UserlandUserAuthenticationFactors\n identities(status: $status, limit: $limit) {\n data {\n ...UserlandIdentity\n }\n }\n }\n}", + "fragmentNames": [ + "UserlandIdentity", + "UserlandUserAuthenticationFactors", + "UserlandUserWithoutIdentities" + ], + "variables": [ + { + "name": "id", + "type": "ID!", + "required": true + }, + { + "name": "status", + "type": "[String!]", + "required": false + }, + { + "name": "limit", + "type": "Int", + "required": false + } + ] + }, + "userManagementOnboarding": { + "name": "userManagementOnboarding", + "kind": "query", + "description": "Return the team for the current dashboard session", + "rootFields": [ + "currentTeam" + ], + "returnTypes": [ + "Team" + ], + "document": "query userManagementOnboarding {\n currentTeam {\n userManagementActivated\n }\n}", + "fragmentNames": [], + "variables": [] + }, + "userSentEmails": { + "name": "userSentEmails", + "kind": "query", + "description": "List emails AuthKit has sent in an environment", + "rootFields": [ + "environment" + ], + "returnTypes": [ + "Environment" + ], + "document": "query userSentEmails($environmentId: String!, $recipient: String!, $before: String, $after: String, $limit: Int, $order: PaginationOrder) {\n environment(id: $environmentId) {\n sentEmails(\n recipient: $recipient\n before: $before\n after: $after\n limit: $limit\n order: $order\n ) {\n data {\n id\n to\n status\n subject\n timestamp\n events {\n type\n subType\n message\n timestamp\n }\n }\n listMetadata {\n before\n after\n }\n }\n }\n}", + "fragmentNames": [], + "variables": [ + { + "name": "environmentId", + "type": "String!", + "required": true + }, + { + "name": "recipient", + "type": "String!", + "required": true + }, + { + "name": "before", + "type": "String", + "required": false + }, + { + "name": "after", + "type": "String", + "required": false + }, + { + "name": "limit", + "type": "Int", + "required": false + }, + { + "name": "order", + "type": "PaginationOrder", + "required": false + } + ] + }, + "validateJwtTemplate": { + "name": "validateJwtTemplate", + "kind": "mutation", + "description": "Validate the structure of a JWT template for an environment without saving it", + "rootFields": [ + "validateJwtTemplate" + ], + "returnTypes": [ + "ValidateJwtTemplateResult" + ], + "document": "mutation validateJwtTemplate($input: ValidateJwtTemplateInput!) {\n validateJwtTemplate(input: $input) {\n __typename\n ... on JwtTemplateValidationResult {\n error\n }\n }\n}", + "fragmentNames": [], + "variables": [ + { + "name": "input", + "type": "ValidateJwtTemplateInput!", + "required": true + } + ] + }, + "vaultKeySettingsByEnvironment": { + "name": "vaultKeySettingsByEnvironment", + "kind": "query", + "description": "Return an environment's Vault key settings", + "rootFields": [ + "environment" + ], + "returnTypes": [ + "Environment" + ], + "document": "query vaultKeySettingsByEnvironment($environmentId: String!, $after: String, $before: String, $limit: Int, $order: PaginationOrder, $keyProviders: [VaultKeyProvider!], $organizationId: String, $organizationName: String, $statuses: [VaultKeyStatus!], $search: String) {\n environment(id: $environmentId) {\n id\n vaultKeySettingsByEnvironment(\n after: $after\n before: $before\n limit: $limit\n order: $order\n keyProviders: $keyProviders\n organizationId: $organizationId\n organizationName: $organizationName\n statuses: $statuses\n search: $search\n ) {\n data {\n id\n organizationId\n organizationName\n keyProvider\n resourceId\n status\n contextKey\n contextValue\n createdAt\n updatedAt\n }\n listMetadata {\n before\n after\n }\n }\n }\n}", + "fragmentNames": [], + "variables": [ + { + "name": "environmentId", + "type": "String!", + "required": true + }, + { + "name": "after", + "type": "String", + "required": false + }, + { + "name": "before", + "type": "String", + "required": false + }, + { + "name": "limit", + "type": "Int", + "required": false + }, + { + "name": "order", + "type": "PaginationOrder", + "required": false + }, + { + "name": "keyProviders", + "type": "[VaultKeyProvider!]", + "required": false + }, + { + "name": "organizationId", + "type": "String", + "required": false + }, + { + "name": "organizationName", + "type": "String", + "required": false + }, + { + "name": "statuses", + "type": "[VaultKeyStatus!]", + "required": false + }, + { + "name": "search", + "type": "String", + "required": false + } + ] + }, + "vaultKeySettingsById": { + "name": "vaultKeySettingsById", + "kind": "query", + "description": "Return Vault key settings by their ID", + "rootFields": [ + "vaultKeySettingsById" + ], + "returnTypes": [ + "VaultKeySettings" + ], + "document": "query vaultKeySettingsById($id: String!) {\n vaultKeySettingsById(id: $id) {\n id\n organizationId\n organizationName\n keyProvider\n resourceId\n status\n contextKey\n contextValue\n createdAt\n updatedAt\n }\n}", + "fragmentNames": [], + "variables": [ + { + "name": "id", + "type": "String!", + "required": true + } + ] + }, + "vaultKeySettingsByOrganization": { + "name": "vaultKeySettingsByOrganization", + "kind": "query", + "description": "Return an organization's Vault key settings", + "rootFields": [ + "organization" + ], + "returnTypes": [ + "Organization" + ], + "document": "query vaultKeySettingsByOrganization($organizationId: String!) {\n organization(id: $organizationId) {\n vaultKeySettingsByOrganization {\n id\n organizationId\n keyProvider\n resourceId\n status\n contextKey\n contextValue\n createdAt\n updatedAt\n }\n }\n}", + "fragmentNames": [], + "variables": [ + { + "name": "organizationId", + "type": "String!", + "required": true + } + ] + }, + "verifyCustomEmailDomain": { + "name": "verifyCustomEmailDomain", + "kind": "mutation", + "description": "Verify the environment's custom email-sending domain and activate billing once verified", + "rootFields": [ + "verifyCustomEmailDomain" + ], + "returnTypes": [ + "VerifyCustomEmailDomainResult" + ], + "document": "mutation verifyCustomEmailDomain {\n verifyCustomEmailDomain {\n ... on VerificationResult {\n customEmailDomain {\n ...CustomEmailDomain\n }\n }\n }\n}", + "fragmentNames": [ + "CustomEmailDomain" + ], + "variables": [] + }, + "verifyEmailChangeAccess": { + "name": "verifyEmailChangeAccess", + "kind": "mutation", + "description": "Verify the code proving the signed-in dashboard user owns their current email before changing it", + "rootFields": [ + "verifyEmailChangeAccess" + ], + "returnTypes": [ + "VerifyEmailChangeAccessResult" + ], + "document": "mutation verifyEmailChangeAccess($input: VerifyEmailChangeAccessInput!) {\n verifyEmailChangeAccess(input: $input) {\n __typename\n ... on EmailChangeAccessVerified {\n authenticationChallengeId\n expiresAt\n }\n ... on EmailChangeNotAvailable {\n message\n }\n ... on VerificationCodeFailure {\n reason\n }\n }\n}", + "fragmentNames": [], + "variables": [ + { + "name": "input", + "type": "VerifyEmailChangeAccessInput!", + "required": true + } + ] + }, + "verifyRedirectUri": { + "name": "verifyRedirectUri", + "kind": "mutation", + "description": "Check whether a redirect URI is valid for the environment's Google OAuth credential", + "rootFields": [ + "verifyRedirectUri" + ], + "returnTypes": [ + "VerifyRedirectUriResult" + ], + "document": "mutation verifyRedirectUri($input: VerifyRedirectUriInput!) {\n verifyRedirectUri(input: $input) {\n __typename\n ... on RedirectUriChecked {\n success\n }\n }\n}", + "fragmentNames": [], + "variables": [ + { + "name": "input", + "type": "VerifyRedirectUriInput!", + "required": true + } + ] + }, + "waitlistEntries": { + "name": "waitlistEntries", + "kind": "query", + "description": "List waitlist entries in an environment, filterable by search, state, and date range", + "rootFields": [ + "waitlistEntries" + ], + "returnTypes": [ + "WaitlistEntryList" + ], + "document": "query waitlistEntries($environmentId: String!, $search: String, $state: WaitlistEntryState, $startDate: DateTime, $endDate: DateTime, $limit: Int, $after: String, $before: String) {\n waitlistEntries(\n environmentId: $environmentId\n search: $search\n state: $state\n startDate: $startDate\n endDate: $endDate\n limit: $limit\n after: $after\n before: $before\n ) {\n data {\n id\n email\n state\n additionalFields\n approvedAt\n createdAt\n updatedAt\n }\n listMetadata {\n before\n after\n }\n }\n}", + "fragmentNames": [], + "variables": [ + { + "name": "environmentId", + "type": "String!", + "required": true + }, + { + "name": "search", + "type": "String", + "required": false + }, + { + "name": "state", + "type": "WaitlistEntryState", + "required": false + }, + { + "name": "startDate", + "type": "DateTime", + "required": false + }, + { + "name": "endDate", + "type": "DateTime", + "required": false + }, + { + "name": "limit", + "type": "Int", + "required": false + }, + { + "name": "after", + "type": "String", + "required": false + }, + { + "name": "before", + "type": "String", + "required": false + } + ] + }, + "webhook": { + "name": "webhook", + "kind": "query", + "description": "Return a single webhook delivery (event) by ID", + "rootFields": [ + "webhook" + ], + "returnTypes": [ + "Webhook" + ], + "document": "query webhook($webhookId: String!) {\n webhook(id: $webhookId) {\n id\n event\n state\n attempt\n deliverAfter\n metadata\n requestBody\n responseBody\n responseStatusCode\n }\n}", + "fragmentNames": [], + "variables": [ + { + "name": "webhookId", + "type": "String!", + "required": true + } + ] + }, + "webhookEndpoint": { + "name": "webhookEndpoint", + "kind": "query", + "description": "Return a single webhook endpoint by ID", + "rootFields": [ + "webhookEndpoint" + ], + "returnTypes": [ + "WebhookEndpoint" + ], + "document": "query webhookEndpoint($id: String!) {\n webhookEndpoint(id: $id) {\n ...WebhookEndpoint\n }\n}", + "fragmentNames": [ + "WebhookEndpoint" + ], + "variables": [ + { + "name": "id", + "type": "String!", + "required": true + } + ] + }, + "webhookEndpoints": { + "name": "webhookEndpoints", + "kind": "query", + "description": "List webhook endpoints in an environment", + "rootFields": [ + "webhookEndpoints" + ], + "returnTypes": [ + "WebhookEndpointList" + ], + "document": "query webhookEndpoints($environmentId: String!, $before: String, $after: String, $limit: Int) {\n webhookEndpoints(\n environmentId: $environmentId\n before: $before\n after: $after\n limit: $limit\n ) {\n data {\n id\n endpointUrl\n events\n state\n createdAt\n }\n listMetadata {\n before\n after\n }\n }\n}", + "fragmentNames": [], + "variables": [ + { + "name": "environmentId", + "type": "String!", + "required": true + }, + { + "name": "before", + "type": "String", + "required": false + }, + { + "name": "after", + "type": "String", + "required": false + }, + { + "name": "limit", + "type": "Int", + "required": false + } + ] + }, + "webhookEvents": { + "name": "webhookEvents", + "kind": "query", + "description": "List the webhook event types the platform can emit (reference metadata, not tenant data)", + "rootFields": [ + "webhookEvents" + ], + "returnTypes": [ + "WebhookEvent" + ], + "document": "query webhookEvents {\n webhookEvents {\n id\n category\n }\n}", + "fragmentNames": [], + "variables": [] + }, + "webhookEventsWithFixtures": { + "name": "webhookEventsWithFixtures", + "kind": "query", + "description": "List the webhook event types the platform can emit (reference metadata, not tenant data)", + "rootFields": [ + "webhookEvents" + ], + "returnTypes": [ + "WebhookEvent" + ], + "document": "query webhookEventsWithFixtures {\n webhookEvents {\n ...WebhookEventWithFixture\n }\n}", + "fragmentNames": [ + "WebhookEventWithFixture" + ], + "variables": [] + }, + "webhooksForEndpoint": { + "name": "webhooksForEndpoint", + "kind": "query", + "description": "List the recent webhook deliveries for a webhook endpoint", + "rootFields": [ + "webhookEndpoint" + ], + "returnTypes": [ + "WebhookEndpoint" + ], + "document": "query webhooksForEndpoint($webhookEndpointId: String!, $before: String, $after: String, $limit: Int, $search: String, $state: WebhookState, $events: [String!]) {\n webhookEndpoint(id: $webhookEndpointId) {\n webhooks(\n before: $before\n after: $after\n limit: $limit\n search: $search\n state: $state\n events: $events\n ) {\n data {\n id\n event\n state\n attempt\n deliverAfter\n }\n listMetadata {\n before\n after\n }\n }\n }\n}", + "fragmentNames": [], + "variables": [ + { + "name": "webhookEndpointId", + "type": "String!", + "required": true + }, + { + "name": "before", + "type": "String", + "required": false + }, + { + "name": "after", + "type": "String", + "required": false + }, + { + "name": "limit", + "type": "Int", + "required": false + }, + { + "name": "search", + "type": "String", + "required": false + }, + { + "name": "state", + "type": "WebhookState", + "required": false + }, + { + "name": "events", + "type": "[String!]", + "required": false + } + ] + } + }, + "fragments": { + "ActionExecution": "fragment ActionExecution on ActionExecution {\n id\n actionsEndpointId\n status\n statusReason\n requestBody\n responseBody\n responseSignature\n responseStatusCode\n requestDurationMs\n attemptCount\n verdict\n type\n failOpen\n createdAt\n updatedAt\n}", + "ActionsEndpoint": "fragment ActionsEndpoint on ActionsEndpoint {\n id\n type\n endpointUrl\n failOpen\n state\n createdAt\n updatedAt\n}", + "AppBranding": "fragment AppBranding on AppBranding {\n id\n displayName\n logoIconFit\n radius\n theme\n font {\n family\n variant\n src\n }\n lightButtonBackgroundColor\n lightButtonForegroundColor\n lightFaviconPath\n lightLinkColor\n lightLogoIconPath\n lightLogoPath\n lightPageBackgroundColor\n darkButtonBackgroundColor\n darkButtonForegroundColor\n darkFaviconPath\n darkLinkColor\n darkLogoIconPath\n darkLogoPath\n darkPageBackgroundColor\n authkitLogoStyle\n emailLogoStyle\n authkitShowLastUsedBadge\n authkitSignUpLinkText\n authkitSignInContentPanelAlignment\n authkitSignInContentPanelHiddenOnMobile\n authkitSignInCustomCss\n authkitSignInCustomHtml\n authkitSignInHeadingText\n authkitSignInLayout\n authkitSignInLegalText\n authkitSignInPrivacyPolicyUrl\n authkitSignInTermsOfServiceUrl\n authkitSignUpActionLinkText\n authkitSignUpContentPanelAlignment\n authkitSignUpContentPanelHiddenOnMobile\n authkitSignUpCustomCss\n authkitSignUpCustomHtml\n authkitSignUpHeadingText\n authkitSignUpLayout\n authkitSignUpLegalText\n authkitSignUpPrivacyPolicyUrl\n authkitSignUpShowNameFields\n authkitSignUpTermsOfServiceUrl\n inviteAdminReplyToEmail\n radarSupportEmail\n authkitSignInHeadingLocalized\n authkitSignUpHeadingLocalized\n authkitSignInLinkTextLocalized\n authkitSignUpLinkTextLocalized\n customCssElements {\n authkitBackground {\n css\n resetCss\n }\n authkitButton {\n css\n resetCss\n }\n authkitCard {\n css\n resetCss\n }\n authkitHeader {\n css\n resetCss\n }\n authkitInput {\n css\n resetCss\n }\n authkitLabel {\n css\n resetCss\n }\n authkitOAuthButton {\n css\n resetCss\n }\n authkitCallout {\n css\n resetCss\n }\n authkitFooter {\n css\n resetCss\n }\n authkitOrgSelection {\n css\n resetCss\n }\n authkitGlobal {\n css\n resetCss\n }\n }\n}", + "Application": "fragment Application on Application {\n __typename\n id\n name\n clientId\n isClientIdMetadataDocument\n createdAt\n clientConfidentiality\n credentials {\n id\n createdAt\n updatedAt\n lastUsedAt\n secretHint\n }\n lightLogoPath\n darkLogoPath\n description\n isFirstParty\n type\n updatedAt\n redirectUris {\n id\n uri\n isDefault\n }\n permissions {\n id\n name\n slug\n }\n ... on ThirdPartyApplication {\n __typename\n organization {\n id\n name\n }\n }\n}", + "AttributeSchema": "fragment AttributeSchema on AttributeSchema {\n schema\n}", + "AuditLogEvent": "fragment AuditLogEvent on AuditLogEvent {\n id\n createdAt\n occurredAt\n action\n actor {\n id\n name\n type\n }\n targets {\n type\n }\n}", + "AuditLogStream": "fragment AuditLogStream on AuditLogStream {\n id\n state\n organization {\n id\n }\n type {\n __typename\n ... on DatadogLogStream {\n __typename\n datadogRegion: region\n }\n ... on SplunkLogStream {\n __typename\n url\n }\n ... on S3LogStream {\n __typename\n accountId\n bucketName\n bucketPath\n externalId\n region\n roleName\n workosAccountId\n }\n ... on GoogleCloudStorageLogStream {\n __typename\n accessKeyId\n bucketName\n bucketPath\n }\n ... on GenericHttpsLogStream {\n __typename\n url\n authHeaderName\n requestBodyFormat\n }\n ... on AzureSentinelLogStream {\n __typename\n tenantId\n clientId\n dataCollectionRuleId\n dataCollectionEndpointUrl\n streamName\n }\n ... on SnowflakeLogStream {\n __typename\n accountUrl\n username\n publicKeyFingerprint\n warehouse\n database\n schema\n table\n }\n }\n}", + "AuditLogTarget": "fragment AuditLogTarget on AuditLogTarget {\n id\n target\n}", + "AuditLogTrail": "fragment AuditLogTrail on AuditLogTrail {\n id\n retentionPeriodInDays\n state\n createdAt\n updatedAt\n}", + "AuditLogValidator": "fragment AuditLogValidator on AuditLogValidator {\n id\n action\n versions {\n id\n version\n schema\n targets {\n id\n target\n }\n }\n}", + "AuthedUser": "fragment AuthedUser on User {\n id\n firstName\n lastName\n name\n email\n workosUserId\n impersonator {\n email\n firstName\n lastName\n }\n impersonationReason\n users_teams {\n ...TeamMembership\n }\n}", + "AuthorizationResourceRoleAssignment": "fragment AuthorizationResourceRoleAssignment on AuthorizationResourceRoleAssignment {\n resourceId\n resourceExternalId\n resourceTypeSlug\n role {\n id\n name\n }\n}", + "AutoMappedCustomAttribute": "fragment AutoMappedCustomAttribute on AutoMappedCustomAttribute {\n name\n attributeKey\n description\n active\n}", + "BambooHrDirectory": "fragment BambooHrDirectory on BambooHrDirectory {\n __typename\n subdomain\n}", + "BillingPaymentHistoryItem": "fragment BillingPaymentHistoryItem on BillingPaymentHistoryItem {\n id\n billingPeriodStart\n additionalFeatureKeys\n paymentStatus\n total\n connectionCount\n}", + "BreatheHrDirectory": "fragment BreatheHrDirectory on BreatheHrDirectory {\n __typename\n}", + "CezanneHrDirectory": "fragment CezanneHrDirectory on CezanneHrDirectory {\n __typename\n clientId\n}", + "Connection": "fragment Connection on Connection {\n __typename\n id\n importedId\n name\n type\n createdAt\n oidc_client_id\n oidc_discovery_endpoint\n oidc_redirect_uri\n oidc_token_endpoint_auth_method\n oidc_pkce\n oidc_id_token_signed_response_alg\n state\n saml_entity_id\n saml_sp_metadata_url\n saml_acs_url\n saml_idp_url\n saml_idp_metadata_url\n samlRelyingPartyTrusts {\n ...SamlRelyingPartyTrust\n }\n samlX509Certificates {\n ...SamlX509Certificate\n }\n latestExpiringCertificate {\n ...SamlX509Certificate\n }\n latestExpiredCertificate {\n ...SamlX509Certificate\n }\n saml {\n idpEntityId\n }\n adpConnectionSslCertificates {\n createdAt\n id\n value\n }\n attributeMap {\n ...ConnectionAttributeMap\n }\n customAttributesWithMappings {\n ...SsoCustomAttributesWithMappings\n }\n organization {\n id\n name\n seeded\n }\n}", + "ConnectionAttributeMap": "fragment ConnectionAttributeMap on AttributeMap {\n id\n attributes {\n idpId\n email\n firstName\n lastName\n groups\n name\n }\n}", + "ConnectionSessionData": "fragment ConnectionSessionData on ConnectionSession {\n __typename\n ... on SAMLSession {\n ...SamlSession\n connectionId\n }\n ... on OidcSession {\n createdAt\n id\n connectionId\n oidcSessionState: state\n oidcSessionInitiator: initiator\n profile {\n id\n idpId\n email\n firstName\n lastName\n name\n groups\n rawAttributes\n role {\n slug\n }\n roles {\n slug\n }\n }\n profileSnapshot {\n ...ProfileSnapshot\n }\n errors {\n ...ConnectionSessionError\n }\n }\n ... on OAuthSession {\n createdAt\n id\n connectionId\n oauthSessionState: state\n profile {\n id\n idpId\n email\n firstName\n lastName\n groups\n rawAttributes\n role {\n slug\n }\n }\n errors {\n ...ConnectionSessionError\n }\n }\n}", + "ConnectionSessionError": "fragment ConnectionSessionError on ConnectionSessionError {\n __typename\n ... on MalformedSamlResponseError {\n code\n reason\n }\n ... on InvalidX509CertError {\n code\n reason\n }\n ... on InvalidAttributesError {\n code\n reason\n received_attributes {\n attribute\n value\n }\n expected_attributes {\n email_attribute\n first_name_attribute\n idp_id_attribute\n last_name_attribute\n name_attribute\n }\n }\n ... on ProfileNotAllowedOutsideOrganizationError {\n code\n reason\n profileDomain\n organizationDomains\n }\n ... on DecryptionFailedError {\n code\n reason\n }\n ... on IdpInitiatedExpiredResponseError {\n code\n reason\n }\n ... on IdpInitiatedWithInResponseToError {\n code\n reason\n }\n ... on MissingSamlIdpUrlError {\n code\n reason\n }\n ... on MissingSamlRelyingPartyPrivateKeyError {\n code\n reason\n }\n ... on MissingSamlEntityIdError {\n code\n reason\n }\n ... on AcsUrlNotFoundError {\n acsUrl\n code\n reason\n }\n ... on DecryptionMalformedEncryptedAssertionError {\n code\n reason\n }\n ... on DecryptionMissingEncryptionKeyError {\n code\n reason\n }\n ... on IdpStatusCodeAuthenticationFailedError {\n code\n reason\n }\n ... on IdpStatusCodeRequestDeniedError {\n code\n reason\n }\n ... on IdpStatusCodeNotSuccessfulError {\n code\n reason\n }\n ... on InvalidAssertionError {\n code\n reason\n }\n ... on InvalidIssueInstantAssertionError {\n code\n reason\n }\n ... on ResponseReplayedError {\n code\n reason\n }\n ... on WorkOsAuthorizationCodeExpiredError {\n code\n reason\n }\n ... on WorkOsAuthorizationCodeAlreadyExchangedError {\n code\n reason\n }\n ... on OidcFailedError {\n code\n reason\n }\n ... on IdTokenInvalidError {\n code\n reason\n }\n ... on IdpIdInvalidError {\n code\n reason\n }\n ... on SamlAssertionMissingSignaturesError {\n code\n reason\n }\n ... on SamlResponseMissingAssertionError {\n code\n reason\n }\n ... on InvalidSamlSignatureError {\n code\n reason\n }\n ... on InvalidEmailError {\n code\n reason\n }\n ... on WorkOsSigninConsentDeniedError {\n code\n reason\n }\n}", + "ConnectionWithOrganizationDirectories": "fragment ConnectionWithOrganizationDirectories on Connection {\n __typename\n id\n name\n type\n createdAt\n oidc_client_id\n oidc_discovery_endpoint\n oidc_redirect_uri\n oidc_token_endpoint_auth_method\n oidc_pkce\n oidc_id_token_signed_response_alg\n state\n saml_entity_id\n saml_sp_metadata_url\n saml_acs_url\n saml_idp_url\n saml_idp_metadata_url\n samlRelyingPartyTrusts {\n ...SamlRelyingPartyTrust\n }\n samlX509Certificates {\n ...SamlX509Certificate\n }\n latestExpiringCertificate {\n ...SamlX509Certificate\n }\n latestExpiredCertificate {\n ...SamlX509Certificate\n }\n saml {\n idpEntityId\n }\n xaaCredentials {\n clientId\n publicKeyJwk\n }\n adpConnectionSslCertificates {\n createdAt\n id\n value\n }\n attributeMap {\n ...ConnectionAttributeMap\n }\n customAttributesWithMappings {\n ...SsoCustomAttributesWithMappings\n }\n organization {\n id\n name\n seeded\n isSsoJitProvisioningEnabled\n directories {\n data {\n ... on Directory {\n id\n summary {\n groupRoleMappingCount\n }\n }\n }\n }\n }\n}", + "CurrentTeam": "fragment CurrentTeam on Team {\n id\n name\n domain\n createdAt\n organizationId\n onboarding {\n __typename\n ... on TeamOnboarding {\n nextStep\n }\n }\n isMfaRequired\n isUsingConsumerDomain\n productionState\n userManagementActivated\n mcpServerOptions {\n isEnabled\n withProduction\n withMutations\n }\n entitlements {\n id\n featureId\n }\n}", + "CustomDomain": "fragment CustomDomain on CustomDomain {\n __typename\n id\n type\n environmentId\n domain\n status\n errors\n createdAt\n updatedAt\n legacyMigration\n dcvDelegationCnameTarget\n customHostnameSslMethod\n}", + "CustomEmailDomain": "fragment CustomEmailDomain on CustomEmailDomain {\n id\n domain\n status\n spf\n dkim1\n dkim2\n spfCname\n dkim1Cname\n dkim2Cname\n dkim1Verified\n dkim2Verified\n spfVerified\n}", + "CustomMappedCustomAttribute": "fragment CustomMappedCustomAttribute on CustomMappedCustomAttribute {\n id\n name\n description\n}", + "DataInstallation": "fragment DataInstallation on DataInstallation {\n id\n state\n createdAt\n organizationId\n organization {\n id\n name\n }\n dataIntegration {\n id\n type\n slug\n }\n}", + "Directory": "fragment Directory on Directory {\n __typename\n id\n type\n name\n endpoint\n directoryTokens {\n id\n tokenSuffix\n createdAt\n lastUsedAt\n }\n state\n setup_url\n config {\n ...DirectoryConfig\n }\n organization {\n id\n name\n domains {\n ...OrganizationDomain\n }\n }\n primaryDomain {\n domain\n }\n provider {\n __typename\n ... on BambooHrDirectory {\n ...BambooHrDirectory\n }\n ... on BreatheHrDirectory {\n ...BreatheHrDirectory\n }\n ... on CezanneHrDirectory {\n ...CezanneHrDirectory\n }\n ... on FourthHrDirectory {\n ...FourthHrDirectory\n }\n ... on GoogleWorkspaceDirectory {\n ...GoogleWorkspaceDirectory\n }\n ... on HibobDirectory {\n ...HibobDirectory\n }\n ... on PeopleHrDirectory {\n ...PeopleHrDirectory\n }\n ... on PersonioDirectory {\n ...PersonioDirectory\n }\n ... on RipplingDirectory {\n ...RipplingDirectory\n }\n ... on SftpDirectory {\n ...SftpDirectory\n }\n ... on WorkdayDirectory {\n ...WorkdayDirectory\n }\n }\n attributeMap {\n ...DirectoryAttributeMap\n }\n}", + "DirectoryAttributeMap": "fragment DirectoryAttributeMap on DirectoryAttributeMap {\n id\n attributes {\n externalId\n username\n emails\n firstName\n lastName\n jobTitle\n groupName\n }\n}", + "DirectoryConfig": "fragment DirectoryConfig on DirectoryConfig {\n id\n directoryId\n groupsSync\n}", + "DirectoryCustomAttribute": "fragment DirectoryCustomAttribute on DirectoryCustomAttribute {\n id\n isRequired\n name\n}", + "DirectoryCustomAttributeMappings": "fragment DirectoryCustomAttributeMappings on DirectoryCustomAttributeMapping {\n id\n attribute\n customAttribute {\n name\n }\n directory {\n id\n }\n}", + "DirectoryGroupConfig": "fragment DirectoryGroupConfig on DirectoryGroupConfig {\n id\n name\n idpId\n shouldSync\n numberOfMemberships\n createdAt\n}", + "DirectoryGroupWithRoleMapping": "fragment DirectoryGroupWithRoleMapping on DirectoryGroup {\n id\n name\n directoryId\n idpId\n summary {\n memberCount\n }\n groupRoleMapping {\n id\n entityId\n stableGroupId\n status\n role {\n id\n slug\n name\n }\n }\n}", + "DirectorySummary": "fragment DirectorySummary on Directory {\n __typename\n state\n directorySyncRuns(limit: $limit, state: $state) {\n data {\n ...DirectorySyncRun\n }\n }\n summary {\n userCount\n groupCount\n }\n}", + "DirectorySyncRun": "fragment DirectorySyncRun on DirectorySyncRun {\n id\n state\n updatedAt\n errors {\n __typename\n ... on MalformedDirectoryUserError {\n code\n reason\n }\n ... on MalformedDirectoryGroupError {\n code\n reason\n }\n }\n}", + "DirectorySyncRunWithInternalErrors": "fragment DirectorySyncRunWithInternalErrors on DirectorySyncRun {\n id\n state\n internalErrors {\n __typename\n ... on EmptyFileError {\n code\n file\n reason\n }\n ... on InvalidFileContentError {\n code\n file\n message\n reason\n }\n ... on MalformedFileError {\n code\n file\n fields\n reason\n }\n ... on MissingFileError {\n code\n file\n reason\n }\n }\n}", + "DirectoryUserWithRole": "fragment DirectoryUserWithRole on DirectoryUser {\n id\n firstName\n lastName\n email\n directoryGroups {\n id\n name\n }\n role {\n id\n name\n }\n roles {\n id\n name\n }\n state\n}", + "DirectoryWithOrganizationConnections": "fragment DirectoryWithOrganizationConnections on Directory {\n __typename\n id\n type\n name\n endpoint\n directoryTokens {\n id\n tokenSuffix\n createdAt\n lastUsedAt\n }\n state\n setup_url\n summary {\n groupRoleMappingCount\n }\n config {\n ...DirectoryConfig\n }\n organization {\n id\n name\n connections {\n data {\n ... on Connection {\n summary {\n groupRoleMappingCount\n }\n }\n }\n }\n domains {\n ...OrganizationDomain\n }\n }\n primaryDomain {\n domain\n }\n provider {\n __typename\n ... on BambooHrDirectory {\n ...BambooHrDirectory\n }\n ... on BreatheHrDirectory {\n ...BreatheHrDirectory\n }\n ... on CezanneHrDirectory {\n ...CezanneHrDirectory\n }\n ... on FourthHrDirectory {\n ...FourthHrDirectory\n }\n ... on GoogleWorkspaceDirectory {\n ...GoogleWorkspaceDirectory\n }\n ... on HibobDirectory {\n ...HibobDirectory\n }\n ... on PeopleHrDirectory {\n ...PeopleHrDirectory\n }\n ... on PersonioDirectory {\n ...PersonioDirectory\n }\n ... on RipplingDirectory {\n ...RipplingDirectory\n }\n ... on SftpDirectory {\n ...SftpDirectory\n }\n ... on WorkdayDirectory {\n ...WorkdayDirectory\n }\n }\n attributeMap {\n ...DirectoryAttributeMap\n }\n attributeSchema {\n ...AttributeSchema\n }\n}", + "Environment": "fragment Environment on Environment {\n id\n name\n logoUrl\n sandbox\n clientId\n platform\n resourceTypeCount\n authkitDomains {\n id\n domain\n }\n customAuthDomain {\n domain\n selfServe\n }\n customAuthDomainScope\n customAdminPortalDomain {\n domain\n selfServe\n }\n customAdminPortalDomainScope\n expiredConnectionCertificates {\n connectionId\n expiryDate\n organizationId\n organizationName\n }\n expiringConnectionCertificates {\n connectionId\n expiryDate\n organizationId\n organizationName\n }\n}", + "EnvironmentIdpAttributesConfig": "fragment EnvironmentIdpAttributesConfig on EnvironmentIdpAttributesConfig {\n id\n ssoCustomAttributeMappingEnabled\n dsyncCustomAttributeMappingEnabled\n}", + "EnvironmentRoleConfig": "fragment EnvironmentRoleConfig on EnvironmentRoleConfig {\n id\n defaultRole\n rolePriorityOrder\n ssoRoleAssignmentEnabled\n dsyncRoleAssignmentEnabled\n multipleRolesEnabled\n}", + "Event": "fragment Event on Event {\n id\n name\n data\n context\n createdAt\n updatedAt\n metadata\n}", + "Flag": "fragment Flag on Flag {\n id\n name\n slug\n description\n projectId\n owner {\n id\n email\n firstName\n lastName\n }\n flagEnvironments {\n id\n environmentId\n flagId\n flagEnabled\n defaultEnabled\n accessType\n organizations {\n id\n name\n }\n users {\n id\n email\n firstName\n lastName\n }\n uniqueUsersCount\n }\n createdAt\n updatedAt\n tags {\n id\n name\n }\n}", + "FlagEnvironment": "fragment FlagEnvironment on FlagEnvironment {\n id\n flagId\n environmentId\n flagEnabled\n defaultEnabled\n accessType\n organizations {\n id\n name\n }\n}", + "FlagListItem": "fragment FlagListItem on Flag {\n id\n name\n slug\n description\n projectId\n owner {\n id\n email\n firstName\n lastName\n }\n flagEnvironments {\n id\n environmentId\n flagId\n flagEnabled\n }\n createdAt\n updatedAt\n tags {\n id\n name\n }\n}", + "FourthHrDirectory": "fragment FourthHrDirectory on FourthHrDirectory {\n __typename\n organizationId\n username\n}", + "GoogleWorkspaceDirectory": "fragment GoogleWorkspaceDirectory on GoogleWorkspaceDirectory {\n __typename\n syncDerivedGroupMembers\n}", + "HibobDirectory": "fragment HibobDirectory on HibobDirectory {\n __typename\n serviceUserId\n}", + "IdpAttribute": "fragment IdpAttribute on IdpAttribute {\n name\n enabled\n required\n}", + "JwtTemplate": "fragment JwtTemplate on JwtTemplate {\n id\n content\n createdAt\n updatedAt\n}", + "Key": "fragment Key on Key {\n id\n createdAt\n expiredAt\n name\n platform\n applicationId\n}", + "OAuthCredential": "fragment OAuthCredential on OAuthCredential {\n id\n type\n state\n redirectUri\n clientId\n redactedClientSecret\n isUsingCustomDomain\n isUserlandEnabled\n customScopes\n returnProviderTokens\n appleTeamId\n appleKeyId\n redactedApplePrivateKey\n}", + "Organization": "fragment Organization on Organization {\n id\n name\n createdAt\n allowProfilesOutsideOrganization\n usersCount\n seeded\n enforcesVerifiedDomains\n domains {\n ...OrganizationDomainWithDomainCapture\n }\n setupLinks {\n ... on PortalSetupLink {\n state\n intents\n expiresAt\n }\n }\n latestConnection {\n id\n type\n state\n certificateState\n certificateExpirationDate\n }\n latestDirectory {\n type\n state\n }\n auditLogTrail {\n ...AuditLogTrail\n }\n stripeCustomerId\n stripeEntitlements\n externalId\n metadata {\n key\n value\n }\n}", + "OrganizationAdmin": "fragment OrganizationAdmin on OrganizationAdmin {\n id\n email\n isBouncing\n}", + "OrganizationDomain": "fragment OrganizationDomain on OrganizationDomain {\n id\n domain\n state\n subdomain\n verificationContent\n verificationStrategy\n}", + "OrganizationDomainWithDomainCapture": "fragment OrganizationDomainWithDomainCapture on OrganizationDomain {\n ...OrganizationDomain\n domainCaptureEnabled\n domainCaptureEnabledBy {\n id\n name\n }\n}", + "OrganizationIdpAttributesConfig": "fragment OrganizationIdpAttributesConfig on OrganizationIdpAttributesConfig {\n id\n ssoCustomAttributeMappingEnabled\n dsyncCustomAttributeMappingEnabled\n}", + "OrganizationRoleConfig": "fragment OrganizationRoleConfig on OrganizationRoleConfig {\n id\n defaultRole\n rolePriorityOrder\n ssoRoleAssignmentEnabled\n dsyncRoleAssignmentEnabled\n}", + "PeopleHrDirectory": "fragment PeopleHrDirectory on PeopleHrDirectory {\n __typename\n}", + "Permission": "fragment Permission on Permission {\n id\n name\n slug\n description\n system\n isDefaultDynamicRegistrationOauthScope\n isEnabledForApiKeys\n isEnabledForUserApiKeys\n environmentId\n resourceTypeId\n resourceType {\n ...ResourceType\n }\n createdAt\n updatedAt\n}", + "PersonioDirectory": "fragment PersonioDirectory on PersonioDirectory {\n __typename\n clientId\n}", + "PortalSettings": "fragment PortalSettings on PortalSettings {\n id\n defaultConnectionSuccessLink\n defaultDirectorySuccessLink\n defaultLogStreamsSuccessLink\n defaultDomainVerificationSuccessLink\n defaultRedirectLink\n}", + "PortalSetupLink": "fragment PortalSetupLink on PortalSetupLink {\n id\n expiresAt\n intents\n token\n url\n state\n}", + "Profile": "fragment Profile on Profile {\n id\n email\n firstName\n connectionType\n lastName\n name\n idpId\n state\n groups\n rawAttributes\n updatedAt\n}", + "ProfileSnapshot": "fragment ProfileSnapshot on ProfileSnapshot {\n idpId\n email\n firstName\n lastName\n name\n groups\n rawAttributes\n role {\n slug\n }\n roles {\n slug\n }\n}", + "RadarDetection": "fragment RadarDetection on RadarDetection {\n id\n control\n action\n reason\n email\n emailDomain\n ipAddress\n locationCity\n locationCountry\n userlandUserId\n userAgent\n botScore\n device\n botDetectionData {\n anomalousIpAddress\n automationSignalDetected\n emailPatternDetected\n excessiveDomainSignUps\n ipRiskDetected\n missingDeviceFingerprint\n }\n restrictionData {\n blocklistType\n list {\n id\n workosManaged\n type\n name\n }\n type\n source\n }\n challengeType\n challengeCompletedAt\n grantType\n isSignup\n createdAt\n updatedAt\n}", + "RadarListEntry": "fragment RadarListEntry on RadarListEntry {\n createdAt\n updatedAt\n value\n}", + "RedirectUri": "fragment RedirectUri on RedirectURI {\n id\n uri\n isDefault\n}", + "ResourceType": "fragment ResourceType on AuthorizationResourceType {\n id\n name\n slug\n description\n}", + "RipplingDirectory": "fragment RipplingDirectory on RipplingDirectory {\n __typename\n}", + "Role": "fragment Role on Role {\n id\n name\n slug\n description\n state\n type\n resourceTypeId\n resourceType {\n ...ResourceType\n }\n createdAt\n updatedAt\n}", + "RoleConfig": "fragment RoleConfig on RoleConfig {\n id\n defaultRole\n rolePriorityOrder\n ssoRoleAssignmentEnabled\n dsyncRoleAssignmentEnabled\n multipleRolesEnabled\n createdAt\n updatedAt\n}", + "SamlRelyingPartyTrust": "fragment SamlRelyingPartyTrust on SamlRelyingPartyTrust {\n id\n notBefore\n notAfter\n}", + "SamlSession": "fragment SamlSession on SAMLSession {\n createdAt\n id\n samlRequest\n samlResponse\n samlSessionState: state\n initiator\n relayState\n redirectUri\n stateParameter\n profile {\n id\n idpId\n email\n firstName\n lastName\n name\n groups\n role {\n slug\n }\n roles {\n slug\n }\n }\n profileSnapshot {\n ...ProfileSnapshot\n }\n errors {\n ...ConnectionSessionError\n }\n}", + "SamlX509Certificate": "fragment SamlX509Certificate on SamlX509Certificate {\n id\n value\n notBefore\n notAfter\n}", + "SftpDirectory": "fragment SftpDirectory on SftpDirectory {\n __typename\n publicKey\n username\n}", + "SlackChannel": "fragment SlackChannel on SlackChannel {\n id\n externalChannelId\n slackTeamId\n}", + "SsoCustomAttributesWithMappings": "fragment SsoCustomAttributesWithMappings on SsoCustomAttributeWithMappingList {\n data {\n id\n name\n ssoCustomAttributeMapping {\n attribute\n }\n }\n}", + "StripeBillingData": "fragment StripeBillingData on StripeBillingData {\n billingAddress {\n name\n line1\n line2\n city\n state\n postalCode\n country\n }\n defaultPaymentMethod {\n brand\n last4\n expirationMonth\n expirationYear\n }\n}", + "TeamConnection": "fragment TeamConnection on Connection {\n id\n name\n state\n type\n}", + "TeamMembership": "fragment TeamMembership on UsersTeam {\n id\n role\n state\n isManagedByDirectory\n complianceTermsAcceptedAt\n isInvitationExpired\n user {\n id\n name\n email\n isMfaConfigured\n lastLoginAt\n }\n team {\n id\n }\n}", + "TeamPlatform": "fragment TeamPlatform on TeamPlatform {\n id\n teamId\n platform\n createdAt\n lastActivityAt\n}", + "UntypedConnection": "fragment UntypedConnection on UntypedConnection {\n __typename\n id\n name\n organization {\n id\n name\n }\n}", + "UntypedDirectory": "fragment UntypedDirectory on UntypedDirectory {\n __typename\n id\n name\n organization {\n id\n name\n }\n}", + "UserlandApplication": "fragment UserlandApplication on UserlandApplication {\n id\n name\n clientId\n environmentId\n createdAt\n updatedAt\n initiateLoginUri\n appHomepageUrl\n signUpUrl\n passwordResetUrl\n userInvitationUrl\n maxSessionTime\n accessTokenExpiry\n inactivityTimeout\n isDefault\n keys {\n ...Key\n }\n redirectUris {\n ...RedirectUri\n }\n logoutUris {\n id\n uri\n isDefault\n }\n webOrigins {\n id\n origin\n }\n}", + "UserlandIdentity": "fragment UserlandIdentity on UserlandIdentity {\n id\n status\n directoryUserId\n role {\n id\n name\n }\n roles {\n id\n name\n }\n ssoProfile {\n __typename\n id\n connection {\n id\n type\n name\n }\n lastLoginAt\n }\n organization {\n id\n name\n }\n assignedResourceCount\n customAttributes\n createdAt\n updatedAt\n}", + "UserlandSession": "fragment UserlandSession on UserlandSession {\n __typename\n createdAt\n id\n ipAddress\n updatedAt\n userAgent\n provider\n impersonator {\n id\n email\n firstName\n lastName\n }\n impersonationReason\n organization {\n id\n name\n }\n application {\n id\n name\n }\n state {\n __typename\n ... on UserlandSessionExpired {\n endedAt\n }\n ... on UserlandSessionIssued {\n expiresAt\n }\n ... on UserlandSessionRevoked {\n expiresAt\n endedAt\n }\n }\n}", + "UserlandUser": "fragment UserlandUser on UserlandUser {\n ...UserlandUserWithoutIdentities\n identities {\n data {\n ...UserlandIdentity\n }\n }\n}", + "UserlandUserAuthenticationFactors": "fragment UserlandUserAuthenticationFactors on UserlandUser {\n authenticationFactors {\n id\n lastVerifiedAt\n factorType {\n __typename\n }\n }\n}", + "UserlandUserInvite": "fragment UserlandUserInvite on UserlandUserInvite {\n __typename\n id\n createdAt\n inviteeEmail\n state\n}", + "UserlandUserOrganizationMembership": "fragment UserlandUserOrganizationMembership on UserlandUserOrganizationMembership {\n id\n type\n status\n organizationId\n userlandUserId\n directoryUserId\n role\n roles\n createdAt\n updatedAt\n}", + "UserlandUserWithoutIdentities": "fragment UserlandUserWithoutIdentities on UserlandUser {\n __typename\n id\n createdAt\n email\n firstName\n lastName\n locale\n directoryUser {\n id\n }\n sessionCount\n lastSignedInAt\n hasPassword\n emailVerifiedAt\n profilePictureUrl\n appleOauthProfile {\n id\n }\n bitbucketOauthProfile {\n id\n }\n discordOauthProfile {\n id\n }\n githubOauthProfile {\n id\n }\n gitlabOauthProfile {\n id\n }\n googleOauthProfile {\n id\n }\n intuitOauthProfile {\n id\n }\n linkedinOauthProfile {\n id\n }\n microsoftOauthProfile {\n id\n }\n slackOauthProfile {\n id\n }\n vercelMarketplaceOauthProfile {\n id\n }\n vercelOauthProfile {\n id\n }\n xeroOauthProfile {\n id\n }\n ssoProfile {\n __typename\n id\n connection {\n id\n type\n name\n }\n lastLoginAt\n }\n salesforceOauthProfile {\n id\n }\n externalId\n metadata {\n key\n value\n }\n}", + "WebhookEndpoint": "fragment WebhookEndpoint on WebhookEndpoint {\n id\n endpointUrl\n events\n state\n}", + "WebhookEventWithFixture": "fragment WebhookEventWithFixture on WebhookEvent {\n id\n description\n fixtures {\n name\n payload\n }\n}", + "WorkdayDirectory": "fragment WorkdayDirectory on WorkdayDirectory {\n __typename\n userEndpoint\n groupEndpoint\n username\n}" + }, + "inputTypes": { + "ActionsEndpointState": { + "name": "ActionsEndpointState", + "kind": "ENUM", + "description": "The state of an actions endpoint.", + "enumValues": [ + "Active", + "Inactive" + ] + }, + "ActionsEndpointType": { + "name": "ActionsEndpointType", + "kind": "ENUM", + "description": "The type of an actions endpoint.", + "enumValues": [ + "Authentication", + "UserRegistration" + ] + }, + "AddAdpConnectionSslCertificateInput": { + "name": "AddAdpConnectionSslCertificateInput", + "kind": "INPUT_OBJECT", + "fields": [ + { + "name": "connectionId", + "type": "String!", + "required": true + }, + { + "name": "privateKey", + "type": "String!", + "required": true + }, + { + "name": "value", + "type": "String!", + "required": true + } + ] + }, + "AddBillingAddressInput": { + "name": "AddBillingAddressInput", + "kind": "INPUT_OBJECT", + "fields": [ + { + "name": "billingAddress", + "type": "BillingAddressInput!", + "required": true + } + ] + }, + "AddOrganizationAdminsInput": { + "name": "AddOrganizationAdminsInput", + "kind": "INPUT_OBJECT", + "fields": [ + { + "name": "emails", + "type": "[String!]!", + "required": true + }, + { + "name": "organizationId", + "type": "String!", + "required": true + } + ] + }, + "AddOrganizationDomainsInput": { + "name": "AddOrganizationDomainsInput", + "kind": "INPUT_OBJECT", + "fields": [ + { + "name": "domains", + "type": "[String!]!", + "required": true + }, + { + "name": "organizationId", + "type": "String!", + "required": true + } + ] + }, + "AddRadarListEntryInput": { + "name": "AddRadarListEntryInput", + "kind": "INPUT_OBJECT", + "fields": [ + { + "name": "action", + "type": "RadarListAction!", + "required": true + }, + { + "name": "environmentId", + "type": "String", + "required": false + }, + { + "name": "type", + "type": "RadarBlocklistType!", + "required": true + }, + { + "name": "value", + "type": "String!", + "required": true + } + ] + }, + "AddTaxIdToTeamInput": { + "name": "AddTaxIdToTeamInput", + "kind": "INPUT_OBJECT", + "fields": [ + { + "name": "countryCode", + "type": "TaxIdCountryCode!", + "required": true + }, + { + "name": "teamId", + "type": "String!", + "required": true + }, + { + "name": "type", + "type": "TaxIdType!", + "required": true + }, + { + "name": "value", + "type": "String!", + "required": true + } + ] + }, + "AddUserlandUserToOrganizationInput": { + "name": "AddUserlandUserToOrganizationInput", + "kind": "INPUT_OBJECT", + "fields": [ + { + "name": "organizationId", + "type": "String!", + "required": true + }, + { + "name": "roleId", + "type": "String", + "required": false + }, + { + "name": "userlandUserId", + "type": "String!", + "required": true + } + ] + }, + "AggregateRadarDetectionsTimeRange": { + "name": "AggregateRadarDetectionsTimeRange", + "kind": "ENUM", + "description": "Enum represents the time range of the aggregated data.", + "enumValues": [ + "Day", + "Month", + "Week" + ] + }, + "ApiKeysDateRangeInput": { + "name": "ApiKeysDateRangeInput", + "kind": "INPUT_OBJECT", + "fields": [ + { + "name": "end", + "type": "DateTime", + "required": false + }, + { + "name": "start", + "type": "DateTime", + "required": false + } + ] + }, + "ApplicationClientConfidentiality": { + "name": "ApplicationClientConfidentiality", + "kind": "ENUM", + "description": "The confidentiality of an application client.", + "enumValues": [ + "Confidential", + "Public" + ] + }, + "ApplicationIntegrationType": { + "name": "ApplicationIntegrationType", + "kind": "ENUM", + "description": "The integration method for the application.", + "enumValues": [ + "M2M", + "OAuth" + ] + }, + "ApplicationRegistration": { + "name": "ApplicationRegistration", + "kind": "ENUM", + "description": "The registration method of the application.", + "enumValues": [ + "Authenticated", + "Dynamic" + ] + }, + "ApplicationType": { + "name": "ApplicationType", + "kind": "ENUM", + "description": "The type of an application.", + "enumValues": [ + "M2M", + "OAuth" + ] + }, + "ApproveWaitlistEntryInput": { + "name": "ApproveWaitlistEntryInput", + "kind": "INPUT_OBJECT", + "fields": [ + { + "name": "id", + "type": "String!", + "required": true + } + ] + }, + "AuditLogStreamState": { + "name": "AuditLogStreamState", + "kind": "ENUM", + "enumValues": [ + "Active", + "Error", + "Inactive", + "Invalid" + ] + }, + "AuditLogStreamType": { + "name": "AuditLogStreamType", + "kind": "ENUM", + "enumValues": [ + "AzureSentinel", + "Datadog", + "GenericHttps", + "GoogleCloudStorage", + "S3", + "Snowflake", + "Splunk" + ] + }, + "AuthKitPageContentPanelAlignment": { + "name": "AuthKitPageContentPanelAlignment", + "kind": "ENUM", + "description": "Enum represents the type of content panel alignment for AuthKit pages.", + "enumValues": [ + "Left", + "Right" + ] + }, + "AuthKitPageLayout": { + "name": "AuthKitPageLayout", + "kind": "ENUM", + "description": "Enum represents the type of layout for AuthKit pages.", + "enumValues": [ + "OneColumn", + "TwoColumn" + ] + }, + "AutoMappedCustomAttributeName": { + "name": "AutoMappedCustomAttributeName", + "kind": "ENUM", + "description": "Enum represents the name of auto-mapped custom attribute.", + "enumValues": [ + "Addresses", + "CostCenterName", + "DepartmentName", + "DisplayName", + "DivisionName", + "Emails", + "EmployeeNumber", + "EmployeeType", + "EmploymentStartDate", + "JobTitle", + "ManagerEmail", + "ManagerId", + "ManagerName", + "Organization", + "PhoneNumbers", + "Username" + ] + }, + "BillingAddressInput": { + "name": "BillingAddressInput", + "kind": "INPUT_OBJECT", + "fields": [ + { + "name": "city", + "type": "String!", + "required": true + }, + { + "name": "country", + "type": "String!", + "required": true + }, + { + "name": "line1", + "type": "String!", + "required": true + }, + { + "name": "line2", + "type": "String!", + "required": true + }, + { + "name": "name", + "type": "String!", + "required": true + }, + { + "name": "postalCode", + "type": "String!", + "required": true + }, + { + "name": "state", + "type": "String!", + "required": true + } + ] + }, + "BindManagedListToEnvironmentInput": { + "name": "BindManagedListToEnvironmentInput", + "kind": "INPUT_OBJECT", + "fields": [ + { + "name": "action", + "type": "RadarListAction!", + "required": true + }, + { + "name": "environmentId", + "type": "String", + "required": false + }, + { + "name": "listId", + "type": "String!", + "required": true + } + ] + }, + "CheckEmailSuppressionInput": { + "name": "CheckEmailSuppressionInput", + "kind": "INPUT_OBJECT", + "fields": [ + { + "name": "email", + "type": "String!", + "required": true + }, + { + "name": "environmentId", + "type": "ID!", + "required": true + } + ] + }, + "ConfigureAzureSentinelLogStreamInput": { + "name": "ConfigureAzureSentinelLogStreamInput", + "kind": "INPUT_OBJECT", + "fields": [ + { + "name": "auditLogStreamId", + "type": "String!", + "required": true + }, + { + "name": "clientId", + "type": "String!", + "required": true + }, + { + "name": "clientSecret", + "type": "String!", + "required": true + }, + { + "name": "dataCollectionEndpointUrl", + "type": "String!", + "required": true + }, + { + "name": "dataCollectionRuleId", + "type": "String!", + "required": true + }, + { + "name": "streamName", + "type": "String!", + "required": true + }, + { + "name": "tenantId", + "type": "String!", + "required": true + } + ] + }, + "ConfigureDatadogLogStreamWithRegionInput": { + "name": "ConfigureDatadogLogStreamWithRegionInput", + "kind": "INPUT_OBJECT", + "fields": [ + { + "name": "apiKey", + "type": "String!", + "required": true + }, + { + "name": "auditLogStreamId", + "type": "String!", + "required": true + }, + { + "name": "region", + "type": "DatadogRegion!", + "required": true + } + ] + }, + "ConfigureGenericHttpsLogStreamInput": { + "name": "ConfigureGenericHttpsLogStreamInput", + "kind": "INPUT_OBJECT", + "fields": [ + { + "name": "auditLogStreamId", + "type": "String!", + "required": true + }, + { + "name": "authHeaderName", + "type": "String!", + "required": true + }, + { + "name": "authHeaderValue", + "type": "String!", + "required": true + }, + { + "name": "requestBodyFormat", + "type": "GenericHttpsLogStreamRequestBodyFormat!", + "required": true + }, + { + "name": "url", + "type": "String!", + "required": true + } + ] + }, + "ConfigureGoogleCloudStorageLogStreamInput": { + "name": "ConfigureGoogleCloudStorageLogStreamInput", + "kind": "INPUT_OBJECT", + "fields": [ + { + "name": "accessKeyId", + "type": "String", + "required": false + }, + { + "name": "auditLogStreamId", + "type": "String!", + "required": true + }, + { + "name": "bucketName", + "type": "String!", + "required": true + }, + { + "name": "bucketPath", + "type": "String", + "required": false + }, + { + "name": "secretAccessKey", + "type": "String", + "required": false + } + ] + }, + "ConfigureS3LogStreamInput": { + "name": "ConfigureS3LogStreamInput", + "kind": "INPUT_OBJECT", + "fields": [ + { + "name": "accountId", + "type": "String!", + "required": true + }, + { + "name": "auditLogStreamId", + "type": "String!", + "required": true + }, + { + "name": "bucketName", + "type": "String!", + "required": true + }, + { + "name": "bucketPath", + "type": "String", + "required": false + }, + { + "name": "region", + "type": "String", + "required": false + }, + { + "name": "roleName", + "type": "String!", + "required": true + } + ] + }, + "ConfigureSplunkLogStreamInput": { + "name": "ConfigureSplunkLogStreamInput", + "kind": "INPUT_OBJECT", + "fields": [ + { + "name": "auditLogStreamId", + "type": "String!", + "required": true + }, + { + "name": "hecToken", + "type": "String!", + "required": true + }, + { + "name": "url", + "type": "String!", + "required": true + } + ] + }, + "ConnectionState": { + "name": "ConnectionState", + "kind": "ENUM", + "description": "Enum represents the state of the connection.", + "enumValues": [ + "Active", + "Deleting", + "Inactive", + "Validating" + ] + }, + "ConnectionType": { + "name": "ConnectionType", + "kind": "ENUM", + "description": "Enum represents the type of connection.", + "enumValues": [ + "ADFSSAML", + "AdpOidc", + "AppleOAuth", + "Auth0Migration", + "Auth0SAML", + "AzureSAML", + "BitbucketOAuth", + "CasSAML", + "ClassLinkSAML", + "CleverOIDC", + "CloudflareSAML", + "CyberArkSAML", + "DiscordOAuth", + "DuoSAML", + "EntraIdOIDC", + "GenericOIDC", + "GenericSAML", + "GitLabOAuth", + "GithubOAuth", + "GoogleOAuth", + "GoogleOIDC", + "GoogleSAML", + "IntuitOAuth", + "JumpCloudSAML", + "KeycloakSAML", + "LastPassSAML", + "LinkedInOAuth", + "LoginGovOidc", + "MagicLink", + "MicrosoftOAuth", + "MiniOrangeSAML", + "NetIqSAML", + "OktaOIDC", + "OktaSAML", + "OneLoginSAML", + "OracleSAML", + "PingFederateSAML", + "PingOneSAML", + "RipplingSAML", + "SalesforceOAuth", + "SalesforceSAML", + "ShibbolethGenericSAML", + "ShibbolethSAML", + "SimpleSamlPhpSAML", + "SlackOAuth", + "TestIdp", + "VMwareSAML", + "VercelMarketplaceOAuth", + "VercelOAuth", + "XeroOAuth" + ] + }, + "CreateApplicationInput": { + "name": "CreateApplicationInput", + "kind": "INPUT_OBJECT", + "fields": [ + { + "name": "clientConfidentiality", + "type": "ApplicationClientConfidentiality", + "required": false + }, + { + "name": "description", + "type": "String", + "required": false + }, + { + "name": "name", + "type": "String!", + "required": true + }, + { + "name": "organizationId", + "type": "ID", + "required": false + }, + { + "name": "type", + "type": "ApplicationType!", + "required": true + } + ] + }, + "CreateAuditLogExportInput": { + "name": "CreateAuditLogExportInput", + "kind": "INPUT_OBJECT", + "fields": [ + { + "name": "actions", + "type": "[String!]", + "required": false + }, + { + "name": "actorIds", + "type": "[String!]", + "required": false + }, + { + "name": "actors", + "type": "[String!]", + "required": false + }, + { + "name": "organizationId", + "type": "String!", + "required": true + }, + { + "name": "rangeEnd", + "type": "DateTime!", + "required": true + }, + { + "name": "rangeStart", + "type": "DateTime!", + "required": true + }, + { + "name": "targetIds", + "type": "[String!]", + "required": false + }, + { + "name": "targets", + "type": "[String!]", + "required": false + } + ] + }, + "CreateAuditLogStreamInput": { + "name": "CreateAuditLogStreamInput", + "kind": "INPUT_OBJECT", + "fields": [ + { + "name": "organizationId", + "type": "String!", + "required": true + }, + { + "name": "type", + "type": "AuditLogStreamType!", + "required": true + } + ] + }, + "CreateAuditLogValidatorInput": { + "name": "CreateAuditLogValidatorInput", + "kind": "INPUT_OBJECT", + "fields": [ + { + "name": "action", + "type": "String!", + "required": true + }, + { + "name": "environmentId", + "type": "String!", + "required": true + }, + { + "name": "schema", + "type": "JSON", + "required": false + }, + { + "name": "targets", + "type": "[String!]!", + "required": true + } + ] + }, + "CreateAuditLogValidatorVersionInput": { + "name": "CreateAuditLogValidatorVersionInput", + "kind": "INPUT_OBJECT", + "fields": [ + { + "name": "auditLogValidatorId", + "type": "String!", + "required": true + }, + { + "name": "schema", + "type": "JSON", + "required": false + }, + { + "name": "targets", + "type": "[String!]!", + "required": true + } + ] + }, + "CreateAuthorizationResourceTypeInput": { + "name": "CreateAuthorizationResourceTypeInput", + "kind": "INPUT_OBJECT", + "fields": [ + { + "name": "description", + "type": "String", + "required": false + }, + { + "name": "name", + "type": "String!", + "required": true + }, + { + "name": "parentTypeIds", + "type": "[String!]!", + "required": true + }, + { + "name": "slug", + "type": "String!", + "required": true + } + ] + }, + "CreateConnectionGroupWithRoleMappingInput": { + "name": "CreateConnectionGroupWithRoleMappingInput", + "kind": "INPUT_OBJECT", + "fields": [ + { + "name": "connectionId", + "type": "String!", + "required": true + }, + { + "name": "idpId", + "type": "String!", + "required": true + }, + { + "name": "name", + "type": "String", + "required": false + }, + { + "name": "roleId", + "type": "String", + "required": false + } + ] + }, + "CreateCustomDataProviderInput": { + "name": "CreateCustomDataProviderInput", + "kind": "INPUT_OBJECT", + "fields": [ + { + "name": "additionalAuthorizationParameters", + "type": "[CustomDataProviderAdditionalAuthorizationParameterInput!]", + "required": false + }, + { + "name": "authenticateVia", + "type": "CustomDataProviderAuthenticateVia", + "required": false + }, + { + "name": "authorizationUrl", + "type": "String!", + "required": true + }, + { + "name": "clientSecretRequired", + "type": "Boolean", + "required": false + }, + { + "name": "description", + "type": "String", + "required": false + }, + { + "name": "environmentId", + "type": "ID!", + "required": true + }, + { + "name": "iconSlug", + "type": "String", + "required": false + }, + { + "name": "name", + "type": "String!", + "required": true + }, + { + "name": "pkceEnabled", + "type": "Boolean", + "required": false + }, + { + "name": "refreshTokenUrl", + "type": "String", + "required": false + }, + { + "name": "requestScopeSeparator", + "type": "String", + "required": false + }, + { + "name": "scopes", + "type": "[CustomDataProviderScopeInput!]", + "required": false + }, + { + "name": "scopesRequired", + "type": "Boolean", + "required": false + }, + { + "name": "slug", + "type": "String!", + "required": true + }, + { + "name": "tokenBodyContentType", + "type": "String", + "required": false + }, + { + "name": "tokenUrl", + "type": "String!", + "required": true + } + ] + }, + "CreateCustomDomainInput": { + "name": "CreateCustomDomainInput", + "kind": "INPUT_OBJECT", + "fields": [ + { + "name": "domain", + "type": "String!", + "required": true + }, + { + "name": "environmentId", + "type": "String!", + "required": true + }, + { + "name": "type", + "type": "CustomDomainType!", + "required": true + } + ] + }, + "CreateCustomEmailDomainInput": { + "name": "CreateCustomEmailDomainInput", + "kind": "INPUT_OBJECT", + "fields": [ + { + "name": "domain", + "type": "String!", + "required": true + } + ] + }, + "CreateCustomEmailProviderInput": { + "name": "CreateCustomEmailProviderInput", + "kind": "INPUT_OBJECT", + "fields": [ + { + "name": "awsSesRegion", + "type": "String", + "required": false + }, + { + "name": "defaultReplyTo", + "type": "String!", + "required": true + }, + { + "name": "defaultSender", + "type": "String!", + "required": true + }, + { + "name": "environmentId", + "type": "String!", + "required": true + }, + { + "name": "mailgunDomain", + "type": "String", + "required": false + }, + { + "name": "mailgunRegion", + "type": "MailgunCustomEmailProviderRegion", + "required": false + }, + { + "name": "providerType", + "type": "CustomEmailProviderType!", + "required": true + }, + { + "name": "unencryptedAwsAccessKeyId", + "type": "String", + "required": false + }, + { + "name": "unencryptedAwsSecretAccessKey", + "type": "String", + "required": false + }, + { + "name": "unencryptedMailgunApiKey", + "type": "String", + "required": false + }, + { + "name": "unencryptedPostmarkAccountToken", + "type": "String", + "required": false + }, + { + "name": "unencryptedPostmarkServerToken", + "type": "String", + "required": false + }, + { + "name": "unencryptedResendApiKey", + "type": "String", + "required": false + }, + { + "name": "unencryptedSendGridApiKey", + "type": "String", + "required": false + } + ] + }, + "CreateCustomMappedCustomAttributeInput": { + "name": "CreateCustomMappedCustomAttributeInput", + "kind": "INPUT_OBJECT", + "fields": [ + { + "name": "description", + "type": "String", + "required": false + }, + { + "name": "name", + "type": "String!", + "required": true + } + ] + }, + "CreateDirectoryConfigInput": { + "name": "CreateDirectoryConfigInput", + "kind": "INPUT_OBJECT", + "fields": [ + { + "name": "directoryId", + "type": "String!", + "required": true + }, + { + "name": "groupsSync", + "type": "DirectoryConfigGroupsSync!", + "required": true + } + ] + }, + "CreateDirectoryTokenInput": { + "name": "CreateDirectoryTokenInput", + "kind": "INPUT_OBJECT", + "fields": [ + { + "name": "directoryId", + "type": "ID!", + "required": true + } + ] + }, + "CreateEnvironmentInput": { + "name": "CreateEnvironmentInput", + "kind": "INPUT_OBJECT", + "fields": [ + { + "name": "isSandbox", + "type": "Boolean!", + "required": true + }, + { + "name": "name", + "type": "String!", + "required": true + } + ] + }, + "CreateFlagInput": { + "name": "CreateFlagInput", + "kind": "INPUT_OBJECT", + "fields": [ + { + "name": "description", + "type": "String", + "required": false + }, + { + "name": "name", + "type": "String!", + "required": true + }, + { + "name": "projectId", + "type": "String!", + "required": true + }, + { + "name": "slug", + "type": "String!", + "required": true + }, + { + "name": "tags", + "type": "[String!]", + "required": false + }, + { + "name": "userId", + "type": "String!", + "required": true + } + ] + }, + "CreateOauthCredentialsInput": { + "name": "CreateOauthCredentialsInput", + "kind": "INPUT_OBJECT", + "fields": [ + { + "name": "environmentId", + "type": "String!", + "required": true + }, + { + "name": "provider", + "type": "ConnectionType!", + "required": true + } + ] + }, + "CreateOrganizationIdpAttributesConfigInput": { + "name": "CreateOrganizationIdpAttributesConfigInput", + "kind": "INPUT_OBJECT", + "fields": [ + { + "name": "dsyncCustomAttributeMappingEnabled", + "type": "Boolean", + "required": false + }, + { + "name": "organizationId", + "type": "String!", + "required": true + }, + { + "name": "ssoCustomAttributeMappingEnabled", + "type": "Boolean", + "required": false + } + ] + }, + "CreateOrganizationInput": { + "name": "CreateOrganizationInput", + "kind": "INPUT_OBJECT", + "fields": [ + { + "name": "allowProfilesOutsideOrganization", + "type": "Boolean", + "required": false + }, + { + "name": "domains", + "type": "[String!]", + "required": false + }, + { + "name": "domainsDeveloperVerified", + "type": "Boolean", + "required": false + }, + { + "name": "environmentId", + "type": "String!", + "required": true + }, + { + "name": "externalId", + "type": "String", + "required": false + }, + { + "name": "name", + "type": "String!", + "required": true + } + ] + }, + "CreateOrganizationRoleConfigInput": { + "name": "CreateOrganizationRoleConfigInput", + "kind": "INPUT_OBJECT", + "fields": [ + { + "name": "defaultRole", + "type": "String", + "required": false + }, + { + "name": "dsyncRoleAssignmentEnabled", + "type": "Boolean", + "required": false + }, + { + "name": "organizationId", + "type": "String!", + "required": true + }, + { + "name": "rolePriorityOrder", + "type": "[String!]", + "required": false + }, + { + "name": "ssoRoleAssignmentEnabled", + "type": "Boolean", + "required": false + } + ] + }, + "CreatePermissionInput": { + "name": "CreatePermissionInput", + "kind": "INPUT_OBJECT", + "fields": [ + { + "name": "description", + "type": "String", + "required": false + }, + { + "name": "name", + "type": "String!", + "required": true + }, + { + "name": "resourceTypeId", + "type": "String", + "required": false + }, + { + "name": "slug", + "type": "String!", + "required": true + } + ] + }, + "CreateProjectWithNewEnvironmentsInput": { + "name": "CreateProjectWithNewEnvironmentsInput", + "kind": "INPUT_OBJECT", + "fields": [ + { + "name": "includeProductionEnvironment", + "type": "Boolean", + "required": false + }, + { + "name": "name", + "type": "String!", + "required": true + } + ] + }, + "CreateRoleInput": { + "name": "CreateRoleInput", + "kind": "INPUT_OBJECT", + "fields": [ + { + "name": "description", + "type": "String", + "required": false + }, + { + "name": "name", + "type": "String!", + "required": true + }, + { + "name": "organizationId", + "type": "String", + "required": false + }, + { + "name": "permissions", + "type": "[String!]", + "required": false + }, + { + "name": "resourceTypeId", + "type": "String", + "required": false + }, + { + "name": "slug", + "type": "String!", + "required": true + } + ] + }, + "CreateUserlandApplicationInput": { + "name": "CreateUserlandApplicationInput", + "kind": "INPUT_OBJECT", + "fields": [ + { + "name": "appHomepageUrl", + "type": "String", + "required": false + }, + { + "name": "environmentId", + "type": "String!", + "required": true + }, + { + "name": "initiateLoginUri", + "type": "String", + "required": false + }, + { + "name": "name", + "type": "String!", + "required": true + }, + { + "name": "passwordResetUrl", + "type": "String", + "required": false + }, + { + "name": "signUpUrl", + "type": "String", + "required": false + }, + { + "name": "userInvitationUrl", + "type": "String", + "required": false + } + ] + }, + "CreateUserlandUserInput": { + "name": "CreateUserlandUserInput", + "kind": "INPUT_OBJECT", + "fields": [ + { + "name": "email", + "type": "String!", + "required": true + }, + { + "name": "environmentId", + "type": "String!", + "required": true + }, + { + "name": "externalId", + "type": "String", + "required": false + }, + { + "name": "firstName", + "type": "String", + "required": false + }, + { + "name": "lastName", + "type": "String", + "required": false + }, + { + "name": "name", + "type": "String", + "required": false + }, + { + "name": "password", + "type": "String", + "required": false + } + ] + }, + "CreateUserlandUserInviteInput": { + "name": "CreateUserlandUserInviteInput", + "kind": "INPUT_OBJECT", + "fields": [ + { + "name": "environmentId", + "type": "String!", + "required": true + }, + { + "name": "expiresInDays", + "type": "Float!", + "required": true + }, + { + "name": "inviteeEmail", + "type": "String!", + "required": true + }, + { + "name": "organizationId", + "type": "String", + "required": false + }, + { + "name": "roleId", + "type": "String", + "required": false + } + ] + }, + "CustomCssElementInput": { + "name": "CustomCssElementInput", + "kind": "INPUT_OBJECT", + "fields": [ + { + "name": "css", + "type": "String!", + "required": true + }, + { + "name": "resetCss", + "type": "Boolean", + "required": false, + "defaultValue": "false" + } + ] + }, + "CustomCssElementsInput": { + "name": "CustomCssElementsInput", + "kind": "INPUT_OBJECT", + "fields": [ + { + "name": "authkitBackground", + "type": "CustomCssElementInput", + "required": false + }, + { + "name": "authkitButton", + "type": "CustomCssElementInput", + "required": false + }, + { + "name": "authkitCallout", + "type": "CustomCssElementInput", + "required": false + }, + { + "name": "authkitCard", + "type": "CustomCssElementInput", + "required": false + }, + { + "name": "authkitFooter", + "type": "CustomCssElementInput", + "required": false + }, + { + "name": "authkitGlobal", + "type": "CustomCssElementInput", + "required": false + }, + { + "name": "authkitHeader", + "type": "CustomCssElementInput", + "required": false + }, + { + "name": "authkitInput", + "type": "CustomCssElementInput", + "required": false + }, + { + "name": "authkitLabel", + "type": "CustomCssElementInput", + "required": false + }, + { + "name": "authkitOAuthButton", + "type": "CustomCssElementInput", + "required": false + }, + { + "name": "authkitOrgSelection", + "type": "CustomCssElementInput", + "required": false + }, + { + "name": "authkitVariables", + "type": "CustomCssElementInput", + "required": false + } + ] + }, + "CustomDataProviderAdditionalAuthorizationParameterInput": { + "name": "CustomDataProviderAdditionalAuthorizationParameterInput", + "kind": "INPUT_OBJECT", + "fields": [ + { + "name": "key", + "type": "String!", + "required": true + }, + { + "name": "value", + "type": "String!", + "required": true + } + ] + }, + "CustomDataProviderAuthenticateVia": { + "name": "CustomDataProviderAuthenticateVia", + "kind": "ENUM", + "description": "How a custom data provider authenticates when exchanging authorization codes and refreshing tokens.", + "enumValues": [ + "BasicAuthHeader", + "RequestBody" + ] + }, + "CustomDataProviderScopeInput": { + "name": "CustomDataProviderScopeInput", + "kind": "INPUT_OBJECT", + "fields": [ + { + "name": "slug", + "type": "String!", + "required": true + } + ] + }, + "CustomDomainScope": { + "name": "CustomDomainScope", + "kind": "ENUM", + "description": "Indicates whether the custom domain applies to all environments in the project or just a single environment.", + "enumValues": [ + "Environment", + "Project" + ] + }, + "CustomDomainType": { + "name": "CustomDomainType", + "kind": "ENUM", + "description": "Enum represents the type of the custom domain.", + "enumValues": [ + "AdminPortal", + "AuthAPI", + "HostedAuthkit", + "OAuthRedirect" + ] + }, + "CustomEmailProviderType": { + "name": "CustomEmailProviderType", + "kind": "ENUM", + "description": "The type of custom email provider.", + "enumValues": [ + "AMAZON_SES", + "MAILGUN", + "POSTMARK", + "RESEND", + "SENDGRID" + ] + }, + "DatadogRegion": { + "name": "DatadogRegion", + "kind": "ENUM", + "enumValues": [ + "AP1", + "EU1", + "US1", + "US1FED", + "US3", + "US5" + ] + }, + "DataIntegrationAuthMethod": { + "name": "DataIntegrationAuthMethod", + "kind": "ENUM", + "description": "The authentication method used for a data integration.", + "enumValues": [ + "ApiKey", + "Oauth" + ] + }, + "DataIntegrationCredentialsInput": { + "name": "DataIntegrationCredentialsInput", + "kind": "INPUT_OBJECT", + "fields": [ + { + "name": "clientId", + "type": "String", + "required": false + }, + { + "name": "clientSecret", + "type": "String", + "required": false + }, + { + "name": "type", + "type": "DataIntegrationCredentialsType!", + "required": true + } + ] + }, + "DataIntegrationCredentialsType": { + "name": "DataIntegrationCredentialsType", + "kind": "ENUM", + "description": "The type of credentials used for the DataIntegration.", + "enumValues": [ + "Custom", + "Organization", + "Shared" + ] + }, + "DataIntegrationStatus": { + "name": "DataIntegrationStatus", + "kind": "ENUM", + "enumValues": [ + "Active", + "Disabled" + ] + }, + "DateTime": { + "name": "DateTime", + "kind": "SCALAR", + "description": "A date-time string at UTC, such as 2019-12-03T09:54:33Z, compliant with the date-time format." + }, + "DeactivateUserlandUserOrganizationMembershipInput": { + "name": "DeactivateUserlandUserOrganizationMembershipInput", + "kind": "INPUT_OBJECT", + "fields": [ + { + "name": "userlandUserOrganizationMembershipId", + "type": "String!", + "required": true + } + ] + }, + "DeleteActionsEndpointInput": { + "name": "DeleteActionsEndpointInput", + "kind": "INPUT_OBJECT", + "fields": [ + { + "name": "actionsEndpointId", + "type": "String!", + "required": true + } + ] + }, + "DeleteAdpConnectionSslCertificateInput": { + "name": "DeleteAdpConnectionSslCertificateInput", + "kind": "INPUT_OBJECT", + "fields": [ + { + "name": "certificateId", + "type": "String!", + "required": true + } + ] + }, + "DeleteApiKeyInput": { + "name": "DeleteApiKeyInput", + "kind": "INPUT_OBJECT", + "fields": [ + { + "name": "apiKeyId", + "type": "String!", + "required": true + } + ] + }, + "DeleteApplicationCredentialInput": { + "name": "DeleteApplicationCredentialInput", + "kind": "INPUT_OBJECT", + "fields": [ + { + "name": "applicationCredentialId", + "type": "ID!", + "required": true + } + ] + }, + "DeleteApplicationInput": { + "name": "DeleteApplicationInput", + "kind": "INPUT_OBJECT", + "fields": [ + { + "name": "applicationId", + "type": "String!", + "required": true + } + ] + }, + "DeleteAuditLogStreamInput": { + "name": "DeleteAuditLogStreamInput", + "kind": "INPUT_OBJECT", + "fields": [ + { + "name": "id", + "type": "String!", + "required": true + } + ] + }, + "DeleteAuditLogValidatorInput": { + "name": "DeleteAuditLogValidatorInput", + "kind": "INPUT_OBJECT", + "fields": [ + { + "name": "id", + "type": "String!", + "required": true + } + ] + }, + "DeleteAuditLogValidatorVersionInput": { + "name": "DeleteAuditLogValidatorVersionInput", + "kind": "INPUT_OBJECT", + "fields": [ + { + "name": "auditLogValidatorVersionId", + "type": "String!", + "required": true + } + ] + }, + "DeleteAuthorizationResourceTypeInput": { + "name": "DeleteAuthorizationResourceTypeInput", + "kind": "INPUT_OBJECT", + "fields": [ + { + "name": "resourceTypeId", + "type": "String!", + "required": true + } + ] + }, + "DeleteConnectionGroupInput": { + "name": "DeleteConnectionGroupInput", + "kind": "INPUT_OBJECT", + "fields": [ + { + "name": "connectionGroupId", + "type": "String!", + "required": true + } + ] + }, + "DeleteCustomDataProviderInput": { + "name": "DeleteCustomDataProviderInput", + "kind": "INPUT_OBJECT", + "fields": [ + { + "name": "id", + "type": "ID!", + "required": true + } + ] + }, + "DeleteCustomEmailProviderInput": { + "name": "DeleteCustomEmailProviderInput", + "kind": "INPUT_OBJECT", + "fields": [ + { + "name": "customEmailProviderId", + "type": "ID!", + "required": true + } + ] + }, + "DeleteCustomMappedCustomAttributeInput": { + "name": "DeleteCustomMappedCustomAttributeInput", + "kind": "INPUT_OBJECT", + "fields": [ + { + "name": "customAttributeId", + "type": "ID!", + "required": true + } + ] + }, + "DeleteDataInstallationInput": { + "name": "DeleteDataInstallationInput", + "kind": "INPUT_OBJECT", + "fields": [ + { + "name": "dataInstallationId", + "type": "String!", + "required": true + } + ] + }, + "DeleteDataIntegrationInput": { + "name": "DeleteDataIntegrationInput", + "kind": "INPUT_OBJECT", + "fields": [ + { + "name": "dataIntegrationId", + "type": "String!", + "required": true + } + ] + }, + "DeleteDirectoryInput": { + "name": "DeleteDirectoryInput", + "kind": "INPUT_OBJECT", + "fields": [ + { + "name": "directoryId", + "type": "String!", + "required": true + } + ] + }, + "DeleteDirectoryTokenInput": { + "name": "DeleteDirectoryTokenInput", + "kind": "INPUT_OBJECT", + "fields": [ + { + "name": "directoryId", + "type": "ID!", + "required": true + }, + { + "name": "directoryTokenId", + "type": "ID!", + "required": true + } + ] + }, + "DeleteFlagInput": { + "name": "DeleteFlagInput", + "kind": "INPUT_OBJECT", + "fields": [ + { + "name": "flagId", + "type": "String!", + "required": true + }, + { + "name": "projectId", + "type": "String!", + "required": true + } + ] + }, + "DeleteGroupRoleMappingsInput": { + "name": "DeleteGroupRoleMappingsInput", + "kind": "INPUT_OBJECT", + "fields": [ + { + "name": "groupRoleMappingIds", + "type": "[String!]!", + "required": true + } + ] + }, + "DeleteJwtTemplateInput": { + "name": "DeleteJwtTemplateInput", + "kind": "INPUT_OBJECT", + "fields": [ + { + "name": "jwtTemplateId", + "type": "String!", + "required": true + } + ] + }, + "DeleteOrganizationAdminMembershipInput": { + "name": "DeleteOrganizationAdminMembershipInput", + "kind": "INPUT_OBJECT", + "fields": [ + { + "name": "organizationAdminId", + "type": "String!", + "required": true + }, + { + "name": "organizationId", + "type": "String!", + "required": true + } + ] + }, + "DeleteOrganizationInput": { + "name": "DeleteOrganizationInput", + "kind": "INPUT_OBJECT", + "fields": [ + { + "name": "organizationId", + "type": "String!", + "required": true + } + ] + }, + "DeletePermissionInput": { + "name": "DeletePermissionInput", + "kind": "INPUT_OBJECT", + "fields": [ + { + "name": "permissionId", + "type": "String!", + "required": true + } + ] + }, + "DeleteRoleInput": { + "name": "DeleteRoleInput", + "kind": "INPUT_OBJECT", + "fields": [ + { + "name": "replacementDefaultRoleId", + "type": "String", + "required": false + }, + { + "name": "roleId", + "type": "String!", + "required": true + } + ] + }, + "DeleteSamlX509CertificateInput": { + "name": "DeleteSamlX509CertificateInput", + "kind": "INPUT_OBJECT", + "fields": [ + { + "name": "samlX509CertificateId", + "type": "String!", + "required": true + } + ] + }, + "DeleteUserlandApplicationInput": { + "name": "DeleteUserlandApplicationInput", + "kind": "INPUT_OBJECT", + "fields": [ + { + "name": "applicationId", + "type": "String!", + "required": true + } + ] + }, + "DeleteUserlandUserInput": { + "name": "DeleteUserlandUserInput", + "kind": "INPUT_OBJECT", + "fields": [ + { + "name": "userlandUserId", + "type": "String!", + "required": true + } + ] + }, + "DeleteWaitlistEntryInput": { + "name": "DeleteWaitlistEntryInput", + "kind": "INPUT_OBJECT", + "fields": [ + { + "name": "id", + "type": "String!", + "required": true + } + ] + }, + "DenyWaitlistEntryInput": { + "name": "DenyWaitlistEntryInput", + "kind": "INPUT_OBJECT", + "fields": [ + { + "name": "id", + "type": "String!", + "required": true + } + ] + }, + "DeprovisionPlatformInput": { + "name": "DeprovisionPlatformInput", + "kind": "INPUT_OBJECT", + "fields": [ + { + "name": "platform", + "type": "String!", + "required": true + } + ] + }, + "DirectoryConfigGroupsSync": { + "name": "DirectoryConfigGroupsSync", + "kind": "ENUM", + "description": "Enum represents the strategy for syncing groups of the Directory.", + "enumValues": [ + "AllowList", + "SyncAll" + ] + }, + "DirectoryCustomAttributeMappingInput": { + "name": "DirectoryCustomAttributeMappingInput", + "kind": "INPUT_OBJECT", + "fields": [ + { + "name": "attribute", + "type": "String!", + "required": true + }, + { + "name": "customAttributeId", + "type": "ID!", + "required": true + } + ] + }, + "DirectorySyncRunState": { + "name": "DirectorySyncRunState", + "kind": "ENUM", + "description": "Enum represents the state of the directory sync run.", + "enumValues": [ + "Completed", + "Error", + "Running" + ] + }, + "DirectoryType": { + "name": "DirectoryType", + "kind": "ENUM", + "description": "Enum represents the type of Directory.", + "enumValues": [ + "AzureSCIMV2_0", + "BambooHR", + "BreatheHr", + "CezanneHr", + "CyberArkScimV2_0", + "FourthHr", + "GenericSCIMV2_0", + "GoogleWorkspace", + "Gusto", + "Hibob", + "JumpCloudScimV2_0", + "OktaSCIMV2_0", + "OneLoginScimV2_0", + "PeopleHr", + "Personio", + "PingFederateScimV2_0", + "Rippling", + "RipplingScimV2_0", + "S3", + "SailPointSCIMV2_0", + "Sftp", + "SftpWorkday", + "Workday" + ] + }, + "DirectoryUserState": { + "name": "DirectoryUserState", + "kind": "ENUM", + "description": "Enum represents the state of the directory user.", + "enumValues": [ + "Active", + "Inactive", + "Suspended", + "Validating" + ] + }, + "EmailNotificationCategory": { + "name": "EmailNotificationCategory", + "kind": "ENUM", + "description": "Represents the email categories that can be unsubscribed from.", + "enumValues": [ + "Connection", + "Error" + ] + }, + "EmailNotificationPreferenceConfiguration": { + "name": "EmailNotificationPreferenceConfiguration", + "kind": "ENUM", + "description": "Represents the possible configurations for email notification preferences.", + "enumValues": [ + "Subscribed", + "Unsubscribed" + ] + }, + "ExpireApiKeyInput": { + "name": "ExpireApiKeyInput", + "kind": "INPUT_OBJECT", + "fields": [ + { + "name": "apiKeyId", + "type": "String!", + "required": true + }, + { + "name": "expiresAt", + "type": "DateTime", + "required": false + } + ] + }, + "ExpirePortalSetupLinksInput": { + "name": "ExpirePortalSetupLinksInput", + "kind": "INPUT_OBJECT", + "fields": [ + { + "name": "expireIntents", + "type": "[PortalSetupLinkIntent!]", + "required": false + }, + { + "name": "organizationId", + "type": "String!", + "required": true + } + ] + }, + "FlagEnvironmentAccessType": { + "name": "FlagEnvironmentAccessType", + "kind": "ENUM", + "description": "The access type for a flag environment.", + "enumValues": [ + "ALL", + "NONE", + "SOME" + ] + }, + "FontInput": { + "name": "FontInput", + "kind": "INPUT_OBJECT", + "fields": [ + { + "name": "family", + "type": "String!", + "required": true + }, + { + "name": "src", + "type": "String", + "required": false + }, + { + "name": "variant", + "type": "String", + "required": false + } + ] + }, + "GeneratePortalSetupLinkInput": { + "name": "GeneratePortalSetupLinkInput", + "kind": "INPUT_OBJECT", + "fields": [ + { + "name": "expireIntents", + "type": "[PortalSetupLinkIntent!]", + "required": false + }, + { + "name": "intents", + "type": "[PortalSetupLinkIntent!]!", + "required": true + }, + { + "name": "organizationId", + "type": "String!", + "required": true + } + ] + }, + "GenericHttpsLogStreamRequestBodyFormat": { + "name": "GenericHttpsLogStreamRequestBodyFormat", + "kind": "ENUM", + "enumValues": [ + "JSON", + "NDJSON" + ] + }, + "GroupRoleMappingDefinitionInput": { + "name": "GroupRoleMappingDefinitionInput", + "kind": "INPUT_OBJECT", + "fields": [ + { + "name": "roleId", + "type": "String!", + "required": true + }, + { + "name": "stableGroupId", + "type": "String!", + "required": true + } + ] + }, + "InviteUserToTeamInput": { + "name": "InviteUserToTeamInput", + "kind": "INPUT_OBJECT", + "fields": [ + { + "name": "user", + "type": "UserInviteInput!", + "required": true + } + ] + }, + "JSON": { + "name": "JSON", + "kind": "SCALAR", + "description": "The `JSON` scalar type represents JSON values as specified by [ECMA-404](http://www.ecma-international.org/publications/files/ECMA-ST/ECMA-404.pdf)." + }, + "LogoIconFit": { + "name": "LogoIconFit", + "kind": "ENUM", + "description": "Enum represents the type of a LogoIconFit.", + "enumValues": [ + "Contain", + "Cover" + ] + }, + "LogoStyle": { + "name": "LogoStyle", + "kind": "ENUM", + "description": "Enum represents the type of a LogoStyle.", + "enumValues": [ + "Icon", + "Logo", + "None" + ] + }, + "MailgunCustomEmailProviderRegion": { + "name": "MailgunCustomEmailProviderRegion", + "kind": "ENUM", + "description": "The region of the Mailgun custom email provider.", + "enumValues": [ + "EU", + "US" + ] + }, + "NotificationState": { + "name": "NotificationState", + "kind": "ENUM", + "enumValues": [ + "Read", + "Unread" + ] + }, + "NotificationType": { + "name": "NotificationType", + "kind": "ENUM", + "enumValues": [ + "AuditLogStreamInvalidCredentials", + "BillingFailed", + "ConnectionAttributesMisconfigured", + "CustomDomainVerificationFailure", + "CustomEmailProviderSendEmailFailed", + "DirectoryCredentialsInvalid", + "ExposedKey", + "InvalidRedirectUri", + "ItAdminEmailBounced", + "RelyingPartyTrustExpiration", + "TeamDomainReserved", + "WebhookEndpointDisabled", + "WebhookEndpointFailureWarning", + "X509CertificateExpired", + "X509CertificateExpiring" + ] + }, + "NullableOrganizationRoleConfigProperties": { + "name": "NullableOrganizationRoleConfigProperties", + "kind": "ENUM", + "enumValues": [ + "dsyncRoleAssignmentEnabled", + "ssoRoleAssignmentEnabled" + ] + }, + "OidcIdTokenSignedResponseAlgorithm": { + "name": "OidcIdTokenSignedResponseAlgorithm", + "kind": "ENUM", + "description": "ID token signing algorithm for OIDC connections.", + "enumValues": [ + "ES256", + "ES384", + "ES512", + "EdDSA", + "HS256", + "HS384", + "HS512", + "PS256", + "PS384", + "PS512", + "RS256", + "RS384", + "RS512" + ] + }, + "OidcPkceMode": { + "name": "OidcPkceMode", + "kind": "ENUM", + "description": "PKCE mode for OIDC connections.", + "enumValues": [ + "Disabled", + "Enabled" + ] + }, + "OidcSessionInitiator": { + "name": "OidcSessionInitiator", + "kind": "ENUM", + "description": "Enum represents the initiator of the OIDC Session.", + "enumValues": [ + "AdminPortalTest", + "ServiceProvider" + ] + }, + "OidcSessionState": { + "name": "OidcSessionState", + "kind": "ENUM", + "description": "Enum represents the state of the OIDC Session.", + "enumValues": [ + "Authorized", + "Failed", + "Started", + "Successful", + "Terminated", + "Timedout" + ] + }, + "OrganizationConnectionState": { + "name": "OrganizationConnectionState", + "kind": "ENUM", + "description": "Enum represents the state of the connection within an organization with setup links.", + "enumValues": [ + "Active", + "AwaitingSetup", + "Deleting", + "Expired", + "Expiring", + "Inactive", + "SetupInProgress", + "Validating" + ] + }, + "OrganizationDirectoryState": { + "name": "OrganizationDirectoryState", + "kind": "ENUM", + "description": "Enum represents the state of the Directory within an organization with setup links.", + "enumValues": [ + "AwaitingSetup", + "Deleting", + "InvalidCredentials", + "Linked", + "SetupInProgress", + "Unlinked", + "Validating" + ] + }, + "OrganizationSortField": { + "name": "OrganizationSortField", + "kind": "ENUM", + "description": "Fields available for sorting organizations.", + "enumValues": [ + "CreatedAt", + "Name" + ] + }, + "PaginationOrder": { + "name": "PaginationOrder", + "kind": "ENUM", + "description": "Enum represents the pagination order.", + "enumValues": [ + "Asc", + "Desc", + "Normal" + ] + }, + "PortalSetupLinkIntent": { + "name": "PortalSetupLinkIntent", + "kind": "ENUM", + "description": "Enum represents the intent for a given of an Admin Portal setup link.", + "enumValues": [ + "BringYourOwnKey", + "CertificateRenewal", + "DomainVerification", + "Dsync", + "LogStreams", + "Sso" + ] + }, + "PreviewJwtTemplateInput": { + "name": "PreviewJwtTemplateInput", + "kind": "INPUT_OBJECT", + "fields": [ + { + "name": "environmentId", + "type": "String!", + "required": true + }, + { + "name": "organizationId", + "type": "String", + "required": false + }, + { + "name": "template", + "type": "String!", + "required": true + }, + { + "name": "userlandUserId", + "type": "String!", + "required": true + } + ] + }, + "ProfileByEmailInput": { + "name": "ProfileByEmailInput", + "kind": "INPUT_OBJECT", + "fields": [ + { + "name": "connectionId", + "type": "String!", + "required": true + }, + { + "name": "email", + "type": "String!", + "required": true + } + ] + }, + "RadarBlocklistType": { + "name": "RadarBlocklistType", + "kind": "ENUM", + "description": "Type of blocklist for Radar lists.", + "enumValues": [ + "Country", + "Device", + "DeviceFingerprint", + "Domain", + "Email", + "IpAddress", + "UserAgent" + ] + }, + "RadarDecision": { + "name": "RadarDecision", + "kind": "ENUM", + "enumValues": [ + "Block", + "Challenge", + "Log" + ] + }, + "RadarDetectionAction": { + "name": "RadarDetectionAction", + "kind": "ENUM", + "description": "Action taken for radar detection.", + "enumValues": [ + "Allow", + "Block", + "Challenge", + "Log" + ] + }, + "RadarDetectionControl": { + "name": "RadarDetectionControl", + "kind": "ENUM", + "description": "Type of radar detection control.", + "enumValues": [ + "BotDetection", + "BruteForceAttack", + "DomainProtections", + "ImpossibleTravel", + "RepeatSignUp", + "Restriction", + "StaleAccount", + "UnrecognizedDevice" + ] + }, + "RadarListAction": { + "name": "RadarListAction", + "kind": "ENUM", + "description": "Action to take on a Radar list.", + "enumValues": [ + "Allow", + "Block", + "Challenge", + "Log" + ] + }, + "RadarMode": { + "name": "RadarMode", + "kind": "ENUM", + "enumValues": [ + "Decision", + "Log", + "Off" + ] + }, + "Radius": { + "name": "Radius", + "kind": "ENUM", + "description": "Enum represents the type of a Radius.", + "enumValues": [ + "Full", + "Large", + "Medium", + "None", + "Small" + ] + }, + "ReactivateUserlandUserOrganizationMembershipInput": { + "name": "ReactivateUserlandUserOrganizationMembershipInput", + "kind": "INPUT_OBJECT", + "fields": [ + { + "name": "userlandUserOrganizationMembershipId", + "type": "String!", + "required": true + } + ] + }, + "RemoveCustomDomainInput": { + "name": "RemoveCustomDomainInput", + "kind": "INPUT_OBJECT", + "fields": [ + { + "name": "customDomainId", + "type": "String!", + "required": true + }, + { + "name": "environmentId", + "type": "String!", + "required": true + }, + { + "name": "scope", + "type": "CustomDomainScope!", + "required": true + } + ] + }, + "RemoveEmailSuppressionInput": { + "name": "RemoveEmailSuppressionInput", + "kind": "INPUT_OBJECT", + "fields": [ + { + "name": "email", + "type": "String!", + "required": true + }, + { + "name": "environmentId", + "type": "ID!", + "required": true + } + ] + }, + "RemoveRadarListEntryInput": { + "name": "RemoveRadarListEntryInput", + "kind": "INPUT_OBJECT", + "fields": [ + { + "name": "action", + "type": "RadarListAction!", + "required": true + }, + { + "name": "environmentId", + "type": "String", + "required": false + }, + { + "name": "type", + "type": "RadarBlocklistType!", + "required": true + }, + { + "name": "value", + "type": "String!", + "required": true + } + ] + }, + "RemoveUserlandUserFromOrganizationInput": { + "name": "RemoveUserlandUserFromOrganizationInput", + "kind": "INPUT_OBJECT", + "fields": [ + { + "name": "organizationId", + "type": "String!", + "required": true + }, + { + "name": "userlandUserId", + "type": "String!", + "required": true + } + ] + }, + "RenameEnvironmentInput": { + "name": "RenameEnvironmentInput", + "kind": "INPUT_OBJECT", + "fields": [ + { + "name": "environmentId", + "type": "String!", + "required": true + }, + { + "name": "name", + "type": "String!", + "required": true + } + ] + }, + "RenameProjectInput": { + "name": "RenameProjectInput", + "kind": "INPUT_OBJECT", + "fields": [ + { + "name": "name", + "type": "String!", + "required": true + }, + { + "name": "projectId", + "type": "String!", + "required": true + } + ] + }, + "RequestCustomDataIntegrationInput": { + "name": "RequestCustomDataIntegrationInput", + "kind": "INPUT_OBJECT", + "fields": [ + { + "name": "environmentId", + "type": "ID!", + "required": true + }, + { + "name": "providerName", + "type": "String!", + "required": true + }, + { + "name": "useCase", + "type": "String!", + "required": true + } + ] + }, + "RequestDataProviderInput": { + "name": "RequestDataProviderInput", + "kind": "INPUT_OBJECT", + "fields": [ + { + "name": "environmentId", + "type": "ID!", + "required": true + }, + { + "name": "providerSlug", + "type": "String!", + "required": true + } + ] + }, + "ResendDashboardInviteInput": { + "name": "ResendDashboardInviteInput", + "kind": "INPUT_OBJECT", + "fields": [ + { + "name": "teamMembershipId", + "type": "String!", + "required": true + } + ] + }, + "ResendUserlandUserInviteInput": { + "name": "ResendUserlandUserInviteInput", + "kind": "INPUT_OBJECT", + "fields": [ + { + "name": "userlandUserInviteId", + "type": "String!", + "required": true + } + ] + }, + "ResendWebhookEventInput": { + "name": "ResendWebhookEventInput", + "kind": "INPUT_OBJECT", + "fields": [ + { + "name": "webhookId", + "type": "String!", + "required": true + } + ] + }, + "ResetOauthCredentialsInput": { + "name": "ResetOauthCredentialsInput", + "kind": "INPUT_OBJECT", + "fields": [ + { + "name": "oauthCredentialId", + "type": "String!", + "required": true + }, + { + "name": "userlandEnabled", + "type": "Boolean", + "required": false + } + ] + }, + "ResetOrganizationIdpAttributesConfigInput": { + "name": "ResetOrganizationIdpAttributesConfigInput", + "kind": "INPUT_OBJECT", + "fields": [ + { + "name": "organizationIdpAttributesConfigId", + "type": "String!", + "required": true + } + ] + }, + "ResetOrganizationRoleConfigInput": { + "name": "ResetOrganizationRoleConfigInput", + "kind": "INPUT_OBJECT", + "fields": [ + { + "name": "resetProperties", + "type": "[NullableOrganizationRoleConfigProperties!]!", + "required": true + }, + { + "name": "roleConfigId", + "type": "String!", + "required": true + } + ] + }, + "ResetUserlandUserAuthenticationFactorsInput": { + "name": "ResetUserlandUserAuthenticationFactorsInput", + "kind": "INPUT_OBJECT", + "fields": [ + { + "name": "userlandUserId", + "type": "String!", + "required": true + } + ] + }, + "RevokeUserlandSessionInput": { + "name": "RevokeUserlandSessionInput", + "kind": "INPUT_OBJECT", + "fields": [ + { + "name": "sessionId", + "type": "String!", + "required": true + } + ] + }, + "RevokeUserlandUserInviteInput": { + "name": "RevokeUserlandUserInviteInput", + "kind": "INPUT_OBJECT", + "fields": [ + { + "name": "userlandUserInviteId", + "type": "String!", + "required": true + } + ] + }, + "RevokeUserlandUserSessionsInput": { + "name": "RevokeUserlandUserSessionsInput", + "kind": "INPUT_OBJECT", + "fields": [ + { + "name": "userlandUserId", + "type": "String!", + "required": true + } + ] + }, + "SamlSessionInitiator": { + "name": "SamlSessionInitiator", + "kind": "ENUM", + "description": "Enum represents how the SAML Session was initiated.", + "enumValues": [ + "AdminPortalTest", + "AdminPortalTestCertificateRenewal", + "IdentityProvider", + "ServiceProvider" + ] + }, + "SAMLSessionState": { + "name": "SAMLSessionState", + "kind": "ENUM", + "description": "Enum represents the state of the SAML Session.", + "enumValues": [ + "Authorized", + "Failed", + "Started", + "Successful", + "Timedout" + ] + }, + "SendSetupLinkEmailsInput": { + "name": "SendSetupLinkEmailsInput", + "kind": "INPUT_OBJECT", + "fields": [ + { + "name": "certificateExpirationDate", + "type": "DateTime", + "required": false + }, + { + "name": "connectionId", + "type": "String", + "required": false + }, + { + "name": "emails", + "type": "[String!]!", + "required": true + }, + { + "name": "intents", + "type": "[PortalSetupLinkIntent!]!", + "required": true + }, + { + "name": "organizationId", + "type": "String!", + "required": true + } + ] + }, + "SendTestEmailInput": { + "name": "SendTestEmailInput", + "kind": "INPUT_OBJECT", + "fields": [ + { + "name": "environmentId", + "type": "String!", + "required": true + } + ] + }, + "SessionProvider": { + "name": "SessionProvider", + "kind": "ENUM", + "description": "Enumeration of the different providers that can be used to create a session.", + "enumValues": [ + "AppleOauth", + "BitbucketOAuth", + "CrossAppAuth", + "DiscordOAuth", + "ExternalAuth", + "GitLabOAuth", + "GithubOAuth", + "GoogleOAuth", + "Impersonation", + "IntuitOAuth", + "LinkedInOAuth", + "MagicAuth", + "MicrosoftOAuth", + "MigratedSession", + "Passkey", + "Password", + "SalesforceOAuth", + "SingleSignOn", + "SlackOAuth", + "VercelMarketplaceOAuth", + "VercelOAuth", + "XeroOAuth" + ] + }, + "SetApplicationPermissionsInput": { + "name": "SetApplicationPermissionsInput", + "kind": "INPUT_OBJECT", + "fields": [ + { + "name": "applicationId", + "type": "ID!", + "required": true + }, + { + "name": "permissionIds", + "type": "[ID!]!", + "required": true + } + ] + }, + "SetAuthkitOauthResourceInput": { + "name": "SetAuthkitOauthResourceInput", + "kind": "INPUT_OBJECT", + "fields": [ + { + "name": "id", + "type": "String", + "required": false + }, + { + "name": "uri", + "type": "String!", + "required": true + } + ] + }, + "SetAuthkitOauthResourcesInput": { + "name": "SetAuthkitOauthResourcesInput", + "kind": "INPUT_OBJECT", + "fields": [ + { + "name": "environmentId", + "type": "String!", + "required": true + }, + { + "name": "resources", + "type": "[SetAuthkitOauthResourceInput!]!", + "required": true + } + ] + }, + "SetAutoMappedCustomAttributesInput": { + "name": "SetAutoMappedCustomAttributesInput", + "kind": "INPUT_OBJECT", + "fields": [ + { + "name": "attributeNames", + "type": "[AutoMappedCustomAttributeName!]!", + "required": true + }, + { + "name": "environmentId", + "type": "String!", + "required": true + } + ] + }, + "SetDirectoryCustomAttributeMappingsInput": { + "name": "SetDirectoryCustomAttributeMappingsInput", + "kind": "INPUT_OBJECT", + "fields": [ + { + "name": "directoryId", + "type": "ID!", + "required": true + }, + { + "name": "mappings", + "type": "[DirectoryCustomAttributeMappingInput!]!", + "required": true + } + ] + }, + "SetEmailNotificationPreferenceInput": { + "name": "SetEmailNotificationPreferenceInput", + "kind": "INPUT_OBJECT", + "fields": [ + { + "name": "category", + "type": "EmailNotificationCategory!", + "required": true + }, + { + "name": "preference", + "type": "EmailNotificationPreferenceConfiguration!", + "required": true + } + ] + }, + "SetEmailNotificationPreferencesInput": { + "name": "SetEmailNotificationPreferencesInput", + "kind": "INPUT_OBJECT", + "fields": [ + { + "name": "preferences", + "type": "[SetEmailNotificationPreferenceInput!]!", + "required": true + } + ] + }, + "SetIdpAttributeInput": { + "name": "SetIdpAttributeInput", + "kind": "INPUT_OBJECT", + "fields": [ + { + "name": "enabled", + "type": "Boolean!", + "required": true + }, + { + "name": "name", + "type": "String!", + "required": true + }, + { + "name": "required", + "type": "Boolean!", + "required": true + } + ] + }, + "SetIdpAttributesInput": { + "name": "SetIdpAttributesInput", + "kind": "INPUT_OBJECT", + "fields": [ + { + "name": "attributes", + "type": "[SetIdpAttributeInput!]!", + "required": true + }, + { + "name": "environmentId", + "type": "String!", + "required": true + } + ] + }, + "SetLogoutUriInput": { + "name": "SetLogoutUriInput", + "kind": "INPUT_OBJECT", + "fields": [ + { + "name": "id", + "type": "String", + "required": false + }, + { + "name": "isDefault", + "type": "Boolean", + "required": false + }, + { + "name": "uri", + "type": "String!", + "required": true + } + ] + }, + "SetLogoutUrisInput": { + "name": "SetLogoutUrisInput", + "kind": "INPUT_OBJECT", + "fields": [ + { + "name": "dryRun", + "type": "Boolean", + "required": false, + "description": "Validate the logout URIs without persisting them to the database." + }, + { + "name": "environmentId", + "type": "String!", + "required": true + }, + { + "name": "logoutUris", + "type": "[SetLogoutUriInput!]!", + "required": true + } + ] + }, + "SetRedirectUriInput": { + "name": "SetRedirectUriInput", + "kind": "INPUT_OBJECT", + "fields": [ + { + "name": "id", + "type": "String", + "required": false + }, + { + "name": "isDefault", + "type": "Boolean", + "required": false + }, + { + "name": "uri", + "type": "String!", + "required": true + } + ] + }, + "SetRedirectUrisInput": { + "name": "SetRedirectUrisInput", + "kind": "INPUT_OBJECT", + "fields": [ + { + "name": "applicationId", + "type": "String", + "required": false + }, + { + "name": "dryRun", + "type": "Boolean", + "required": false, + "description": "Validate the redirect URIs without persisting them to the database." + }, + { + "name": "environmentId", + "type": "String", + "required": false + }, + { + "name": "redirectUris", + "type": "[SetRedirectUriInput!]!", + "required": true + } + ] + }, + "SetSsoCustomAttributeMappingsInput": { + "name": "SetSsoCustomAttributeMappingsInput", + "kind": "INPUT_OBJECT", + "fields": [ + { + "name": "connectionId", + "type": "ID!", + "required": true + }, + { + "name": "customAttributeMappings", + "type": "[SsoCustomAttributeMappingInput!]!", + "required": true + } + ] + }, + "SetUserlandApplicationLogoutUriInput": { + "name": "SetUserlandApplicationLogoutUriInput", + "kind": "INPUT_OBJECT", + "fields": [ + { + "name": "id", + "type": "String", + "required": false + }, + { + "name": "isDefault", + "type": "Boolean", + "required": false + }, + { + "name": "uri", + "type": "String!", + "required": true + } + ] + }, + "SetUserlandApplicationLogoutUrisInput": { + "name": "SetUserlandApplicationLogoutUrisInput", + "kind": "INPUT_OBJECT", + "fields": [ + { + "name": "applicationId", + "type": "String!", + "required": true + }, + { + "name": "dryRun", + "type": "Boolean", + "required": false, + "description": "Validate the logout URIs without persisting them to the database." + }, + { + "name": "logoutUris", + "type": "[SetUserlandApplicationLogoutUriInput!]!", + "required": true + } + ] + }, + "SetUserlandApplicationRedirectUriInput": { + "name": "SetUserlandApplicationRedirectUriInput", + "kind": "INPUT_OBJECT", + "fields": [ + { + "name": "id", + "type": "String", + "required": false + }, + { + "name": "isDefault", + "type": "Boolean", + "required": false + }, + { + "name": "uri", + "type": "String!", + "required": true + } + ] + }, + "SetUserlandApplicationRedirectUrisInput": { + "name": "SetUserlandApplicationRedirectUrisInput", + "kind": "INPUT_OBJECT", + "fields": [ + { + "name": "applicationId", + "type": "String!", + "required": true + }, + { + "name": "dryRun", + "type": "Boolean", + "required": false, + "description": "Validate the redirect URIs without persisting them to the database." + }, + { + "name": "redirectUris", + "type": "[SetUserlandApplicationRedirectUriInput!]!", + "required": true + } + ] + }, + "SetUserlandApplicationWebOriginsInput": { + "name": "SetUserlandApplicationWebOriginsInput", + "kind": "INPUT_OBJECT", + "fields": [ + { + "name": "applicationId", + "type": "String!", + "required": true + }, + { + "name": "dryRun", + "type": "Boolean!", + "required": false, + "defaultValue": "false", + "description": "Validate the web origins without persisting them to the database." + }, + { + "name": "origins", + "type": "[String!]!", + "required": true + } + ] + }, + "SftpSourceProvider": { + "name": "SftpSourceProvider", + "kind": "ENUM", + "description": "Enum represents the type of the SFTP source provider.", + "enumValues": [ + "Generic", + "Workday" + ] + }, + "SsoCustomAttributeMappingInput": { + "name": "SsoCustomAttributeMappingInput", + "kind": "INPUT_OBJECT", + "fields": [ + { + "name": "attribute", + "type": "String!", + "required": true + }, + { + "name": "customAttributeId", + "type": "ID!", + "required": true + } + ] + }, + "TaxIdCountryCode": { + "name": "TaxIdCountryCode", + "kind": "ENUM", + "description": "Supported tax id country codes from Orb.", + "enumValues": [ + "AD", + "AE", + "AR", + "AT", + "AU", + "BE", + "BG", + "BO", + "BR", + "CA", + "CH", + "CL", + "CN", + "CO", + "CR", + "CY", + "CZ", + "DE", + "DK", + "DO", + "EC", + "EE", + "EG", + "ES", + "EU", + "FI", + "FR", + "GB", + "GE", + "GR", + "HK", + "HR", + "HU", + "ID", + "IE", + "IL", + "IN", + "IS", + "IT", + "JP", + "KE", + "KR", + "LI", + "LT", + "LU", + "LV", + "MT", + "MX", + "MY", + "NL", + "NO", + "NZ", + "PE", + "PH", + "PL", + "PT", + "RO", + "RS", + "RU", + "SA", + "SE", + "SG", + "SI", + "SK", + "SV", + "TH", + "TR", + "TW", + "UA", + "US", + "UY", + "VE", + "VN", + "ZA" + ] + }, + "TaxIdType": { + "name": "TaxIdType", + "kind": "ENUM", + "description": "Supported tax id types from Orb.", + "enumValues": [ + "ad_nrt", + "ae_trn", + "ar_cuit", + "au_abn", + "au_arn", + "bg_uic", + "bo_tin", + "br_cnpj", + "br_cpf", + "ca_bn", + "ca_gst_hst", + "ca_pst_bc", + "ca_pst_mb", + "ca_pst_sk", + "ca_qst", + "ch_vat", + "cl_tin", + "cn_tin", + "co_nit", + "cr_tin", + "do_rcn", + "ec_ruc", + "eg_tin", + "es_cif", + "eu_oss_vat", + "eu_vat", + "gb_vat", + "ge_vat", + "hk_br", + "hu_tin", + "id_npwp", + "il_vat", + "in_gst", + "is_vat", + "jp_cn", + "jp_rn", + "jp_trn", + "ke_pin", + "kr_brn", + "li_uid", + "mx_rfc", + "my_frp", + "my_itn", + "my_sst", + "no_vat", + "nz_gst", + "pe_ruc", + "ph_tin", + "ro_tin", + "rs_pib", + "ru_inn", + "ru_kpp", + "sa_vat", + "sg_gst", + "sg_uen", + "si_tin", + "sv_nit", + "th_vat", + "tr_tin", + "tw_vat", + "ua_vat", + "us_ein", + "uy_ruc", + "ve_rif", + "vn_tin", + "za_vat" + ] + }, + "TestActionEndpointInput": { + "name": "TestActionEndpointInput", + "kind": "INPUT_OBJECT", + "fields": [ + { + "name": "context", + "type": "JSON!", + "required": true + }, + { + "name": "environmentId", + "type": "String!", + "required": true + }, + { + "name": "type", + "type": "ActionsEndpointType!", + "required": true + } + ] + }, + "Theme": { + "name": "Theme", + "kind": "ENUM", + "description": "Enum represents the type of a Theme.", + "enumValues": [ + "Dark", + "Light", + "System" + ] + }, + "UnbindManagedListFromEnvironmentInput": { + "name": "UnbindManagedListFromEnvironmentInput", + "kind": "INPUT_OBJECT", + "fields": [ + { + "name": "action", + "type": "RadarListAction!", + "required": true + }, + { + "name": "environmentId", + "type": "String", + "required": false + }, + { + "name": "listId", + "type": "String!", + "required": true + } + ] + }, + "UpdateAppBrandingInput": { + "name": "UpdateAppBrandingInput", + "kind": "INPUT_OBJECT", + "fields": [ + { + "name": "authkitLogoStyle", + "type": "LogoStyle", + "required": false + }, + { + "name": "authkitShowLastUsedBadge", + "type": "Boolean", + "required": false + }, + { + "name": "authkitSignInContentPanelAlignment", + "type": "AuthKitPageContentPanelAlignment", + "required": false + }, + { + "name": "authkitSignInContentPanelHiddenOnMobile", + "type": "Boolean", + "required": false + }, + { + "name": "authkitSignInCustomCss", + "type": "String", + "required": false + }, + { + "name": "authkitSignInCustomHtml", + "type": "String", + "required": false + }, + { + "name": "authkitSignInHeadingLocalized", + "type": "JSON", + "required": false + }, + { + "name": "authkitSignInHeadingText", + "type": "String", + "required": false + }, + { + "name": "authkitSignInLayout", + "type": "AuthKitPageLayout", + "required": false + }, + { + "name": "authkitSignInLegalText", + "type": "String", + "required": false + }, + { + "name": "authkitSignInLinkTextLocalized", + "type": "JSON", + "required": false + }, + { + "name": "authkitSignInPrivacyPolicyUrl", + "type": "String", + "required": false + }, + { + "name": "authkitSignInTermsOfServiceUrl", + "type": "String", + "required": false + }, + { + "name": "authkitSignUpActionLinkText", + "type": "String", + "required": false + }, + { + "name": "authkitSignUpContentPanelAlignment", + "type": "AuthKitPageContentPanelAlignment", + "required": false + }, + { + "name": "authkitSignUpContentPanelHiddenOnMobile", + "type": "Boolean", + "required": false + }, + { + "name": "authkitSignUpCustomCss", + "type": "String", + "required": false + }, + { + "name": "authkitSignUpCustomHtml", + "type": "String", + "required": false + }, + { + "name": "authkitSignUpHeadingLocalized", + "type": "JSON", + "required": false + }, + { + "name": "authkitSignUpHeadingText", + "type": "String", + "required": false + }, + { + "name": "authkitSignUpLayout", + "type": "AuthKitPageLayout", + "required": false + }, + { + "name": "authkitSignUpLegalText", + "type": "String", + "required": false + }, + { + "name": "authkitSignUpLinkText", + "type": "String", + "required": false + }, + { + "name": "authkitSignUpLinkTextLocalized", + "type": "JSON", + "required": false + }, + { + "name": "authkitSignUpPrivacyPolicyUrl", + "type": "String", + "required": false + }, + { + "name": "authkitSignUpShowNameFields", + "type": "Boolean", + "required": false + }, + { + "name": "authkitSignUpTermsOfServiceUrl", + "type": "String", + "required": false + }, + { + "name": "customCssElements", + "type": "CustomCssElementsInput", + "required": false + }, + { + "name": "darkButtonBackgroundColor", + "type": "String", + "required": false + }, + { + "name": "darkButtonForegroundColor", + "type": "String", + "required": false + }, + { + "name": "darkFaviconFile", + "type": "Upload", + "required": false + }, + { + "name": "darkLinkColor", + "type": "String", + "required": false + }, + { + "name": "darkLogoFile", + "type": "Upload", + "required": false + }, + { + "name": "darkLogoIconFile", + "type": "Upload", + "required": false + }, + { + "name": "darkPageBackgroundColor", + "type": "String", + "required": false + }, + { + "name": "displayName", + "type": "String", + "required": false + }, + { + "name": "emailLogoStyle", + "type": "LogoStyle", + "required": false + }, + { + "name": "environmentId", + "type": "String", + "required": false, + "description": "When provided, scopes the update to the branding record for this environment instead of resolving by `id`. With per-environment branding enabled, only that environment’s branding is updated." + }, + { + "name": "font", + "type": "FontInput", + "required": false + }, + { + "name": "id", + "type": "String!", + "required": true + }, + { + "name": "inviteAdminReplyToEmail", + "type": "String", + "required": false + }, + { + "name": "lightButtonBackgroundColor", + "type": "String", + "required": false + }, + { + "name": "lightButtonForegroundColor", + "type": "String", + "required": false + }, + { + "name": "lightFaviconFile", + "type": "Upload", + "required": false + }, + { + "name": "lightLinkColor", + "type": "String", + "required": false + }, + { + "name": "lightLogoFile", + "type": "Upload", + "required": false + }, + { + "name": "lightLogoIconFile", + "type": "Upload", + "required": false + }, + { + "name": "lightPageBackgroundColor", + "type": "String", + "required": false + }, + { + "name": "logoIconFit", + "type": "LogoIconFit", + "required": false + }, + { + "name": "radarSupportEmail", + "type": "String", + "required": false, + "description": "Email address shown to end-users on the Radar access-blocked page. When null, the default message (without an email) is shown." + }, + { + "name": "radius", + "type": "Radius", + "required": false + }, + { + "name": "theme", + "type": "Theme", + "required": false + } + ] + }, + "UpdateApplicationInput": { + "name": "UpdateApplicationInput", + "kind": "INPUT_OBJECT", + "fields": [ + { + "name": "applicationId", + "type": "String!", + "required": true + }, + { + "name": "darkLogoFile", + "type": "Upload", + "required": false + }, + { + "name": "description", + "type": "String", + "required": false + }, + { + "name": "lightLogoFile", + "type": "Upload", + "required": false + }, + { + "name": "name", + "type": "String", + "required": false + } + ] + }, + "UpdateAttributeMapInput": { + "name": "UpdateAttributeMapInput", + "kind": "INPUT_OBJECT", + "fields": [ + { + "name": "attributeMapId", + "type": "String!", + "required": true + }, + { + "name": "emailAttribute", + "type": "String!", + "required": true + }, + { + "name": "firstNameAttribute", + "type": "String!", + "required": true + }, + { + "name": "groupsAttribute", + "type": "String", + "required": false + }, + { + "name": "idpIdAttribute", + "type": "String!", + "required": true + }, + { + "name": "lastNameAttribute", + "type": "String!", + "required": true + }, + { + "name": "nameAttribute", + "type": "String", + "required": false + } + ] + }, + "UpdateAuthorizationResourceTypeInput": { + "name": "UpdateAuthorizationResourceTypeInput", + "kind": "INPUT_OBJECT", + "fields": [ + { + "name": "description", + "type": "String", + "required": false + }, + { + "name": "name", + "type": "String!", + "required": true + }, + { + "name": "parentTypeIds", + "type": "[String!]", + "required": false + }, + { + "name": "resourceTypeId", + "type": "String!", + "required": true + } + ] + }, + "UpdateBambooHrDirectoryInput": { + "name": "UpdateBambooHrDirectoryInput", + "kind": "INPUT_OBJECT", + "fields": [ + { + "name": "apiKey", + "type": "String", + "required": false + }, + { + "name": "subdomain", + "type": "String", + "required": false + } + ] + }, + "UpdateBotDetectionInput": { + "name": "UpdateBotDetectionInput", + "kind": "INPUT_OBJECT", + "fields": [ + { + "name": "decision", + "type": "RadarDecision", + "required": false + }, + { + "name": "enabled", + "type": "Boolean", + "required": false + }, + { + "name": "notifyAdmin", + "type": "Boolean", + "required": false + } + ] + }, + "UpdateBreatheHrDirectoryInput": { + "name": "UpdateBreatheHrDirectoryInput", + "kind": "INPUT_OBJECT", + "fields": [ + { + "name": "apiKey", + "type": "String", + "required": false + } + ] + }, + "UpdateBruteForceAttackInput": { + "name": "UpdateBruteForceAttackInput", + "kind": "INPUT_OBJECT", + "fields": [ + { + "name": "decision", + "type": "RadarDecision", + "required": false + }, + { + "name": "enabled", + "type": "Boolean", + "required": false + }, + { + "name": "notifyAdmin", + "type": "Boolean", + "required": false + } + ] + }, + "UpdateCezanneHrDirectoryInput": { + "name": "UpdateCezanneHrDirectoryInput", + "kind": "INPUT_OBJECT", + "fields": [ + { + "name": "clientId", + "type": "String", + "required": false + }, + { + "name": "clientSecret", + "type": "String", + "required": false + } + ] + }, + "UpdateConnectionFromDiscoveryEndpointInput": { + "name": "UpdateConnectionFromDiscoveryEndpointInput", + "kind": "INPUT_OBJECT", + "fields": [ + { + "name": "clientId", + "type": "String!", + "required": true + }, + { + "name": "clientSecret", + "type": "String!", + "required": true + }, + { + "name": "connectionId", + "type": "String!", + "required": true + }, + { + "name": "discoveryEndpoint", + "type": "String!", + "required": true + }, + { + "name": "idTokenSigningAlgorithm", + "type": "OidcIdTokenSignedResponseAlgorithm", + "required": false + }, + { + "name": "ignoreWarnings", + "type": "Boolean", + "required": false + }, + { + "name": "name", + "type": "String!", + "required": true + }, + { + "name": "pkce", + "type": "OidcPkceMode", + "required": false + } + ] + }, + "UpdateConnectionFromMetadataUrlInput": { + "name": "UpdateConnectionFromMetadataUrlInput", + "kind": "INPUT_OBJECT", + "fields": [ + { + "name": "connectionId", + "type": "String!", + "required": true + }, + { + "name": "metadataUrl", + "type": "String!", + "required": true + } + ] + }, + "UpdateConnectionFromMetadataXmlInput": { + "name": "UpdateConnectionFromMetadataXmlInput", + "kind": "INPUT_OBJECT", + "fields": [ + { + "name": "connectionId", + "type": "String!", + "required": true + }, + { + "name": "metadataXml", + "type": "String!", + "required": true + } + ] + }, + "UpdateConnectionGroupInput": { + "name": "UpdateConnectionGroupInput", + "kind": "INPUT_OBJECT", + "fields": [ + { + "name": "connectionGroupId", + "type": "String!", + "required": true + }, + { + "name": "name", + "type": "String", + "required": false + } + ] + }, + "UpdateConnectionInput": { + "name": "UpdateConnectionInput", + "kind": "INPUT_OBJECT", + "fields": [ + { + "name": "connectionId", + "type": "String!", + "required": true + }, + { + "name": "name", + "type": "String", + "required": false + }, + { + "name": "oidc", + "type": "UpdateOidcConnectionInput", + "required": false + }, + { + "name": "saml", + "type": "UpdateSamlConnectionInput", + "required": false + } + ] + }, + "UpdateCredentialStuffingInput": { + "name": "UpdateCredentialStuffingInput", + "kind": "INPUT_OBJECT", + "fields": [ + { + "name": "decision", + "type": "RadarDecision", + "required": false + }, + { + "name": "enabled", + "type": "Boolean", + "required": false + }, + { + "name": "notifyAdmin", + "type": "Boolean", + "required": false + } + ] + }, + "UpdateCustomDataProviderInput": { + "name": "UpdateCustomDataProviderInput", + "kind": "INPUT_OBJECT", + "fields": [ + { + "name": "additionalAuthorizationParameters", + "type": "[CustomDataProviderAdditionalAuthorizationParameterInput!]", + "required": false + }, + { + "name": "authenticateVia", + "type": "CustomDataProviderAuthenticateVia", + "required": false + }, + { + "name": "authorizationUrl", + "type": "String", + "required": false + }, + { + "name": "clientSecretRequired", + "type": "Boolean", + "required": false + }, + { + "name": "description", + "type": "String", + "required": false + }, + { + "name": "iconSlug", + "type": "String", + "required": false + }, + { + "name": "id", + "type": "ID!", + "required": true + }, + { + "name": "name", + "type": "String", + "required": false + }, + { + "name": "pkceEnabled", + "type": "Boolean", + "required": false + }, + { + "name": "refreshTokenUrl", + "type": "String", + "required": false + }, + { + "name": "requestScopeSeparator", + "type": "String", + "required": false + }, + { + "name": "scopesRequired", + "type": "Boolean", + "required": false + }, + { + "name": "slug", + "type": "String", + "required": false + }, + { + "name": "tokenBodyContentType", + "type": "String", + "required": false + }, + { + "name": "tokenUrl", + "type": "String", + "required": false + } + ] + }, + "UpdateCustomEmailProviderInput": { + "name": "UpdateCustomEmailProviderInput", + "kind": "INPUT_OBJECT", + "fields": [ + { + "name": "awsSesRegion", + "type": "String", + "required": false + }, + { + "name": "customEmailProviderId", + "type": "String!", + "required": true + }, + { + "name": "defaultReplyTo", + "type": "String!", + "required": true + }, + { + "name": "defaultSender", + "type": "String!", + "required": true + }, + { + "name": "mailgunDomain", + "type": "String", + "required": false + }, + { + "name": "mailgunRegion", + "type": "MailgunCustomEmailProviderRegion", + "required": false + }, + { + "name": "unencryptedAwsAccessKeyId", + "type": "String", + "required": false + }, + { + "name": "unencryptedAwsSecretAccessKey", + "type": "String", + "required": false + }, + { + "name": "unencryptedMailgunApiKey", + "type": "String", + "required": false + }, + { + "name": "unencryptedPostmarkAccountToken", + "type": "String", + "required": false + }, + { + "name": "unencryptedPostmarkServerToken", + "type": "String", + "required": false + }, + { + "name": "unencryptedResendApiKey", + "type": "String", + "required": false + }, + { + "name": "unencryptedSendGridApiKey", + "type": "String", + "required": false + } + ] + }, + "UpdateCustomMappedCustomAttributeInput": { + "name": "UpdateCustomMappedCustomAttributeInput", + "kind": "INPUT_OBJECT", + "fields": [ + { + "name": "customAttributeId", + "type": "ID!", + "required": true + }, + { + "name": "description", + "type": "String", + "required": false + } + ] + }, + "UpdateDataIntegrationInput": { + "name": "UpdateDataIntegrationInput", + "kind": "INPUT_OBJECT", + "fields": [ + { + "name": "authMethods", + "type": "[DataIntegrationAuthMethod!]", + "required": false + }, + { + "name": "credentials", + "type": "DataIntegrationCredentialsInput", + "required": false + }, + { + "name": "dataIntegrationId", + "type": "ID!", + "required": true + }, + { + "name": "description", + "type": "String", + "required": false + }, + { + "name": "enabled", + "type": "Boolean", + "required": false + }, + { + "name": "name", + "type": "String", + "required": false + }, + { + "name": "scopes", + "type": "[String!]", + "required": false + }, + { + "name": "slug", + "type": "String", + "required": false + } + ] + }, + "UpdateDirectoryConfigInput": { + "name": "UpdateDirectoryConfigInput", + "kind": "INPUT_OBJECT", + "fields": [ + { + "name": "directoryConfigId", + "type": "String!", + "required": true + }, + { + "name": "groupsSync", + "type": "DirectoryConfigGroupsSync!", + "required": true + } + ] + }, + "UpdateDirectoryGroupConfigsAllowListInput": { + "name": "UpdateDirectoryGroupConfigsAllowListInput", + "kind": "INPUT_OBJECT", + "fields": [ + { + "name": "directoryId", + "type": "String!", + "required": true + }, + { + "name": "syncDirectoryGroupConfigIds", + "type": "[String!]", + "required": false + }, + { + "name": "unSyncDirectoryGroupConfigIds", + "type": "[String!]", + "required": false + } + ] + }, + "UpdateDirectoryInput": { + "name": "UpdateDirectoryInput", + "kind": "INPUT_OBJECT", + "fields": [ + { + "name": "bambooHr", + "type": "UpdateBambooHrDirectoryInput", + "required": false + }, + { + "name": "breatheHr", + "type": "UpdateBreatheHrDirectoryInput", + "required": false + }, + { + "name": "cezanneHr", + "type": "UpdateCezanneHrDirectoryInput", + "required": false + }, + { + "name": "directoryId", + "type": "String!", + "required": true + }, + { + "name": "fourthHr", + "type": "UpdateFourthHrDirectoryInput", + "required": false + }, + { + "name": "hibob", + "type": "UpdateHibobDirectoryInput", + "required": false + }, + { + "name": "name", + "type": "String", + "required": false + }, + { + "name": "peopleHr", + "type": "UpdatePeopleHrDirectoryInput", + "required": false + }, + { + "name": "personio", + "type": "UpdatePersonioDirectoryInput", + "required": false + }, + { + "name": "rippling", + "type": "UpdateRipplingDirectoryInput", + "required": false + }, + { + "name": "sftp", + "type": "UpdateSftpDirectoryInput", + "required": false + }, + { + "name": "workday", + "type": "UpdateWorkdayDirectoryInput", + "required": false + } + ] + }, + "UpdateEnvironmentLocalizationInput": { + "name": "UpdateEnvironmentLocalizationInput", + "kind": "INPUT_OBJECT", + "fields": [ + { + "name": "defaultLocale", + "type": "String", + "required": false + }, + { + "name": "environmentId", + "type": "String!", + "required": true + }, + { + "name": "localizationEnabled", + "type": "Boolean", + "required": false + } + ] + }, + "UpdateEnvironmentOrganizationAdminCollectionEnabledInput": { + "name": "UpdateEnvironmentOrganizationAdminCollectionEnabledInput", + "kind": "INPUT_OBJECT", + "fields": [ + { + "name": "environmentId", + "type": "String!", + "required": true + }, + { + "name": "organizationAdminCollectionEnabled", + "type": "Boolean!", + "required": true + } + ] + }, + "UpdateEnvironmentSsoSigninConsentInput": { + "name": "UpdateEnvironmentSsoSigninConsentInput", + "kind": "INPUT_OBJECT", + "fields": [ + { + "name": "environmentId", + "type": "String!", + "required": true + }, + { + "name": "ssoSignInConsentEnabled", + "type": "Boolean!", + "required": true + } + ] + }, + "UpdateFasterThanPossibleTravelInput": { + "name": "UpdateFasterThanPossibleTravelInput", + "kind": "INPUT_OBJECT", + "fields": [ + { + "name": "decision", + "type": "RadarDecision", + "required": false + }, + { + "name": "enabled", + "type": "Boolean", + "required": false + }, + { + "name": "notifyAdmin", + "type": "Boolean", + "required": false + }, + { + "name": "notifyUser", + "type": "Boolean", + "required": false + } + ] + }, + "UpdateFlagEnvironmentInput": { + "name": "UpdateFlagEnvironmentInput", + "kind": "INPUT_OBJECT", + "fields": [ + { + "name": "accessType", + "type": "FlagEnvironmentAccessType!", + "required": true + }, + { + "name": "defaultEnabled", + "type": "Boolean!", + "required": true + }, + { + "name": "flagEnabled", + "type": "Boolean!", + "required": true + }, + { + "name": "flagEnvironmentId", + "type": "String!", + "required": true + }, + { + "name": "organizationIds", + "type": "[String!]", + "required": false + }, + { + "name": "userIds", + "type": "[String!]", + "required": false + } + ] + }, + "UpdateFlagInput": { + "name": "UpdateFlagInput", + "kind": "INPUT_OBJECT", + "fields": [ + { + "name": "description", + "type": "String", + "required": false + }, + { + "name": "flagId", + "type": "String!", + "required": true + }, + { + "name": "name", + "type": "String!", + "required": true + }, + { + "name": "tags", + "type": "[String!]", + "required": false + }, + { + "name": "userId", + "type": "String", + "required": false + } + ] + }, + "UpdateFourthHrDirectoryInput": { + "name": "UpdateFourthHrDirectoryInput", + "kind": "INPUT_OBJECT", + "fields": [ + { + "name": "organizationId", + "type": "String", + "required": false + }, + { + "name": "password", + "type": "String", + "required": false + }, + { + "name": "username", + "type": "String", + "required": false + } + ] + }, + "UpdateHibobDirectoryInput": { + "name": "UpdateHibobDirectoryInput", + "kind": "INPUT_OBJECT", + "fields": [ + { + "name": "apiToken", + "type": "String", + "required": false + }, + { + "name": "serviceUserId", + "type": "String", + "required": false + } + ] + }, + "UpdateIdpAttributesConfigInput": { + "name": "UpdateIdpAttributesConfigInput", + "kind": "INPUT_OBJECT", + "fields": [ + { + "name": "dsyncCustomAttributeMappingEnabled", + "type": "Boolean!", + "required": true + }, + { + "name": "idpAttributesConfigId", + "type": "String!", + "required": true + }, + { + "name": "ssoCustomAttributeMappingEnabled", + "type": "Boolean!", + "required": true + } + ] + }, + "UpdateMyselfInput": { + "name": "UpdateMyselfInput", + "kind": "INPUT_OBJECT", + "fields": [ + { + "name": "firstName", + "type": "String!", + "required": true + }, + { + "name": "lastName", + "type": "String!", + "required": true + } + ] + }, + "UpdateOauthCredentialsInput": { + "name": "UpdateOauthCredentialsInput", + "kind": "INPUT_OBJECT", + "fields": [ + { + "name": "appleKeyId", + "type": "String", + "required": false + }, + { + "name": "applePrivateKey", + "type": "String", + "required": false + }, + { + "name": "appleTeamId", + "type": "String", + "required": false + }, + { + "name": "clientId", + "type": "String", + "required": false + }, + { + "name": "clientSecret", + "type": "String", + "required": false + }, + { + "name": "customScopes", + "type": "[String!]", + "required": false + }, + { + "name": "isUserlandEnabled", + "type": "Boolean", + "required": false + }, + { + "name": "oauthCredentialId", + "type": "String!", + "required": true + }, + { + "name": "redirectUri", + "type": "String", + "required": false + }, + { + "name": "returnProviderTokens", + "type": "Boolean", + "required": false + } + ] + }, + "UpdateOidcConnectionInput": { + "name": "UpdateOidcConnectionInput", + "kind": "INPUT_OBJECT", + "fields": [ + { + "name": "clientId", + "type": "String", + "required": false + }, + { + "name": "clientSecret", + "type": "String", + "required": false + }, + { + "name": "discoveryEndpoint", + "type": "String", + "required": false + }, + { + "name": "returnProviderTokens", + "type": "Boolean", + "required": false + } + ] + }, + "UpdatePeopleHrDirectoryInput": { + "name": "UpdatePeopleHrDirectoryInput", + "kind": "INPUT_OBJECT", + "fields": [ + { + "name": "apiKey", + "type": "String", + "required": false + } + ] + }, + "UpdatePermissionInput": { + "name": "UpdatePermissionInput", + "kind": "INPUT_OBJECT", + "fields": [ + { + "name": "description", + "type": "String", + "required": false + }, + { + "name": "name", + "type": "String!", + "required": true + }, + { + "name": "permissionId", + "type": "String!", + "required": true + } + ] + }, + "UpdatePersonioDirectoryInput": { + "name": "UpdatePersonioDirectoryInput", + "kind": "INPUT_OBJECT", + "fields": [ + { + "name": "clientId", + "type": "String", + "required": false + }, + { + "name": "clientSecret", + "type": "String", + "required": false + } + ] + }, + "UpdatePortalSettingsInput": { + "name": "UpdatePortalSettingsInput", + "kind": "INPUT_OBJECT", + "fields": [ + { + "name": "defaultConnectionSuccessLink", + "type": "String", + "required": false + }, + { + "name": "defaultDirectorySuccessLink", + "type": "String", + "required": false + }, + { + "name": "defaultDomainVerificationSuccessLink", + "type": "String", + "required": false + }, + { + "name": "defaultLogStreamsSuccessLink", + "type": "String", + "required": false + }, + { + "name": "defaultRedirectLink", + "type": "String", + "required": false + }, + { + "name": "id", + "type": "String!", + "required": true + } + ] + }, + "UpdateRadarConfigurationInput": { + "name": "UpdateRadarConfigurationInput", + "kind": "INPUT_OBJECT", + "fields": [ + { + "name": "botDetection", + "type": "UpdateBotDetectionInput", + "required": false + }, + { + "name": "bruteForceAttack", + "type": "UpdateBruteForceAttackInput", + "required": false + }, + { + "name": "credentialStuffing", + "type": "UpdateCredentialStuffingInput", + "required": false + }, + { + "name": "fasterThanPossibleTravel", + "type": "UpdateFasterThanPossibleTravelInput", + "required": false + }, + { + "name": "repeatSignUp", + "type": "UpdateRepeatSignUpInput", + "required": false + }, + { + "name": "staleAccount", + "type": "UpdateStaleAccountInput", + "required": false + }, + { + "name": "unrecognizedDevice", + "type": "UpdateUnrecognizedDeviceInput", + "required": false + } + ] + }, + "UpdateRadarModeInput": { + "name": "UpdateRadarModeInput", + "kind": "INPUT_OBJECT", + "fields": [ + { + "name": "environmentId", + "type": "String!", + "required": true + }, + { + "name": "radarMode", + "type": "RadarMode!", + "required": true + } + ] + }, + "UpdateRadarSettingsInput": { + "name": "UpdateRadarSettingsInput", + "kind": "INPUT_OBJECT", + "fields": [ + { + "name": "configuration", + "type": "UpdateRadarConfigurationInput", + "required": false + }, + { + "name": "domainProtectionsEnabled", + "type": "Boolean", + "required": false + }, + { + "name": "environmentId", + "type": "String!", + "required": true + }, + { + "name": "smsChallengesEnabled", + "type": "Boolean", + "required": false + } + ] + }, + "UpdateRepeatSignUpInput": { + "name": "UpdateRepeatSignUpInput", + "kind": "INPUT_OBJECT", + "fields": [ + { + "name": "decision", + "type": "RadarDecision", + "required": false + }, + { + "name": "enabled", + "type": "Boolean", + "required": false + }, + { + "name": "notifyAdmin", + "type": "Boolean", + "required": false + } + ] + }, + "UpdateRipplingDirectoryInput": { + "name": "UpdateRipplingDirectoryInput", + "kind": "INPUT_OBJECT", + "fields": [ + { + "name": "apiKey", + "type": "String", + "required": false + } + ] + }, + "UpdateRoleConfigInput": { + "name": "UpdateRoleConfigInput", + "kind": "INPUT_OBJECT", + "fields": [ + { + "name": "defaultRole", + "type": "String", + "required": false + }, + { + "name": "dsyncRoleAssignmentEnabled", + "type": "Boolean", + "required": false + }, + { + "name": "multipleRolesEnabled", + "type": "Boolean", + "required": false + }, + { + "name": "roleConfigId", + "type": "String!", + "required": true + }, + { + "name": "rolePriorityOrder", + "type": "[String!]", + "required": false + }, + { + "name": "ssoRoleAssignmentEnabled", + "type": "Boolean", + "required": false + } + ] + }, + "UpdateRoleInput": { + "name": "UpdateRoleInput", + "kind": "INPUT_OBJECT", + "fields": [ + { + "name": "description", + "type": "String", + "required": false + }, + { + "name": "name", + "type": "String!", + "required": true + }, + { + "name": "permissions", + "type": "[String!]", + "required": false + }, + { + "name": "roleId", + "type": "String!", + "required": true + } + ] + }, + "UpdateRoleOnOrganizationMembershipInput": { + "name": "UpdateRoleOnOrganizationMembershipInput", + "kind": "INPUT_OBJECT", + "fields": [ + { + "name": "organizationMembershipId", + "type": "String!", + "required": true + }, + { + "name": "roleId", + "type": "String", + "required": false + }, + { + "name": "roleIds", + "type": "[String!]", + "required": false + } + ] + }, + "UpdateSamlConnectionInput": { + "name": "UpdateSamlConnectionInput", + "kind": "INPUT_OBJECT", + "fields": [ + { + "name": "idpEntityId", + "type": "String", + "required": false + }, + { + "name": "idpMetadataUrl", + "type": "String", + "required": false + }, + { + "name": "idpUrl", + "type": "String", + "required": false + }, + { + "name": "x509Certs", + "type": "[String!]", + "required": false + } + ] + }, + "UpdateSftpDirectoryInput": { + "name": "UpdateSftpDirectoryInput", + "kind": "INPUT_OBJECT", + "fields": [ + { + "name": "publicKey", + "type": "String", + "required": false + }, + { + "name": "sourceProvider", + "type": "SftpSourceProvider", + "required": false + } + ] + }, + "UpdateStaleAccountInput": { + "name": "UpdateStaleAccountInput", + "kind": "INPUT_OBJECT", + "fields": [ + { + "name": "enabled", + "type": "Boolean", + "required": false + }, + { + "name": "notifyAdmin", + "type": "Boolean", + "required": false + }, + { + "name": "notifyUser", + "type": "Boolean", + "required": false + } + ] + }, + "UpdateTeamDetailsInput": { + "name": "UpdateTeamDetailsInput", + "kind": "INPUT_OBJECT", + "fields": [ + { + "name": "name", + "type": "String!", + "required": true + } + ] + }, + "UpdateTeamMfaRequirementInput": { + "name": "UpdateTeamMfaRequirementInput", + "kind": "INPUT_OBJECT", + "fields": [ + { + "name": "requireMfa", + "type": "Boolean!", + "required": true + } + ] + }, + "UpdateUnrecognizedDeviceInput": { + "name": "UpdateUnrecognizedDeviceInput", + "kind": "INPUT_OBJECT", + "fields": [ + { + "name": "enabled", + "type": "Boolean", + "required": false + }, + { + "name": "notifyAdmin", + "type": "Boolean", + "required": false + }, + { + "name": "notifyUser", + "type": "Boolean", + "required": false + } + ] + }, + "UpdateUserlandApplicationInput": { + "name": "UpdateUserlandApplicationInput", + "kind": "INPUT_OBJECT", + "fields": [ + { + "name": "accessTokenExpiry", + "type": "Int", + "required": false + }, + { + "name": "appHomepageUrl", + "type": "String", + "required": false + }, + { + "name": "applicationId", + "type": "String!", + "required": true + }, + { + "name": "inactivityTimeout", + "type": "Int", + "required": false + }, + { + "name": "initiateLoginUri", + "type": "String", + "required": false + }, + { + "name": "maxSessionTime", + "type": "Int", + "required": false + }, + { + "name": "name", + "type": "String", + "required": false + }, + { + "name": "passwordResetUrl", + "type": "String", + "required": false + }, + { + "name": "signUpUrl", + "type": "String", + "required": false + }, + { + "name": "userInvitationUrl", + "type": "String", + "required": false + } + ] + }, + "UpdateUserlandEmailSettingsInput": { + "name": "UpdateUserlandEmailSettingsInput", + "kind": "INPUT_OBJECT", + "fields": [ + { + "name": "environmentId", + "type": "String!", + "required": true + }, + { + "name": "isEmailVerificationEmailEnabled", + "type": "Boolean", + "required": false + }, + { + "name": "isInvitationEmailEnabled", + "type": "Boolean", + "required": false + }, + { + "name": "isMagicAuthEmailEnabled", + "type": "Boolean", + "required": false + }, + { + "name": "isResetPasswordEmailEnabled", + "type": "Boolean", + "required": false + }, + { + "name": "isWaitlistConfirmationEmailEnabled", + "type": "Boolean", + "required": false + } + ] + }, + "UpdateUserlandExternalLoginUriInput": { + "name": "UpdateUserlandExternalLoginUriInput", + "kind": "INPUT_OBJECT", + "fields": [ + { + "name": "environmentId", + "type": "String!", + "required": true + }, + { + "name": "externalLoginUri", + "type": "String", + "required": false + } + ] + }, + "UpdateUserlandImpersonationSettingsInput": { + "name": "UpdateUserlandImpersonationSettingsInput", + "kind": "INPUT_OBJECT", + "fields": [ + { + "name": "environmentId", + "type": "String!", + "required": true + }, + { + "name": "isEnabled", + "type": "Boolean!", + "required": true + } + ] + }, + "UpdateUserlandSettingsInput": { + "name": "UpdateUserlandSettingsInput", + "kind": "INPUT_OBJECT", + "fields": [ + { + "name": "accessTokenExpiry", + "type": "Int", + "required": false + }, + { + "name": "allowSignUp", + "type": "Boolean", + "required": false + }, + { + "name": "appHomepageUrl", + "type": "String", + "required": false + }, + { + "name": "environmentId", + "type": "String!", + "required": true + }, + { + "name": "inactivityTimeout", + "type": "Int", + "required": false + }, + { + "name": "invitationExpiry", + "type": "Int", + "required": false + }, + { + "name": "isAppleOauthEnabled", + "type": "Boolean", + "required": false + }, + { + "name": "isAuthkitClientIdMetadataDocumentEnabled", + "type": "Boolean", + "required": false + }, + { + "name": "isAuthkitDynamicClientRegistrationEnabled", + "type": "Boolean", + "required": false + }, + { + "name": "isAuthkitEnabled", + "type": "Boolean", + "required": false + }, + { + "name": "isAuthkitIdpInitiatedSsoEnabled", + "type": "Boolean", + "required": false + }, + { + "name": "isBitbucketOauthEnabled", + "type": "Boolean", + "required": false + }, + { + "name": "isDiscordOauthEnabled", + "type": "Boolean", + "required": false + }, + { + "name": "isEmailVerificationRequired", + "type": "Boolean", + "required": false + }, + { + "name": "isGitLabOauthEnabled", + "type": "Boolean", + "required": false + }, + { + "name": "isGithubOauthEnabled", + "type": "Boolean", + "required": false + }, + { + "name": "isGoogleOauthEnabled", + "type": "Boolean", + "required": false + }, + { + "name": "isIntuitOauthEnabled", + "type": "Boolean", + "required": false + }, + { + "name": "isLinkedInOauthEnabled", + "type": "Boolean", + "required": false + }, + { + "name": "isMagicAuthEnabled", + "type": "Boolean", + "required": false + }, + { + "name": "isMfaRequired", + "type": "Boolean", + "required": false + }, + { + "name": "isMicrosoftOauthEnabled", + "type": "Boolean", + "required": false + }, + { + "name": "isPasskeyAuthEnabled", + "type": "Boolean", + "required": false + }, + { + "name": "isPasskeyProgressiveEnrollmentEnabled", + "type": "Boolean", + "required": false + }, + { + "name": "isPasswordAuthEnabled", + "type": "Boolean", + "required": false + }, + { + "name": "isPasswordHistoryEnabled", + "type": "Boolean", + "required": false + }, + { + "name": "isPasswordLowercaseRequired", + "type": "Boolean", + "required": false + }, + { + "name": "isPasswordNumberRequired", + "type": "Boolean", + "required": false + }, + { + "name": "isPasswordPwnedRequired", + "type": "Boolean", + "required": false + }, + { + "name": "isPasswordSymbolRequired", + "type": "Boolean", + "required": false + }, + { + "name": "isPasswordUppercaseRequired", + "type": "Boolean", + "required": false + }, + { + "name": "isSalesforceOauthEnabled", + "type": "Boolean", + "required": false + }, + { + "name": "isSlackOauthEnabled", + "type": "Boolean", + "required": false + }, + { + "name": "isSsoEnabled", + "type": "Boolean", + "required": false + }, + { + "name": "isVercelMarketplaceOauthEnabled", + "type": "Boolean", + "required": false + }, + { + "name": "isVercelOauthEnabled", + "type": "Boolean", + "required": false + }, + { + "name": "isWaitlistEnabled", + "type": "Boolean", + "required": false + }, + { + "name": "isXeroOauthEnabled", + "type": "Boolean", + "required": false + }, + { + "name": "maxSessionTime", + "type": "Int", + "required": false + }, + { + "name": "mfaEnabled", + "type": "UserlandSettingsMfaEnabled", + "required": false + }, + { + "name": "passwordHistorySize", + "type": "Int", + "required": false + }, + { + "name": "passwordMinimumLength", + "type": "Int", + "required": false + }, + { + "name": "passwordMinimumStrength", + "type": "Int", + "required": false + }, + { + "name": "passwordResetUrl", + "type": "String", + "required": false + }, + { + "name": "signUpUrl", + "type": "String", + "required": false + }, + { + "name": "userInvitationUrl", + "type": "String", + "required": false + } + ] + }, + "UpdateUserlandUserInput": { + "name": "UpdateUserlandUserInput", + "kind": "INPUT_OBJECT", + "fields": [ + { + "name": "email", + "type": "String", + "required": false + }, + { + "name": "externalId", + "type": "String", + "required": false + }, + { + "name": "firstName", + "type": "String", + "required": false + }, + { + "name": "lastName", + "type": "String", + "required": false + }, + { + "name": "locale", + "type": "String", + "required": false + }, + { + "name": "metadata", + "type": "JSON", + "required": false + }, + { + "name": "name", + "type": "String", + "required": false + }, + { + "name": "userlandUserId", + "type": "String!", + "required": true + } + ] + }, + "UpdateWorkdayDirectoryInput": { + "name": "UpdateWorkdayDirectoryInput", + "kind": "INPUT_OBJECT", + "fields": [ + { + "name": "groupEndpoint", + "type": "String", + "required": false + }, + { + "name": "password", + "type": "String", + "required": false + }, + { + "name": "userEndpoint", + "type": "String", + "required": false + }, + { + "name": "username", + "type": "String", + "required": false + } + ] + }, + "Upload": { + "name": "Upload", + "kind": "SCALAR", + "description": "File upload — not settable via the MCP (JSON) transport. Use the dashboard, or a URL/asset-based operation if one exists." + }, + "UpsertActionsEndpointInput": { + "name": "UpsertActionsEndpointInput", + "kind": "INPUT_OBJECT", + "fields": [ + { + "name": "endpointUrl", + "type": "String!", + "required": true + }, + { + "name": "environmentId", + "type": "String!", + "required": true + }, + { + "name": "failOpen", + "type": "Boolean!", + "required": true + }, + { + "name": "state", + "type": "ActionsEndpointState!", + "required": true + }, + { + "name": "type", + "type": "ActionsEndpointType!", + "required": true + } + ] + }, + "UpsertConnectionGroupRoleMappingsInput": { + "name": "UpsertConnectionGroupRoleMappingsInput", + "kind": "INPUT_OBJECT", + "fields": [ + { + "name": "connectionId", + "type": "String!", + "required": true + }, + { + "name": "groupRoleMappingDefinitions", + "type": "[GroupRoleMappingDefinitionInput!]!", + "required": true + } + ] + }, + "UpsertDirectoryGroupRoleMappingsInput": { + "name": "UpsertDirectoryGroupRoleMappingsInput", + "kind": "INPUT_OBJECT", + "fields": [ + { + "name": "directoryId", + "type": "String!", + "required": true + }, + { + "name": "groupRoleMappingDefinitions", + "type": "[GroupRoleMappingDefinitionInput!]!", + "required": true + } + ] + }, + "UpsertJwtTemplateInput": { + "name": "UpsertJwtTemplateInput", + "kind": "INPUT_OBJECT", + "fields": [ + { + "name": "content", + "type": "String!", + "required": true + }, + { + "name": "environmentId", + "type": "String!", + "required": true + } + ] + }, + "UserInviteInput": { + "name": "UserInviteInput", + "kind": "INPUT_OBJECT", + "fields": [ + { + "name": "email", + "type": "String!", + "required": true + }, + { + "name": "firstName", + "type": "String", + "required": false + }, + { + "name": "lastName", + "type": "String", + "required": false + }, + { + "name": "role", + "type": "UsersOrganizationsRole!", + "required": true + } + ] + }, + "UserlandSessionStateFilter": { + "name": "UserlandSessionStateFilter", + "kind": "ENUM", + "enumValues": [ + "Ephemeral", + "Issued", + "Revoked" + ] + }, + "UserlandSettingsMfaEnabled": { + "name": "UserlandSettingsMfaEnabled", + "kind": "ENUM", + "description": "The configuration for MFA for the environment.", + "enumValues": [ + "Off", + "Optional", + "Required" + ] + }, + "UserlandUserAuthenticationMethod": { + "name": "UserlandUserAuthenticationMethod", + "kind": "ENUM", + "description": "Authentication methods available to userland users.", + "enumValues": [ + "AppleOAuth", + "DiscordOAuth", + "GitHubOAuth", + "GitLabOAuth", + "GoogleOAuth", + "LinkedInOAuth", + "MicrosoftOAuth", + "None", + "Passkey", + "Password", + "SSO", + "SlackOAuth", + "XeroOAuth" + ] + }, + "UserlandUserOrganizationMembershipsByUserIdsArgs": { + "name": "UserlandUserOrganizationMembershipsByUserIdsArgs", + "kind": "INPUT_OBJECT", + "fields": [ + { + "name": "organizationId", + "type": "String!", + "required": true + }, + { + "name": "userlandUserIds", + "type": "[String!]!", + "required": true + } + ] + }, + "UserlandUserSessionStatus": { + "name": "UserlandUserSessionStatus", + "kind": "ENUM", + "description": "Filter users by their session status - Active (has sessions) or Inactive (no sessions).", + "enumValues": [ + "Active", + "Inactive" + ] + }, + "UserlandUserSortField": { + "name": "UserlandUserSortField", + "kind": "ENUM", + "description": "Fields available for sorting userland users.", + "enumValues": [ + "Email", + "LastSignInAt", + "SignInCount" + ] + }, + "UsersOrganizationsRole": { + "name": "UsersOrganizationsRole", + "kind": "ENUM", + "description": "Roles a user can have in an organization.", + "enumValues": [ + "ADMIN", + "MEMBER", + "MEMBER_SANDBOX", + "SUPPORT", + "SUPPORT_VIEWER" + ] + }, + "ValidateJwtTemplateInput": { + "name": "ValidateJwtTemplateInput", + "kind": "INPUT_OBJECT", + "fields": [ + { + "name": "environmentId", + "type": "String!", + "required": true + }, + { + "name": "template", + "type": "String!", + "required": true + } + ] + }, + "VaultKeyProvider": { + "name": "VaultKeyProvider", + "kind": "ENUM", + "enumValues": [ + "AWS_KMS", + "AZURE_KEY_VAULT", + "GCP_KMS", + "WOS_VAULT" + ] + }, + "VaultKeyStatus": { + "name": "VaultKeyStatus", + "kind": "ENUM", + "enumValues": [ + "Active", + "Disabled", + "InvalidPermissions", + "Validating" + ] + }, + "VerifyEmailChangeAccessInput": { + "name": "VerifyEmailChangeAccessInput", + "kind": "INPUT_OBJECT", + "fields": [ + { + "name": "code", + "type": "String!", + "required": true + } + ] + }, + "VerifyRedirectUriInput": { + "name": "VerifyRedirectUriInput", + "kind": "INPUT_OBJECT", + "fields": [ + { + "name": "environmentId", + "type": "String!", + "required": true + }, + { + "name": "redirectUri", + "type": "String!", + "required": true + } + ] + }, + "WaitlistEntryState": { + "name": "WaitlistEntryState", + "kind": "ENUM", + "enumValues": [ + "Approved", + "Denied", + "Pending" + ] + }, + "WebhookEndpointState": { + "name": "WebhookEndpointState", + "kind": "ENUM", + "description": "Enum represents the state of the webhook endpoint.", + "enumValues": [ + "Active", + "InActive" + ] + }, + "WebhookEventFixtures": { + "name": "WebhookEventFixtures", + "kind": "ENUM", + "description": "Enum represents event fixtures of Webhooks.", + "enumValues": [ + "ApiKeyCreated", + "ApiKeyRevoked", + "ApiKeyUpdated", + "AuthenticationEmailVerificationSucceeded", + "AuthenticationMagicAuthFailed", + "AuthenticationMagicAuthSucceeded", + "AuthenticationMfaSucceeded", + "AuthenticationOauthFailed", + "AuthenticationOauthSucceeded", + "AuthenticationPasswordFailed", + "AuthenticationPasswordSucceeded", + "AuthenticationSsoFailed", + "AuthenticationSsoStarted", + "AuthenticationSsoSucceeded", + "AuthenticationSsoTimedOut", + "ConnectionActivated", + "ConnectionDeactivated", + "ConnectionDeleted", + "ConnectionSamlCertificateRenewalRequired", + "ConnectionSamlCertificateRenewed", + "DirectoryActivated", + "DirectoryDeleted", + "DirectoryGroupCreated", + "DirectoryGroupDeleted", + "DirectoryGroupUpdated", + "EmailVerificationCreated", + "FlagCreated", + "FlagDeleted", + "FlagRuleUpdated", + "FlagUpdated", + "GroupCreated", + "GroupDeleted", + "GroupMemberAdded", + "GroupMemberRemoved", + "GroupUpdated", + "InvitationAccepted", + "InvitationCreated", + "InvitationRevoked", + "MagicAuthCreated", + "OrganizationCreated", + "OrganizationDeleted", + "OrganizationDomainCreated", + "OrganizationDomainDeleted", + "OrganizationDomainUpdated", + "OrganizationDomainVerificationFailed", + "OrganizationDomainVerified", + "OrganizationMembershipCreated", + "OrganizationMembershipDeleted", + "OrganizationMembershipUpdated", + "OrganizationRoleCreated", + "OrganizationRoleDeleted", + "OrganizationRoleUpdated", + "OrganizationUpdated", + "PasswordResetCreated", + "PasswordResetSucceeded", + "PermissionCreated", + "PermissionDeleted", + "PermissionUpdated", + "RoleCreated", + "RoleDeleted", + "RoleUpdated", + "SessionCreated", + "SessionRevoked", + "UserAddedToGroup", + "UserCreated", + "UserDeleted", + "UserManagementUserCreated", + "UserManagementUserDeleted", + "UserManagementUserUpdated", + "UserRemovedFromGroup", + "UserUpdated", + "WaitlistUserApproved", + "WaitlistUserCreated", + "WaitlistUserDenied" + ] + }, + "WebhookState": { + "name": "WebhookState", + "kind": "ENUM", + "description": "Enum represents the state of the webhook.", + "enumValues": [ + "Canceled", + "Delivered", + "Failed", + "InProgress", + "Scheduled" + ] + } + } +} From d372878d2b8892a9859e83b51a97b8668f7dade9 Mon Sep 17 00:00:00 2001 From: Nick Nisi Date: Thu, 25 Jun 2026 16:06:54 -0500 Subject: [PATCH 05/22] feat(catalog): add justification manifest, curation, and reused confirmation MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- .github/workflows/ci.yml | 3 + package.json | 3 +- scripts/check-justification.ts | 39 ++++++++ src/catalog/confirm.spec.ts | 140 ++++++++++++++++++++++++++++ src/catalog/confirm.ts | 66 +++++++++++++ src/catalog/curation.ts | 84 +++++++++++++++++ src/catalog/justification.spec.ts | 116 +++++++++++++++++++++++ src/catalog/justification.ts | 125 +++++++++++++++++++++++++ src/catalog/manifest-types.ts | 63 +++++++++++++ src/catalog/manifest.ts | 19 ++++ src/catalog/no-graphql-leak.spec.ts | 65 +++++++++++++ 11 files changed, 722 insertions(+), 1 deletion(-) create mode 100644 scripts/check-justification.ts create mode 100644 src/catalog/confirm.spec.ts create mode 100644 src/catalog/confirm.ts create mode 100644 src/catalog/curation.ts create mode 100644 src/catalog/justification.spec.ts create mode 100644 src/catalog/justification.ts create mode 100644 src/catalog/manifest-types.ts create mode 100644 src/catalog/manifest.ts create mode 100644 src/catalog/no-graphql-leak.spec.ts diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index b93b6621..579642e7 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -34,6 +34,9 @@ jobs: - name: Typecheck run: pnpm typecheck + - name: Justification check + run: pnpm justification:check + - name: Build run: pnpm build diff --git a/package.json b/package.json index ec9e58d8..2489df9d 100644 --- a/package.json +++ b/package.json @@ -109,7 +109,8 @@ "eval:show": "tsx tests/evals/index.ts show", "gen:routes": "tsx scripts/gen-routes.ts", "catalog:vendor": "tsx scripts/vendor-catalog.ts", - "check:coverage": "tsx scripts/check-coverage.ts" + "check:coverage": "tsx scripts/check-coverage.ts", + "justification:check": "tsx scripts/check-justification.ts" }, "author": "WorkOS", "license": "MIT" diff --git a/scripts/check-justification.ts b/scripts/check-justification.ts new file mode 100644 index 00000000..33c172ef --- /dev/null +++ b/scripts/check-justification.ts @@ -0,0 +1,39 @@ +#!/usr/bin/env tsx +/** + * CI gate for the command justification manifest. + * + * Loads the curated manifest and the vendored Management catalog, runs + * `validateManifest`, and exits non-zero if any entry is incomplete or maps to + * an operation the catalog does not contain. Wired to `pnpm justification:check` + * and run in CI so an unjustified or drifted command cannot ship. + * + * Usage: + * pnpm justification:check + */ + +import { getManifest } from '../src/catalog/manifest.js'; +import { loadManagementCatalog } from '../src/catalog/loader.js'; +import { validateManifest } from '../src/catalog/justification.js'; + +function main(): void { + const manifest = getManifest(); + // Validate against the full catalog (including feature-flag-gated ops): a + // manifest entry may legitimately target a flagged operation, and we only + // want to fail on genuinely-unknown `mapsTo` values, not on visibility. + const catalog = loadManagementCatalog(undefined, { includeFeatureFlagged: true }); + + const { ok, errors } = validateManifest(manifest, catalog); + + if (ok) { + console.log(`justification:check — OK (${manifest.length} command(s) validated)`); + process.exit(0); + } + + console.error(`justification:check — FAILED (${errors.length} error(s)):`); + for (const error of errors) { + console.error(` - ${error}`); + } + process.exit(1); +} + +main(); diff --git a/src/catalog/confirm.spec.ts b/src/catalog/confirm.spec.ts new file mode 100644 index 00000000..08a70994 --- /dev/null +++ b/src/catalog/confirm.spec.ts @@ -0,0 +1,140 @@ +import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'; + +const mockConfirm = vi.fn(); +const mockIsCancel = vi.fn(() => false); +vi.mock('../utils/clack.js', () => ({ + default: { + confirm: (...args: unknown[]) => mockConfirm(...args), + isCancel: (...args: unknown[]) => mockIsCancel(...args), + }, +})); + +const { setOutputMode } = await import('../utils/output.js'); +const { resetInteractionModeForTests, setInteractionMode } = await import('../utils/interaction-mode.js'); +const { confirmDestructive } = await import('./confirm.js'); +const { CliExit } = await import('../utils/cli-exit.js'); + +async function expectExit(promise: Promise, code: number): Promise { + try { + await promise; + } catch (err) { + if (err instanceof CliExit) { + expect(err.exitCode).toBe(code); + return err; + } + throw err; + } + throw new Error(`Expected promise to reject with CliExit(${code}) but it resolved`); +} + +let stderrOutput: string[]; +let stdoutOutput: string[]; +let errorSpy: ReturnType; +let logSpy: ReturnType; + +beforeEach(() => { + resetInteractionModeForTests(); + setOutputMode('human'); + mockConfirm.mockReset(); + mockIsCancel.mockReset(); + mockIsCancel.mockReturnValue(false); + stderrOutput = []; + stdoutOutput = []; + errorSpy = vi.spyOn(console, 'error').mockImplementation((msg?: unknown) => { + stderrOutput.push(String(msg)); + }); + logSpy = vi.spyOn(console, 'log').mockImplementation((msg?: unknown) => { + stdoutOutput.push(String(msg)); + }); +}); + +afterEach(() => { + errorSpy.mockRestore(); + logSpy.mockRestore(); + resetInteractionModeForTests(); + setOutputMode('human'); +}); + +describe('confirmDestructive', () => { + it('exits 1 with confirmation_required in agent mode without --yes', async () => { + setInteractionMode({ mode: 'agent', source: 'agent_env' }); + setOutputMode('json'); + const err = await expectExit(confirmDestructive({}, { action: 'delete user usr_1' }), 1); + expect(err.context?.errorCode).toBe('confirmation_required'); + const errorLine = stderrOutput.find((line) => { + try { + const parsed = JSON.parse(line) as { error?: { code?: string } }; + return parsed.error?.code === 'confirmation_required'; + } catch { + return false; + } + }); + expect(errorLine).toBeDefined(); + expect(mockConfirm).not.toHaveBeenCalled(); + }); + + it('exits 1 with confirmation_required in CI mode without --yes', async () => { + setInteractionMode({ mode: 'ci', source: 'ci_env' }); + setOutputMode('json'); + await expectExit(confirmDestructive({}, { action: 'delete user usr_1' }), 1); + const errorLine = stderrOutput.find((line) => { + try { + const parsed = JSON.parse(line) as { error?: { code?: string; message?: string } }; + return parsed.error?.code === 'confirmation_required' && /CI mode/.test(parsed.error?.message ?? ''); + } catch { + return false; + } + }); + expect(errorLine).toBeDefined(); + }); + + it('proceeds in agent mode when --yes is set', async () => { + setInteractionMode({ mode: 'agent', source: 'agent_env' }); + setOutputMode('json'); + await expect(confirmDestructive({ yes: true }, { action: 'delete user usr_1' })).resolves.toBeUndefined(); + expect(mockConfirm).not.toHaveBeenCalled(); + }); + + it('exits 1 with confirmation_required in JSON mode even when interactive', async () => { + // Human/interactive mode, but JSON output requested: prompting would pollute + // stdout, so refuse rather than prompt. + setInteractionMode({ mode: 'human', source: 'default' }); + setOutputMode('json'); + await expectExit(confirmDestructive({}, { action: 'delete user usr_1' }), 1); + const errorLine = stderrOutput.find((line) => { + try { + const parsed = JSON.parse(line) as { error?: { code?: string; message?: string } }; + return parsed.error?.code === 'confirmation_required' && /JSON mode/.test(parsed.error?.message ?? ''); + } catch { + return false; + } + }); + expect(errorLine).toBeDefined(); + expect(mockConfirm).not.toHaveBeenCalled(); + }); + + it('exits 2 (CANCELLED) when the interactive prompt is declined', async () => { + setInteractionMode({ mode: 'human', source: 'default' }); + setOutputMode('human'); + mockConfirm.mockResolvedValue(false); + await expectExit(confirmDestructive({}, { action: 'delete user usr_1' }), 2); + expect(mockConfirm).toHaveBeenCalledOnce(); + }); + + it('exits 2 (CANCELLED) when the interactive prompt is cancelled (Ctrl+C)', async () => { + setInteractionMode({ mode: 'human', source: 'default' }); + setOutputMode('human'); + const cancelSymbol = Symbol('clack-cancel'); + mockConfirm.mockResolvedValue(cancelSymbol); + mockIsCancel.mockImplementation((v: unknown) => v === cancelSymbol); + await expectExit(confirmDestructive({}, { action: 'delete user usr_1' }), 2); + }); + + it('proceeds when the interactive prompt is confirmed', async () => { + setInteractionMode({ mode: 'human', source: 'default' }); + setOutputMode('human'); + mockConfirm.mockResolvedValue(true); + await expect(confirmDestructive({}, { action: 'delete user usr_1' })).resolves.toBeUndefined(); + expect(mockConfirm).toHaveBeenCalledOnce(); + }); +}); diff --git a/src/catalog/confirm.ts b/src/catalog/confirm.ts new file mode 100644 index 00000000..204ae747 --- /dev/null +++ b/src/catalog/confirm.ts @@ -0,0 +1,66 @@ +import chalk from 'chalk'; +import { exitWithError, isJsonMode } from '../utils/output.js'; +import { ExitCode, exitWithCode } from '../utils/exit-codes.js'; +import { isCiMode, isPromptAllowed } from '../utils/interaction-mode.js'; + +/** + * Destructive-confirmation gate for catalog-driven category commands. + * + * Mirrors the request-confirmation pattern in `src/commands/api/index.ts` + * (the `MUTATING_METHODS` gate): a non-interactive caller must pass `--yes`, + * a JSON-mode caller must pass `--yes` (so stdout stays machine-readable), and + * an interactive caller is prompted via clack. The api gate is entangled with + * request-specific state (endpoint, method, body, recovery-command building), + * so the shared behavior is mirrored here rather than extracted — see the + * Phase 2 implementation notes. + * + * Exit-code contract (follows the `gh`-style convention in + * `src/utils/exit-codes.ts`): + * - Non-interactive + destructive + no `--yes` => exit 1, `confirmation_required` + * - Non-interactive + `--yes` => proceed (resolves) + * - Interactive, user cancels => exit 2 (CANCELLED) + * - Interactive, user confirms => proceed (resolves) + * + * Note: exit 2 is reserved for active prompt cancellation only. A + * non-interactive refusal is a general error (exit 1), NOT a cancellation. + */ +export interface ConfirmDestructiveOptions { + /** Human-readable description of what will happen, e.g. "delete user usr_123". */ + action: string; +} + +export async function confirmDestructive( + argv: { yes?: boolean; json?: boolean }, + opts: ConfirmDestructiveOptions, +): Promise { + // Explicit consent always proceeds, regardless of interaction/output mode. + if (argv.yes) return; + + // Non-interactive (agent/CI/non-TTY): there is no one to prompt. Refuse with a + // general error so scripts get a stable, non-zero exit and a recoverable code. + if (!isPromptAllowed()) { + exitWithError({ + code: 'confirmation_required', + message: isCiMode() + ? `Destructive operations in CI mode require --yes. Refusing to ${opts.action}.` + : `Destructive operations in agent mode require --yes. Refusing to ${opts.action}.`, + }); + } + + // JSON output requested in an interactive terminal: prompting would pollute + // machine-readable stdout, so require explicit consent instead. + if (isJsonMode() || argv.json) { + exitWithError({ + code: 'confirmation_required', + message: `Destructive operations in JSON mode require --yes to keep stdout machine-readable.`, + }); + } + + const clack = (await import('../utils/clack.js')).default; + console.log(`\n${chalk.yellow('About to')} ${opts.action}`); + const ok = await clack.confirm({ message: 'Proceed?' }); + if (!ok || clack.isCancel(ok)) { + // Active cancellation of an interactive prompt => CANCELLED (exit 2). + exitWithCode(ExitCode.CANCELLED); + } +} diff --git a/src/catalog/curation.ts b/src/catalog/curation.ts new file mode 100644 index 00000000..fac0f037 --- /dev/null +++ b/src/catalog/curation.ts @@ -0,0 +1,84 @@ +import type { CatalogOperation } from './catalog-types.js'; + +/** + * The curation layer: maps a raw catalog operation to a clean, user-facing + * WorkOS command noun + description. + * + * The catalog mirrors the dashboard's internal GraphQL operations, so operation + * names and descriptions can leak internal naming — most notably the `userland*` + * prefix and the word "graphql" — and some descriptions are simply wrong + * (`teamProjectsV2` is described as "Return the team for the current dashboard + * session"). This layer hides that, applying the same clean rendering the + * existing `whoami` command uses (User / Team / Environment nouns, no GraphQL). + * + * Contract: + * - Any op whose catalog `name` OR `description` matches {@link LEAK_PATTERN} + * MUST have an entry in {@link OVERRIDES}. This is enforced by + * `no-graphql-leak.spec.ts` over the curated manifest. + * - The default `describe` falls back to the catalog description only when it is + * clean; a rotten or leaky description requires an override. + */ + +/** Internal naming that must never reach a user-facing command/description. */ +export const LEAK_PATTERN = /graphql|userland/i; + +export interface CommandMeta { + /** Clean user-facing command noun, e.g. "project list". */ + command: string; + /** Clean user-facing description. */ + describe: string; +} + +/** + * Per-operation overrides for command name + description. + * + * One entry per curated op whose catalog `name`/`description` leaks internal + * naming (`userland`/`graphql`) or is wrong/rotten. Keyed by catalog operation + * name. + */ +export const OVERRIDES: Record = { + // Description is wrong upstream ("Return the team for the current dashboard + // session") — it actually lists a team's projects. + teamProjectsV2: { command: 'project list', describe: 'List projects in the current team' }, + + // `userland*` ops: the prefix is internal dashboard naming. The user-facing + // noun is just "user". + userlandUsers: { command: 'user list', describe: 'List AuthKit users in the current environment' }, + createUserlandUser: { command: 'user create', describe: 'Create a user' }, + deleteUserlandUser: { command: 'user delete', describe: 'Delete a user' }, + createUserlandUserInvite: { command: 'user invite', describe: 'Invite a user by email' }, + resendUserlandUserInvite: { command: 'user invite resend', describe: 'Resend a pending user invitation' }, + revokeUserlandUserInvite: { command: 'user invite revoke', describe: 'Revoke a pending user invitation' }, +}; + +/** + * Resolves a catalog operation to clean, user-facing command metadata. + * + * Resolution order: + * 1. An explicit {@link OVERRIDES} entry wins. + * 2. Otherwise, the catalog name/description are used as-is — but only when both + * are clean. If either leaks internal naming ({@link LEAK_PATTERN}) and there + * is no override, the result still carries the leaked value so the + * no-graphql-leak test fails loudly; this is the signal that an override is + * required. (See {@link findLeaks} for the programmatic check used by the + * spec.) + */ +export function resolveCommandMeta(op: CatalogOperation): CommandMeta { + const override = OVERRIDES[op.name]; + if (override) return override; + return { command: op.name, describe: op.description }; +} + +/** + * Returns the field names ('command' and/or 'describe') of resolved metadata + * that still match {@link LEAK_PATTERN}. Empty array means clean. + * + * The no-graphql-leak test runs this over every curated op's resolved metadata + * and asserts it is always empty. + */ +export function findLeaks(meta: CommandMeta): Array { + const leaks: Array = []; + if (LEAK_PATTERN.test(meta.command)) leaks.push('command'); + if (LEAK_PATTERN.test(meta.describe)) leaks.push('describe'); + return leaks; +} diff --git a/src/catalog/justification.spec.ts b/src/catalog/justification.spec.ts new file mode 100644 index 00000000..ffc8ad39 --- /dev/null +++ b/src/catalog/justification.spec.ts @@ -0,0 +1,116 @@ +import { describe, it, expect } from 'vitest'; +import { validateManifest } from './justification.js'; +import type { CommandJustification } from './manifest-types.js'; +import type { ManagementCatalog } from './catalog-types.js'; + +function makeCatalog(opNames: string[]): ManagementCatalog { + return { + operations: opNames.map((name) => ({ + name, + kind: 'mutation' as const, + description: `desc for ${name}`, + rootFields: [name], + returnTypes: ['Thing'], + document: `mutation ${name} { ${name} }`, + fragmentNames: [], + variables: [], + })), + fragments: {}, + inputTypes: {}, + }; +} + +const catalog = makeCatalog(['inviteUserToTeam', 'createEnvironment']); + +function validEntry(overrides: Partial = {}): CommandJustification { + return { + command: 'team invite', + mapsTo: 'inviteUserToTeam', + audiences: ['human', 'agent'], + useCase: 'Invite a teammate without leaving the terminal', + load: 'cheap', + mutation: true, + destructive: false, + ciPolicy: 'require-flag', + ...overrides, + }; +} + +describe('validateManifest', () => { + it('passes an empty manifest', () => { + const result = validateManifest([], catalog); + expect(result.ok).toBe(true); + expect(result.errors).toEqual([]); + }); + + it('passes a fully valid entry', () => { + const result = validateManifest([validEntry()], catalog); + expect(result.ok).toBe(true); + expect(result.errors).toEqual([]); + }); + + it('rejects a missing useCase', () => { + const result = validateManifest([validEntry({ useCase: '' })], catalog); + expect(result.ok).toBe(false); + expect(result.errors.some((e) => /useCase/.test(e))).toBe(true); + }); + + it('rejects a whitespace-only useCase', () => { + const result = validateManifest([validEntry({ useCase: ' ' })], catalog); + expect(result.ok).toBe(false); + expect(result.errors.some((e) => /useCase/.test(e))).toBe(true); + }); + + it('rejects a mapsTo not present in the catalog', () => { + const result = validateManifest([validEntry({ mapsTo: 'noSuchOperation' })], catalog); + expect(result.ok).toBe(false); + expect(result.errors.some((e) => /not an operation in the catalog/.test(e))).toBe(true); + }); + + it('rejects empty audiences', () => { + const result = validateManifest([validEntry({ audiences: [] })], catalog); + expect(result.ok).toBe(false); + expect(result.errors.some((e) => /audiences/.test(e))).toBe(true); + }); + + it('rejects an out-of-enum load', () => { + const result = validateManifest([validEntry({ load: 'gigantic' as CommandJustification['load'] })], catalog); + expect(result.ok).toBe(false); + expect(result.errors.some((e) => /"load"/.test(e))).toBe(true); + }); + + it('rejects an out-of-enum ciPolicy', () => { + const result = validateManifest( + [validEntry({ ciPolicy: 'whenever' as CommandJustification['ciPolicy'] })], + catalog, + ); + expect(result.ok).toBe(false); + expect(result.errors.some((e) => /"ciPolicy"/.test(e))).toBe(true); + }); + + it('rejects an invalid audience value', () => { + const result = validateManifest( + [validEntry({ audiences: ['robot' as CommandJustification['audiences'][number]] })], + catalog, + ); + expect(result.ok).toBe(false); + expect(result.errors.some((e) => /invalid audience/.test(e))).toBe(true); + }); + + it('reports all errors across multiple entries', () => { + const result = validateManifest( + [validEntry({ command: 'a', useCase: '' }), validEntry({ command: 'b', mapsTo: 'nope' })], + catalog, + ); + expect(result.ok).toBe(false); + expect(result.errors.some((e) => /"a".*useCase/.test(e))).toBe(true); + expect(result.errors.some((e) => /"b".*not an operation/.test(e))).toBe(true); + }); + + it('labels a malformed entry by index when command is absent', () => { + const broken = { mapsTo: 'inviteUserToTeam' } as unknown as CommandJustification; + const result = validateManifest([broken], catalog); + expect(result.ok).toBe(false); + expect(result.errors.some((e) => /#0/.test(e))).toBe(true); + }); +}); diff --git a/src/catalog/justification.ts b/src/catalog/justification.ts new file mode 100644 index 00000000..444cc00c --- /dev/null +++ b/src/catalog/justification.ts @@ -0,0 +1,125 @@ +import type { ManagementCatalog } from './catalog-types.js'; +import type { Audience, CiPolicy, CommandJustification, Load } from './manifest-types.js'; + +/** + * The hard gate behind "justify every command". + * + * {@link validateManifest} rejects any manifest entry that is incomplete or that + * maps to an operation the catalog does not contain. It is the manifest-level + * drift safety net: until the dedicated CI drift gate lands in a later phase, a + * `mapsTo` pointing at a stale/renamed operation is caught here. + * + * The CI entrypoint (`scripts/check-justification.ts`, wired to + * `pnpm justification:check`) calls this and exits non-zero on any error. + */ + +const LOADS: readonly Load[] = ['cheap', 'expensive', 'bulk']; +const CI_POLICIES: readonly CiPolicy[] = ['allow', 'require-flag', 'block-noninteractive']; +const AUDIENCES: readonly Audience[] = ['human', 'agent', 'ci']; + +/** + * The required keys of {@link CommandJustification}, asserted at the type level. + * + * `satisfies` forces this tuple to list exactly the interface's keys: drop one + * and TypeScript errors here, add a new field to the interface without listing + * it and TypeScript errors here. This keeps the validated field set in lockstep + * with the type (the documented "false pass" mitigation). + */ +const REQUIRED_KEYS = [ + 'command', + 'mapsTo', + 'audiences', + 'useCase', + 'load', + 'mutation', + 'destructive', + 'ciPolicy', +] as const satisfies ReadonlyArray; + +// Compile-time completeness check: every key of CommandJustification must be in +// REQUIRED_KEYS. If a field is added to the interface but not to the tuple +// above, this assignment fails to type-check. +type _MissingKey = Exclude; +const _exhaustive: _MissingKey extends never ? true : never = true; +void _exhaustive; + +export interface ValidationResult { + ok: boolean; + errors: string[]; +} + +function isNonEmptyString(value: unknown): value is string { + return typeof value === 'string' && value.trim().length > 0; +} + +/** + * Validates a manifest against the catalog. Returns `{ ok, errors }`; `ok` is + * true only when `errors` is empty. + * + * Per entry, an error is recorded for: a missing required key; an empty/blank + * `command` or `useCase`; an empty `audiences` (or one containing an invalid + * audience); a `load` or `ciPolicy` outside its enum; a non-boolean `mutation` + * or `destructive`; or a `mapsTo` that names no operation in the catalog. + */ +export function validateManifest(manifest: CommandJustification[], catalog: ManagementCatalog): ValidationResult { + const errors: string[] = []; + const opNames = new Set(catalog.operations.map((op) => op.name)); + + manifest.forEach((entry, index) => { + // A stable label for error messages: prefer the command name, fall back to + // the index so even a malformed entry is identifiable. + const label = isNonEmptyString(entry?.command) ? `"${entry.command}"` : `#${index}`; + + if (entry == null || typeof entry !== 'object') { + errors.push(`Entry ${label}: not an object`); + return; + } + + for (const key of REQUIRED_KEYS) { + if (!(key in entry)) { + errors.push(`Entry ${label}: missing required field "${key}"`); + } + } + + if (!isNonEmptyString(entry.command)) { + errors.push(`Entry ${label}: "command" must be a non-empty string`); + } + + if (!isNonEmptyString(entry.useCase)) { + errors.push(`Entry ${label}: "useCase" must be a non-empty string`); + } + + if (!Array.isArray(entry.audiences) || entry.audiences.length === 0) { + errors.push(`Entry ${label}: "audiences" must be a non-empty array`); + } else { + const invalid = entry.audiences.filter((a) => !AUDIENCES.includes(a)); + if (invalid.length > 0) { + errors.push(`Entry ${label}: invalid audience(s) [${invalid.join(', ')}] (allowed: ${AUDIENCES.join(', ')})`); + } + } + + if (!LOADS.includes(entry.load)) { + errors.push(`Entry ${label}: "load" must be one of [${LOADS.join(', ')}]`); + } + + if (!CI_POLICIES.includes(entry.ciPolicy)) { + errors.push(`Entry ${label}: "ciPolicy" must be one of [${CI_POLICIES.join(', ')}]`); + } + + if (typeof entry.mutation !== 'boolean') { + errors.push(`Entry ${label}: "mutation" must be a boolean`); + } + + if (typeof entry.destructive !== 'boolean') { + errors.push(`Entry ${label}: "destructive" must be a boolean`); + } + + if (!isNonEmptyString(entry.mapsTo)) { + errors.push(`Entry ${label}: "mapsTo" must be a non-empty string`); + } else if (!opNames.has(entry.mapsTo)) { + errors.push(`Entry ${label}: "mapsTo" "${entry.mapsTo}" is not an operation in the catalog`); + } + }); + + return { ok: errors.length === 0, errors }; +} diff --git a/src/catalog/manifest-types.ts b/src/catalog/manifest-types.ts new file mode 100644 index 00000000..de1866c4 --- /dev/null +++ b/src/catalog/manifest-types.ts @@ -0,0 +1,63 @@ +/** + * Types for the command justification manifest — the two-axis rubric that makes + * "justify every command" enforceable. + * + * Every catalog-driven command the CLI ships must have a complete + * {@link CommandJustification} record. `validateManifest` (justification.ts) + * rejects any record missing a field, an empty `useCase`/`audiences`, an + * out-of-enum `load`/`ciPolicy`, or a `mapsTo` that is not a real catalog + * operation. The CI gate (`pnpm justification:check`) fails the build on any + * such error. + * + * The manifest records the clean WorkOS command noun (`command`) and the + * internal catalog operation it resolves to (`mapsTo`). The `command` string is + * always the user-facing noun — it must never contain GraphQL or `userland*` + * naming. The curation layer (curation.ts) produces these clean names. + */ + +/** Who a command earns its place for. At least one is required. */ +export type Audience = 'human' | 'agent' | 'ci'; + +/** + * Cost class of the underlying operation. `cheap` = a single bounded + * read/write; `expensive` = potentially large or slow; `bulk` = fans out over a + * collection. The load-capping engine that acts on this lands in a later phase; + * the manifest records it now so the data is present. + */ +export type Load = 'cheap' | 'expensive' | 'bulk'; + +/** + * How the command behaves in CI / non-interactive contexts. `allow` = runs + * freely; `require-flag` = needs an explicit confirmation flag (e.g. `--yes`); + * `block-noninteractive` = refused outright outside an interactive terminal. + */ +export type CiPolicy = 'allow' | 'require-flag' | 'block-noninteractive'; + +/** + * The two-axis justification record. A command cannot ship without a complete + * one — see {@link validateManifest} for the enforced field list. + */ +export interface CommandJustification { + /** Clean user-facing WorkOS noun, e.g. "team invite". Never GraphQL/userland. */ + command: string; + /** Catalog operation name this command resolves to, e.g. "inviteUserToTeam". */ + mapsTo: string; + /** Who this command is for. Non-empty. */ + audiences: Audience[]; + /** Why this operation earns a first-class command. Non-empty. */ + useCase: string; + /** Cost class of the underlying operation. */ + load: Load; + /** Whether the operation mutates state. */ + mutation: boolean; + /** + * Whether the operation is destructive enough to require confirmation. + * Curation-set with a heuristic default (remove/delete/deactivate, or + * production-environment writes) — NOT inherited from `catalog.confirmation`, + * which is set on only a handful of the 357 operations and is too sparse to + * rely on. + */ + destructive: boolean; + /** CI / non-interactive behavior. */ + ciPolicy: CiPolicy; +} diff --git a/src/catalog/manifest.ts b/src/catalog/manifest.ts new file mode 100644 index 00000000..3131be04 --- /dev/null +++ b/src/catalog/manifest.ts @@ -0,0 +1,19 @@ +import type { CommandJustification } from './manifest-types.js'; + +/** + * The curated allowlist of catalog-driven commands. + * + * Each entry is a complete {@link CommandJustification} — the two-axis rubric + * that justifies surfacing a catalog operation as a first-class WorkOS command. + * Entries are validated by `validateManifest` (justification.ts) and gated in CI + * via `pnpm justification:check`. + * + * Phase 2 ships the machinery with an empty manifest; the first real entries + * (the first command category) land in Phase 3. + */ +const MANIFEST: CommandJustification[] = []; + +/** Returns the curated command allowlist. */ +export function getManifest(): CommandJustification[] { + return MANIFEST; +} diff --git a/src/catalog/no-graphql-leak.spec.ts b/src/catalog/no-graphql-leak.spec.ts new file mode 100644 index 00000000..5a23b594 --- /dev/null +++ b/src/catalog/no-graphql-leak.spec.ts @@ -0,0 +1,65 @@ +import { describe, it, expect } from 'vitest'; +import { loadManagementCatalog } from './loader.js'; +import { getManifest } from './manifest.js'; +import { OVERRIDES, resolveCommandMeta, findLeaks, LEAK_PATTERN } from './curation.js'; +import type { CatalogOperation } from './catalog-types.js'; + +const catalog = loadManagementCatalog(undefined, { includeFeatureFlagged: true }); +const byName = new Map(catalog.operations.map((op) => [op.name, op])); + +function op(name: string): CatalogOperation { + const found = byName.get(name); + if (!found) throw new Error(`Test fixture op "${name}" not found in catalog`); + return found; +} + +describe('no GraphQL/userland leak in curated command metadata', () => { + it('every manifest entry resolves to clean command metadata', () => { + // Forward guard: when commands land in the manifest (Phase 3+), each one's + // resolved metadata must be free of internal naming. + for (const entry of getManifest()) { + const opForEntry = byName.get(entry.mapsTo); + expect(opForEntry, `manifest op "${entry.mapsTo}" missing from catalog`).toBeDefined(); + const meta = resolveCommandMeta(opForEntry!); + expect(findLeaks(meta), `${entry.mapsTo} -> ${JSON.stringify(meta)}`).toEqual([]); + // The clean command noun is the one the manifest records. + expect(meta.command).toBe(entry.command); + } + }); + + it('every override entry resolves to clean metadata', () => { + for (const name of Object.keys(OVERRIDES)) { + const meta = resolveCommandMeta(op(name)); + expect(findLeaks(meta), `${name} -> ${JSON.stringify(meta)}`).toEqual([]); + } + }); + + it('resolves a userland-named op (userlandUsers) to a clean noun', () => { + // The op NAME leaks ("userland") even though its description is clean. + const raw = op('userlandUsers'); + expect(LEAK_PATTERN.test(raw.name)).toBe(true); + + const meta = resolveCommandMeta(raw); + expect(meta.command).toBe('user list'); + expect(findLeaks(meta)).toEqual([]); + }); + + it('overrides a rotten description (teamProjectsV2)', () => { + // The catalog description is wrong upstream: it claims to "Return the team + // for the current dashboard session" when the op lists projects. + const raw = op('teamProjectsV2'); + expect(raw.description).toMatch(/return the team for the current dashboard session/i); + + const meta = resolveCommandMeta(raw); + expect(meta.command).toBe('project list'); + expect(meta.describe).toBe('List projects in the current team'); + expect(findLeaks(meta)).toEqual([]); + }); + + it('findLeaks flags both fields when metadata leaks', () => { + expect(findLeaks({ command: 'user list', describe: 'List users' })).toEqual([]); + expect(findLeaks({ command: 'userland list', describe: 'clean' })).toEqual(['command']); + expect(findLeaks({ command: 'clean', describe: 'a graphql thing' })).toEqual(['describe']); + expect(findLeaks({ command: 'userland', describe: 'graphql' })).toEqual(['command', 'describe']); + }); +}); From d51e4b17f480eb41ab7164fe90ee0fece9ef906b Mon Sep 17 00:00:00 2001 From: Nick Nisi Date: Thu, 25 Jun 2026 16:16:52 -0500 Subject: [PATCH 06/22] feat: add account-plane lifecycle commands (environment, project, team) 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. --- src/bin.ts | 199 +++++++++++++++++++ src/catalog/confirm.spec.ts | 39 +++- src/catalog/confirm.ts | 43 +++++ src/catalog/curation.ts | 20 ++ src/catalog/manifest.ts | 142 +++++++++++++- src/catalog/operation.ts | 103 ++++++++++ src/commands/environment.spec.ts | 105 ++++++++++ src/commands/environment.ts | 91 +++++++++ src/commands/project.spec.ts | 161 ++++++++++++++++ src/commands/project.ts | 160 ++++++++++++++++ src/commands/team.spec.ts | 239 +++++++++++++++++++++++ src/commands/team.ts | 316 +++++++++++++++++++++++++++++++ src/utils/help-json.ts | 157 +++++++++++++++ 13 files changed, 1773 insertions(+), 2 deletions(-) create mode 100644 src/catalog/operation.ts create mode 100644 src/commands/environment.spec.ts create mode 100644 src/commands/environment.ts create mode 100644 src/commands/project.spec.ts create mode 100644 src/commands/project.ts create mode 100644 src/commands/team.spec.ts create mode 100644 src/commands/team.ts diff --git a/src/bin.ts b/src/bin.ts index 83371d1f..8ef90d19 100644 --- a/src/bin.ts +++ b/src/bin.ts @@ -423,6 +423,205 @@ async function runCli(): Promise { await runWhoami(); }, ) + .command( + 'environment', + 'Manage WorkOS environments (create, rename) on the dashboard account plane', + (yargs) => { + yargs.options(insecureStorageOption); + registerSubcommand( + yargs, + 'create ', + 'Create a sandbox or production environment', + (y) => + y + .positional('name', { type: 'string', demandOption: true, describe: 'Environment name' }) + .option('sandbox', { type: 'boolean', default: false, describe: 'Create a sandbox environment' }), + async (argv) => { + await applyInsecureStorage(argv.insecureStorage); + const { runEnvironmentCreate } = await import('./commands/environment.js'); + await runEnvironmentCreate({ name: argv.name, sandbox: Boolean(argv.sandbox) }); + }, + ); + registerSubcommand( + yargs, + 'rename ', + 'Rename an environment', + (y) => + y + .positional('environmentId', { type: 'string', demandOption: true, describe: 'Environment ID' }) + .positional('name', { type: 'string', demandOption: true, describe: 'New environment name' }), + async (argv) => { + await applyInsecureStorage(argv.insecureStorage); + const { runEnvironmentRename } = await import('./commands/environment.js'); + await runEnvironmentRename({ environmentId: argv.environmentId, name: argv.name }); + }, + ); + return yargs.demandCommand(1, 'Please specify an environment subcommand').strict(); + }, + ) + .command('project', 'Manage WorkOS projects (create, rename, list) on the dashboard account plane', (yargs) => { + yargs.options(insecureStorageOption); + registerSubcommand( + yargs, + 'create ', + 'Create a project with fresh staging and production environments', + (y) => + y + .positional('name', { type: 'string', demandOption: true, describe: 'Project name' }) + .option('production', { + type: 'boolean', + default: true, + describe: 'Provision a production environment (use --no-production for staging only)', + }) + .option('yes', { alias: 'y', type: 'boolean', default: false, describe: 'Confirm in non-interactive mode' }), + async (argv) => { + await applyInsecureStorage(argv.insecureStorage); + const { runProjectCreate } = await import('./commands/project.js'); + await runProjectCreate({ + name: argv.name, + production: argv.production !== false, + yes: argv.yes, + json: argv.json as boolean | undefined, + }); + }, + ); + registerSubcommand( + yargs, + 'rename ', + 'Rename a project', + (y) => + y + .positional('projectId', { type: 'string', demandOption: true, describe: 'Project ID' }) + .positional('name', { type: 'string', demandOption: true, describe: 'New project name' }), + async (argv) => { + await applyInsecureStorage(argv.insecureStorage); + const { runProjectRename } = await import('./commands/project.js'); + await runProjectRename({ projectId: argv.projectId, name: argv.name }); + }, + ); + registerSubcommand( + yargs, + 'list', + 'List projects in the current team', + (y) => y, + async (argv) => { + await applyInsecureStorage(argv.insecureStorage); + const { runProjectList } = await import('./commands/project.js'); + await runProjectList(); + }, + ); + return yargs.demandCommand(1, 'Please specify a project subcommand').strict(); + }) + .command('team', 'Manage the WorkOS dashboard team (members, invites, settings)', (yargs) => { + yargs.options(insecureStorageOption); + registerSubcommand( + yargs, + 'members', + 'List members of the current team', + (y) => y, + async (argv) => { + await applyInsecureStorage(argv.insecureStorage); + const { runTeamMembers } = await import('./commands/team.js'); + await runTeamMembers(); + }, + ); + registerSubcommand( + yargs, + 'invite ', + 'Invite a user to the current team by email', + (y) => + y + .positional('email', { type: 'string', demandOption: true, describe: 'Email address to invite' }) + .option('role', { type: 'string', demandOption: true, describe: 'Role (ADMIN, MEMBER, ...)' }) + .option('first-name', { type: 'string', describe: 'First name' }) + .option('last-name', { type: 'string', describe: 'Last name' }), + async (argv) => { + await applyInsecureStorage(argv.insecureStorage); + const { runTeamInvite } = await import('./commands/team.js'); + await runTeamInvite({ + email: argv.email, + role: argv.role as string, + firstName: argv.firstName as string | undefined, + lastName: argv.lastName as string | undefined, + }); + }, + ); + registerSubcommand( + yargs, + 'change-role ', + "Change a team member's role", + (y) => + y + .positional('membershipId', { type: 'string', demandOption: true, describe: 'Team membership ID' }) + .positional('role', { type: 'string', demandOption: true, describe: 'New role (ADMIN, MEMBER, ...)' }) + .option('yes', { alias: 'y', type: 'boolean', default: false, describe: 'Confirm in non-interactive mode' }), + async (argv) => { + await applyInsecureStorage(argv.insecureStorage); + const { runTeamChangeRole } = await import('./commands/team.js'); + await runTeamChangeRole({ + membershipId: argv.membershipId, + role: argv.role, + yes: argv.yes, + json: argv.json as boolean | undefined, + }); + }, + ); + registerSubcommand( + yargs, + 'remove ', + 'Remove a member from the current team', + (y) => + y + .positional('membershipId', { type: 'string', demandOption: true, describe: 'Team membership ID' }) + .option('yes', { alias: 'y', type: 'boolean', default: false, describe: 'Skip the confirmation prompt' }), + async (argv) => { + await applyInsecureStorage(argv.insecureStorage); + const { runTeamRemove } = await import('./commands/team.js'); + await runTeamRemove({ membershipId: argv.membershipId, yes: argv.yes, json: argv.json as boolean | undefined }); + }, + ); + registerSubcommand( + yargs, + 'resend-invite ', + 'Resend an expired team invitation', + (y) => y.positional('membershipId', { type: 'string', demandOption: true, describe: 'Team membership ID' }), + async (argv) => { + await applyInsecureStorage(argv.insecureStorage); + const { runTeamResendInvite } = await import('./commands/team.js'); + await runTeamResendInvite({ membershipId: argv.membershipId }); + }, + ); + registerSubcommand( + yargs, + 'update ', + 'Rename the current team', + (y) => y.positional('name', { type: 'string', demandOption: true, describe: 'New team name' }), + async (argv) => { + await applyInsecureStorage(argv.insecureStorage); + const { runTeamUpdate } = await import('./commands/team.js'); + await runTeamUpdate({ name: argv.name }); + }, + ); + registerSubcommand( + yargs, + 'set-mfa ', + 'Set whether MFA is required for the team', + (y) => + y + .positional('required', { type: 'boolean', demandOption: true, describe: 'true to require MFA, false to relax' }) + .option('yes', { alias: 'y', type: 'boolean', default: false, describe: 'Confirm in non-interactive mode' }), + async (argv) => { + await applyInsecureStorage(argv.insecureStorage); + const { runTeamSetMfa } = await import('./commands/team.js'); + await runTeamSetMfa({ + required: Boolean(argv.required), + yes: argv.yes, + json: argv.json as boolean | undefined, + }); + }, + ); + return yargs.demandCommand(1, 'Please specify a team subcommand').strict(); + }) .command('telemetry', 'Manage telemetry collection (opt-out, opt-in, status)', (yargs) => { registerSubcommand( yargs, diff --git a/src/catalog/confirm.spec.ts b/src/catalog/confirm.spec.ts index 08a70994..5057f7f8 100644 --- a/src/catalog/confirm.spec.ts +++ b/src/catalog/confirm.spec.ts @@ -11,7 +11,7 @@ vi.mock('../utils/clack.js', () => ({ const { setOutputMode } = await import('../utils/output.js'); const { resetInteractionModeForTests, setInteractionMode } = await import('../utils/interaction-mode.js'); -const { confirmDestructive } = await import('./confirm.js'); +const { confirmDestructive, requireConfirmationFlag } = await import('./confirm.js'); const { CliExit } = await import('../utils/cli-exit.js'); async function expectExit(promise: Promise, code: number): Promise { @@ -138,3 +138,40 @@ describe('confirmDestructive', () => { expect(mockConfirm).toHaveBeenCalledOnce(); }); }); + +describe('requireConfirmationFlag', () => { + it('exits 1 with confirmation_required in agent mode without --yes', async () => { + setInteractionMode({ mode: 'agent', source: 'agent_env' }); + setOutputMode('json'); + const err = await expectExit(requireConfirmationFlag({}, { action: 'change a role' }), 1); + expect(err.context?.errorCode).toBe('confirmation_required'); + expect(mockConfirm).not.toHaveBeenCalled(); + }); + + it('exits 1 with confirmation_required in CI mode without --yes', async () => { + setInteractionMode({ mode: 'ci', source: 'ci_env' }); + setOutputMode('json'); + const err = await expectExit(requireConfirmationFlag({}, { action: 'change a role' }), 1); + expect(err.context?.errorCode).toBe('confirmation_required'); + }); + + it('exits 1 in JSON mode even when interactive (stdout must stay machine-readable)', async () => { + setInteractionMode({ mode: 'human', source: 'default' }); + setOutputMode('json'); + await expectExit(requireConfirmationFlag({}, { action: 'change a role' }), 1); + expect(mockConfirm).not.toHaveBeenCalled(); + }); + + it('proceeds non-interactive when --yes is set', async () => { + setInteractionMode({ mode: 'agent', source: 'agent_env' }); + setOutputMode('json'); + await expect(requireConfirmationFlag({ yes: true }, { action: 'change a role' })).resolves.toBeUndefined(); + }); + + it('proceeds interactively without --yes and never prompts (human is trusted)', async () => { + setInteractionMode({ mode: 'human', source: 'default' }); + setOutputMode('human'); + await expect(requireConfirmationFlag({}, { action: 'change a role' })).resolves.toBeUndefined(); + expect(mockConfirm).not.toHaveBeenCalled(); + }); +}); diff --git a/src/catalog/confirm.ts b/src/catalog/confirm.ts index 204ae747..c7b6f8d5 100644 --- a/src/catalog/confirm.ts +++ b/src/catalog/confirm.ts @@ -64,3 +64,46 @@ export async function confirmDestructive( exitWithCode(ExitCode.CANCELLED); } } + +export interface RequireConfirmationFlagOptions { + /** Human-readable description of what will happen, e.g. "change a member's role". */ + action: string; +} + +/** + * The `require-flag` ci_policy gate for non-destructive but sensitive mutations + * (privilege or security-posture changes, or fan-out provisioning). + * + * Unlike {@link confirmDestructive}, this never prompts: an interactive human is + * trusted to mean what they typed. Its only job is to stop a *non-interactive* + * caller (agent/CI/non-TTY, or JSON output) from making the change without + * explicit consent — the "broken CI loop" guard from the interview. A + * non-interactive caller must pass `--yes`. + * + * Exit-code contract (matches {@link confirmDestructive} for the refusal path): + * - Non-interactive + no `--yes` => exit 1, `confirmation_required` + * - Non-interactive + `--yes` => proceed (resolves) + * - Interactive => proceed (resolves; no prompt) + * + * This is NOT the expensive-op load-capping engine (a later phase); it is the + * cheap-load `require-flag` enforcement the first command category needs. + */ +export async function requireConfirmationFlag( + argv: { yes?: boolean; json?: boolean }, + opts: RequireConfirmationFlagOptions, +): Promise { + // Explicit consent always proceeds. + if (argv.yes) return; + + // Interactive humans are trusted — no prompt, no flag required. + if (isPromptAllowed() && !isJsonMode() && !argv.json) return; + + // Non-interactive (or JSON output): refuse without explicit consent so a + // scripted/agent run can't silently make a sensitive change. + exitWithError({ + code: 'confirmation_required', + message: isCiMode() + ? `This change requires --yes in CI mode. Refusing to ${opts.action}.` + : `This change requires --yes in non-interactive mode. Refusing to ${opts.action}.`, + }); +} diff --git a/src/catalog/curation.ts b/src/catalog/curation.ts index fac0f037..3c053ae5 100644 --- a/src/catalog/curation.ts +++ b/src/catalog/curation.ts @@ -37,9 +37,29 @@ export interface CommandMeta { * name. */ export const OVERRIDES: Record = { + // --- Phase 3: account-plane lifecycle --- + // Every curated op gets an override so the manifest's clean `command` noun is + // the single source of truth (the catalog operation names like + // `createEnvironment` are internal and would otherwise leak through as the + // command name). Descriptions are rewritten to the user-facing voice and to + // avoid GraphQL/internal phrasing. + createEnvironment: { command: 'environment create', describe: 'Create a sandbox or production environment' }, + renameEnvironment: { command: 'environment rename', describe: 'Rename an environment' }, // Description is wrong upstream ("Return the team for the current dashboard // session") — it actually lists a team's projects. teamProjectsV2: { command: 'project list', describe: 'List projects in the current team' }, + createProjectWithNewEnvironments: { + command: 'project create', + describe: 'Create a project with fresh staging and production environments', + }, + renameProject: { command: 'project rename', describe: 'Rename a project' }, + teamMemberships: { command: 'team members', describe: 'List members of the current team' }, + inviteUserToTeam: { command: 'team invite', describe: 'Invite a user to the current team by email' }, + changeRole: { command: 'team change-role', describe: "Change a team member's role" }, + removeUserFromTeam: { command: 'team remove', describe: 'Remove a member from the current team' }, + resendDashboardInvite: { command: 'team resend-invite', describe: 'Resend an expired team invitation' }, + updateTeamDetails: { command: 'team update', describe: 'Rename the current team' }, + updateTeamMfaRequirement: { command: 'team set-mfa', describe: 'Set whether MFA is required for the team' }, // `userland*` ops: the prefix is internal dashboard naming. The user-facing // noun is just "user". diff --git a/src/catalog/manifest.ts b/src/catalog/manifest.ts index 3131be04..79f76b02 100644 --- a/src/catalog/manifest.ts +++ b/src/catalog/manifest.ts @@ -10,8 +10,148 @@ import type { CommandJustification } from './manifest-types.js'; * * Phase 2 ships the machinery with an empty manifest; the first real entries * (the first command category) land in Phase 3. + * + * Phase 3 adds the first category: account-plane lifecycle (environment / + * project / team) — the dashboard management actions a user performs that the + * CLI could not do before. Naturally low-load (cheap mutations + argument-less + * reads), which is why it is first: it proves the framework without exercising + * the deferred load-capping engine. */ -const MANIFEST: CommandJustification[] = []; +const MANIFEST: CommandJustification[] = [ + // --- environment --- + { + command: 'environment create', + mapsTo: 'createEnvironment', + audiences: ['human', 'agent', 'ci'], + useCase: 'Provision a sandbox/prod environment from setup scripts or CI', + load: 'cheap', + mutation: true, + destructive: false, + ciPolicy: 'allow', + }, + { + command: 'environment rename', + mapsTo: 'renameEnvironment', + audiences: ['human', 'agent'], + useCase: 'Fix environment naming drift without the dashboard', + load: 'cheap', + mutation: true, + destructive: false, + ciPolicy: 'allow', + }, + // --- project --- + { + command: 'project create', + mapsTo: 'createProjectWithNewEnvironments', + audiences: ['human', 'ci'], + useCase: 'Provision a project with fresh staging+prod envs during team/tenant setup', + load: 'cheap', + mutation: true, + destructive: false, + // require-flag: provisions a project AND multiple environments; gating + // non-interactive runs behind --yes prevents a broken CI loop from spawning + // many projects. + ciPolicy: 'require-flag', + }, + { + command: 'project rename', + mapsTo: 'renameProject', + audiences: ['human', 'agent'], + useCase: 'Rename a project', + load: 'cheap', + mutation: true, + destructive: false, + ciPolicy: 'allow', + }, + { + command: 'project list', + mapsTo: 'teamProjectsV2', + audiences: ['human', 'agent', 'ci'], + useCase: "List the team's projects (scripting, discovery)", + load: 'cheap', + mutation: false, + destructive: false, + ciPolicy: 'allow', + }, + // --- team --- + { + command: 'team members', + mapsTo: 'teamMemberships', + audiences: ['human', 'agent', 'ci'], + useCase: 'List dashboard team members (audits, offboarding scripts)', + load: 'cheap', + mutation: false, + destructive: false, + ciPolicy: 'allow', + }, + { + command: 'team invite', + mapsTo: 'inviteUserToTeam', + audiences: ['human', 'ci'], + useCase: 'Invite a teammate to the dashboard team during onboarding automation', + load: 'cheap', + mutation: true, + destructive: false, + ciPolicy: 'allow', + }, + { + command: 'team change-role', + mapsTo: 'changeRole', + audiences: ['human'], + useCase: "Change a team member's dashboard role", + load: 'cheap', + mutation: true, + destructive: false, + // require-flag: a privilege change; require explicit consent in + // non-interactive use even though it is not destructive. + ciPolicy: 'require-flag', + }, + { + command: 'team remove', + mapsTo: 'removeUserFromTeam', + audiences: ['human'], + useCase: 'Offboard a member from the dashboard team', + load: 'cheap', + mutation: true, + // destructive: revokes access, so confirmDestructive() forces interactive + // confirmation or --yes. ciPolicy stays `allow` because the destructive gate + // already covers the non-interactive case. + destructive: true, + ciPolicy: 'allow', + }, + { + command: 'team resend-invite', + mapsTo: 'resendDashboardInvite', + audiences: ['human', 'ci'], + useCase: 'Resend an expired dashboard invite', + load: 'cheap', + mutation: true, + destructive: false, + ciPolicy: 'allow', + }, + { + command: 'team update', + mapsTo: 'updateTeamDetails', + audiences: ['human'], + useCase: 'Rename the team/account', + load: 'cheap', + mutation: true, + destructive: false, + ciPolicy: 'allow', + }, + { + command: 'team set-mfa', + mapsTo: 'updateTeamMfaRequirement', + audiences: ['human'], + useCase: 'Require or relax MFA for the dashboard team', + load: 'cheap', + mutation: true, + destructive: false, + // require-flag: a security-posture change; require explicit consent in + // non-interactive use. + ciPolicy: 'require-flag', + }, +]; /** Returns the curated command allowlist. */ export function getManifest(): CommandJustification[] { diff --git a/src/catalog/operation.ts b/src/catalog/operation.ts new file mode 100644 index 00000000..b693c2dc --- /dev/null +++ b/src/catalog/operation.ts @@ -0,0 +1,103 @@ +import { loadManagementCatalog } from './loader.js'; +import { DashboardGraphqlError } from '../lib/dashboard-graphql.js'; +import { exitWithError } from '../utils/output.js'; +import type { CatalogOperation, ManagementCatalog } from './catalog-types.js'; + +/** + * Catalog-operation accessors for command handlers. + * + * Category command handlers (Phase 3+) never import the snapshot or hand-write + * GraphQL: they look an operation up by name and ask for its executable document. + * The GraphQL `document` text stays internal to this layer — handlers pass the + * returned string straight to `dashboardGraphqlRequest()` and otherwise treat it + * as opaque (the no-graphql-leak contract is about user-facing strings, not the + * wire document). + */ + +/** + * Look up a catalog operation by its name (the `mapsTo` value in the manifest). + * + * Includes feature-flag-gated operations so a handler can still resolve an op + * that the live MCP would hide — the CLI guards those at the command layer (via + * the manifest + clear errors), not by making them un-loadable here. Throws if + * the name is absent, which only happens on a manifest/catalog drift that + * `justification:check` would already have failed on. + */ +export function getOperation( + name: string, + catalog: ManagementCatalog = loadManagementCatalog(undefined, { includeFeatureFlagged: true }), +): CatalogOperation { + const op = catalog.operations.find((candidate) => candidate.name === name); + if (!op) { + throw new Error(`Catalog operation "${name}" not found. The vendored snapshot may be stale; run \`pnpm catalog:vendor\`.`); + } + return op; +} + +/** + * Returns the full executable GraphQL document for an operation: its own + * `document` text plus the definitions of every fragment it transitively + * depends on. + * + * The catalog stores operation text WITHOUT its fragments (they are deduped into + * `catalog.fragments`), so sending `op.document` alone would fail with "Unknown + * fragment" whenever `op.fragmentNames` is non-empty. This stitches them back + * together so the document is valid on the wire. The result is internal — never + * surface it to users. + */ +export function resolveExecutableDocument( + op: CatalogOperation, + catalog: ManagementCatalog = loadManagementCatalog(undefined, { includeFeatureFlagged: true }), +): string { + if (op.fragmentNames.length === 0) return op.document; + + const fragments = op.fragmentNames.map((fragmentName) => { + const fragment = catalog.fragments[fragmentName]; + if (!fragment) { + throw new Error( + `Fragment "${fragmentName}" required by operation "${op.name}" is missing from the catalog snapshot.`, + ); + } + return fragment; + }); + + return [op.document, ...fragments].join('\n\n'); +} + +/** + * Translate a {@link DashboardGraphqlError} into a clean exit, reusing the + * `whoami` error taxonomy (forbidden / http_error / graphql_error / + * network_error). + * + * The dashboard OAuth-bearer capability the account-plane commands rely on is + * feature-flag-gated server-side (staging today), so a 403 is the *expected* + * outcome wherever the flag is off. We surface that distinctly — and without + * ever naming GraphQL — so the failure is actionable rather than a generic 403. + * Never returns. + */ +export function reportDashboardError(error: unknown): never { + if (error instanceof DashboardGraphqlError) { + // Build a clean, user-facing message per code. We deliberately do NOT echo + // `error.message`: the underlying client phrases every failure in terms of + // "the dashboard GraphQL API", and GraphQL must never surface to users. + exitWithError({ code: error.code, message: dashboardErrorMessage(error) }); + } + throw error; +} + +function dashboardErrorMessage(error: DashboardGraphqlError): string { + switch (error.code) { + case 'forbidden': + return ( + 'This account-plane capability is not enabled for this API host. ' + + 'It must be turned on (currently staging only), with a token belonging to a WorkOS dashboard account that has a team.' + ); + case 'http_error': + return `The WorkOS account plane returned an unexpected response${error.status ? ` (HTTP ${error.status})` : ''}.`; + case 'network_error': + return 'Could not reach the WorkOS account plane. Check your connection and try again.'; + case 'graphql_error': + default: + return 'The WorkOS account plane could not complete this request.'; + } +} diff --git a/src/commands/environment.spec.ts b/src/commands/environment.spec.ts new file mode 100644 index 00000000..fc303404 --- /dev/null +++ b/src/commands/environment.spec.ts @@ -0,0 +1,105 @@ +import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'; + +const mockGetAccessToken = vi.fn(); +const mockGraphqlRequest = vi.fn(); + +vi.mock('../lib/credentials.js', () => ({ + getAccessToken: () => mockGetAccessToken(), +})); + +vi.mock('../lib/dashboard-graphql.js', async (importActual) => { + const actual = await importActual(); + return { + ...actual, + dashboardGraphqlRequest: (...args: unknown[]) => mockGraphqlRequest(...args), + }; +}); + +const { setOutputMode } = await import('../utils/output.js'); +const { DashboardGraphqlError } = await import('../lib/dashboard-graphql.js'); +const { CliExit } = await import('../utils/cli-exit.js'); +const { runEnvironmentCreate, runEnvironmentRename } = await import('./environment.js'); + +describe('environment command', () => { + let consoleOutput: string[]; + + beforeEach(() => { + vi.clearAllMocks(); + mockGetAccessToken.mockReturnValue('tok_123'); + consoleOutput = []; + vi.spyOn(console, 'log').mockImplementation((...args: unknown[]) => { + consoleOutput.push(args.map(String).join(' ')); + }); + vi.spyOn(console, 'error').mockImplementation(() => {}); + }); + + afterEach(() => { + vi.restoreAllMocks(); + setOutputMode('human'); + }); + + describe('create', () => { + it('maps name + sandbox to the createEnvironment input', async () => { + mockGraphqlRequest.mockResolvedValue({ createEnvironment: { environment: { id: 'env_1', name: 'Staging', sandbox: true } } }); + await runEnvironmentCreate({ name: 'Staging', sandbox: true }); + expect(mockGraphqlRequest).toHaveBeenCalledWith(expect.stringContaining('createEnvironment'), { + token: 'tok_123', + variables: { input: { name: 'Staging', isSandbox: true } }, + }); + expect(consoleOutput.join('\n')).toContain('Staging'); + }); + + it('exits auth-required (code 4) when not logged in', async () => { + mockGetAccessToken.mockReturnValue(null); + await expect(runEnvironmentCreate({ name: 'Staging', sandbox: false })).rejects.toMatchObject({ + name: 'CliExit', + exitCode: 4, + }); + expect(mockGraphqlRequest).not.toHaveBeenCalled(); + }); + + it('surfaces the gated-capability case on a 403 without naming GraphQL', async () => { + const consoleErrors: string[] = []; + vi.spyOn(console, 'error').mockImplementation((...args: unknown[]) => { + consoleErrors.push(args.map(String).join(' ')); + }); + mockGraphqlRequest.mockRejectedValue( + new DashboardGraphqlError('The dashboard GraphQL API rejected this session (HTTP 403).', 'forbidden', 403), + ); + await expect(runEnvironmentCreate({ name: 'Staging', sandbox: false })).rejects.toBeInstanceOf(CliExit); + const err = consoleErrors.join('\n'); + // The cleaned message must explain the gated capability but never echo the + // client's "GraphQL" phrasing. + expect(err).toMatch(/account-plane capability/i); + expect(err).not.toMatch(/graphql/i); + }); + + it('outputs JSON in json mode', async () => { + setOutputMode('json'); + mockGraphqlRequest.mockResolvedValue({ createEnvironment: { environment: { id: 'env_1', name: 'Prod', sandbox: false } } }); + await runEnvironmentCreate({ name: 'Prod', sandbox: false }); + const out = JSON.parse(consoleOutput[0]); + expect(out.environment.id).toBe('env_1'); + }); + }); + + describe('rename', () => { + it('maps environmentId + name to the renameEnvironment input', async () => { + mockGraphqlRequest.mockResolvedValue({ renameEnvironment: { environment: { id: 'env_1', name: 'Renamed' } } }); + await runEnvironmentRename({ environmentId: 'env_1', name: 'Renamed' }); + expect(mockGraphqlRequest).toHaveBeenCalledWith(expect.stringContaining('renameEnvironment'), { + token: 'tok_123', + variables: { input: { environmentId: 'env_1', name: 'Renamed' } }, + }); + expect(consoleOutput.join('\n')).toContain('Renamed'); + }); + + it('outputs JSON in json mode', async () => { + setOutputMode('json'); + mockGraphqlRequest.mockResolvedValue({ renameEnvironment: { environment: { id: 'env_1', name: 'Renamed' } } }); + await runEnvironmentRename({ environmentId: 'env_1', name: 'Renamed' }); + const out = JSON.parse(consoleOutput[0]); + expect(out.environment.name).toBe('Renamed'); + }); + }); +}); diff --git a/src/commands/environment.ts b/src/commands/environment.ts new file mode 100644 index 00000000..aaa26260 --- /dev/null +++ b/src/commands/environment.ts @@ -0,0 +1,91 @@ +/** + * `workos environment` — account-plane environment lifecycle. + * + * These operate on *WorkOS environments* (the server-side environments that hang + * off a project/team), via the dashboard account plane with the user's OAuth + * bearer — distinct from `workos env`, which manages *local* CLI environment + * profiles (API keys / endpoints stored on this machine). See the Phase 3 + * naming decision in the spec. + * + * The underlying capability is the same OAuth-bearer dashboard access `whoami` + * uses, which is feature-flag-gated server-side (staging today); a flag-off or + * non-team token fails cleanly via the shared error taxonomy. + */ + +import chalk from 'chalk'; +import { getAccessToken } from '../lib/credentials.js'; +import { dashboardGraphqlRequest } from '../lib/dashboard-graphql.js'; +import { getOperation, resolveExecutableDocument, reportDashboardError } from '../catalog/operation.js'; +import { isJsonMode, outputJson, outputSuccess } from '../utils/output.js'; +import { exitWithAuthRequired } from '../utils/exit-codes.js'; +import { formatWorkOSCommand } from '../utils/command-invocation.js'; + +interface EnvironmentNode { + id: string; + name: string | null; + sandbox?: boolean | null; +} + +function requireToken(): string { + const token = getAccessToken(); + if (!token) { + exitWithAuthRequired(`Not logged in. Run \`${formatWorkOSCommand('auth login')}\` to authenticate.`); + } + return token; +} + +export interface EnvironmentCreateOptions { + name: string; + sandbox: boolean; +} + +export async function runEnvironmentCreate(options: EnvironmentCreateOptions): Promise { + const token = requireToken(); + const op = getOperation('createEnvironment'); + + let data: { createEnvironment: { environment: EnvironmentNode } }; + try { + data = await dashboardGraphqlRequest(resolveExecutableDocument(op), { + token, + variables: { input: { name: options.name, isSandbox: options.sandbox } }, + }); + } catch (error) { + reportDashboardError(error); + } + + const env = data.createEnvironment.environment; + if (isJsonMode()) { + outputJson({ environment: env }); + return; + } + outputSuccess(`Created environment ${chalk.bold(env.name ?? env.id)}${env.sandbox ? chalk.dim(' (sandbox)') : ''}`); + console.log(chalk.dim(` env id: ${env.id}`)); +} + +export interface EnvironmentRenameOptions { + environmentId: string; + name: string; +} + +export async function runEnvironmentRename(options: EnvironmentRenameOptions): Promise { + const token = requireToken(); + const op = getOperation('renameEnvironment'); + + let data: { renameEnvironment: { environment: EnvironmentNode } }; + try { + data = await dashboardGraphqlRequest(resolveExecutableDocument(op), { + token, + variables: { input: { environmentId: options.environmentId, name: options.name } }, + }); + } catch (error) { + reportDashboardError(error); + } + + const env = data.renameEnvironment.environment; + if (isJsonMode()) { + outputJson({ environment: env }); + return; + } + outputSuccess(`Renamed environment to ${chalk.bold(env.name ?? env.id)}`); + console.log(chalk.dim(` env id: ${env.id}`)); +} diff --git a/src/commands/project.spec.ts b/src/commands/project.spec.ts new file mode 100644 index 00000000..fe1e9a35 --- /dev/null +++ b/src/commands/project.spec.ts @@ -0,0 +1,161 @@ +import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'; + +const mockGetAccessToken = vi.fn(); +const mockGraphqlRequest = vi.fn(); + +vi.mock('../lib/credentials.js', () => ({ + getAccessToken: () => mockGetAccessToken(), +})); + +vi.mock('../lib/dashboard-graphql.js', async (importActual) => { + const actual = await importActual(); + return { + ...actual, + dashboardGraphqlRequest: (...args: unknown[]) => mockGraphqlRequest(...args), + }; +}); + +const { setOutputMode } = await import('../utils/output.js'); +const { resetInteractionModeForTests, setInteractionMode } = await import('../utils/interaction-mode.js'); +const { CliExit } = await import('../utils/cli-exit.js'); +const { loadManagementCatalog } = await import('../catalog/loader.js'); +const { resolveCommandMeta } = await import('../catalog/curation.js'); +const { runProjectCreate, runProjectRename, runProjectList } = await import('./project.js'); + +async function expectExit(promise: Promise, code: number): Promise { + try { + await promise; + } catch (err) { + if (err instanceof CliExit) { + expect(err.exitCode).toBe(code); + return err; + } + throw err; + } + throw new Error(`Expected CliExit(${code}) but promise resolved`); +} + +describe('project command', () => { + let consoleOutput: string[]; + + beforeEach(() => { + vi.clearAllMocks(); + resetInteractionModeForTests(); + setOutputMode('human'); + mockGetAccessToken.mockReturnValue('tok_123'); + consoleOutput = []; + vi.spyOn(console, 'log').mockImplementation((...args: unknown[]) => { + consoleOutput.push(args.map(String).join(' ')); + }); + vi.spyOn(console, 'error').mockImplementation(() => {}); + }); + + afterEach(() => { + vi.restoreAllMocks(); + resetInteractionModeForTests(); + setOutputMode('human'); + }); + + describe('create (require-flag)', () => { + it('refuses non-interactive without --yes (exit 1, confirmation_required)', async () => { + setInteractionMode({ mode: 'ci', source: 'ci_env' }); + const err = await expectExit(runProjectCreate({ name: 'Acme', production: true, yes: false }), 1); + expect(err.context?.errorCode).toBe('confirmation_required'); + expect(mockGraphqlRequest).not.toHaveBeenCalled(); + }); + + it('proceeds non-interactive with --yes', async () => { + setInteractionMode({ mode: 'ci', source: 'ci_env' }); + mockGraphqlRequest.mockResolvedValue({ + createProjectWithNewEnvironments: { __typename: 'ProjectCreated', project: { id: 'proj_1', name: 'Acme' } }, + }); + await runProjectCreate({ name: 'Acme', production: true, yes: true }); + expect(mockGraphqlRequest).toHaveBeenCalledWith(expect.stringContaining('createProjectWithNewEnvironments'), { + token: 'tok_123', + variables: { input: { name: 'Acme', includeProductionEnvironment: true } }, + }); + }); + + it('proceeds interactively without --yes (human is trusted)', async () => { + setInteractionMode({ mode: 'human', source: 'default' }); + mockGraphqlRequest.mockResolvedValue({ + createProjectWithNewEnvironments: { __typename: 'ProjectCreated', project: { id: 'proj_1', name: 'Acme' } }, + }); + await runProjectCreate({ name: 'Acme', production: false, yes: false }); + expect(mockGraphqlRequest).toHaveBeenCalledWith(expect.anything(), { + token: 'tok_123', + variables: { input: { name: 'Acme', includeProductionEnvironment: false } }, + }); + }); + + it('errors when the project name already exists', async () => { + setInteractionMode({ mode: 'human', source: 'default' }); + mockGraphqlRequest.mockResolvedValue({ + createProjectWithNewEnvironments: { __typename: 'ProjectNameAlreadyUsed', name: 'Acme' }, + }); + const err = await expectExit(runProjectCreate({ name: 'Acme', production: true, yes: false }), 1); + expect(err.context?.errorCode).toBe('name_already_used'); + }); + }); + + describe('rename', () => { + it('maps projectId + name to the input', async () => { + mockGraphqlRequest.mockResolvedValue({ + renameProject: { __typename: 'ProjectRenamed', project: { id: 'proj_1', name: 'New' } }, + }); + await runProjectRename({ projectId: 'proj_1', name: 'New' }); + expect(mockGraphqlRequest).toHaveBeenCalledWith(expect.stringContaining('renameProject'), { + token: 'tok_123', + variables: { input: { projectId: 'proj_1', name: 'New' } }, + }); + }); + + it('errors on ProjectNotFound', async () => { + mockGraphqlRequest.mockResolvedValue({ + renameProject: { __typename: 'ProjectNotFound', projectId: 'proj_x' }, + }); + const err = await expectExit(runProjectRename({ projectId: 'proj_x', name: 'New' }), 1); + expect(err.context?.errorCode).toBe('not_found'); + }); + }); + + describe('list', () => { + it('renders projects in human mode', async () => { + mockGraphqlRequest.mockResolvedValue({ + currentTeam: { id: 'team_1', projectsV2: [{ id: 'proj_1', name: 'Acme', environments: [{ id: 'env_1' }] }] }, + }); + await runProjectList(); + const out = consoleOutput.join('\n'); + expect(out).toContain('proj_1'); + expect(out).toContain('Acme'); + }); + + it('outputs JSON in json mode', async () => { + setOutputMode('json'); + mockGraphqlRequest.mockResolvedValue({ + currentTeam: { id: 'team_1', projectsV2: [{ id: 'proj_1', name: 'Acme', environments: [] }] }, + }); + await runProjectList(); + const out = JSON.parse(consoleOutput[0]); + expect(out.projects[0].id).toBe('proj_1'); + }); + + it('handles an empty team', async () => { + mockGraphqlRequest.mockResolvedValue({ currentTeam: null }); + await runProjectList(); + expect(consoleOutput.join('\n')).toContain('No projects found.'); + }); + + it('uses the curated (non-rotten) description, not the catalog rot string', () => { + // teamProjectsV2's catalog description is wrong upstream ("Return the team + // for the current dashboard session"). The curation override must win. + const catalog = loadManagementCatalog(undefined, { includeFeatureFlagged: true }); + const op = catalog.operations.find((o) => o.name === 'teamProjectsV2')!; + expect(op.description).toMatch(/return the team for the current dashboard session/i); + const meta = resolveCommandMeta(op); + expect(meta.command).toBe('project list'); + expect(meta.describe).toBe('List projects in the current team'); + expect(meta.describe).not.toMatch(/return the team for the current dashboard session/i); + }); + }); +}); diff --git a/src/commands/project.ts b/src/commands/project.ts new file mode 100644 index 00000000..8d995c97 --- /dev/null +++ b/src/commands/project.ts @@ -0,0 +1,160 @@ +/** + * `workos project` — account-plane project lifecycle. + * + * Projects group a team's environments. These commands run against the dashboard + * account plane with the user's OAuth bearer (the same gated capability `whoami` + * uses). `project create` provisions a project plus fresh environments, so it is + * a `require-flag` operation: a non-interactive caller must pass `--yes` (the + * "don't let a CI loop spawn many projects" guard). + */ + +import chalk from 'chalk'; +import { getAccessToken } from '../lib/credentials.js'; +import { dashboardGraphqlRequest } from '../lib/dashboard-graphql.js'; +import { getOperation, resolveExecutableDocument, reportDashboardError } from '../catalog/operation.js'; +import { requireConfirmationFlag } from '../catalog/confirm.js'; +import { isJsonMode, outputJson, outputSuccess, exitWithError } from '../utils/output.js'; +import { exitWithAuthRequired } from '../utils/exit-codes.js'; +import { formatTable } from '../utils/table.js'; +import { formatWorkOSCommand } from '../utils/command-invocation.js'; + +interface ProjectNode { + id: string; + name: string | null; +} + +function requireToken(): string { + const token = getAccessToken(); + if (!token) { + exitWithAuthRequired(`Not logged in. Run \`${formatWorkOSCommand('auth login')}\` to authenticate.`); + } + return token; +} + +export interface ProjectCreateOptions { + name: string; + /** When false, only a staging environment is provisioned (no production env). */ + production: boolean; + yes?: boolean; + json?: boolean; +} + +export async function runProjectCreate(options: ProjectCreateOptions): Promise { + // require-flag: non-interactive callers must pass --yes before provisioning. + await requireConfirmationFlag(options, { action: `create project "${options.name}"` }); + + const token = requireToken(); + const op = getOperation('createProjectWithNewEnvironments'); + + let data: { + createProjectWithNewEnvironments: + | { __typename: 'ProjectCreated'; project: ProjectNode } + | { __typename: 'ProjectNameAlreadyUsed'; name: string } + | { __typename: string }; + }; + try { + data = await dashboardGraphqlRequest(resolveExecutableDocument(op), { + token, + variables: { input: { name: options.name, includeProductionEnvironment: options.production } }, + }); + } catch (error) { + reportDashboardError(error); + } + + const result = data.createProjectWithNewEnvironments; + if (result.__typename === 'ProjectNameAlreadyUsed') { + exitWithError({ code: 'name_already_used', message: `A project named "${options.name}" already exists in this team.` }); + } + if (result.__typename !== 'ProjectCreated' || !('project' in result)) { + exitWithError({ code: 'unexpected_result', message: `Could not create project "${options.name}".` }); + } + + const project = (result as { project: ProjectNode }).project; + if (isJsonMode()) { + outputJson({ project }); + return; + } + outputSuccess(`Created project ${chalk.bold(project.name ?? project.id)}`); + console.log(chalk.dim(` project id: ${project.id}`)); +} + +export interface ProjectRenameOptions { + projectId: string; + name: string; +} + +export async function runProjectRename(options: ProjectRenameOptions): Promise { + const token = requireToken(); + const op = getOperation('renameProject'); + + let data: { + renameProject: + | { __typename: 'ProjectRenamed'; project: ProjectNode } + | { __typename: 'ProjectNameAlreadyUsed'; name: string } + | { __typename: 'ProjectNotFound'; projectId: string } + | { __typename: string }; + }; + try { + data = await dashboardGraphqlRequest(resolveExecutableDocument(op), { + token, + variables: { input: { projectId: options.projectId, name: options.name } }, + }); + } catch (error) { + reportDashboardError(error); + } + + const result = data.renameProject; + if (result.__typename === 'ProjectNameAlreadyUsed') { + exitWithError({ code: 'name_already_used', message: `A project named "${options.name}" already exists in this team.` }); + } + if (result.__typename === 'ProjectNotFound') { + exitWithError({ code: 'not_found', message: `Project "${options.projectId}" was not found.` }); + } + if (result.__typename !== 'ProjectRenamed' || !('project' in result)) { + exitWithError({ code: 'unexpected_result', message: `Could not rename project "${options.projectId}".` }); + } + + const project = (result as { project: ProjectNode }).project; + if (isJsonMode()) { + outputJson({ project }); + return; + } + outputSuccess(`Renamed project to ${chalk.bold(project.name ?? project.id)}`); + console.log(chalk.dim(` project id: ${project.id}`)); +} + +interface ProjectListNode { + id: string; + name: string | null; + environments: Array<{ id: string; name: string | null; sandbox?: boolean | null }>; +} + +export async function runProjectList(): Promise { + const token = requireToken(); + const op = getOperation('teamProjectsV2'); + + let data: { currentTeam: { id: string; projectsV2: ProjectListNode[] } | null }; + try { + data = await dashboardGraphqlRequest(resolveExecutableDocument(op), { token }); + } catch (error) { + reportDashboardError(error); + } + + const projects = data.currentTeam?.projectsV2 ?? []; + if (isJsonMode()) { + outputJson({ projects }); + return; + } + + if (projects.length === 0) { + console.log('No projects found.'); + return; + } + + const rows = projects.map((project) => [ + project.id, + project.name ?? chalk.dim('(unnamed)'), + String(project.environments?.length ?? 0), + ]); + console.log(formatTable([{ header: 'ID' }, { header: 'Name' }, { header: 'Environments' }], rows)); +} diff --git a/src/commands/team.spec.ts b/src/commands/team.spec.ts new file mode 100644 index 00000000..209961c9 --- /dev/null +++ b/src/commands/team.spec.ts @@ -0,0 +1,239 @@ +import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'; + +const mockGetAccessToken = vi.fn(); +const mockGraphqlRequest = vi.fn(); +const mockConfirm = vi.fn(); +const mockIsCancel = vi.fn(() => false); + +vi.mock('../lib/credentials.js', () => ({ + getAccessToken: () => mockGetAccessToken(), +})); + +vi.mock('../lib/dashboard-graphql.js', async (importActual) => { + const actual = await importActual(); + return { + ...actual, + dashboardGraphqlRequest: (...args: unknown[]) => mockGraphqlRequest(...args), + }; +}); + +vi.mock('../utils/clack.js', () => ({ + default: { + confirm: (...args: unknown[]) => mockConfirm(...args), + isCancel: (...args: unknown[]) => mockIsCancel(...args), + }, +})); + +const { setOutputMode } = await import('../utils/output.js'); +const { resetInteractionModeForTests, setInteractionMode } = await import('../utils/interaction-mode.js'); +const { CliExit } = await import('../utils/cli-exit.js'); +const { + runTeamMembers, + runTeamInvite, + runTeamChangeRole, + runTeamRemove, + runTeamResendInvite, + runTeamUpdate, + runTeamSetMfa, +} = await import('./team.js'); + +async function expectExit(promise: Promise, code: number): Promise { + try { + await promise; + } catch (err) { + if (err instanceof CliExit) { + expect(err.exitCode).toBe(code); + return err; + } + throw err; + } + throw new Error(`Expected CliExit(${code}) but promise resolved`); +} + +describe('team command', () => { + let consoleOutput: string[]; + + beforeEach(() => { + vi.clearAllMocks(); + resetInteractionModeForTests(); + setOutputMode('human'); + mockGetAccessToken.mockReturnValue('tok_123'); + mockConfirm.mockReset(); + mockIsCancel.mockReset(); + mockIsCancel.mockReturnValue(false); + consoleOutput = []; + vi.spyOn(console, 'log').mockImplementation((...args: unknown[]) => { + consoleOutput.push(args.map(String).join(' ')); + }); + vi.spyOn(console, 'error').mockImplementation(() => {}); + }); + + afterEach(() => { + vi.restoreAllMocks(); + resetInteractionModeForTests(); + setOutputMode('human'); + }); + + describe('members', () => { + it('lists members in human mode', async () => { + mockGraphqlRequest.mockResolvedValue({ + currentTeam: { + memberships: [ + { id: 'uo_1', role: 'ADMIN', state: 'active', user: { id: 'u_1', name: 'Nick', email: 'nick@workos.com' } }, + ], + }, + }); + await runTeamMembers(); + const out = consoleOutput.join('\n'); + expect(out).toContain('nick@workos.com'); + expect(out).toContain('ADMIN'); + }); + + it('outputs JSON in json mode', async () => { + setOutputMode('json'); + mockGraphqlRequest.mockResolvedValue({ currentTeam: { memberships: [{ id: 'uo_1', role: 'ADMIN', state: 'active', user: null }] } }); + await runTeamMembers(); + const out = JSON.parse(consoleOutput[0]); + expect(out.members[0].id).toBe('uo_1'); + }); + }); + + describe('invite', () => { + it('maps email + role (uppercased) into the nested user input', async () => { + mockGraphqlRequest.mockResolvedValue({ + inviteUserToTeam: { + __typename: 'UserInvitedToTeam', + invitedMember: { id: 'uo_2', role: 'MEMBER', state: 'invited', user: { id: 'u_2', email: 'a@b.com', name: null } }, + }, + }); + await runTeamInvite({ email: 'a@b.com', role: 'member' }); + expect(mockGraphqlRequest).toHaveBeenCalledWith(expect.stringContaining('inviteUserToTeam'), { + token: 'tok_123', + variables: { input: { user: { email: 'a@b.com', role: 'MEMBER' } } }, + }); + }); + + it('rejects an invalid role with a usage error', async () => { + const err = await expectExit(runTeamInvite({ email: 'a@b.com', role: 'wizard' }), 1); + expect(err.context?.errorCode).toBe('invalid_role'); + expect(mockGraphqlRequest).not.toHaveBeenCalled(); + }); + + it('errors when the user already belongs to the team', async () => { + mockGraphqlRequest.mockResolvedValue({ + inviteUserToTeam: { __typename: 'UserAlreadyBelongsToCurrentTeam', email: 'a@b.com' }, + }); + const err = await expectExit(runTeamInvite({ email: 'a@b.com', role: 'MEMBER' }), 1); + expect(err.context?.errorCode).toBe('already_member'); + }); + }); + + describe('change-role (require-flag)', () => { + it('refuses non-interactive without --yes (exit 1, confirmation_required)', async () => { + setInteractionMode({ mode: 'agent', source: 'agent_env' }); + const err = await expectExit(runTeamChangeRole({ membershipId: 'uo_1', role: 'ADMIN', yes: false }), 1); + expect(err.context?.errorCode).toBe('confirmation_required'); + expect(mockGraphqlRequest).not.toHaveBeenCalled(); + }); + + it('proceeds non-interactive with --yes', async () => { + setInteractionMode({ mode: 'agent', source: 'agent_env' }); + mockGraphqlRequest.mockResolvedValue({ changeRole: { id: 'uo_1', role: 'ADMIN' } }); + await runTeamChangeRole({ membershipId: 'uo_1', role: 'admin', yes: true }); + expect(mockGraphqlRequest).toHaveBeenCalledWith(expect.stringContaining('changeRole'), { + token: 'tok_123', + variables: { usersOrganizationsId: 'uo_1', role: 'ADMIN' }, + }); + }); + }); + + describe('remove (destructive)', () => { + it('refuses non-interactive without --yes (exit 1, confirmation_required)', async () => { + setInteractionMode({ mode: 'ci', source: 'ci_env' }); + const err = await expectExit(runTeamRemove({ membershipId: 'uo_1', yes: false }), 1); + expect(err.context?.errorCode).toBe('confirmation_required'); + expect(mockGraphqlRequest).not.toHaveBeenCalled(); + }); + + it('proceeds non-interactive with --yes', async () => { + setInteractionMode({ mode: 'ci', source: 'ci_env' }); + mockGraphqlRequest.mockResolvedValue({ removeUserFromTeam: 'uo_1' }); + await runTeamRemove({ membershipId: 'uo_1', yes: true }); + expect(mockGraphqlRequest).toHaveBeenCalledWith(expect.stringContaining('removeUserFromTeam'), { + token: 'tok_123', + variables: { usersOrganizationsId: 'uo_1' }, + }); + }); + + it('prompts interactively and proceeds when confirmed', async () => { + setInteractionMode({ mode: 'human', source: 'default' }); + mockConfirm.mockResolvedValue(true); + mockGraphqlRequest.mockResolvedValue({ removeUserFromTeam: 'uo_1' }); + await runTeamRemove({ membershipId: 'uo_1', yes: false }); + expect(mockConfirm).toHaveBeenCalledOnce(); + expect(mockGraphqlRequest).toHaveBeenCalled(); + }); + + it('cancels (exit 2) when the interactive prompt is declined', async () => { + setInteractionMode({ mode: 'human', source: 'default' }); + mockConfirm.mockResolvedValue(false); + await expectExit(runTeamRemove({ membershipId: 'uo_1', yes: false }), 2); + expect(mockGraphqlRequest).not.toHaveBeenCalled(); + }); + }); + + describe('resend-invite', () => { + it('proceeds on a resendable invite', async () => { + mockGraphqlRequest.mockResolvedValue({ resendDashboardInvite: { __typename: 'DashboardInviteResent', resentInvitation: true } }); + await runTeamResendInvite({ membershipId: 'uo_1' }); + expect(consoleOutput.join('\n')).toContain('uo_1'); + }); + + it('errors when the invite has not expired', async () => { + mockGraphqlRequest.mockResolvedValue({ resendDashboardInvite: { __typename: 'DashboardInviteNotExpired' } }); + const err = await expectExit(runTeamResendInvite({ membershipId: 'uo_1' }), 1); + expect(err.context?.errorCode).toBe('invite_not_expired'); + }); + }); + + describe('update', () => { + it('maps name to the input and renders the new name', async () => { + mockGraphqlRequest.mockResolvedValue({ + updateTeamDetails: { __typename: 'TeamDetailsUpdated', team: { id: 'team_1', name: 'New Co' } }, + }); + await runTeamUpdate({ name: 'New Co' }); + expect(mockGraphqlRequest).toHaveBeenCalledWith(expect.stringContaining('updateTeamDetails'), { + token: 'tok_123', + variables: { input: { name: 'New Co' } }, + }); + expect(consoleOutput.join('\n')).toContain('New Co'); + }); + + it('errors on InvalidTeamName', async () => { + mockGraphqlRequest.mockResolvedValue({ updateTeamDetails: { __typename: 'InvalidTeamName', team: { id: 'team_1' } } }); + const err = await expectExit(runTeamUpdate({ name: '' }), 1); + expect(err.context?.errorCode).toBe('invalid_team_name'); + }); + }); + + describe('set-mfa (require-flag)', () => { + it('refuses non-interactive without --yes', async () => { + setInteractionMode({ mode: 'ci', source: 'ci_env' }); + const err = await expectExit(runTeamSetMfa({ required: true, yes: false }), 1); + expect(err.context?.errorCode).toBe('confirmation_required'); + expect(mockGraphqlRequest).not.toHaveBeenCalled(); + }); + + it('proceeds non-interactive with --yes and maps requireMfa', async () => { + setInteractionMode({ mode: 'ci', source: 'ci_env' }); + mockGraphqlRequest.mockResolvedValue({ + updateTeamMfaRequirement: { __typename: 'TeamMfaRequirementUpdated', team: { id: 'team_1', isMfaRequired: true } }, + }); + await runTeamSetMfa({ required: true, yes: true }); + expect(mockGraphqlRequest).toHaveBeenCalledWith(expect.stringContaining('updateTeamMfaRequirement'), { + token: 'tok_123', + variables: { input: { requireMfa: true } }, + }); + }); + }); +}); diff --git a/src/commands/team.ts b/src/commands/team.ts new file mode 100644 index 00000000..df626416 --- /dev/null +++ b/src/commands/team.ts @@ -0,0 +1,316 @@ +/** + * `workos team` — account-plane dashboard-team lifecycle. + * + * These manage the WorkOS *dashboard team* (the account's members, invites, and + * team-wide settings), via the dashboard account plane with the user's OAuth + * bearer — the same gated capability `whoami` uses. Safety posture per the + * manifest: + * - `team remove` is destructive → `confirmDestructive` (prompt, or --yes). + * - `team change-role` / `team set-mfa` are `require-flag` → non-interactive + * callers must pass --yes (privilege / security-posture changes). + */ + +import chalk from 'chalk'; +import { getAccessToken } from '../lib/credentials.js'; +import { dashboardGraphqlRequest } from '../lib/dashboard-graphql.js'; +import { getOperation, resolveExecutableDocument, reportDashboardError } from '../catalog/operation.js'; +import { confirmDestructive, requireConfirmationFlag } from '../catalog/confirm.js'; +import { isJsonMode, outputJson, outputSuccess, exitWithError } from '../utils/output.js'; +import { exitWithAuthRequired } from '../utils/exit-codes.js'; +import { formatTable } from '../utils/table.js'; +import { formatWorkOSCommand } from '../utils/command-invocation.js'; + +/** Dashboard team roles, mirroring the catalog `UsersOrganizationsRole` enum. */ +export const TEAM_ROLES = ['ADMIN', 'MEMBER', 'MEMBER_SANDBOX', 'SUPPORT', 'SUPPORT_VIEWER'] as const; +export type TeamRole = (typeof TEAM_ROLES)[number]; + +function requireToken(): string { + const token = getAccessToken(); + if (!token) { + exitWithAuthRequired(`Not logged in. Run \`${formatWorkOSCommand('auth login')}\` to authenticate.`); + } + return token; +} + +function normalizeRole(role: string): TeamRole { + const upper = role.toUpperCase(); + if (!(TEAM_ROLES as readonly string[]).includes(upper)) { + exitWithError({ + code: 'invalid_role', + message: `Invalid role "${role}". Allowed roles: ${TEAM_ROLES.join(', ')}.`, + }); + } + return upper as TeamRole; +} + +interface MembershipNode { + id: string; + role: string | null; + state: string | null; + isInvitationExpired?: boolean | null; + user: { id: string; name: string | null; email: string | null } | null; +} + +export async function runTeamMembers(): Promise { + const token = requireToken(); + const op = getOperation('teamMemberships'); + + let data: { currentTeam: { memberships: MembershipNode[] } | null }; + try { + data = await dashboardGraphqlRequest(resolveExecutableDocument(op), { token }); + } catch (error) { + reportDashboardError(error); + } + + const memberships = data.currentTeam?.memberships ?? []; + if (isJsonMode()) { + outputJson({ members: memberships }); + return; + } + + if (memberships.length === 0) { + console.log('No team members found.'); + return; + } + + const rows = memberships.map((m) => [ + m.id, + m.user?.email ?? chalk.dim('(no email)'), + m.user?.name ?? chalk.dim('—'), + m.role ?? chalk.dim('—'), + m.state ?? chalk.dim('—'), + ]); + console.log( + formatTable( + [{ header: 'Membership ID' }, { header: 'Email' }, { header: 'Name' }, { header: 'Role' }, { header: 'State' }], + rows, + ), + ); +} + +export interface TeamInviteOptions { + email: string; + role: string; + firstName?: string; + lastName?: string; +} + +export async function runTeamInvite(options: TeamInviteOptions): Promise { + const role = normalizeRole(options.role); + const token = requireToken(); + const op = getOperation('inviteUserToTeam'); + + let data: { + inviteUserToTeam: + | { __typename: 'UserInvitedToTeam'; invitedMember: MembershipNode } + | { __typename: 'UserAlreadyBelongsToCurrentTeam'; email: string } + | { __typename: 'UserAlreadyBelongsToAnotherTeam'; email: string } + | { __typename: string }; + }; + try { + data = await dashboardGraphqlRequest(resolveExecutableDocument(op), { + token, + variables: { + input: { + user: { + email: options.email, + role, + ...(options.firstName ? { firstName: options.firstName } : {}), + ...(options.lastName ? { lastName: options.lastName } : {}), + }, + }, + }, + }); + } catch (error) { + reportDashboardError(error); + } + + const result = data.inviteUserToTeam; + if (result.__typename === 'UserAlreadyBelongsToCurrentTeam') { + exitWithError({ code: 'already_member', message: `${options.email} already belongs to this team.` }); + } + if (result.__typename === 'UserAlreadyBelongsToAnotherTeam') { + exitWithError({ code: 'belongs_to_another_team', message: `${options.email} already belongs to another team.` }); + } + if (result.__typename !== 'UserInvitedToTeam' || !('invitedMember' in result)) { + exitWithError({ code: 'unexpected_result', message: `Could not invite ${options.email}.` }); + } + + const member = (result as { invitedMember: MembershipNode }).invitedMember; + if (isJsonMode()) { + outputJson({ member }); + return; + } + outputSuccess(`Invited ${chalk.bold(options.email)} as ${member.role ?? role}`); + console.log(chalk.dim(` membership id: ${member.id}`)); +} + +export interface TeamChangeRoleOptions { + membershipId: string; + role: string; + yes?: boolean; + json?: boolean; +} + +export async function runTeamChangeRole(options: TeamChangeRoleOptions): Promise { + const role = normalizeRole(options.role); + // require-flag: a privilege change; non-interactive callers must pass --yes. + await requireConfirmationFlag(options, { action: `change the role of ${options.membershipId} to ${role}` }); + + const token = requireToken(); + const op = getOperation('changeRole'); + + let data: { changeRole: { id: string; role: string | null } }; + try { + data = await dashboardGraphqlRequest(resolveExecutableDocument(op), { + token, + variables: { usersOrganizationsId: options.membershipId, role }, + }); + } catch (error) { + reportDashboardError(error); + } + + if (isJsonMode()) { + outputJson({ member: data.changeRole }); + return; + } + outputSuccess(`Changed role of ${chalk.bold(options.membershipId)} to ${data.changeRole.role ?? role}`); +} + +export interface TeamRemoveOptions { + membershipId: string; + yes?: boolean; + json?: boolean; +} + +export async function runTeamRemove(options: TeamRemoveOptions): Promise { + // Destructive: revokes the member's access. Prompt (or require --yes). + await confirmDestructive(options, { action: `remove member ${options.membershipId} from the team` }); + + const token = requireToken(); + const op = getOperation('removeUserFromTeam'); + + try { + await dashboardGraphqlRequest(resolveExecutableDocument(op), { + token, + variables: { usersOrganizationsId: options.membershipId }, + }); + } catch (error) { + reportDashboardError(error); + } + + if (isJsonMode()) { + outputJson({ removed: options.membershipId }); + return; + } + outputSuccess(`Removed member ${chalk.bold(options.membershipId)} from the team`); +} + +export interface TeamResendInviteOptions { + membershipId: string; +} + +export async function runTeamResendInvite(options: TeamResendInviteOptions): Promise { + const token = requireToken(); + const op = getOperation('resendDashboardInvite'); + + let data: { resendDashboardInvite: { __typename: string } }; + try { + data = await dashboardGraphqlRequest(resolveExecutableDocument(op), { + token, + variables: { input: { teamMembershipId: options.membershipId } }, + }); + } catch (error) { + reportDashboardError(error); + } + + const result = data.resendDashboardInvite; + if (result.__typename === 'DashboardInviteNotFound') { + exitWithError({ code: 'not_found', message: `No dashboard invite found for ${options.membershipId}.` }); + } + if (result.__typename === 'DashboardInviteNotExpired') { + exitWithError({ + code: 'invite_not_expired', + message: `The invite for ${options.membershipId} has not expired; nothing to resend.`, + }); + } + + if (isJsonMode()) { + outputJson({ resent: options.membershipId }); + return; + } + outputSuccess(`Resent invite for ${chalk.bold(options.membershipId)}`); +} + +export interface TeamUpdateOptions { + name: string; +} + +export async function runTeamUpdate(options: TeamUpdateOptions): Promise { + const token = requireToken(); + const op = getOperation('updateTeamDetails'); + + let data: { + updateTeamDetails: + | { __typename: 'TeamDetailsUpdated'; team: { id: string; name: string | null } } + | { __typename: 'InvalidTeamName'; team: { id: string } } + | { __typename: string }; + }; + try { + data = await dashboardGraphqlRequest(resolveExecutableDocument(op), { + token, + variables: { input: { name: options.name } }, + }); + } catch (error) { + reportDashboardError(error); + } + + const result = data.updateTeamDetails; + if (result.__typename === 'InvalidTeamName') { + exitWithError({ code: 'invalid_team_name', message: `"${options.name}" is not a valid team name.` }); + } + if (result.__typename !== 'TeamDetailsUpdated' || !('team' in result)) { + exitWithError({ code: 'unexpected_result', message: 'Could not update the team.' }); + } + + const team = (result as { team: { id: string; name: string | null } }).team; + if (isJsonMode()) { + outputJson({ team }); + return; + } + outputSuccess(`Renamed team to ${chalk.bold(team.name ?? team.id)}`); +} + +export interface TeamSetMfaOptions { + required: boolean; + yes?: boolean; + json?: boolean; +} + +export async function runTeamSetMfa(options: TeamSetMfaOptions): Promise { + // require-flag: a security-posture change; non-interactive callers must pass --yes. + await requireConfirmationFlag(options, { + action: `set MFA requirement to ${options.required ? 'required' : 'not required'}`, + }); + + const token = requireToken(); + const op = getOperation('updateTeamMfaRequirement'); + + let data: { + updateTeamMfaRequirement: { __typename: string; team?: { id: string; isMfaRequired?: boolean | null } }; + }; + try { + data = await dashboardGraphqlRequest(resolveExecutableDocument(op), { + token, + variables: { input: { requireMfa: options.required } }, + }); + } catch (error) { + reportDashboardError(error); + } + + if (isJsonMode()) { + outputJson({ team: data.updateTeamMfaRequirement.team ?? null, requireMfa: options.required }); + return; + } + outputSuccess(`MFA is now ${chalk.bold(options.required ? 'required' : 'not required')} for the team`); +} diff --git a/src/utils/help-json.ts b/src/utils/help-json.ts index 9516de0c..ec7186c2 100644 --- a/src/utils/help-json.ts +++ b/src/utils/help-json.ts @@ -117,6 +117,163 @@ const commands: CommandSchema[] = [ description: 'Show the authenticated user, team, and environment (dashboard session)', options: [insecureStorageOpt], }, + { + name: 'environment', + description: 'Manage WorkOS environments (create, rename) on the dashboard account plane', + options: [insecureStorageOpt], + commands: [ + { + name: 'create', + description: 'Create a sandbox or production environment', + positionals: [{ name: 'name', type: 'string', description: 'Environment name', required: true }], + options: [ + { + name: 'sandbox', + type: 'boolean', + description: 'Create a sandbox environment', + required: false, + default: false, + hidden: false, + }, + ], + }, + { + name: 'rename', + description: 'Rename an environment', + positionals: [ + { name: 'environmentId', type: 'string', description: 'Environment ID', required: true }, + { name: 'name', type: 'string', description: 'New environment name', required: true }, + ], + }, + ], + }, + { + name: 'project', + description: 'Manage WorkOS projects (create, rename, list) on the dashboard account plane', + options: [insecureStorageOpt], + commands: [ + { + name: 'create', + description: 'Create a project with fresh staging and production environments', + positionals: [{ name: 'name', type: 'string', description: 'Project name', required: true }], + options: [ + { + name: 'production', + type: 'boolean', + description: 'Provision a production environment (use --no-production for staging only)', + required: false, + default: true, + hidden: false, + }, + { + name: 'yes', + type: 'boolean', + description: 'Confirm in non-interactive mode', + required: false, + default: false, + alias: 'y', + hidden: false, + }, + ], + }, + { + name: 'rename', + description: 'Rename a project', + positionals: [ + { name: 'projectId', type: 'string', description: 'Project ID', required: true }, + { name: 'name', type: 'string', description: 'New project name', required: true }, + ], + }, + { + name: 'list', + description: 'List projects in the current team', + }, + ], + }, + { + name: 'team', + description: 'Manage the WorkOS dashboard team (members, invites, settings)', + options: [insecureStorageOpt], + commands: [ + { + name: 'members', + description: 'List members of the current team', + }, + { + name: 'invite', + description: 'Invite a user to the current team by email', + positionals: [{ name: 'email', type: 'string', description: 'Email address to invite', required: true }], + options: [ + { name: 'role', type: 'string', description: 'Role (ADMIN, MEMBER, ...)', required: true, hidden: false }, + { name: 'first-name', type: 'string', description: 'First name', required: false, hidden: false }, + { name: 'last-name', type: 'string', description: 'Last name', required: false, hidden: false }, + ], + }, + { + name: 'change-role', + description: "Change a team member's role", + positionals: [ + { name: 'membershipId', type: 'string', description: 'Team membership ID', required: true }, + { name: 'role', type: 'string', description: 'New role (ADMIN, MEMBER, ...)', required: true }, + ], + options: [ + { + name: 'yes', + type: 'boolean', + description: 'Confirm in non-interactive mode', + required: false, + default: false, + alias: 'y', + hidden: false, + }, + ], + }, + { + name: 'remove', + description: 'Remove a member from the current team', + positionals: [{ name: 'membershipId', type: 'string', description: 'Team membership ID', required: true }], + options: [ + { + name: 'yes', + type: 'boolean', + description: 'Skip the confirmation prompt', + required: false, + default: false, + alias: 'y', + hidden: false, + }, + ], + }, + { + name: 'resend-invite', + description: 'Resend an expired team invitation', + positionals: [{ name: 'membershipId', type: 'string', description: 'Team membership ID', required: true }], + }, + { + name: 'update', + description: 'Rename the current team', + positionals: [{ name: 'name', type: 'string', description: 'New team name', required: true }], + }, + { + name: 'set-mfa', + description: 'Set whether MFA is required for the team', + positionals: [ + { name: 'required', type: 'boolean', description: 'true to require MFA, false to relax', required: true }, + ], + options: [ + { + name: 'yes', + type: 'boolean', + description: 'Confirm in non-interactive mode', + required: false, + default: false, + alias: 'y', + hidden: false, + }, + ], + }, + ], + }, { name: 'telemetry', description: 'Manage telemetry collection (opt-out, opt-in, status)', From e06b3e6ed5b03c6cd346e8ebf4143249c1e76daa Mon Sep 17 00:00:00 2001 From: Nick Nisi Date: Thu, 25 Jun 2026 17:13:36 -0500 Subject: [PATCH 07/22] feat: add authkit app config commands (redirect URIs, CORS, logout URIs, 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. --- src/bin.ts | 142 +++++++++++++ src/catalog/curation.ts | 14 ++ src/catalog/manifest.ts | 83 ++++++++ src/commands/authkit.spec.ts | 268 ++++++++++++++++++++++++ src/commands/authkit.ts | 383 +++++++++++++++++++++++++++++++++++ src/utils/help-json.ts | 86 ++++++++ 6 files changed, 976 insertions(+) create mode 100644 src/commands/authkit.spec.ts create mode 100644 src/commands/authkit.ts diff --git a/src/bin.ts b/src/bin.ts index 8ef90d19..25370417 100644 --- a/src/bin.ts +++ b/src/bin.ts @@ -512,6 +512,148 @@ async function runCli(): Promise { ); return yargs.demandCommand(1, 'Please specify a project subcommand').strict(); }) + .command( + 'authkit', + 'Manage AuthKit app config (redirect URIs, CORS, logout URIs, branding) on the dashboard account plane', + (yargs) => { + yargs.options(insecureStorageOption); + + yargs.command('redirect-uris', 'Manage AuthKit redirect URIs', (yargs) => { + registerSubcommand( + yargs, + 'list', + 'List configured redirect URIs for an environment', + (y) => + y + .option('environment-id', { type: 'string', demandOption: true, describe: 'Environment ID' }) + .option('limit', { type: 'number', describe: 'Maximum number of URIs to return' }), + async (argv) => { + await applyInsecureStorage(argv.insecureStorage); + const { runAuthkitRedirectUrisList } = await import('./commands/authkit.js'); + await runAuthkitRedirectUrisList({ + environmentId: argv.environmentId as string, + limit: argv.limit as number | undefined, + }); + }, + ); + registerSubcommand( + yargs, + 'set', + 'Set the allowed redirect URIs for an environment (replaces the full list)', + (y) => + y + .option('environment-id', { type: 'string', demandOption: true, describe: 'Environment ID' }) + .option('uri', { type: 'string', array: true, demandOption: true, describe: 'Redirect URI (repeatable)' }) + .option('default', { type: 'string', describe: 'Which URI to mark as the default' }) + .option('dry-run', { type: 'boolean', default: false, describe: 'Validate without saving' }), + async (argv) => { + await applyInsecureStorage(argv.insecureStorage); + const { runAuthkitRedirectUrisSet } = await import('./commands/authkit.js'); + await runAuthkitRedirectUrisSet({ + environmentId: argv.environmentId as string, + uris: argv.uri as string[], + default: argv.default as string | undefined, + dryRun: Boolean(argv.dryRun), + }); + }, + ); + return yargs.demandCommand(1, 'Please specify a redirect-uris subcommand').strict(); + }); + + yargs.command('cors', 'Manage AuthKit CORS web origins', (yargs) => { + registerSubcommand( + yargs, + 'get', + 'Show the allowed web origins (CORS) for an environment', + (y) => y.option('environment-id', { type: 'string', demandOption: true, describe: 'Environment ID' }), + async (argv) => { + await applyInsecureStorage(argv.insecureStorage); + const { runAuthkitCorsGet } = await import('./commands/authkit.js'); + await runAuthkitCorsGet({ environmentId: argv.environmentId as string }); + }, + ); + registerSubcommand( + yargs, + 'set', + 'Set the allowed web origins (CORS) for an environment (replaces the full list)', + (y) => + y + .option('environment-id', { type: 'string', demandOption: true, describe: 'Environment ID' }) + .option('origin', { type: 'string', array: true, demandOption: true, describe: 'Web origin (repeatable)' }) + .option('dry-run', { type: 'boolean', default: false, describe: 'Validate without saving' }), + async (argv) => { + await applyInsecureStorage(argv.insecureStorage); + const { runAuthkitCorsSet } = await import('./commands/authkit.js'); + await runAuthkitCorsSet({ + environmentId: argv.environmentId as string, + origins: argv.origin as string[], + dryRun: Boolean(argv.dryRun), + }); + }, + ); + return yargs.demandCommand(1, 'Please specify a cors subcommand').strict(); + }); + + yargs.command('logout-uris', 'Manage AuthKit logout URIs', (yargs) => { + registerSubcommand( + yargs, + 'list', + 'List configured logout URIs for an environment', + (y) => + y + .option('environment-id', { type: 'string', demandOption: true, describe: 'Environment ID' }) + .option('limit', { type: 'number', describe: 'Maximum number of URIs to return' }), + async (argv) => { + await applyInsecureStorage(argv.insecureStorage); + const { runAuthkitLogoutUrisList } = await import('./commands/authkit.js'); + await runAuthkitLogoutUrisList({ + environmentId: argv.environmentId as string, + limit: argv.limit as number | undefined, + }); + }, + ); + registerSubcommand( + yargs, + 'set', + 'Set the allowed logout URIs for an environment (replaces the full list)', + (y) => + y + .option('environment-id', { type: 'string', demandOption: true, describe: 'Environment ID' }) + .option('uri', { type: 'string', array: true, demandOption: true, describe: 'Logout URI (repeatable)' }) + .option('default', { type: 'string', describe: 'Which URI to mark as the default' }) + .option('dry-run', { type: 'boolean', default: false, describe: 'Validate without saving' }), + async (argv) => { + await applyInsecureStorage(argv.insecureStorage); + const { runAuthkitLogoutUrisSet } = await import('./commands/authkit.js'); + await runAuthkitLogoutUrisSet({ + environmentId: argv.environmentId as string, + uris: argv.uri as string[], + default: argv.default as string | undefined, + dryRun: Boolean(argv.dryRun), + }); + }, + ); + return yargs.demandCommand(1, 'Please specify a logout-uris subcommand').strict(); + }); + + yargs.command('branding', 'Manage AuthKit branding', (yargs) => { + registerSubcommand( + yargs, + 'get', + 'Show AuthKit branding (logos, theme) for an environment', + (y) => y.option('environment-id', { type: 'string', demandOption: true, describe: 'Environment ID' }), + async (argv) => { + await applyInsecureStorage(argv.insecureStorage); + const { runAuthkitBrandingGet } = await import('./commands/authkit.js'); + await runAuthkitBrandingGet({ environmentId: argv.environmentId as string }); + }, + ); + return yargs.demandCommand(1, 'Please specify a branding subcommand').strict(); + }); + + return yargs.demandCommand(1, 'Please specify an authkit subcommand').strict(); + }, + ) .command('team', 'Manage the WorkOS dashboard team (members, invites, settings)', (yargs) => { yargs.options(insecureStorageOption); registerSubcommand( diff --git a/src/catalog/curation.ts b/src/catalog/curation.ts index 3c053ae5..17c883ae 100644 --- a/src/catalog/curation.ts +++ b/src/catalog/curation.ts @@ -69,6 +69,20 @@ export const OVERRIDES: Record = { createUserlandUserInvite: { command: 'user invite', describe: 'Invite a user by email' }, resendUserlandUserInvite: { command: 'user invite resend', describe: 'Resend a pending user invitation' }, revokeUserlandUserInvite: { command: 'user invite revoke', describe: 'Revoke a pending user invitation' }, + + // --- Phase 4: AuthKit app config --- + // These op names/descriptions are already clean (no leak), but each still needs + // an override so resolveCommandMeta returns the manifest's clean noun (the leak + // spec asserts meta.command === manifest entry.command). Branding maps to + // `environmentAppBranding`, NOT `appBranding` (whose upstream description is the + // rot "Return the team for the current dashboard session"). + redirectUris: { command: 'authkit redirect-uris list', describe: 'List configured redirect URIs for an environment' }, + setRedirectUris: { command: 'authkit redirect-uris set', describe: 'Set the allowed redirect URIs for an environment' }, + corsConfig: { command: 'authkit cors get', describe: 'Show the allowed web origins (CORS) for an environment' }, + updateCorsConfig: { command: 'authkit cors set', describe: 'Set the allowed web origins (CORS) for an environment' }, + logoutUris: { command: 'authkit logout-uris list', describe: 'List configured logout URIs for an environment' }, + setLogoutUris: { command: 'authkit logout-uris set', describe: 'Set the allowed logout URIs for an environment' }, + environmentAppBranding: { command: 'authkit branding get', describe: 'Show AuthKit branding (logos, theme) for an environment' }, }; /** diff --git a/src/catalog/manifest.ts b/src/catalog/manifest.ts index 79f76b02..15f8286e 100644 --- a/src/catalog/manifest.ts +++ b/src/catalog/manifest.ts @@ -151,6 +151,89 @@ const MANIFEST: CommandJustification[] = [ // non-interactive use. ciPolicy: 'require-flag', }, + + // --- Phase 4: AuthKit app config --- + // Per-environment AuthKit setup surface (redirect URIs, CORS, logout URIs, + // branding). All cheap + imperative; setters replace the full list but expose + // a native `--dry-run` as the safety affordance, so none are `destructive` and + // none are routed through confirmDestructive. `ci_policy: allow` because + // setting these IS the setup automation we want humans/agents/CI to run. + // + // Selection note: we deliberately map to the environment-level ops + // (`setRedirectUris`/`setLogoutUris`), NOT the application-level + // `setAuthkitApplication*` ops, whose input types are named + // `SetUserlandApplication*Input` (the `userland` leak the leak test cannot see, + // since it only inspects op names/descriptions, not input-type names). + { + command: 'authkit redirect-uris list', + mapsTo: 'redirectUris', + audiences: ['human', 'agent', 'ci'], + useCase: 'Inspect configured redirect URIs (verify setup, audits, scripting)', + load: 'cheap', + mutation: false, + destructive: false, + ciPolicy: 'allow', + }, + { + command: 'authkit redirect-uris set', + mapsTo: 'setRedirectUris', + audiences: ['human', 'agent', 'ci'], + useCase: 'Set allowed redirect URIs when wiring AuthKit (setup scripts/CI); --dry-run validates first', + load: 'cheap', + mutation: true, + destructive: false, + ciPolicy: 'allow', + }, + { + command: 'authkit cors get', + mapsTo: 'corsConfig', + audiences: ['human', 'agent', 'ci'], + useCase: 'Inspect allowed web origins (CORS)', + load: 'cheap', + mutation: false, + destructive: false, + ciPolicy: 'allow', + }, + { + command: 'authkit cors set', + mapsTo: 'updateCorsConfig', + audiences: ['human', 'agent', 'ci'], + useCase: 'Set allowed web origins for the web/SPA app during setup; --dry-run validates first', + load: 'cheap', + mutation: true, + destructive: false, + ciPolicy: 'allow', + }, + { + command: 'authkit logout-uris list', + mapsTo: 'logoutUris', + audiences: ['human', 'agent', 'ci'], + useCase: 'Inspect configured logout URIs', + load: 'cheap', + mutation: false, + destructive: false, + ciPolicy: 'allow', + }, + { + command: 'authkit logout-uris set', + mapsTo: 'setLogoutUris', + audiences: ['human', 'agent', 'ci'], + useCase: 'Set allowed logout URIs during setup; --dry-run validates first', + load: 'cheap', + mutation: true, + destructive: false, + ciPolicy: 'allow', + }, + { + command: 'authkit branding get', + mapsTo: 'environmentAppBranding', + audiences: ['human', 'agent'], + useCase: 'Inspect AuthKit branding (logos, theme) for an environment', + load: 'cheap', + mutation: false, + destructive: false, + ciPolicy: 'allow', + }, ]; /** Returns the curated command allowlist. */ diff --git a/src/commands/authkit.spec.ts b/src/commands/authkit.spec.ts new file mode 100644 index 00000000..abab6711 --- /dev/null +++ b/src/commands/authkit.spec.ts @@ -0,0 +1,268 @@ +import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'; + +const mockGetAccessToken = vi.fn(); +const mockGraphqlRequest = vi.fn(); + +vi.mock('../lib/credentials.js', () => ({ + getAccessToken: () => mockGetAccessToken(), +})); + +vi.mock('../lib/dashboard-graphql.js', async (importActual) => { + const actual = await importActual(); + return { + ...actual, + dashboardGraphqlRequest: (...args: unknown[]) => mockGraphqlRequest(...args), + }; +}); + +const { setOutputMode } = await import('../utils/output.js'); +const { DashboardGraphqlError } = await import('../lib/dashboard-graphql.js'); +const { CliExit } = await import('../utils/cli-exit.js'); +const { + runAuthkitRedirectUrisList, + runAuthkitRedirectUrisSet, + runAuthkitCorsGet, + runAuthkitCorsSet, + runAuthkitLogoutUrisList, + runAuthkitLogoutUrisSet, + runAuthkitBrandingGet, +} = await import('./authkit.js'); + +describe('authkit command', () => { + let consoleOutput: string[]; + + beforeEach(() => { + vi.clearAllMocks(); + mockGetAccessToken.mockReturnValue('tok_123'); + consoleOutput = []; + vi.spyOn(console, 'log').mockImplementation((...args: unknown[]) => { + consoleOutput.push(args.map(String).join(' ')); + }); + vi.spyOn(console, 'error').mockImplementation(() => {}); + }); + + afterEach(() => { + vi.restoreAllMocks(); + setOutputMode('human'); + }); + + describe('redirect-uris list', () => { + it('passes environmentId and renders the URIs', async () => { + mockGraphqlRequest.mockResolvedValue({ + redirectUris: { data: [{ id: 'ru_1', uri: 'https://app.com/callback', isDefault: true }] }, + }); + await runAuthkitRedirectUrisList({ environmentId: 'env_1' }); + expect(mockGraphqlRequest).toHaveBeenCalledWith(expect.stringContaining('redirectUris'), { + token: 'tok_123', + variables: { environmentId: 'env_1' }, + }); + expect(consoleOutput.join('\n')).toContain('https://app.com/callback'); + }); + + it('rejects a missing --environment-id before calling the API', async () => { + await expect(runAuthkitRedirectUrisList({ environmentId: '' })).rejects.toBeInstanceOf(CliExit); + expect(mockGraphqlRequest).not.toHaveBeenCalled(); + }); + + it('outputs JSON in json mode', async () => { + setOutputMode('json'); + mockGraphqlRequest.mockResolvedValue({ redirectUris: { data: [{ id: 'ru_1', uri: 'https://a.com', isDefault: false }] } }); + await runAuthkitRedirectUrisList({ environmentId: 'env_1' }); + const out = JSON.parse(consoleOutput[0]); + expect(out.redirectUris[0].uri).toBe('https://a.com'); + }); + }); + + describe('redirect-uris set', () => { + const ok = { setRedirectUris: { __typename: 'RedirectUrisSet', redirectUris: [{ id: 'ru_1', uri: 'https://a.com/cb', isDefault: false }] } }; + + it('maps --uri to the env-level setRedirectUris input (not the application-level userland op)', async () => { + mockGraphqlRequest.mockResolvedValue(ok); + await runAuthkitRedirectUrisSet({ environmentId: 'env_1', uris: ['https://a.com/cb'] }); + expect(mockGraphqlRequest).toHaveBeenCalledWith(expect.stringContaining('setRedirectUris'), { + token: 'tok_123', + variables: { input: { environmentId: 'env_1', redirectUris: [{ uri: 'https://a.com/cb' }], dryRun: false } }, + }); + // Selection-correctness: the leak test cannot catch a wrong op here (the + // application-level op only leaks `userland` in its input *type* name), so + // assert directly that we wired the env-level op. + const sentDocument = mockGraphqlRequest.mock.calls[0][0] as string; + expect(sentDocument).not.toContain('AuthkitApplication'); + expect(sentDocument).not.toMatch(/userland/i); + }); + + it('marks the chosen --default URI as isDefault', async () => { + mockGraphqlRequest.mockResolvedValue(ok); + await runAuthkitRedirectUrisSet({ + environmentId: 'env_1', + uris: ['https://a.com/cb', 'https://b.com/cb'], + default: 'https://b.com/cb', + }); + const variables = mockGraphqlRequest.mock.calls[0][1] as { variables: { input: { redirectUris: unknown[] } } }; + expect(variables.variables.input.redirectUris).toEqual([ + { uri: 'https://a.com/cb', isDefault: false }, + { uri: 'https://b.com/cb', isDefault: true }, + ]); + }); + + it('rejects a --default that matches none of the --uri values', async () => { + vi.spyOn(console, 'error').mockImplementation(() => {}); + await expect( + runAuthkitRedirectUrisSet({ environmentId: 'env_1', uris: ['https://a.com/cb'], default: 'https://typo.com' }), + ).rejects.toMatchObject({ name: 'CliExit', context: { errorCode: 'invalid_argument' } }); + expect(mockGraphqlRequest).not.toHaveBeenCalled(); + }); + + it('--dry-run sends dryRun:true and reports validation, not a save', async () => { + mockGraphqlRequest.mockResolvedValue(ok); + await runAuthkitRedirectUrisSet({ environmentId: 'env_1', uris: ['https://a.com/cb'], dryRun: true }); + const variables = mockGraphqlRequest.mock.calls[0][1] as { variables: { input: { dryRun: boolean } } }; + expect(variables.variables.input.dryRun).toBe(true); + const out = consoleOutput.join('\n'); + expect(out).toMatch(/validated/i); + expect(out).toMatch(/dry run/i); + }); + + it('rejects when no --uri is given', async () => { + await expect(runAuthkitRedirectUrisSet({ environmentId: 'env_1', uris: [] })).rejects.toBeInstanceOf(CliExit); + expect(mockGraphqlRequest).not.toHaveBeenCalled(); + }); + + it('surfaces a validation union error cleanly', async () => { + const consoleErrors: string[] = []; + vi.spyOn(console, 'error').mockImplementation((...args: unknown[]) => { + consoleErrors.push(args.map(String).join(' ')); + }); + mockGraphqlRequest.mockResolvedValue({ + setRedirectUris: { __typename: 'InvalidRedirectUriError', message: 'Not a valid URI', uri: 'http://bad' }, + }); + await expect(runAuthkitRedirectUrisSet({ environmentId: 'env_1', uris: ['http://bad'] })).rejects.toMatchObject({ + name: 'CliExit', + context: { errorCode: 'invalid_redirect_uri' }, + }); + expect(consoleErrors.join('\n')).toContain('Not a valid URI'); + }); + + it('surfaces the gated-capability case on a 403 without naming GraphQL', async () => { + const consoleErrors: string[] = []; + vi.spyOn(console, 'error').mockImplementation((...args: unknown[]) => { + consoleErrors.push(args.map(String).join(' ')); + }); + mockGraphqlRequest.mockRejectedValue( + new DashboardGraphqlError('The dashboard GraphQL API rejected this session (HTTP 403).', 'forbidden', 403), + ); + await expect(runAuthkitRedirectUrisSet({ environmentId: 'env_1', uris: ['https://a.com/cb'] })).rejects.toBeInstanceOf( + CliExit, + ); + const err = consoleErrors.join('\n'); + expect(err).toMatch(/account-plane capability/i); + expect(err).not.toMatch(/graphql/i); + }); + }); + + describe('cors', () => { + it('reads origins from the nested webOrigins shape', async () => { + mockGraphqlRequest.mockResolvedValue({ webOrigins: { webOrigins: { origins: ['https://app.com'] } } }); + await runAuthkitCorsGet({ environmentId: 'env_1' }); + expect(mockGraphqlRequest).toHaveBeenCalledWith(expect.stringContaining('corsConfig'), { + token: 'tok_123', + variables: { environmentId: 'env_1' }, + }); + expect(consoleOutput.join('\n')).toContain('https://app.com'); + }); + + it('set maps --origin to flat top-level variables (not an input object)', async () => { + mockGraphqlRequest.mockResolvedValue({ setWebOrigins: { __typename: 'WebOriginsSet', origins: ['https://app.com'] } }); + await runAuthkitCorsSet({ environmentId: 'env_1', origins: ['https://app.com'] }); + expect(mockGraphqlRequest).toHaveBeenCalledWith(expect.stringContaining('updateCorsConfig'), { + token: 'tok_123', + variables: { environmentId: 'env_1', origins: ['https://app.com'], dryRun: false }, + }); + }); + + it('set surfaces a malformed-origin union error', async () => { + vi.spyOn(console, 'error').mockImplementation(() => {}); + mockGraphqlRequest.mockResolvedValue({ + setWebOrigins: { __typename: 'MalformedWebOrigin', message: 'bad', uri: 'nope' }, + }); + await expect(runAuthkitCorsSet({ environmentId: 'env_1', origins: ['nope'] })).rejects.toMatchObject({ + name: 'CliExit', + context: { errorCode: 'invalid_web_origin' }, + }); + }); + }); + + describe('logout-uris', () => { + it('list passes environmentId', async () => { + mockGraphqlRequest.mockResolvedValue({ logoutUris: { data: [{ id: 'lo_1', uri: 'https://app.com/out', isDefault: false }] } }); + await runAuthkitLogoutUrisList({ environmentId: 'env_1' }); + expect(mockGraphqlRequest).toHaveBeenCalledWith(expect.stringContaining('logoutUris'), { + token: 'tok_123', + variables: { environmentId: 'env_1' }, + }); + expect(consoleOutput.join('\n')).toContain('https://app.com/out'); + }); + + it('set maps --uri to the setLogoutUris input', async () => { + mockGraphqlRequest.mockResolvedValue({ + setLogoutUris: { __typename: 'LogoutUrisSet', logoutUris: [{ id: 'lo_1', uri: 'https://app.com/out', isDefault: false }] }, + }); + await runAuthkitLogoutUrisSet({ environmentId: 'env_1', uris: ['https://app.com/out'] }); + expect(mockGraphqlRequest).toHaveBeenCalledWith(expect.stringContaining('setLogoutUris'), { + token: 'tok_123', + variables: { input: { environmentId: 'env_1', logoutUris: [{ uri: 'https://app.com/out' }], dryRun: false } }, + }); + }); + }); + + describe('branding get', () => { + it('maps to the env-scoped environmentAppBranding op (not the session-scoped appBranding)', async () => { + mockGraphqlRequest.mockResolvedValue({ + environment: { appBranding: { id: 'br_1', displayName: 'Acme', theme: 'light' } }, + }); + await runAuthkitBrandingGet({ environmentId: 'env_1' }); + const sentDocument = mockGraphqlRequest.mock.calls[0][0] as string; + expect(sentDocument).toContain('environmentAppBranding'); + expect(mockGraphqlRequest).toHaveBeenCalledWith(expect.stringContaining('environmentAppBranding'), { + token: 'tok_123', + variables: { environmentId: 'env_1' }, + }); + expect(consoleOutput.join('\n')).toContain('Acme'); + }); + + it('emits JSON with no graphql/userland strings', async () => { + setOutputMode('json'); + mockGraphqlRequest.mockResolvedValue({ + environment: { appBranding: { id: 'br_1', displayName: 'Acme', theme: 'light' } }, + }); + await runAuthkitBrandingGet({ environmentId: 'env_1' }); + const raw = consoleOutput[0]; + expect(raw).not.toMatch(/graphql|userland/i); + const out = JSON.parse(raw); + expect(out.branding.displayName).toBe('Acme'); + }); + }); + + describe('required-flag validation (shared guards)', () => { + it('cors get requires --environment-id', async () => { + await expect(runAuthkitCorsGet({ environmentId: '' })).rejects.toBeInstanceOf(CliExit); + expect(mockGraphqlRequest).not.toHaveBeenCalled(); + }); + it('cors set requires at least one --origin', async () => { + await expect(runAuthkitCorsSet({ environmentId: 'env_1', origins: [] })).rejects.toBeInstanceOf(CliExit); + expect(mockGraphqlRequest).not.toHaveBeenCalled(); + }); + it('logout-uris list requires --environment-id', async () => { + await expect(runAuthkitLogoutUrisList({ environmentId: '' })).rejects.toBeInstanceOf(CliExit); + expect(mockGraphqlRequest).not.toHaveBeenCalled(); + }); + it('logout-uris set requires at least one --uri', async () => { + await expect(runAuthkitLogoutUrisSet({ environmentId: 'env_1', uris: [] })).rejects.toBeInstanceOf(CliExit); + expect(mockGraphqlRequest).not.toHaveBeenCalled(); + }); + it('branding get requires --environment-id', async () => { + await expect(runAuthkitBrandingGet({ environmentId: '' })).rejects.toBeInstanceOf(CliExit); + expect(mockGraphqlRequest).not.toHaveBeenCalled(); + }); + }); +}); diff --git a/src/commands/authkit.ts b/src/commands/authkit.ts new file mode 100644 index 00000000..84f9853d --- /dev/null +++ b/src/commands/authkit.ts @@ -0,0 +1,383 @@ +/** + * `workos authkit` — per-environment AuthKit app configuration. + * + * The AuthKit setup surface a developer (or an agent/CI script) configures when + * wiring auth: redirect URIs, CORS web origins, logout URIs, and branding. These + * run on the dashboard account plane with the user's OAuth bearer — the same + * gated capability `whoami` / `environment` / `team` use, distinct from the + * API-key-based `workos config` command (which adds individual redirect/CORS + * entries via the REST plane). Here the writes are full-list *set* operations and + * expose a native `--dry-run` validation, so none are destructive. + * + * Selection note: the redirect/logout setters map to the environment-level ops + * (`setRedirectUris`/`setLogoutUris`), not the application-level + * `setAuthkitApplication*` ops whose input types carry internal `userland` + * naming. Branding reads via `environmentAppBranding` (env-scoped, clean), not + * `appBranding` (session-scoped with a rotten upstream description). + */ + +import chalk from 'chalk'; +import { getAccessToken } from '../lib/credentials.js'; +import { dashboardGraphqlRequest } from '../lib/dashboard-graphql.js'; +import { getOperation, resolveExecutableDocument, reportDashboardError } from '../catalog/operation.js'; +import { isJsonMode, outputJson, outputSuccess, exitWithError } from '../utils/output.js'; +import { exitWithAuthRequired } from '../utils/exit-codes.js'; +import { formatTable } from '../utils/table.js'; +import { formatWorkOSCommand } from '../utils/command-invocation.js'; + +function requireToken(): string { + const token = getAccessToken(); + if (!token) { + exitWithAuthRequired(`Not logged in. Run \`${formatWorkOSCommand('auth login')}\` to authenticate.`); + } + return token; +} + +/** Guard a required string flag at the handler level (also unit-testable). */ +function requireFlag(value: string | undefined, flag: string): string { + if (!value || value.trim() === '') { + exitWithError({ code: 'missing_argument', message: `${flag} is required.` }); + } + return value; +} + +/** Guard a flag that must have at least one value. */ +function requireAtLeastOne(values: string[] | undefined, flag: string): string[] { + if (!values || values.length === 0) { + exitWithError({ code: 'missing_argument', message: `At least one ${flag} is required.` }); + } + return values; +} + +interface UriNode { + id?: string | null; + uri: string; + isDefault?: boolean | null; +} + +/** Map repeatable `--uri` (+ optional `--default`) onto the GraphQL input shape. */ +function toUriInputs(uris: string[], defaultUri?: string): Array<{ uri: string; isDefault?: boolean }> { + // A `--default` that matches none of the supplied URIs is almost always a typo; + // fail loudly rather than silently persisting a list with no default. + if (defaultUri !== undefined && !uris.includes(defaultUri)) { + exitWithError({ + code: 'invalid_argument', + message: `--default "${defaultUri}" must be one of the provided --uri values.`, + }); + } + return uris.map((uri) => (defaultUri === undefined ? { uri } : { uri, isDefault: uri === defaultUri })); +} + +/** Render a list of redirect/logout URIs as a table (human) — JSON handled by caller. */ +function renderUriTable(items: UriNode[]): void { + if (items.length === 0) { + console.log('No URIs configured.'); + return; + } + const rows = items.map((item) => [ + item.uri, + item.isDefault ? chalk.green('yes') : chalk.dim('—'), + item.id ?? chalk.dim('—'), + ]); + console.log(formatTable([{ header: 'URI' }, { header: 'Default' }, { header: 'ID' }], rows)); +} + +/** Summarize the outcome of a URI set, marking the default. */ +function renderUriSetResult(items: UriNode[], noun: string, dryRun: boolean): void { + const verb = dryRun ? 'Validated' : 'Set'; + const suffix = dryRun ? chalk.dim(' (dry run, not saved)') : ''; + outputSuccess(`${verb} ${items.length} ${noun}${items.length === 1 ? '' : 's'}${suffix}`); + for (const item of items) { + const marker = item.isDefault ? chalk.green(' (default)') : ''; + console.log(chalk.dim(` • ${item.uri}${marker}`)); + } +} + +// --- redirect URIs --- + +export interface RedirectUrisListOptions { + environmentId: string; + limit?: number; +} + +export async function runAuthkitRedirectUrisList(options: RedirectUrisListOptions): Promise { + const environmentId = requireFlag(options.environmentId, '--environment-id'); + const token = requireToken(); + const op = getOperation('redirectUris'); + + let data: { redirectUris: { data: UriNode[] } | null }; + try { + data = await dashboardGraphqlRequest(resolveExecutableDocument(op), { + token, + variables: { environmentId, ...(options.limit !== undefined ? { limit: options.limit } : {}) }, + }); + } catch (error) { + reportDashboardError(error); + } + + const uris = data.redirectUris?.data ?? []; + if (isJsonMode()) { + outputJson({ redirectUris: uris }); + return; + } + renderUriTable(uris); +} + +export interface RedirectUrisSetOptions { + environmentId: string; + uris: string[]; + default?: string; + dryRun?: boolean; +} + +export async function runAuthkitRedirectUrisSet(options: RedirectUrisSetOptions): Promise { + const environmentId = requireFlag(options.environmentId, '--environment-id'); + const uris = requireAtLeastOne(options.uris, '--uri'); + const dryRun = !!options.dryRun; + const token = requireToken(); + const op = getOperation('setRedirectUris'); + + let data: { + setRedirectUris: + | { __typename: 'RedirectUrisSet'; redirectUris: UriNode[] } + | { __typename: 'InvalidRedirectUriError'; message: string; uri: string } + | { __typename: 'InvalidWildcardRedirectUri'; message: string; uri: string } + | { __typename: string }; + }; + try { + data = await dashboardGraphqlRequest(resolveExecutableDocument(op), { + token, + variables: { input: { environmentId, redirectUris: toUriInputs(uris, options.default), dryRun } }, + }); + } catch (error) { + reportDashboardError(error); + } + + const result = data.setRedirectUris; + if (result.__typename !== 'RedirectUrisSet' || !('redirectUris' in result)) { + const detail = 'message' in result ? `${result.message} (${(result as { uri: string }).uri})` : 'Invalid redirect URI.'; + exitWithError({ code: 'invalid_redirect_uri', message: detail }); + } + + const saved = (result as { redirectUris: UriNode[] }).redirectUris; + if (isJsonMode()) { + outputJson({ redirectUris: saved, dryRun }); + return; + } + renderUriSetResult(saved, 'redirect URI', dryRun); +} + +// --- CORS --- + +export interface CorsGetOptions { + environmentId: string; +} + +export async function runAuthkitCorsGet(options: CorsGetOptions): Promise { + const environmentId = requireFlag(options.environmentId, '--environment-id'); + const token = requireToken(); + const op = getOperation('corsConfig'); + + let data: { webOrigins: { webOrigins: { origins: string[] } | null } | null }; + try { + data = await dashboardGraphqlRequest(resolveExecutableDocument(op), { + token, + variables: { environmentId }, + }); + } catch (error) { + reportDashboardError(error); + } + + const origins = data.webOrigins?.webOrigins?.origins ?? []; + if (isJsonMode()) { + outputJson({ origins }); + return; + } + if (origins.length === 0) { + console.log('No web origins configured.'); + return; + } + for (const origin of origins) console.log(` • ${origin}`); +} + +export interface CorsSetOptions { + environmentId: string; + origins: string[]; + dryRun?: boolean; +} + +export async function runAuthkitCorsSet(options: CorsSetOptions): Promise { + const environmentId = requireFlag(options.environmentId, '--environment-id'); + const origins = requireAtLeastOne(options.origins, '--origin'); + const dryRun = !!options.dryRun; + const token = requireToken(); + const op = getOperation('updateCorsConfig'); + + let data: { + setWebOrigins: + | { __typename: 'WebOriginsSet'; origins: string[] } + | { __typename: string; message?: string; uri?: string }; + }; + try { + data = await dashboardGraphqlRequest(resolveExecutableDocument(op), { + token, + variables: { environmentId, origins, dryRun }, + }); + } catch (error) { + reportDashboardError(error); + } + + const result = data.setWebOrigins; + if (result.__typename !== 'WebOriginsSet' || !('origins' in result)) { + const detail = + 'message' in result && result.message + ? `${result.message}${result.uri ? ` (${result.uri})` : ''}` + : 'Invalid web origin.'; + exitWithError({ code: 'invalid_web_origin', message: detail }); + } + + const saved = (result as { origins: string[] }).origins; + if (isJsonMode()) { + outputJson({ origins: saved, dryRun }); + return; + } + const verb = dryRun ? 'Validated' : 'Set'; + const suffix = dryRun ? chalk.dim(' (dry run, not saved)') : ''; + outputSuccess(`${verb} ${saved.length} web origin${saved.length === 1 ? '' : 's'}${suffix}`); + for (const origin of saved) console.log(chalk.dim(` • ${origin}`)); +} + +// --- logout URIs --- + +export interface LogoutUrisListOptions { + environmentId: string; + limit?: number; +} + +export async function runAuthkitLogoutUrisList(options: LogoutUrisListOptions): Promise { + const environmentId = requireFlag(options.environmentId, '--environment-id'); + const token = requireToken(); + const op = getOperation('logoutUris'); + + let data: { logoutUris: { data: UriNode[] } | null }; + try { + data = await dashboardGraphqlRequest(resolveExecutableDocument(op), { + token, + variables: { environmentId, ...(options.limit !== undefined ? { limit: options.limit } : {}) }, + }); + } catch (error) { + reportDashboardError(error); + } + + const uris = data.logoutUris?.data ?? []; + if (isJsonMode()) { + outputJson({ logoutUris: uris }); + return; + } + renderUriTable(uris); +} + +export interface LogoutUrisSetOptions { + environmentId: string; + uris: string[]; + default?: string; + dryRun?: boolean; +} + +export async function runAuthkitLogoutUrisSet(options: LogoutUrisSetOptions): Promise { + const environmentId = requireFlag(options.environmentId, '--environment-id'); + const uris = requireAtLeastOne(options.uris, '--uri'); + const dryRun = !!options.dryRun; + const token = requireToken(); + const op = getOperation('setLogoutUris'); + + let data: { + setLogoutUris: + | { __typename: 'LogoutUrisSet'; logoutUris: UriNode[] } + | { __typename: string; message?: string; uri?: string }; + }; + try { + data = await dashboardGraphqlRequest(resolveExecutableDocument(op), { + token, + variables: { input: { environmentId, logoutUris: toUriInputs(uris, options.default), dryRun } }, + }); + } catch (error) { + reportDashboardError(error); + } + + const result = data.setLogoutUris; + if (result.__typename !== 'LogoutUrisSet' || !('logoutUris' in result)) { + const detail = + 'message' in result && result.message + ? `${result.message}${result.uri ? ` (${result.uri})` : ''}` + : 'Invalid logout URI.'; + exitWithError({ code: 'invalid_logout_uri', message: detail }); + } + + const saved = (result as { logoutUris: UriNode[] }).logoutUris; + if (isJsonMode()) { + outputJson({ logoutUris: saved, dryRun }); + return; + } + renderUriSetResult(saved, 'logout URI', dryRun); +} + +// --- branding --- + +interface AppBranding { + id?: string | null; + displayName?: string | null; + theme?: string | null; + lightLogoPath?: string | null; + darkLogoPath?: string | null; + font?: { family?: string | null } | null; + [key: string]: unknown; +} + +export interface BrandingGetOptions { + environmentId: string; +} + +export async function runAuthkitBrandingGet(options: BrandingGetOptions): Promise { + const environmentId = requireFlag(options.environmentId, '--environment-id'); + const token = requireToken(); + const op = getOperation('environmentAppBranding'); + + let data: { environment: { appBranding: AppBranding | null } | null }; + try { + data = await dashboardGraphqlRequest(resolveExecutableDocument(op), { + token, + variables: { environmentId }, + }); + } catch (error) { + reportDashboardError(error); + } + + const branding = data.environment?.appBranding ?? null; + if (isJsonMode()) { + outputJson({ branding }); + return; + } + + if (!branding) { + console.log('No branding configured for this environment.'); + return; + } + // Human view shows a concise subset; the full object (custom CSS/HTML, all + // colors, localized text) is available via --json. + const fields: Array<[string, unknown]> = [ + ['Display name', branding.displayName], + ['Theme', branding.theme], + ['Font', branding.font?.family], + ['Light logo', branding.lightLogoPath], + ['Dark logo', branding.darkLogoPath], + ]; + const shown = fields.filter(([, value]) => value !== null && value !== undefined && value !== ''); + if (shown.length === 0) { + // Branding exists but only sets fields outside this summary (e.g. custom CSS). + console.log('Branding is configured. Run with --json to view the full configuration.'); + return; + } + for (const [label, value] of shown) console.log(`${chalk.bold(label)}: ${String(value)}`); + console.log(chalk.dim('Run with --json for the full branding configuration.')); +} diff --git a/src/utils/help-json.ts b/src/utils/help-json.ts index ec7186c2..54cda398 100644 --- a/src/utils/help-json.ts +++ b/src/utils/help-json.ts @@ -190,6 +190,92 @@ const commands: CommandSchema[] = [ }, ], }, + { + name: 'authkit', + description: 'Manage AuthKit app config (redirect URIs, CORS, logout URIs, branding) on the dashboard account plane', + options: [insecureStorageOpt], + commands: [ + { + name: 'redirect-uris', + description: 'Manage AuthKit redirect URIs', + commands: [ + { + name: 'list', + description: 'List configured redirect URIs for an environment', + options: [ + { name: 'environment-id', type: 'string', description: 'Environment ID', required: true, hidden: false }, + { name: 'limit', type: 'number', description: 'Maximum number of URIs to return', required: false, hidden: false }, + ], + }, + { + name: 'set', + description: 'Set the allowed redirect URIs for an environment (replaces the full list)', + options: [ + { name: 'environment-id', type: 'string', description: 'Environment ID', required: true, hidden: false }, + { name: 'uri', type: 'string', description: 'Redirect URI (repeatable)', required: true, hidden: false }, + { name: 'default', type: 'string', description: 'Which URI to mark as the default', required: false, hidden: false }, + { name: 'dry-run', type: 'boolean', description: 'Validate without saving', required: false, default: false, hidden: false }, + ], + }, + ], + }, + { + name: 'cors', + description: 'Manage AuthKit CORS web origins', + commands: [ + { + name: 'get', + description: 'Show the allowed web origins (CORS) for an environment', + options: [{ name: 'environment-id', type: 'string', description: 'Environment ID', required: true, hidden: false }], + }, + { + name: 'set', + description: 'Set the allowed web origins (CORS) for an environment (replaces the full list)', + options: [ + { name: 'environment-id', type: 'string', description: 'Environment ID', required: true, hidden: false }, + { name: 'origin', type: 'string', description: 'Web origin (repeatable)', required: true, hidden: false }, + { name: 'dry-run', type: 'boolean', description: 'Validate without saving', required: false, default: false, hidden: false }, + ], + }, + ], + }, + { + name: 'logout-uris', + description: 'Manage AuthKit logout URIs', + commands: [ + { + name: 'list', + description: 'List configured logout URIs for an environment', + options: [ + { name: 'environment-id', type: 'string', description: 'Environment ID', required: true, hidden: false }, + { name: 'limit', type: 'number', description: 'Maximum number of URIs to return', required: false, hidden: false }, + ], + }, + { + name: 'set', + description: 'Set the allowed logout URIs for an environment (replaces the full list)', + options: [ + { name: 'environment-id', type: 'string', description: 'Environment ID', required: true, hidden: false }, + { name: 'uri', type: 'string', description: 'Logout URI (repeatable)', required: true, hidden: false }, + { name: 'default', type: 'string', description: 'Which URI to mark as the default', required: false, hidden: false }, + { name: 'dry-run', type: 'boolean', description: 'Validate without saving', required: false, default: false, hidden: false }, + ], + }, + ], + }, + { + name: 'branding', + description: 'Manage AuthKit branding', + commands: [ + { + name: 'get', + description: 'Show AuthKit branding (logos, theme) for an environment', + options: [{ name: 'environment-id', type: 'string', description: 'Environment ID', required: true, hidden: false }], + }, + ], + }, + ], + }, { name: 'team', description: 'Manage the WorkOS dashboard team (members, invites, settings)', From 4ce58a340ca9e17892b909a2582dd61fcecdb246 Mon Sep 17 00:00:00 2001 From: Nick Nisi Date: Fri, 10 Jul 2026 11:22:10 -0500 Subject: [PATCH 08/22] docs(catalog): note deferred userland->user ops and retained test fixture 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. --- src/catalog/curation.ts | 15 ++++++++++++++- 1 file changed, 14 insertions(+), 1 deletion(-) diff --git a/src/catalog/curation.ts b/src/catalog/curation.ts index 17c883ae..a053bb81 100644 --- a/src/catalog/curation.ts +++ b/src/catalog/curation.ts @@ -61,8 +61,21 @@ export const OVERRIDES: Record = { updateTeamDetails: { command: 'team update', describe: 'Rename the current team' }, updateTeamMfaRequirement: { command: 'team set-mfa', describe: 'Set whether MFA is required for the team' }, - // `userland*` ops: the prefix is internal dashboard naming. The user-facing + // `userland*` ops: the prefix is internal dashboard naming; the user-facing // noun is just "user". + // + // DEFERRED/DROPPED (Phase 5, decided 2026-06-26). These were pre-staged for a + // catalog-driven AuthKit-user category that is NOT being built: the nouns below + // (`user list`, `user create`, `user delete`, ...) would collide head-on with + // the existing REST `user` command (src/commands/user.ts), which already lists, + // creates, updates, and deletes AuthKit users on the public API plane. Routing + // those through the internal dashboard plane would duplicate working commands + // and risk a naming leak. Do NOT add these to manifest.ts without first + // resolving that collision (see the contract's Out of Scope). + // + // `userlandUsers` is intentionally retained: no-graphql-leak.spec.ts uses it as + // the worked example proving the curation layer cleans a `userland`-leaking op + // name to a clean noun. Removing it would weaken that machinery test. userlandUsers: { command: 'user list', describe: 'List AuthKit users in the current environment' }, createUserlandUser: { command: 'user create', describe: 'Create a user' }, deleteUserlandUser: { command: 'user delete', describe: 'Delete a user' }, From fa7b58f60d7f0ea1dcaa0f161f6854c8519748e4 Mon Sep 17 00:00:00 2001 From: Nick Nisi Date: Wed, 22 Jul 2026 16:54:17 -0500 Subject: [PATCH 09/22] feat: command-path auth guard with silent token refresh 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). --- src/catalog/no-graphql-leak.spec.ts | 36 +++ src/catalog/operation.ts | 27 ++- src/commands/authkit.spec.ts | 14 +- src/commands/authkit.ts | 26 +- src/commands/environment.spec.ts | 20 +- src/commands/environment.ts | 21 +- src/commands/project.spec.ts | 14 +- src/commands/project.ts | 18 +- src/commands/team.spec.ts | 14 +- src/commands/team.ts | 26 +- src/commands/whoami.spec.ts | 38 ++- src/commands/whoami.ts | 32 +-- src/lib/command-auth.spec.ts | 336 ++++++++++++++++++++++++++ src/lib/command-auth.ts | 171 +++++++++++++ src/lib/dashboard-graphql.ts | 7 +- src/lib/ensure-auth.ts | 140 +++++------ src/utils/command-hints-guard.spec.ts | 2 + 17 files changed, 722 insertions(+), 220 deletions(-) create mode 100644 src/lib/command-auth.spec.ts create mode 100644 src/lib/command-auth.ts diff --git a/src/catalog/no-graphql-leak.spec.ts b/src/catalog/no-graphql-leak.spec.ts index 5a23b594..eadb3ab8 100644 --- a/src/catalog/no-graphql-leak.spec.ts +++ b/src/catalog/no-graphql-leak.spec.ts @@ -2,6 +2,9 @@ import { describe, it, expect } from 'vitest'; import { loadManagementCatalog } from './loader.js'; import { getManifest } from './manifest.js'; import { OVERRIDES, resolveCommandMeta, findLeaks, LEAK_PATTERN } from './curation.js'; +import { dashboardErrorMessage } from './operation.js'; +import { DASHBOARD_ERROR_MESSAGES } from '../lib/command-auth.js'; +import { DashboardGraphqlError, type DashboardGraphqlErrorCode } from '../lib/dashboard-graphql.js'; import type { CatalogOperation } from './catalog-types.js'; const catalog = loadManagementCatalog(undefined, { includeFeatureFlagged: true }); @@ -63,3 +66,36 @@ describe('no GraphQL/userland leak in curated command metadata', () => { expect(findLeaks({ command: 'userland', describe: 'graphql' })).toEqual(['command', 'describe']); }); }); + +describe('no GraphQL/userland leak in dashboard-plane error copy', () => { + it('every DASHBOARD_ERROR_MESSAGES constant is a clean, non-empty string', () => { + const entries = Object.entries(DASHBOARD_ERROR_MESSAGES); + // Getter-backed object: emptying it (or breaking a getter) must fail loudly. + expect(entries.length).toBeGreaterThanOrEqual(4); + for (const [key, message] of entries) { + expect(typeof message, key).toBe('string'); + expect(message.length, key).toBeGreaterThan(0); + expect(LEAK_PATTERN.test(message), `${key}: ${message}`).toBe(false); + } + }); + + it('dashboardErrorMessage copy is clean for every error code', () => { + const codes: DashboardGraphqlErrorCode[] = ['forbidden', 'http_error', 'graphql_error', 'network_error']; + for (const code of codes) { + // The client's own message ALWAYS leaks ("dashboard GraphQL API") — the + // user-facing translation must never echo it. + const message = dashboardErrorMessage( + new DashboardGraphqlError('The dashboard GraphQL API rejected this session (HTTP 403).', code, 403), + ); + expect(message.length, code).toBeGreaterThan(0); + expect(LEAK_PATTERN.test(message), `${code}: ${message}`).toBe(false); + } + }); + + it('forbidden copy no longer claims the capability is staging-gated', () => { + expect(DASHBOARD_ERROR_MESSAGES.forbidden).not.toMatch(/staging/i); + expect( + dashboardErrorMessage(new DashboardGraphqlError('x', 'forbidden', 403)), + ).toBe(DASHBOARD_ERROR_MESSAGES.forbidden); + }); +}); diff --git a/src/catalog/operation.ts b/src/catalog/operation.ts index b693c2dc..6b5cf352 100644 --- a/src/catalog/operation.ts +++ b/src/catalog/operation.ts @@ -1,5 +1,6 @@ import { loadManagementCatalog } from './loader.js'; import { DashboardGraphqlError } from '../lib/dashboard-graphql.js'; +import { DASHBOARD_ERROR_MESSAGES } from '../lib/command-auth.js'; import { exitWithError } from '../utils/output.js'; import type { CatalogOperation, ManagementCatalog } from './catalog-types.js'; @@ -65,14 +66,15 @@ export function resolveExecutableDocument( } /** - * Translate a {@link DashboardGraphqlError} into a clean exit, reusing the - * `whoami` error taxonomy (forbidden / http_error / graphql_error / - * network_error). + * Translate a {@link DashboardGraphqlError} into a clean exit, using the + * shared dashboard-plane error taxonomy (forbidden / http_error / + * graphql_error / network_error). * * The dashboard OAuth-bearer capability the account-plane commands rely on is - * feature-flag-gated server-side (staging today), so a 403 is the *expected* - * outcome wherever the flag is off. We surface that distinctly — and without - * ever naming GraphQL — so the failure is actionable rather than a generic 403. + * enabled in production but gated by a per-team feature flag (fail-closed), so + * a 403 remains an expected outcome: the flag is off for the caller's team, or + * the account isn't team-backed. We surface that distinctly — and without ever + * naming GraphQL — so the failure is actionable rather than a generic 403. * Never returns. */ export function reportDashboardError(error: unknown): never { @@ -85,13 +87,16 @@ export function reportDashboardError(error: unknown): never { throw error; } -function dashboardErrorMessage(error: DashboardGraphqlError): string { +/** + * Per-code user-facing copy. Exported so `no-graphql-leak.spec.ts` can run the + * leak gate over every branch. `forbidden` (exit 1, not auth-recoverable) is + * shared copy from {@link DASHBOARD_ERROR_MESSAGES}, distinct from the + * `auth_required` (exit 4) messages that `requireCommandToken()` owns. + */ +export function dashboardErrorMessage(error: DashboardGraphqlError): string { switch (error.code) { case 'forbidden': - return ( - 'This account-plane capability is not enabled for this API host. ' + - 'It must be turned on (currently staging only), with a token belonging to a WorkOS dashboard account that has a team.' - ); + return DASHBOARD_ERROR_MESSAGES.forbidden; case 'http_error': return `The WorkOS account plane returned an unexpected response${error.status ? ` (HTTP ${error.status})` : ''}.`; case 'network_error': diff --git a/src/commands/authkit.spec.ts b/src/commands/authkit.spec.ts index abab6711..95ea9da1 100644 --- a/src/commands/authkit.spec.ts +++ b/src/commands/authkit.spec.ts @@ -1,11 +1,15 @@ import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'; -const mockGetAccessToken = vi.fn(); +const mockRequireCommandToken = vi.fn(); const mockGraphqlRequest = vi.fn(); -vi.mock('../lib/credentials.js', () => ({ - getAccessToken: () => mockGetAccessToken(), -})); +vi.mock('../lib/command-auth.js', async (importActual) => { + const actual = await importActual(); + return { + ...actual, + requireCommandToken: () => mockRequireCommandToken(), + }; +}); vi.mock('../lib/dashboard-graphql.js', async (importActual) => { const actual = await importActual(); @@ -33,7 +37,7 @@ describe('authkit command', () => { beforeEach(() => { vi.clearAllMocks(); - mockGetAccessToken.mockReturnValue('tok_123'); + mockRequireCommandToken.mockResolvedValue('tok_123'); consoleOutput = []; vi.spyOn(console, 'log').mockImplementation((...args: unknown[]) => { consoleOutput.push(args.map(String).join(' ')); diff --git a/src/commands/authkit.ts b/src/commands/authkit.ts index 84f9853d..04b21ea0 100644 --- a/src/commands/authkit.ts +++ b/src/commands/authkit.ts @@ -17,21 +17,11 @@ */ import chalk from 'chalk'; -import { getAccessToken } from '../lib/credentials.js'; +import { requireCommandToken } from '../lib/command-auth.js'; import { dashboardGraphqlRequest } from '../lib/dashboard-graphql.js'; import { getOperation, resolveExecutableDocument, reportDashboardError } from '../catalog/operation.js'; import { isJsonMode, outputJson, outputSuccess, exitWithError } from '../utils/output.js'; -import { exitWithAuthRequired } from '../utils/exit-codes.js'; import { formatTable } from '../utils/table.js'; -import { formatWorkOSCommand } from '../utils/command-invocation.js'; - -function requireToken(): string { - const token = getAccessToken(); - if (!token) { - exitWithAuthRequired(`Not logged in. Run \`${formatWorkOSCommand('auth login')}\` to authenticate.`); - } - return token; -} /** Guard a required string flag at the handler level (also unit-testable). */ function requireFlag(value: string | undefined, flag: string): string { @@ -102,7 +92,7 @@ export interface RedirectUrisListOptions { export async function runAuthkitRedirectUrisList(options: RedirectUrisListOptions): Promise { const environmentId = requireFlag(options.environmentId, '--environment-id'); - const token = requireToken(); + const token = await requireCommandToken(); const op = getOperation('redirectUris'); let data: { redirectUris: { data: UriNode[] } | null }; @@ -134,7 +124,7 @@ export async function runAuthkitRedirectUrisSet(options: RedirectUrisSetOptions) const environmentId = requireFlag(options.environmentId, '--environment-id'); const uris = requireAtLeastOne(options.uris, '--uri'); const dryRun = !!options.dryRun; - const token = requireToken(); + const token = await requireCommandToken(); const op = getOperation('setRedirectUris'); let data: { @@ -175,7 +165,7 @@ export interface CorsGetOptions { export async function runAuthkitCorsGet(options: CorsGetOptions): Promise { const environmentId = requireFlag(options.environmentId, '--environment-id'); - const token = requireToken(); + const token = await requireCommandToken(); const op = getOperation('corsConfig'); let data: { webOrigins: { webOrigins: { origins: string[] } | null } | null }; @@ -210,7 +200,7 @@ export async function runAuthkitCorsSet(options: CorsSetOptions): Promise const environmentId = requireFlag(options.environmentId, '--environment-id'); const origins = requireAtLeastOne(options.origins, '--origin'); const dryRun = !!options.dryRun; - const token = requireToken(); + const token = await requireCommandToken(); const op = getOperation('updateCorsConfig'); let data: { @@ -256,7 +246,7 @@ export interface LogoutUrisListOptions { export async function runAuthkitLogoutUrisList(options: LogoutUrisListOptions): Promise { const environmentId = requireFlag(options.environmentId, '--environment-id'); - const token = requireToken(); + const token = await requireCommandToken(); const op = getOperation('logoutUris'); let data: { logoutUris: { data: UriNode[] } | null }; @@ -288,7 +278,7 @@ export async function runAuthkitLogoutUrisSet(options: LogoutUrisSetOptions): Pr const environmentId = requireFlag(options.environmentId, '--environment-id'); const uris = requireAtLeastOne(options.uris, '--uri'); const dryRun = !!options.dryRun; - const token = requireToken(); + const token = await requireCommandToken(); const op = getOperation('setLogoutUris'); let data: { @@ -340,7 +330,7 @@ export interface BrandingGetOptions { export async function runAuthkitBrandingGet(options: BrandingGetOptions): Promise { const environmentId = requireFlag(options.environmentId, '--environment-id'); - const token = requireToken(); + const token = await requireCommandToken(); const op = getOperation('environmentAppBranding'); let data: { environment: { appBranding: AppBranding | null } | null }; diff --git a/src/commands/environment.spec.ts b/src/commands/environment.spec.ts index fc303404..5ccbe979 100644 --- a/src/commands/environment.spec.ts +++ b/src/commands/environment.spec.ts @@ -1,11 +1,15 @@ import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'; -const mockGetAccessToken = vi.fn(); +const mockRequireCommandToken = vi.fn(); const mockGraphqlRequest = vi.fn(); -vi.mock('../lib/credentials.js', () => ({ - getAccessToken: () => mockGetAccessToken(), -})); +vi.mock('../lib/command-auth.js', async (importActual) => { + const actual = await importActual(); + return { + ...actual, + requireCommandToken: () => mockRequireCommandToken(), + }; +}); vi.mock('../lib/dashboard-graphql.js', async (importActual) => { const actual = await importActual(); @@ -25,7 +29,7 @@ describe('environment command', () => { beforeEach(() => { vi.clearAllMocks(); - mockGetAccessToken.mockReturnValue('tok_123'); + mockRequireCommandToken.mockResolvedValue('tok_123'); consoleOutput = []; vi.spyOn(console, 'log').mockImplementation((...args: unknown[]) => { consoleOutput.push(args.map(String).join(' ')); @@ -50,7 +54,11 @@ describe('environment command', () => { }); it('exits auth-required (code 4) when not logged in', async () => { - mockGetAccessToken.mockReturnValue(null); + // requireCommandToken never returns without a usable session: it throws + // a structured exit-4 (see command-auth.spec.ts for the full matrix). + mockRequireCommandToken.mockImplementation(() => { + throw new CliExit(4, { reason: 'auth_required', errorCode: 'auth_required' }); + }); await expect(runEnvironmentCreate({ name: 'Staging', sandbox: false })).rejects.toMatchObject({ name: 'CliExit', exitCode: 4, diff --git a/src/commands/environment.ts b/src/commands/environment.ts index aaa26260..2137122f 100644 --- a/src/commands/environment.ts +++ b/src/commands/environment.ts @@ -8,17 +8,16 @@ * naming decision in the spec. * * The underlying capability is the same OAuth-bearer dashboard access `whoami` - * uses, which is feature-flag-gated server-side (staging today); a flag-off or - * non-team token fails cleanly via the shared error taxonomy. + * uses — enabled in production but gated by a per-team feature flag + * (fail-closed); a flag-off or non-team token fails cleanly via the shared + * error taxonomy. */ import chalk from 'chalk'; -import { getAccessToken } from '../lib/credentials.js'; +import { requireCommandToken } from '../lib/command-auth.js'; import { dashboardGraphqlRequest } from '../lib/dashboard-graphql.js'; import { getOperation, resolveExecutableDocument, reportDashboardError } from '../catalog/operation.js'; import { isJsonMode, outputJson, outputSuccess } from '../utils/output.js'; -import { exitWithAuthRequired } from '../utils/exit-codes.js'; -import { formatWorkOSCommand } from '../utils/command-invocation.js'; interface EnvironmentNode { id: string; @@ -26,21 +25,13 @@ interface EnvironmentNode { sandbox?: boolean | null; } -function requireToken(): string { - const token = getAccessToken(); - if (!token) { - exitWithAuthRequired(`Not logged in. Run \`${formatWorkOSCommand('auth login')}\` to authenticate.`); - } - return token; -} - export interface EnvironmentCreateOptions { name: string; sandbox: boolean; } export async function runEnvironmentCreate(options: EnvironmentCreateOptions): Promise { - const token = requireToken(); + const token = await requireCommandToken(); const op = getOperation('createEnvironment'); let data: { createEnvironment: { environment: EnvironmentNode } }; @@ -68,7 +59,7 @@ export interface EnvironmentRenameOptions { } export async function runEnvironmentRename(options: EnvironmentRenameOptions): Promise { - const token = requireToken(); + const token = await requireCommandToken(); const op = getOperation('renameEnvironment'); let data: { renameEnvironment: { environment: EnvironmentNode } }; diff --git a/src/commands/project.spec.ts b/src/commands/project.spec.ts index fe1e9a35..53b106eb 100644 --- a/src/commands/project.spec.ts +++ b/src/commands/project.spec.ts @@ -1,11 +1,15 @@ import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'; -const mockGetAccessToken = vi.fn(); +const mockRequireCommandToken = vi.fn(); const mockGraphqlRequest = vi.fn(); -vi.mock('../lib/credentials.js', () => ({ - getAccessToken: () => mockGetAccessToken(), -})); +vi.mock('../lib/command-auth.js', async (importActual) => { + const actual = await importActual(); + return { + ...actual, + requireCommandToken: () => mockRequireCommandToken(), + }; +}); vi.mock('../lib/dashboard-graphql.js', async (importActual) => { const actual = await importActual(); @@ -42,7 +46,7 @@ describe('project command', () => { vi.clearAllMocks(); resetInteractionModeForTests(); setOutputMode('human'); - mockGetAccessToken.mockReturnValue('tok_123'); + mockRequireCommandToken.mockResolvedValue('tok_123'); consoleOutput = []; vi.spyOn(console, 'log').mockImplementation((...args: unknown[]) => { consoleOutput.push(args.map(String).join(' ')); diff --git a/src/commands/project.ts b/src/commands/project.ts index 8d995c97..cc634a51 100644 --- a/src/commands/project.ts +++ b/src/commands/project.ts @@ -9,28 +9,18 @@ */ import chalk from 'chalk'; -import { getAccessToken } from '../lib/credentials.js'; +import { requireCommandToken } from '../lib/command-auth.js'; import { dashboardGraphqlRequest } from '../lib/dashboard-graphql.js'; import { getOperation, resolveExecutableDocument, reportDashboardError } from '../catalog/operation.js'; import { requireConfirmationFlag } from '../catalog/confirm.js'; import { isJsonMode, outputJson, outputSuccess, exitWithError } from '../utils/output.js'; -import { exitWithAuthRequired } from '../utils/exit-codes.js'; import { formatTable } from '../utils/table.js'; -import { formatWorkOSCommand } from '../utils/command-invocation.js'; interface ProjectNode { id: string; name: string | null; } -function requireToken(): string { - const token = getAccessToken(); - if (!token) { - exitWithAuthRequired(`Not logged in. Run \`${formatWorkOSCommand('auth login')}\` to authenticate.`); - } - return token; -} - export interface ProjectCreateOptions { name: string; /** When false, only a staging environment is provisioned (no production env). */ @@ -43,7 +33,7 @@ export async function runProjectCreate(options: ProjectCreateOptions): Promise { - const token = requireToken(); + const token = await requireCommandToken(); const op = getOperation('renameProject'); let data: { @@ -130,7 +120,7 @@ interface ProjectListNode { } export async function runProjectList(): Promise { - const token = requireToken(); + const token = await requireCommandToken(); const op = getOperation('teamProjectsV2'); let data: { currentTeam: { id: string; projectsV2: ProjectListNode[] } | null }; diff --git a/src/commands/team.spec.ts b/src/commands/team.spec.ts index 209961c9..dd8659f6 100644 --- a/src/commands/team.spec.ts +++ b/src/commands/team.spec.ts @@ -1,13 +1,17 @@ import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'; -const mockGetAccessToken = vi.fn(); +const mockRequireCommandToken = vi.fn(); const mockGraphqlRequest = vi.fn(); const mockConfirm = vi.fn(); const mockIsCancel = vi.fn(() => false); -vi.mock('../lib/credentials.js', () => ({ - getAccessToken: () => mockGetAccessToken(), -})); +vi.mock('../lib/command-auth.js', async (importActual) => { + const actual = await importActual(); + return { + ...actual, + requireCommandToken: () => mockRequireCommandToken(), + }; +}); vi.mock('../lib/dashboard-graphql.js', async (importActual) => { const actual = await importActual(); @@ -57,7 +61,7 @@ describe('team command', () => { vi.clearAllMocks(); resetInteractionModeForTests(); setOutputMode('human'); - mockGetAccessToken.mockReturnValue('tok_123'); + mockRequireCommandToken.mockResolvedValue('tok_123'); mockConfirm.mockReset(); mockIsCancel.mockReset(); mockIsCancel.mockReturnValue(false); diff --git a/src/commands/team.ts b/src/commands/team.ts index df626416..b21ad528 100644 --- a/src/commands/team.ts +++ b/src/commands/team.ts @@ -11,27 +11,17 @@ */ import chalk from 'chalk'; -import { getAccessToken } from '../lib/credentials.js'; +import { requireCommandToken } from '../lib/command-auth.js'; import { dashboardGraphqlRequest } from '../lib/dashboard-graphql.js'; import { getOperation, resolveExecutableDocument, reportDashboardError } from '../catalog/operation.js'; import { confirmDestructive, requireConfirmationFlag } from '../catalog/confirm.js'; import { isJsonMode, outputJson, outputSuccess, exitWithError } from '../utils/output.js'; -import { exitWithAuthRequired } from '../utils/exit-codes.js'; import { formatTable } from '../utils/table.js'; -import { formatWorkOSCommand } from '../utils/command-invocation.js'; /** Dashboard team roles, mirroring the catalog `UsersOrganizationsRole` enum. */ export const TEAM_ROLES = ['ADMIN', 'MEMBER', 'MEMBER_SANDBOX', 'SUPPORT', 'SUPPORT_VIEWER'] as const; export type TeamRole = (typeof TEAM_ROLES)[number]; -function requireToken(): string { - const token = getAccessToken(); - if (!token) { - exitWithAuthRequired(`Not logged in. Run \`${formatWorkOSCommand('auth login')}\` to authenticate.`); - } - return token; -} - function normalizeRole(role: string): TeamRole { const upper = role.toUpperCase(); if (!(TEAM_ROLES as readonly string[]).includes(upper)) { @@ -52,7 +42,7 @@ interface MembershipNode { } export async function runTeamMembers(): Promise { - const token = requireToken(); + const token = await requireCommandToken(); const op = getOperation('teamMemberships'); let data: { currentTeam: { memberships: MembershipNode[] } | null }; @@ -97,7 +87,7 @@ export interface TeamInviteOptions { export async function runTeamInvite(options: TeamInviteOptions): Promise { const role = normalizeRole(options.role); - const token = requireToken(); + const token = await requireCommandToken(); const op = getOperation('inviteUserToTeam'); let data: { @@ -157,7 +147,7 @@ export async function runTeamChangeRole(options: TeamChangeRoleOptions): Promise // require-flag: a privilege change; non-interactive callers must pass --yes. await requireConfirmationFlag(options, { action: `change the role of ${options.membershipId} to ${role}` }); - const token = requireToken(); + const token = await requireCommandToken(); const op = getOperation('changeRole'); let data: { changeRole: { id: string; role: string | null } }; @@ -187,7 +177,7 @@ export async function runTeamRemove(options: TeamRemoveOptions): Promise { // Destructive: revokes the member's access. Prompt (or require --yes). await confirmDestructive(options, { action: `remove member ${options.membershipId} from the team` }); - const token = requireToken(); + const token = await requireCommandToken(); const op = getOperation('removeUserFromTeam'); try { @@ -211,7 +201,7 @@ export interface TeamResendInviteOptions { } export async function runTeamResendInvite(options: TeamResendInviteOptions): Promise { - const token = requireToken(); + const token = await requireCommandToken(); const op = getOperation('resendDashboardInvite'); let data: { resendDashboardInvite: { __typename: string } }; @@ -247,7 +237,7 @@ export interface TeamUpdateOptions { } export async function runTeamUpdate(options: TeamUpdateOptions): Promise { - const token = requireToken(); + const token = await requireCommandToken(); const op = getOperation('updateTeamDetails'); let data: { @@ -293,7 +283,7 @@ export async function runTeamSetMfa(options: TeamSetMfaOptions): Promise { action: `set MFA requirement to ${options.required ? 'required' : 'not required'}`, }); - const token = requireToken(); + const token = await requireCommandToken(); const op = getOperation('updateTeamMfaRequirement'); let data: { diff --git a/src/commands/whoami.spec.ts b/src/commands/whoami.spec.ts index 2a6ad81d..6bf5a248 100644 --- a/src/commands/whoami.spec.ts +++ b/src/commands/whoami.spec.ts @@ -1,11 +1,15 @@ import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'; -const mockGetAccessToken = vi.fn(); +const mockRequireCommandToken = vi.fn(); const mockGraphqlRequest = vi.fn(); -vi.mock('../lib/credentials.js', () => ({ - getAccessToken: () => mockGetAccessToken(), -})); +vi.mock('../lib/command-auth.js', async (importActual) => { + const actual = await importActual(); + return { + ...actual, + requireCommandToken: () => mockRequireCommandToken(), + }; +}); // Replace only the request function; keep the real DashboardGraphqlError so the // command's `instanceof` check matches what the tests throw. @@ -51,20 +55,24 @@ describe('whoami command', () => { }); it('exits auth-required (code 4) when not logged in', async () => { - mockGetAccessToken.mockReturnValue(null); + // requireCommandToken never returns without a usable session: it throws + // a structured exit-4 (see command-auth.spec.ts for the full matrix). + mockRequireCommandToken.mockImplementation(() => { + throw new CliExit(4, { reason: 'auth_required', errorCode: 'auth_required' }); + }); await expect(runWhoami()).rejects.toMatchObject({ name: 'CliExit', exitCode: 4 }); expect(mockGraphqlRequest).not.toHaveBeenCalled(); }); it('calls the dashboard GraphQL API with the bearer token', async () => { - mockGetAccessToken.mockReturnValue('tok_123'); + mockRequireCommandToken.mockResolvedValue('tok_123'); mockGraphqlRequest.mockResolvedValue(sampleData()); await runWhoami(); expect(mockGraphqlRequest).toHaveBeenCalledWith(expect.stringContaining('workosCliWhoami'), { token: 'tok_123' }); }); it('renders user, team, and environment in human mode', async () => { - mockGetAccessToken.mockReturnValue('tok'); + mockRequireCommandToken.mockResolvedValue('tok'); mockGraphqlRequest.mockResolvedValue(sampleData()); await runWhoami(); const out = consoleOutput.join('\n'); @@ -76,7 +84,7 @@ describe('whoami command', () => { }); it('omits the team and environment blocks when absent', async () => { - mockGetAccessToken.mockReturnValue('tok'); + mockRequireCommandToken.mockResolvedValue('tok'); mockGraphqlRequest.mockResolvedValue({ ...sampleData(), currentTeam: null, currentEnvironment: null }); await runWhoami(); const out = consoleOutput.join('\n'); @@ -85,14 +93,18 @@ describe('whoami command', () => { expect(out).not.toContain('Environment'); }); - it('explains the gated-capability case on a 403', async () => { - mockGetAccessToken.mockReturnValue('tok'); + it('explains the gated-capability case on a 403 without naming GraphQL', async () => { + mockRequireCommandToken.mockResolvedValue('tok'); mockGraphqlRequest.mockRejectedValue( new DashboardGraphqlError('The dashboard GraphQL API rejected this session (HTTP 403).', 'forbidden', 403), ); await expect(runWhoami()).rejects.toBeInstanceOf(CliExit); const err = consoleErrors.join('\n'); - expect(err).toContain('staging only'); + // Shared taxonomy copy: capability off for the team / account not + // team-backed — with no stale staging claim and no GraphQL leak. + expect(err).toMatch(/account-plane capability/i); + expect(err).not.toMatch(/staging/i); + expect(err).not.toMatch(/graphql/i); }); describe('JSON output mode', () => { @@ -105,7 +117,7 @@ describe('whoami command', () => { }); it('outputs user/team/environment as JSON', async () => { - mockGetAccessToken.mockReturnValue('tok'); + mockRequireCommandToken.mockResolvedValue('tok'); mockGraphqlRequest.mockResolvedValue(sampleData()); await runWhoami(); const output = JSON.parse(consoleOutput[0]); @@ -116,7 +128,7 @@ describe('whoami command', () => { }); it('preserves null team/environment in JSON', async () => { - mockGetAccessToken.mockReturnValue('tok'); + mockRequireCommandToken.mockResolvedValue('tok'); mockGraphqlRequest.mockResolvedValue({ ...sampleData(), currentTeam: null, currentEnvironment: null }); await runWhoami(); const output = JSON.parse(consoleOutput[0]); diff --git a/src/commands/whoami.ts b/src/commands/whoami.ts index 24c79613..65fd81d3 100644 --- a/src/commands/whoami.ts +++ b/src/commands/whoami.ts @@ -8,11 +8,10 @@ */ import chalk from 'chalk'; -import { getAccessToken } from '../lib/credentials.js'; -import { dashboardGraphqlRequest, DashboardGraphqlError } from '../lib/dashboard-graphql.js'; -import { isJsonMode, outputJson, exitWithError } from '../utils/output.js'; -import { exitWithAuthRequired } from '../utils/exit-codes.js'; -import { formatWorkOSCommand } from '../utils/command-invocation.js'; +import { requireCommandToken } from '../lib/command-auth.js'; +import { dashboardGraphqlRequest } from '../lib/dashboard-graphql.js'; +import { reportDashboardError } from '../catalog/operation.js'; +import { isJsonMode, outputJson } from '../utils/output.js'; // Every field below is read straight from the bearer guard's request context // (`context.user` / `context.team` / `context.environment`), so all three root @@ -63,21 +62,15 @@ interface WhoamiData { } export async function runWhoami(): Promise { - const token = getAccessToken(); - if (!token) { - // Read-only by design: a missing/expired token is reported, not silently - // refreshed. Exit 4 follows the CLI's auth-required convention. - exitWithAuthRequired(`Not logged in. Run \`${formatWorkOSCommand('auth login')}\` to authenticate.`); - } + // Resolve a usable bearer, silently refreshing an expired access token when + // a valid refresh token exists; exits 4 when no usable session remains. + const token = await requireCommandToken(); let data: WhoamiData; try { data = await dashboardGraphqlRequest(WHOAMI_QUERY, { token }); } catch (error) { - if (error instanceof DashboardGraphqlError) { - reportGraphqlError(error); - } - throw error; + reportDashboardError(error); } if (isJsonMode()) { @@ -92,15 +85,6 @@ export async function runWhoami(): Promise { renderHuman(data); } -function reportGraphqlError(error: DashboardGraphqlError): never { - const message = - error.code === 'forbidden' - ? `${error.message} This requires the dashboard OAuth bearer capability to be enabled for this API host ` + - '(currently staging only) and a token belonging to a WorkOS dashboard account with a team.' - : error.message; - exitWithError({ code: error.code, message }); -} - function renderHuman(data: WhoamiData): void { const { me, currentTeam, currentEnvironment } = data; diff --git a/src/lib/command-auth.spec.ts b/src/lib/command-auth.spec.ts new file mode 100644 index 00000000..1f711574 --- /dev/null +++ b/src/lib/command-auth.spec.ts @@ -0,0 +1,336 @@ +import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'; +import { existsSync, unlinkSync, rmdirSync, mkdtempSync } from 'node:fs'; +import { join } from 'node:path'; +import { tmpdir } from 'node:os'; +import type { Credentials } from './credentials.js'; + +// Create a mock home directory for all tests (same fixture pattern as +// ensure-auth.spec.ts: real credential store, file-backed, isolated homedir). +let testDir: string; +let installerDir: string; +let credentialsFile: string; + +vi.mock('node:os', async (importOriginal) => { + const original = await importOriginal(); + return { + ...original, + default: { + ...original, + homedir: () => testDir, + }, + homedir: () => testDir, + }; +}); + +vi.mock('../utils/debug.js', () => ({ + logInfo: vi.fn(), + logError: vi.fn(), + logWarn: vi.fn(), +})); + +vi.mock('./settings.js', () => ({ + getCliAuthClientId: vi.fn(() => 'test_client_id'), + getAuthkitDomain: vi.fn(() => 'https://auth.test.com'), + getConfig: vi.fn(() => ({ + nodeVersion: '>=20', + logging: { debugMode: false }, + documentation: { workosDocsUrl: '', dashboardUrl: '', issuesUrl: '' }, + telemetry: { enabled: false, eventName: '' }, + legacy: { oauthPort: 0 }, + })), +})); + +const mockRefreshAccessToken = vi.fn(); +vi.mock('./token-refresh-client.js', () => ({ + refreshAccessToken: (...args: unknown[]) => mockRefreshAccessToken(...args), +})); + +// Pass-through spy: keep the REAL credential store (file-backed via the mocked +// homedir) while recording updateTokens calls, so tests can pin "persisted +// exactly once" without faking the store. +const updateTokensSpy = vi.fn(); +vi.mock('./credentials.js', async (importActual) => { + const actual = await importActual(); + return { + ...actual, + updateTokens: (...args: Parameters) => { + updateTokensSpy(...args); + return actual.updateTokens(...args); + }, + }; +}); + +// Import after mocks are set up. exit-codes is deliberately REAL: the matrix +// asserts the thrown CliExit's exit code and errorCode. +const { saveCredentials, getCredentials, setInsecureStorage, hasCredentials } = await import('./credentials.js'); +const { getCliAuthClientId } = await import('./settings.js'); +const { formatWorkOSCommand } = await import('../utils/command-invocation.js'); +const { CliExit } = await import('../utils/cli-exit.js'); +const { refreshIfExpired, requireCommandToken, DASHBOARD_ERROR_MESSAGES } = await import('./command-auth.js'); + +async function expectAuthExit(promise: Promise): Promise { + try { + await promise; + } catch (err) { + if (err instanceof CliExit) { + expect(err.exitCode).toBe(4); + expect(err.context?.errorCode).toBe('auth_required'); + return err; + } + throw err; + } + throw new Error('Expected CliExit(4) but promise resolved'); +} + +describe('command-auth', () => { + let consoleErrors: string[]; + + beforeEach(() => { + testDir = mkdtempSync(join(tmpdir(), 'command-auth-test-')); + installerDir = join(testDir, '.workos'); + credentialsFile = join(installerDir, 'credentials.json'); + vi.clearAllMocks(); + setInsecureStorage(true); + // Isolate from a developer machine where WORKOS_API_KEY may be exported. + vi.stubEnv('WORKOS_API_KEY', ''); + consoleErrors = []; + vi.spyOn(console, 'error').mockImplementation((...args: unknown[]) => { + consoleErrors.push(args.map(String).join(' ')); + }); + }); + + afterEach(() => { + vi.unstubAllEnvs(); + vi.restoreAllMocks(); + if (existsSync(credentialsFile)) unlinkSync(credentialsFile); + if (existsSync(installerDir)) rmdirSync(installerDir); + if (existsSync(testDir)) rmdirSync(testDir); + }); + + const validCreds: Credentials = { + accessToken: 'access_token_123', + expiresAt: Date.now() + 60 * 60 * 1000, + userId: 'user_abc', + email: 'test@example.com', + refreshToken: 'refresh_token_456', + }; + + const expiredCreds: Credentials = { + ...validCreds, + expiresAt: Date.now() - 1000, + }; + + const expiredCredsNoRefresh: Credentials = { + accessToken: 'access_token_123', + expiresAt: Date.now() - 1000, + userId: 'user_abc', + email: 'test@example.com', + }; + + describe('refreshIfExpired', () => { + it('returns the stored token without refreshing when still valid', async () => { + saveCredentials(validCreds); + + const session = await refreshIfExpired(); + + expect(session).toEqual({ accessToken: 'access_token_123', refreshed: false }); + expect(mockRefreshAccessToken).not.toHaveBeenCalled(); + }); + + it('returns null when no credentials exist', async () => { + const session = await refreshIfExpired(); + + expect(session).toBeNull(); + expect(mockRefreshAccessToken).not.toHaveBeenCalled(); + }); + + it('refreshes an expired token and persists the new tokens', async () => { + saveCredentials(expiredCreds); + mockRefreshAccessToken.mockResolvedValue({ + success: true, + accessToken: 'new_access_token', + expiresAt: Date.now() + 3_600_000, + refreshToken: 'new_refresh_token', + }); + + const session = await refreshIfExpired(); + + expect(session).toEqual({ accessToken: 'new_access_token', refreshed: true }); + expect(mockRefreshAccessToken).toHaveBeenCalledExactlyOnceWith('https://auth.test.com', 'test_client_id'); + expect(updateTokensSpy).toHaveBeenCalledExactlyOnceWith('new_access_token', expect.any(Number), 'new_refresh_token'); + const stored = getCredentials(); + expect(stored?.accessToken).toBe('new_access_token'); + expect(stored?.refreshToken).toBe('new_refresh_token'); + }); + + it('clears credentials and returns null on invalid_grant', async () => { + saveCredentials(expiredCreds); + mockRefreshAccessToken.mockResolvedValue({ + success: false, + errorType: 'invalid_grant', + error: 'Refresh token expired', + }); + + const session = await refreshIfExpired(); + + expect(session).toBeNull(); + expect(hasCredentials()).toBe(false); + }); + + it('keeps credentials and returns null on a network error', async () => { + saveCredentials(expiredCreds); + mockRefreshAccessToken.mockResolvedValue({ success: false, errorType: 'network', error: 'Network error' }); + + const session = await refreshIfExpired(); + + expect(session).toBeNull(); + expect(hasCredentials()).toBe(true); + }); + + it('keeps credentials and returns null on a server (5xx) error', async () => { + saveCredentials(expiredCreds); + mockRefreshAccessToken.mockResolvedValue({ success: false, errorType: 'server', error: 'Server error' }); + + const session = await refreshIfExpired(); + + expect(session).toBeNull(); + expect(hasCredentials()).toBe(true); + }); + + it('clears credentials when expired with no refresh token', async () => { + saveCredentials(expiredCredsNoRefresh); + + const session = await refreshIfExpired(); + + expect(session).toBeNull(); + expect(hasCredentials()).toBe(false); + expect(mockRefreshAccessToken).not.toHaveBeenCalled(); + }); + + it('clears credentials when the auth client config is missing', async () => { + saveCredentials(expiredCreds); + vi.mocked(getCliAuthClientId).mockReturnValueOnce(undefined as unknown as string); + + const session = await refreshIfExpired(); + + expect(session).toBeNull(); + expect(hasCredentials()).toBe(false); + expect(mockRefreshAccessToken).not.toHaveBeenCalled(); + }); + }); + + describe('requireCommandToken', () => { + it('resolves the stored token when still valid', async () => { + saveCredentials(validCreds); + + await expect(requireCommandToken()).resolves.toBe('access_token_123'); + expect(mockRefreshAccessToken).not.toHaveBeenCalled(); + }); + + it('silently refreshes an expired token and proceeds', async () => { + saveCredentials(expiredCreds); + mockRefreshAccessToken.mockResolvedValue({ + success: true, + accessToken: 'new_access_token', + expiresAt: Date.now() + 3_600_000, + refreshToken: 'new_refresh_token', + }); + + await expect(requireCommandToken()).resolves.toBe('new_access_token'); + expect(mockRefreshAccessToken).toHaveBeenCalledOnce(); + // Tokens were persisted exactly once via updateTokens. + expect(updateTokensSpy).toHaveBeenCalledOnce(); + expect(getCredentials()?.accessToken).toBe('new_access_token'); + }); + + it('exits 4 and clears credentials when refresh fails with invalid_grant', async () => { + saveCredentials(expiredCreds); + mockRefreshAccessToken.mockResolvedValue({ + success: false, + errorType: 'invalid_grant', + error: 'Refresh token expired', + }); + + await expectAuthExit(requireCommandToken()); + + expect(hasCredentials()).toBe(false); + expect(consoleErrors.join('\n')).toContain(DASHBOARD_ERROR_MESSAGES.authRequired); + }); + + it('exits 4 but KEEPS credentials on a transient refresh failure, wording it as such', async () => { + saveCredentials(expiredCreds); + mockRefreshAccessToken.mockResolvedValue({ success: false, errorType: 'network', error: 'Network error' }); + + await expectAuthExit(requireCommandToken()); + + expect(hasCredentials()).toBe(true); + const err = consoleErrors.join('\n'); + // The message must say the refresh failed — not that the user is logged out. + expect(err).toContain(DASHBOARD_ERROR_MESSAGES.refreshFailed); + expect(err).toMatch(/refresh/i); + expect(err).not.toContain('Not logged in'); + // Both escape hatches, asserted independently of the constant's wording. + expect(err).toContain(`\`${formatWorkOSCommand('auth login')}\``); + expect(err).toContain(`\`${formatWorkOSCommand('api')}\``); + }); + + it('adds the API-key caveat to the transient-failure copy when WORKOS_API_KEY is set', async () => { + vi.stubEnv('WORKOS_API_KEY', 'sk_test_x'); + saveCredentials(expiredCreds); + mockRefreshAccessToken.mockResolvedValue({ success: false, errorType: 'network', error: 'Network error' }); + + await expectAuthExit(requireCommandToken()); + + expect(hasCredentials()).toBe(true); + const err = consoleErrors.join('\n'); + expect(err).toContain(DASHBOARD_ERROR_MESSAGES.refreshFailedApiKeySet); + expect(err).toContain('WORKOS_API_KEY'); + expect(err).toContain('does not accept API keys'); + expect(err).not.toContain('Not logged in'); + }); + + it('exits 4 with both escape hatches when no credentials exist', async () => { + await expectAuthExit(requireCommandToken()); + + const err = consoleErrors.join('\n'); + expect(err).toContain(DASHBOARD_ERROR_MESSAGES.authRequired); + // Asserted via the formatted commands, independent of the constant's wording. + expect(err).toContain(`\`${formatWorkOSCommand('auth login')}\``); + expect(err).toContain(`\`${formatWorkOSCommand('api')}\``); + expect(mockRefreshAccessToken).not.toHaveBeenCalled(); + }); + + it('explains that API keys do not work here when WORKOS_API_KEY is set', async () => { + vi.stubEnv('WORKOS_API_KEY', 'sk_test_x'); + + await expectAuthExit(requireCommandToken()); + + const err = consoleErrors.join('\n'); + expect(err).toContain(DASHBOARD_ERROR_MESSAGES.authRequiredApiKeySet); + expect(err).toContain('WORKOS_API_KEY'); + expect(err).toContain('does not accept API keys'); + expect(err).toContain(`\`${formatWorkOSCommand('auth login')}\``); + expect(err).toContain(`\`${formatWorkOSCommand('api')}\``); + }); + }); + + describe('DASHBOARD_ERROR_MESSAGES', () => { + it('exposes plain strings through Object.entries (leak-gate contract)', () => { + const entries = Object.entries(DASHBOARD_ERROR_MESSAGES); + expect(entries.length).toBeGreaterThanOrEqual(4); + for (const [key, message] of entries) { + expect(typeof message, key).toBe('string'); + expect(message.length, key).toBeGreaterThan(0); + } + }); + + it('every auth-required variant names both escape hatches', () => { + const login = `\`${formatWorkOSCommand('auth login')}\``; + const api = `\`${formatWorkOSCommand('api')}\``; + for (const key of ['authRequired', 'authRequiredApiKeySet', 'refreshFailed', 'refreshFailedApiKeySet'] as const) { + expect(DASHBOARD_ERROR_MESSAGES[key], key).toContain(login); + expect(DASHBOARD_ERROR_MESSAGES[key], key).toContain(api); + } + }); + }); +}); diff --git a/src/lib/command-auth.ts b/src/lib/command-auth.ts new file mode 100644 index 00000000..3f744044 --- /dev/null +++ b/src/lib/command-auth.ts @@ -0,0 +1,171 @@ +/** + * Command-path auth for dashboard-plane resource commands. + * + * `refreshIfExpired()` is the shared refresh core extracted from + * `ensure-auth.ts`: it owns "is this session usable, and can it be made usable + * silently?" and stays policy-free about what to do when it can't. The two + * callers own their dead-session policy: + * + * - Install flows (`ensureAuthenticated()` in `ensure-auth.ts`) launch the + * login flow (or exit 4 when prompting isn't allowed). + * - Resource commands (`requireCommandToken()` here) always exit 4 with a + * structured error — they never open a browser. + */ + +import { getCredentials, hasCredentials, updateTokens, isTokenExpired, clearCredentials } from './credentials.js'; +import { refreshAccessToken } from './token-refresh-client.js'; +import { getCliAuthClientId, getAuthkitDomain } from './settings.js'; +import { logInfo } from '../utils/debug.js'; +import { exitWithAuthRequired } from '../utils/exit-codes.js'; +import { formatWorkOSCommand } from '../utils/command-invocation.js'; + +export interface UsableSession { + accessToken: string; + /** True when the token was silently refreshed during this call. */ + refreshed: boolean; +} + +/** + * User-facing error copy for the dashboard (account-plane) command surface. + * + * Getters, not plain strings: the copy embeds `formatWorkOSCommand()`, which + * inspects the npm environment at call time (`workos` vs `npx workos@latest`), + * so evaluation must be deferred to access time. `Object.entries()` still + * yields plain strings, which `no-graphql-leak.spec.ts` iterates — never name + * GraphQL (or other internal terms) in these messages. + */ +export const DASHBOARD_ERROR_MESSAGES: Record< + 'authRequired' | 'authRequiredApiKeySet' | 'refreshFailed' | 'refreshFailedApiKeySet' | 'forbidden', + string +> = { + /** No usable session: never logged in, or the session is dead. */ + get authRequired() { + return ( + `Not logged in. Run \`${formatWorkOSCommand('auth login')}\` to authenticate, ` + + `or use \`${formatWorkOSCommand('api')}\` for API-key requests.` + ); + }, + /** + * Same as `authRequired`, but WORKOS_API_KEY is set — the one failure mode + * that must be self-explanatory: these commands act as the logged-in + * dashboard user and do not accept API keys. + */ + get authRequiredApiKeySet() { + return ( + 'Not logged in. WORKOS_API_KEY is set, but this command uses your WorkOS dashboard session ' + + `and does not accept API keys. Run \`${formatWorkOSCommand('auth login')}\` to authenticate, ` + + `or use \`${formatWorkOSCommand('api')}\` for API-key requests.` + ); + }, + /** Transient refresh failure: the session may still be fine — say so. */ + get refreshFailed() { + return ( + 'Could not refresh your session (network or server error). Your saved login was kept. ' + + `Try again, run \`${formatWorkOSCommand('auth login')}\` to re-authenticate, ` + + `or use \`${formatWorkOSCommand('api')}\` for API-key requests.` + ); + }, + /** Transient refresh failure while WORKOS_API_KEY is set: same, plus the API-key caveat. */ + get refreshFailedApiKeySet() { + return ( + 'Could not refresh your session (network or server error). Your saved login was kept. ' + + 'WORKOS_API_KEY is set, but this command uses your WorkOS dashboard session and does not accept API keys. ' + + `Try again, run \`${formatWorkOSCommand('auth login')}\` to re-authenticate, ` + + `or use \`${formatWorkOSCommand('api')}\` for API-key requests.` + ); + }, + /** 403 from the dashboard plane: capability off for the team, or no team. */ + get forbidden() { + return ( + 'This account-plane capability is not enabled for this team, ' + + 'or the logged-in account is not backed by a WorkOS dashboard team.' + ); + }, +}; + +/** + * Return a usable access token, refreshing silently when the stored one has + * expired; `null` when no usable session exists (or can be minted). + * + * Side effects mirror the original `ensure-auth.ts` semantics verbatim: + * - refresh success → tokens persisted via `updateTokens()` + * - `invalid_grant` → credentials cleared (the session is dead) + * - network/server refresh errors → credentials KEPT for retry + * - no credentials / no refresh path → credentials cleared (corrupt-file cleanup) + * + * Deliberately policy-free about the dead-session case: callers can derive the + * reason from observable store state (credentials surviving the call means the + * failure was transient). + */ +export async function refreshIfExpired(): Promise { + const creds = getCredentials(); + + // No credentials (or unreadable/corrupt) — clean up any leftover files. + if (!creds) { + clearCredentials(); + return null; + } + + if (!isTokenExpired(creds)) { + return { accessToken: creds.accessToken, refreshed: false }; + } + + if (creds.refreshToken) { + const clientId = getCliAuthClientId(); + const authkitDomain = getAuthkitDomain(); + + if (clientId && authkitDomain) { + logInfo('[token-lifecycle] Access token expired, attempting refresh'); + const refreshResult = await refreshAccessToken(authkitDomain, clientId); + + if (refreshResult.success && refreshResult.accessToken && refreshResult.expiresAt) { + updateTokens(refreshResult.accessToken, refreshResult.expiresAt, refreshResult.refreshToken); + return { accessToken: refreshResult.accessToken, refreshed: true }; + } + + if (refreshResult.errorType === 'invalid_grant') { + // The refresh token was rejected: the session is dead, and stale + // credentials would only mislead the next invocation. + logInfo('[token-lifecycle] Refresh token rejected (invalid_grant), clearing credentials'); + clearCredentials(); + return null; + } + + // Network or server error — transient. A flaky network must not + // silently delete a working session. + logInfo(`[token-lifecycle] Refresh failed (${refreshResult.errorType}), keeping credentials for retry`); + return null; + } + } + + // Expired with no refresh token (or no client config to refresh with). + logInfo('[token-lifecycle] No usable refresh path, clearing credentials'); + clearCredentials(); + return null; +} + +/** + * Command-path guard: resolve a usable bearer token (refreshing silently if + * needed) or exit 4 with a structured `auth_required` error. Never opens a + * browser or triggers the login flow. Never returns on failure. + */ +export async function requireCommandToken(): Promise { + const hadSession = getCredentials() !== null; + + const session = await refreshIfExpired(); + if (session) { + return session.accessToken; + } + + // Credentials surviving the refresh attempt means the failure was transient + // (network/server); the user is NOT logged out and must not be told so. + if (hadSession && hasCredentials()) { + exitWithAuthRequired( + process.env.WORKOS_API_KEY ? DASHBOARD_ERROR_MESSAGES.refreshFailedApiKeySet : DASHBOARD_ERROR_MESSAGES.refreshFailed, + ); + } + + exitWithAuthRequired( + process.env.WORKOS_API_KEY ? DASHBOARD_ERROR_MESSAGES.authRequiredApiKeySet : DASHBOARD_ERROR_MESSAGES.authRequired, + ); +} diff --git a/src/lib/dashboard-graphql.ts b/src/lib/dashboard-graphql.ts index b342f129..ab35bff2 100644 --- a/src/lib/dashboard-graphql.ts +++ b/src/lib/dashboard-graphql.ts @@ -7,9 +7,10 @@ * that REST commands use via the WorkOS SDK. The dashboard `/graphql` endpoint * accepts the bearer through `DashboardOAuthBearerGuard`. * - * The capability is gated server-side by a feature flag (enabled in staging), - * so a 403 here is the expected outcome wherever the flag is off — callers - * should surface that distinctly rather than as a generic failure. + * The capability is enabled in production but gated server-side by a per-team + * feature flag (fail-closed), so a 403 here remains an expected outcome + * wherever the flag is off for the caller's team — callers should surface that + * distinctly rather than as a generic failure. */ import { getWorkOSApiUrl } from '../utils/urls.js'; diff --git a/src/lib/ensure-auth.ts b/src/lib/ensure-auth.ts index b40323c4..4ab11ebb 100644 --- a/src/lib/ensure-auth.ts +++ b/src/lib/ensure-auth.ts @@ -1,10 +1,15 @@ /** * Startup auth guard - ensures valid authentication before command execution. + * + * Install-flow policy only: the expiry-check/refresh core lives in + * `command-auth.ts` (`refreshIfExpired`), shared with the resource-command + * guard `requireCommandToken()`. This wrapper owns what install flows do when + * no usable session exists: trigger the login flow (or exit 4 when prompting + * isn't allowed). */ -import { getCredentials, updateTokens, isTokenExpired, clearCredentials } from './credentials.js'; -import { refreshAccessToken } from './token-refresh-client.js'; -import { getCliAuthClientId, getAuthkitDomain } from './settings.js'; +import { getCredentials, hasCredentials } from './credentials.js'; +import { refreshIfExpired } from './command-auth.js'; import { runLogin } from '../commands/login.js'; import { logInfo } from '../utils/debug.js'; import { isAgentMode, isCiMode, isPromptAllowed } from '../utils/interaction-mode.js'; @@ -22,14 +27,11 @@ export interface EnsureAuthResult { } /** - * Ensure valid authentication before command execution. - * - * - No credentials: triggers login flow - * - Expired access token (valid refresh): silently refreshes - * - Expired refresh token: triggers login flow - * - * @returns Result indicating what actions were taken - * @throws Error if login fails or refresh fails unexpectedly + * NOTE: the "set WORKOS_API_KEY" hints below stay: `ensureAuthenticated()` is + * consumed only by install flows (via `resolveInstallCredentials`), which + * honor WORKOS_API_KEY through an env-var early return. The + * API-keys-don't-work-here copy applies only to dashboard-plane resource + * commands and lives in `command-auth.ts`. */ function exitForAuthRequired(message?: string): never { if (isCiMode()) { @@ -48,6 +50,16 @@ function exitForAuthRequired(message?: string): never { exitWithAuthRequired(message); } +/** + * Ensure valid authentication before command execution. + * + * - No credentials: triggers login flow + * - Expired access token (valid refresh): silently refreshes + * - Expired refresh token: triggers login flow + * + * @returns Result indicating what actions were taken + * @throws Error if login fails or refresh fails unexpectedly + */ export async function ensureAuthenticated(): Promise { const result: EnsureAuthResult = { authenticated: false, @@ -57,86 +69,48 @@ export async function ensureAuthenticated(): Promise { await warnIfSandboxed(); - // Case 1: No credentials or invalid credentials - const creds = getCredentials(); - if (!creds) { - clearCredentials(); // Clean up any corrupt/empty files - if (!isPromptAllowed()) { - exitForAuthRequired(); - } - logInfo('[ensure-auth] No valid credentials found, triggering login'); - await runLogin(); - result.loginTriggered = true; - result.authenticated = getCredentials() !== null; - return result; - } + // Snapshot before the refresh core runs: afterwards, store state alone + // cannot distinguish "never logged in" from "session died and was cleared". + const hadCredentials = getCredentials() !== null; - // Case 2: Access token still valid - if (!isTokenExpired(creds)) { + const session = await refreshIfExpired(); + if (session) { result.authenticated = true; + result.tokenRefreshed = session.refreshed; return result; } - // Case 3: Access token expired, try refresh - if (creds.refreshToken) { - logInfo('[ensure-auth] Access token expired, attempting refresh'); - - const clientId = getCliAuthClientId(); - const authkitDomain = getAuthkitDomain(); - - if (clientId && authkitDomain) { - const refreshResult = await refreshAccessToken(authkitDomain, clientId); - - if (refreshResult.success && refreshResult.accessToken && refreshResult.expiresAt) { - updateTokens(refreshResult.accessToken, refreshResult.expiresAt, refreshResult.refreshToken); - result.tokenRefreshed = true; - result.authenticated = true; - return result; - } - - // Refresh failed - check if it's recoverable - if (refreshResult.errorType === 'invalid_grant') { - clearCredentials(); - if (!isPromptAllowed()) { - exitForAuthRequired( - isCiMode() - ? 'Session expired. Refresh credentials before running in CI, or set WORKOS_API_KEY.' - : `Session expired. Run \`${formatWorkOSCommand('auth login')}\` on the host shell or set WORKOS_API_KEY.`, - ); - } - logInfo('[ensure-auth] Refresh token expired, triggering login'); - await runLogin(); - result.loginTriggered = true; - result.authenticated = getCredentials() !== null; - return result; - } - - // Network or server error - keep credentials intact for retry - if (!isPromptAllowed()) { - exitForAuthRequired( - isCiMode() - ? `Authentication refresh failed (${refreshResult.errorType}). Refresh credentials before running in CI, or set WORKOS_API_KEY.` - : `Authentication refresh failed (${refreshResult.errorType}). Run \`${formatWorkOSCommand('auth login')}\` on the host shell or set WORKOS_API_KEY.`, - ); - } - logInfo(`[ensure-auth] Refresh failed (${refreshResult.errorType}), triggering login`); - await runLogin(); - result.loginTriggered = true; - result.authenticated = getCredentials() !== null; - return result; + // No usable session. Derive the case from observable store state — the + // shared core keeps credentials only on transient refresh failures. + if (!hadCredentials) { + // Never logged in (corrupt credential files were already cleaned up). + if (!isPromptAllowed()) { + exitForAuthRequired(); + } + logInfo('[ensure-auth] No valid credentials found, triggering login'); + } else if (hasCredentials()) { + // Credentials survived the attempt: transient refresh failure. + if (!isPromptAllowed()) { + exitForAuthRequired( + isCiMode() + ? 'Authentication refresh failed. Refresh credentials before running in CI, or set WORKOS_API_KEY.' + : `Authentication refresh failed. Run \`${formatWorkOSCommand('auth login')}\` on the host shell or set WORKOS_API_KEY.`, + ); } + logInfo('[ensure-auth] Refresh failed, triggering login'); + } else { + // Credentials were cleared: the session is dead (expired refresh token, + // no refresh token, or no client config to refresh with). + if (!isPromptAllowed()) { + exitForAuthRequired( + isCiMode() + ? 'Session expired. Refresh credentials before running in CI, or set WORKOS_API_KEY.' + : `Session expired. Run \`${formatWorkOSCommand('auth login')}\` on the host shell or set WORKOS_API_KEY.`, + ); + } + logInfo('[ensure-auth] Session expired, triggering login'); } - // Case 4: No refresh token available — clear stale creds, must login - clearCredentials(); - if (!isPromptAllowed()) { - exitForAuthRequired( - isCiMode() - ? 'Session expired. Refresh credentials before running in CI, or set WORKOS_API_KEY.' - : `Session expired. Run \`${formatWorkOSCommand('auth login')}\` on the host shell or set WORKOS_API_KEY.`, - ); - } - logInfo('[ensure-auth] No refresh token, triggering login'); await runLogin(); result.loginTriggered = true; result.authenticated = getCredentials() !== null; diff --git a/src/utils/command-hints-guard.spec.ts b/src/utils/command-hints-guard.spec.ts index f006b29b..34471823 100644 --- a/src/utils/command-hints-guard.spec.ts +++ b/src/utils/command-hints-guard.spec.ts @@ -26,6 +26,8 @@ const ALLOWLIST = new Set([ 'utils/help-json.ts :: workos api', // examples array (mirrors bin.ts) 'commands/seed.ts :: workos seed', // SEED_TEMPLATE persisted-file comment 'emulate/workos/index.ts :: workos seed', // prose error label, not a runnable command + 'commands/whoami.ts :: workos user', // "workos user:" display label for the WorkOS user ID, not a command hint + 'commands/debug.ts :: workos dev', // prose in an env-var effect description, not a runnable hint ]); async function discover(): Promise> { From e62883897ddca1e196f8de5490fcca8a5ef4500a Mon Sep 17 00:00:00 2001 From: Nick Nisi Date: Wed, 22 Jul 2026 17:26:37 -0500 Subject: [PATCH 10/22] feat: target the active environment on dashboard-plane commands 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). --- src/bin.ts | 48 ++-- src/catalog/operation.ts | 9 + src/commands/authkit.spec.ts | 91 +++++-- src/commands/authkit.ts | 88 +++++-- src/commands/env.spec.ts | 50 ++++ src/commands/env.ts | 16 ++ src/commands/environment.spec.ts | 42 +++- src/commands/environment.ts | 23 +- src/commands/login.spec.ts | 31 +++ src/commands/login.ts | 9 + src/commands/project.spec.ts | 8 + src/commands/project.ts | 7 + src/commands/team.spec.ts | 8 + src/commands/team.ts | 10 + src/commands/whoami.spec.ts | 47 +++- src/commands/whoami.ts | 21 +- src/lib/config-store.ts | 23 ++ src/lib/dashboard-graphql.ts | 9 + src/lib/environment-target.spec.ts | 384 +++++++++++++++++++++++++++++ src/lib/environment-target.ts | 260 +++++++++++++++++++ src/utils/help-json.ts | 32 ++- 21 files changed, 1147 insertions(+), 69 deletions(-) create mode 100644 src/lib/environment-target.spec.ts create mode 100644 src/lib/environment-target.ts diff --git a/src/bin.ts b/src/bin.ts index 25370417..925b6d5a 100644 --- a/src/bin.ts +++ b/src/bin.ts @@ -416,11 +416,15 @@ async function runCli(): Promise { .command( 'whoami', 'Show the authenticated user, team, and environment (dashboard session)', - (yargs) => yargs.options(insecureStorageOption), + (yargs) => + yargs.options(insecureStorageOption).option('environment-id', { + type: 'string', + describe: 'Environment ID to target (defaults to the active environment)', + }), async (argv) => { await applyInsecureStorage(argv.insecureStorage as boolean | undefined); const { runWhoami } = await import('./commands/whoami.js'); - await runWhoami(); + await runWhoami({ environmentId: argv.environmentId as string | undefined }); }, ) .command( @@ -435,11 +439,19 @@ async function runCli(): Promise { (y) => y .positional('name', { type: 'string', demandOption: true, describe: 'Environment name' }) - .option('sandbox', { type: 'boolean', default: false, describe: 'Create a sandbox environment' }), + .option('sandbox', { type: 'boolean', default: false, describe: 'Create a sandbox environment' }) + .option('environment-id', { + type: 'string', + describe: 'Environment ID whose project receives the new environment (defaults to the active environment)', + }), async (argv) => { await applyInsecureStorage(argv.insecureStorage); const { runEnvironmentCreate } = await import('./commands/environment.js'); - await runEnvironmentCreate({ name: argv.name, sandbox: Boolean(argv.sandbox) }); + await runEnvironmentCreate({ + name: argv.name, + sandbox: Boolean(argv.sandbox), + environmentId: argv.environmentId as string | undefined, + }); }, ); registerSubcommand( @@ -525,13 +537,13 @@ async function runCli(): Promise { 'List configured redirect URIs for an environment', (y) => y - .option('environment-id', { type: 'string', demandOption: true, describe: 'Environment ID' }) + .option('environment-id', { type: 'string', describe: 'Environment ID (defaults to the active environment)' }) .option('limit', { type: 'number', describe: 'Maximum number of URIs to return' }), async (argv) => { await applyInsecureStorage(argv.insecureStorage); const { runAuthkitRedirectUrisList } = await import('./commands/authkit.js'); await runAuthkitRedirectUrisList({ - environmentId: argv.environmentId as string, + environmentId: argv.environmentId as string | undefined, limit: argv.limit as number | undefined, }); }, @@ -542,7 +554,7 @@ async function runCli(): Promise { 'Set the allowed redirect URIs for an environment (replaces the full list)', (y) => y - .option('environment-id', { type: 'string', demandOption: true, describe: 'Environment ID' }) + .option('environment-id', { type: 'string', describe: 'Environment ID (defaults to the active environment)' }) .option('uri', { type: 'string', array: true, demandOption: true, describe: 'Redirect URI (repeatable)' }) .option('default', { type: 'string', describe: 'Which URI to mark as the default' }) .option('dry-run', { type: 'boolean', default: false, describe: 'Validate without saving' }), @@ -550,7 +562,7 @@ async function runCli(): Promise { await applyInsecureStorage(argv.insecureStorage); const { runAuthkitRedirectUrisSet } = await import('./commands/authkit.js'); await runAuthkitRedirectUrisSet({ - environmentId: argv.environmentId as string, + environmentId: argv.environmentId as string | undefined, uris: argv.uri as string[], default: argv.default as string | undefined, dryRun: Boolean(argv.dryRun), @@ -565,11 +577,11 @@ async function runCli(): Promise { yargs, 'get', 'Show the allowed web origins (CORS) for an environment', - (y) => y.option('environment-id', { type: 'string', demandOption: true, describe: 'Environment ID' }), + (y) => y.option('environment-id', { type: 'string', describe: 'Environment ID (defaults to the active environment)' }), async (argv) => { await applyInsecureStorage(argv.insecureStorage); const { runAuthkitCorsGet } = await import('./commands/authkit.js'); - await runAuthkitCorsGet({ environmentId: argv.environmentId as string }); + await runAuthkitCorsGet({ environmentId: argv.environmentId as string | undefined }); }, ); registerSubcommand( @@ -578,14 +590,14 @@ async function runCli(): Promise { 'Set the allowed web origins (CORS) for an environment (replaces the full list)', (y) => y - .option('environment-id', { type: 'string', demandOption: true, describe: 'Environment ID' }) + .option('environment-id', { type: 'string', describe: 'Environment ID (defaults to the active environment)' }) .option('origin', { type: 'string', array: true, demandOption: true, describe: 'Web origin (repeatable)' }) .option('dry-run', { type: 'boolean', default: false, describe: 'Validate without saving' }), async (argv) => { await applyInsecureStorage(argv.insecureStorage); const { runAuthkitCorsSet } = await import('./commands/authkit.js'); await runAuthkitCorsSet({ - environmentId: argv.environmentId as string, + environmentId: argv.environmentId as string | undefined, origins: argv.origin as string[], dryRun: Boolean(argv.dryRun), }); @@ -601,13 +613,13 @@ async function runCli(): Promise { 'List configured logout URIs for an environment', (y) => y - .option('environment-id', { type: 'string', demandOption: true, describe: 'Environment ID' }) + .option('environment-id', { type: 'string', describe: 'Environment ID (defaults to the active environment)' }) .option('limit', { type: 'number', describe: 'Maximum number of URIs to return' }), async (argv) => { await applyInsecureStorage(argv.insecureStorage); const { runAuthkitLogoutUrisList } = await import('./commands/authkit.js'); await runAuthkitLogoutUrisList({ - environmentId: argv.environmentId as string, + environmentId: argv.environmentId as string | undefined, limit: argv.limit as number | undefined, }); }, @@ -618,7 +630,7 @@ async function runCli(): Promise { 'Set the allowed logout URIs for an environment (replaces the full list)', (y) => y - .option('environment-id', { type: 'string', demandOption: true, describe: 'Environment ID' }) + .option('environment-id', { type: 'string', describe: 'Environment ID (defaults to the active environment)' }) .option('uri', { type: 'string', array: true, demandOption: true, describe: 'Logout URI (repeatable)' }) .option('default', { type: 'string', describe: 'Which URI to mark as the default' }) .option('dry-run', { type: 'boolean', default: false, describe: 'Validate without saving' }), @@ -626,7 +638,7 @@ async function runCli(): Promise { await applyInsecureStorage(argv.insecureStorage); const { runAuthkitLogoutUrisSet } = await import('./commands/authkit.js'); await runAuthkitLogoutUrisSet({ - environmentId: argv.environmentId as string, + environmentId: argv.environmentId as string | undefined, uris: argv.uri as string[], default: argv.default as string | undefined, dryRun: Boolean(argv.dryRun), @@ -641,11 +653,11 @@ async function runCli(): Promise { yargs, 'get', 'Show AuthKit branding (logos, theme) for an environment', - (y) => y.option('environment-id', { type: 'string', demandOption: true, describe: 'Environment ID' }), + (y) => y.option('environment-id', { type: 'string', describe: 'Environment ID (defaults to the active environment)' }), async (argv) => { await applyInsecureStorage(argv.insecureStorage); const { runAuthkitBrandingGet } = await import('./commands/authkit.js'); - await runAuthkitBrandingGet({ environmentId: argv.environmentId as string }); + await runAuthkitBrandingGet({ environmentId: argv.environmentId as string | undefined }); }, ); return yargs.demandCommand(1, 'Please specify a branding subcommand').strict(); diff --git a/src/catalog/operation.ts b/src/catalog/operation.ts index 6b5cf352..21295742 100644 --- a/src/catalog/operation.ts +++ b/src/catalog/operation.ts @@ -13,6 +13,15 @@ import type { CatalogOperation, ManagementCatalog } from './catalog-types.js'; * returned string straight to `dashboardGraphqlRequest()` and otherwise treat it * as opaque (the no-graphql-leak contract is about user-facing strings, not the * wire document). + * + * Environment targeting contract: each call site classifies itself explicitly. + * Environment-scoped operations resolve their target through + * `resolveEnvironmentTarget()` (src/lib/environment-target.ts) and thread the + * resolved ID into `dashboardGraphqlRequest({ environmentId })`; the operation's + * `kind` ('query' | 'mutation', on {@link CatalogOperation}) drives the + * resolver's `forMutation` pre-validation. Team-scoped operations deliberately + * pass no `environmentId`. Never bypass the resolver for an environment-scoped + * call — a missing header silently targets the team's production environment. */ /** diff --git a/src/commands/authkit.spec.ts b/src/commands/authkit.spec.ts index 95ea9da1..0ee04d21 100644 --- a/src/commands/authkit.spec.ts +++ b/src/commands/authkit.spec.ts @@ -19,6 +19,18 @@ vi.mock('../lib/dashboard-graphql.js', async (importActual) => { }; }); +// Resolver matrix is covered in environment-target.spec.ts; these tests only +// assert the commands thread its output (flag override or profile default) +// into the request as both operation variable and environment header. +const mockResolveEnvironmentTarget = vi.fn(); +vi.mock('../lib/environment-target.js', async (importActual) => { + const actual = await importActual(); + return { + ...actual, + resolveEnvironmentTarget: (...args: unknown[]) => mockResolveEnvironmentTarget(...args), + }; +}); + const { setOutputMode } = await import('../utils/output.js'); const { DashboardGraphqlError } = await import('../lib/dashboard-graphql.js'); const { CliExit } = await import('../utils/cli-exit.js'); @@ -38,6 +50,10 @@ describe('authkit command', () => { beforeEach(() => { vi.clearAllMocks(); mockRequireCommandToken.mockResolvedValue('tok_123'); + mockResolveEnvironmentTarget.mockImplementation(async (_token: string, opts: { flagValue?: string }) => ({ + environmentId: opts.flagValue?.trim() || 'env_profile', + source: opts.flagValue?.trim() ? 'flag' : 'profile', + })); consoleOutput = []; vi.spyOn(console, 'log').mockImplementation((...args: unknown[]) => { consoleOutput.push(args.map(String).join(' ')); @@ -51,7 +67,7 @@ describe('authkit command', () => { }); describe('redirect-uris list', () => { - it('passes environmentId and renders the URIs', async () => { + it('passes environmentId (variable + header) and renders the URIs', async () => { mockGraphqlRequest.mockResolvedValue({ redirectUris: { data: [{ id: 'ru_1', uri: 'https://app.com/callback', isDefault: true }] }, }); @@ -59,12 +75,30 @@ describe('authkit command', () => { expect(mockGraphqlRequest).toHaveBeenCalledWith(expect.stringContaining('redirectUris'), { token: 'tok_123', variables: { environmentId: 'env_1' }, + environmentId: 'env_1', }); expect(consoleOutput.join('\n')).toContain('https://app.com/callback'); }); - it('rejects a missing --environment-id before calling the API', async () => { - await expect(runAuthkitRedirectUrisList({ environmentId: '' })).rejects.toBeInstanceOf(CliExit); + it('defaults environmentId from the active profile when the flag is omitted', async () => { + mockGraphqlRequest.mockResolvedValue({ redirectUris: { data: [] } }); + await runAuthkitRedirectUrisList({}); + expect(mockResolveEnvironmentTarget).toHaveBeenCalledWith('tok_123', { + flagValue: undefined, + forMutation: false, + }); + expect(mockGraphqlRequest).toHaveBeenCalledWith(expect.stringContaining('redirectUris'), { + token: 'tok_123', + variables: { environmentId: 'env_profile' }, + environmentId: 'env_profile', + }); + }); + + it('never calls the API when the environment target cannot be resolved', async () => { + mockResolveEnvironmentTarget.mockRejectedValue( + new CliExit(1, { reason: 'validation_error', errorCode: 'environment_unresolved' }), + ); + await expect(runAuthkitRedirectUrisList({})).rejects.toBeInstanceOf(CliExit); expect(mockGraphqlRequest).not.toHaveBeenCalled(); }); @@ -86,6 +120,12 @@ describe('authkit command', () => { expect(mockGraphqlRequest).toHaveBeenCalledWith(expect.stringContaining('setRedirectUris'), { token: 'tok_123', variables: { input: { environmentId: 'env_1', redirectUris: [{ uri: 'https://a.com/cb' }], dryRun: false } }, + environmentId: 'env_1', + }); + // Set operations are mutations: the resolver must pre-validate the target. + expect(mockResolveEnvironmentTarget).toHaveBeenCalledWith('tok_123', { + flagValue: 'env_1', + forMutation: true, }); // Selection-correctness: the leak test cannot catch a wrong op here (the // application-level op only leaks `userland` in its input *type* name), so @@ -171,6 +211,7 @@ describe('authkit command', () => { expect(mockGraphqlRequest).toHaveBeenCalledWith(expect.stringContaining('corsConfig'), { token: 'tok_123', variables: { environmentId: 'env_1' }, + environmentId: 'env_1', }); expect(consoleOutput.join('\n')).toContain('https://app.com'); }); @@ -181,6 +222,7 @@ describe('authkit command', () => { expect(mockGraphqlRequest).toHaveBeenCalledWith(expect.stringContaining('updateCorsConfig'), { token: 'tok_123', variables: { environmentId: 'env_1', origins: ['https://app.com'], dryRun: false }, + environmentId: 'env_1', }); }); @@ -203,6 +245,7 @@ describe('authkit command', () => { expect(mockGraphqlRequest).toHaveBeenCalledWith(expect.stringContaining('logoutUris'), { token: 'tok_123', variables: { environmentId: 'env_1' }, + environmentId: 'env_1', }); expect(consoleOutput.join('\n')).toContain('https://app.com/out'); }); @@ -215,6 +258,7 @@ describe('authkit command', () => { expect(mockGraphqlRequest).toHaveBeenCalledWith(expect.stringContaining('setLogoutUris'), { token: 'tok_123', variables: { input: { environmentId: 'env_1', logoutUris: [{ uri: 'https://app.com/out' }], dryRun: false } }, + environmentId: 'env_1', }); }); }); @@ -230,6 +274,7 @@ describe('authkit command', () => { expect(mockGraphqlRequest).toHaveBeenCalledWith(expect.stringContaining('environmentAppBranding'), { token: 'tok_123', variables: { environmentId: 'env_1' }, + environmentId: 'env_1', }); expect(consoleOutput.join('\n')).toContain('Acme'); }); @@ -248,25 +293,43 @@ describe('authkit command', () => { }); describe('required-flag validation (shared guards)', () => { - it('cors get requires --environment-id', async () => { - await expect(runAuthkitCorsGet({ environmentId: '' })).rejects.toBeInstanceOf(CliExit); - expect(mockGraphqlRequest).not.toHaveBeenCalled(); - }); it('cors set requires at least one --origin', async () => { await expect(runAuthkitCorsSet({ environmentId: 'env_1', origins: [] })).rejects.toBeInstanceOf(CliExit); expect(mockGraphqlRequest).not.toHaveBeenCalled(); }); - it('logout-uris list requires --environment-id', async () => { - await expect(runAuthkitLogoutUrisList({ environmentId: '' })).rejects.toBeInstanceOf(CliExit); - expect(mockGraphqlRequest).not.toHaveBeenCalled(); - }); it('logout-uris set requires at least one --uri', async () => { await expect(runAuthkitLogoutUrisSet({ environmentId: 'env_1', uris: [] })).rejects.toBeInstanceOf(CliExit); expect(mockGraphqlRequest).not.toHaveBeenCalled(); }); - it('branding get requires --environment-id', async () => { - await expect(runAuthkitBrandingGet({ environmentId: '' })).rejects.toBeInstanceOf(CliExit); - expect(mockGraphqlRequest).not.toHaveBeenCalled(); + }); + + describe('environment defaulting (previously required --environment-id)', () => { + it('cors get resolves the environment from the active profile', async () => { + mockGraphqlRequest.mockResolvedValue({ webOrigins: { webOrigins: { origins: [] } } }); + await runAuthkitCorsGet({}); + expect(mockGraphqlRequest).toHaveBeenCalledWith(expect.stringContaining('corsConfig'), { + token: 'tok_123', + variables: { environmentId: 'env_profile' }, + environmentId: 'env_profile', + }); + }); + it('logout-uris list resolves the environment from the active profile', async () => { + mockGraphqlRequest.mockResolvedValue({ logoutUris: { data: [] } }); + await runAuthkitLogoutUrisList({}); + expect(mockGraphqlRequest).toHaveBeenCalledWith(expect.stringContaining('logoutUris'), { + token: 'tok_123', + variables: { environmentId: 'env_profile' }, + environmentId: 'env_profile', + }); + }); + it('branding get resolves the environment from the active profile', async () => { + mockGraphqlRequest.mockResolvedValue({ environment: { appBranding: null } }); + await runAuthkitBrandingGet({}); + expect(mockGraphqlRequest).toHaveBeenCalledWith(expect.stringContaining('environmentAppBranding'), { + token: 'tok_123', + variables: { environmentId: 'env_profile' }, + environmentId: 'env_profile', + }); }); }); }); diff --git a/src/commands/authkit.ts b/src/commands/authkit.ts index 04b21ea0..cd0c812d 100644 --- a/src/commands/authkit.ts +++ b/src/commands/authkit.ts @@ -19,18 +19,11 @@ import chalk from 'chalk'; import { requireCommandToken } from '../lib/command-auth.js'; import { dashboardGraphqlRequest } from '../lib/dashboard-graphql.js'; +import { resolveEnvironmentTarget } from '../lib/environment-target.js'; import { getOperation, resolveExecutableDocument, reportDashboardError } from '../catalog/operation.js'; import { isJsonMode, outputJson, outputSuccess, exitWithError } from '../utils/output.js'; import { formatTable } from '../utils/table.js'; -/** Guard a required string flag at the handler level (also unit-testable). */ -function requireFlag(value: string | undefined, flag: string): string { - if (!value || value.trim() === '') { - exitWithError({ code: 'missing_argument', message: `${flag} is required.` }); - } - return value; -} - /** Guard a flag that must have at least one value. */ function requireAtLeastOne(values: string[] | undefined, flag: string): string[] { if (!values || values.length === 0) { @@ -86,20 +79,28 @@ function renderUriSetResult(items: UriNode[], noun: string, dryRun: boolean): vo // --- redirect URIs --- export interface RedirectUrisListOptions { - environmentId: string; + /** `--environment-id` override; defaults from the active profile. */ + environmentId?: string; limit?: number; } export async function runAuthkitRedirectUrisList(options: RedirectUrisListOptions): Promise { - const environmentId = requireFlag(options.environmentId, '--environment-id'); const token = await requireCommandToken(); const op = getOperation('redirectUris'); + // Environment-scoped: the resolved target rides as both the operation + // variable and the environment header. + const { environmentId } = await resolveEnvironmentTarget(token, { + flagValue: options.environmentId, + forMutation: op.kind === 'mutation', + }); + let data: { redirectUris: { data: UriNode[] } | null }; try { data = await dashboardGraphqlRequest(resolveExecutableDocument(op), { token, variables: { environmentId, ...(options.limit !== undefined ? { limit: options.limit } : {}) }, + environmentId, }); } catch (error) { reportDashboardError(error); @@ -114,19 +115,26 @@ export async function runAuthkitRedirectUrisList(options: RedirectUrisListOption } export interface RedirectUrisSetOptions { - environmentId: string; + /** `--environment-id` override; defaults from the active profile. */ + environmentId?: string; uris: string[]; default?: string; dryRun?: boolean; } export async function runAuthkitRedirectUrisSet(options: RedirectUrisSetOptions): Promise { - const environmentId = requireFlag(options.environmentId, '--environment-id'); const uris = requireAtLeastOne(options.uris, '--uri'); const dryRun = !!options.dryRun; const token = await requireCommandToken(); const op = getOperation('setRedirectUris'); + // Environment-scoped mutation: pre-validated resolved target, sent as both + // input field and environment header. + const { environmentId } = await resolveEnvironmentTarget(token, { + flagValue: options.environmentId, + forMutation: op.kind === 'mutation', + }); + let data: { setRedirectUris: | { __typename: 'RedirectUrisSet'; redirectUris: UriNode[] } @@ -138,6 +146,7 @@ export async function runAuthkitRedirectUrisSet(options: RedirectUrisSetOptions) data = await dashboardGraphqlRequest(resolveExecutableDocument(op), { token, variables: { input: { environmentId, redirectUris: toUriInputs(uris, options.default), dryRun } }, + environmentId, }); } catch (error) { reportDashboardError(error); @@ -160,19 +169,26 @@ export async function runAuthkitRedirectUrisSet(options: RedirectUrisSetOptions) // --- CORS --- export interface CorsGetOptions { - environmentId: string; + /** `--environment-id` override; defaults from the active profile. */ + environmentId?: string; } export async function runAuthkitCorsGet(options: CorsGetOptions): Promise { - const environmentId = requireFlag(options.environmentId, '--environment-id'); const token = await requireCommandToken(); const op = getOperation('corsConfig'); + // Environment-scoped: resolved target as variable + header. + const { environmentId } = await resolveEnvironmentTarget(token, { + flagValue: options.environmentId, + forMutation: op.kind === 'mutation', + }); + let data: { webOrigins: { webOrigins: { origins: string[] } | null } | null }; try { data = await dashboardGraphqlRequest(resolveExecutableDocument(op), { token, variables: { environmentId }, + environmentId, }); } catch (error) { reportDashboardError(error); @@ -191,18 +207,24 @@ export async function runAuthkitCorsGet(options: CorsGetOptions): Promise } export interface CorsSetOptions { - environmentId: string; + /** `--environment-id` override; defaults from the active profile. */ + environmentId?: string; origins: string[]; dryRun?: boolean; } export async function runAuthkitCorsSet(options: CorsSetOptions): Promise { - const environmentId = requireFlag(options.environmentId, '--environment-id'); const origins = requireAtLeastOne(options.origins, '--origin'); const dryRun = !!options.dryRun; const token = await requireCommandToken(); const op = getOperation('updateCorsConfig'); + // Environment-scoped mutation: pre-validated resolved target. + const { environmentId } = await resolveEnvironmentTarget(token, { + flagValue: options.environmentId, + forMutation: op.kind === 'mutation', + }); + let data: { setWebOrigins: | { __typename: 'WebOriginsSet'; origins: string[] } @@ -212,6 +234,7 @@ export async function runAuthkitCorsSet(options: CorsSetOptions): Promise data = await dashboardGraphqlRequest(resolveExecutableDocument(op), { token, variables: { environmentId, origins, dryRun }, + environmentId, }); } catch (error) { reportDashboardError(error); @@ -240,20 +263,27 @@ export async function runAuthkitCorsSet(options: CorsSetOptions): Promise // --- logout URIs --- export interface LogoutUrisListOptions { - environmentId: string; + /** `--environment-id` override; defaults from the active profile. */ + environmentId?: string; limit?: number; } export async function runAuthkitLogoutUrisList(options: LogoutUrisListOptions): Promise { - const environmentId = requireFlag(options.environmentId, '--environment-id'); const token = await requireCommandToken(); const op = getOperation('logoutUris'); + // Environment-scoped: resolved target as variable + header. + const { environmentId } = await resolveEnvironmentTarget(token, { + flagValue: options.environmentId, + forMutation: op.kind === 'mutation', + }); + let data: { logoutUris: { data: UriNode[] } | null }; try { data = await dashboardGraphqlRequest(resolveExecutableDocument(op), { token, variables: { environmentId, ...(options.limit !== undefined ? { limit: options.limit } : {}) }, + environmentId, }); } catch (error) { reportDashboardError(error); @@ -268,19 +298,25 @@ export async function runAuthkitLogoutUrisList(options: LogoutUrisListOptions): } export interface LogoutUrisSetOptions { - environmentId: string; + /** `--environment-id` override; defaults from the active profile. */ + environmentId?: string; uris: string[]; default?: string; dryRun?: boolean; } export async function runAuthkitLogoutUrisSet(options: LogoutUrisSetOptions): Promise { - const environmentId = requireFlag(options.environmentId, '--environment-id'); const uris = requireAtLeastOne(options.uris, '--uri'); const dryRun = !!options.dryRun; const token = await requireCommandToken(); const op = getOperation('setLogoutUris'); + // Environment-scoped mutation: pre-validated resolved target. + const { environmentId } = await resolveEnvironmentTarget(token, { + flagValue: options.environmentId, + forMutation: op.kind === 'mutation', + }); + let data: { setLogoutUris: | { __typename: 'LogoutUrisSet'; logoutUris: UriNode[] } @@ -290,6 +326,7 @@ export async function runAuthkitLogoutUrisSet(options: LogoutUrisSetOptions): Pr data = await dashboardGraphqlRequest(resolveExecutableDocument(op), { token, variables: { input: { environmentId, logoutUris: toUriInputs(uris, options.default), dryRun } }, + environmentId, }); } catch (error) { reportDashboardError(error); @@ -325,19 +362,26 @@ interface AppBranding { } export interface BrandingGetOptions { - environmentId: string; + /** `--environment-id` override; defaults from the active profile. */ + environmentId?: string; } export async function runAuthkitBrandingGet(options: BrandingGetOptions): Promise { - const environmentId = requireFlag(options.environmentId, '--environment-id'); const token = await requireCommandToken(); const op = getOperation('environmentAppBranding'); + // Environment-scoped: resolved target as variable + header. + const { environmentId } = await resolveEnvironmentTarget(token, { + flagValue: options.environmentId, + forMutation: op.kind === 'mutation', + }); + let data: { environment: { appBranding: AppBranding | null } | null }; try { data = await dashboardGraphqlRequest(resolveExecutableDocument(op), { token, variables: { environmentId }, + environmentId, }); } catch (error) { reportDashboardError(error); diff --git a/src/commands/env.spec.ts b/src/commands/env.spec.ts index fa856fee..c228247d 100644 --- a/src/commands/env.spec.ts +++ b/src/commands/env.spec.ts @@ -35,6 +35,17 @@ vi.mock('../lib/unclaimed-env-api.js', async (importOriginal) => { // Guard: runEnvProvision must NEVER write project .env files. vi.mock('../lib/env-writer.js', () => ({ writeCredentialsEnv: vi.fn() })); +// Best-effort dashboard environment resolution — full behavior is covered in +// environment-target.spec.ts; here we only assert the add/switch wiring. +const mockTryResolveProfileEnvironmentId = vi.fn(); +vi.mock('../lib/environment-target.js', async (importActual) => { + const actual = await importActual(); + return { + ...actual, + tryResolveProfileEnvironmentId: (...args: unknown[]) => mockTryResolveProfileEnvironmentId(...args), + }; +}); + let testDir: string; vi.mock('node:os', async (importOriginal) => { @@ -66,6 +77,8 @@ describe('env commands', () => { delete process.env.WORKOS_API_URL; delete process.env.WORKOS_API_BASE_URL; vi.clearAllMocks(); + // Default: logged out — resolution defers to first dashboard-command use. + mockTryResolveProfileEnvironmentId.mockResolvedValue(false); }); afterEach(() => { @@ -130,6 +143,18 @@ describe('env commands', () => { expect(clack.text).not.toHaveBeenCalled(); }); + it('attempts best-effort dashboard environment resolution for the new profile', async () => { + await runEnvAdd({ name: 'prod', apiKey: 'sk_live_abc' }); + expect(mockTryResolveProfileEnvironmentId).toHaveBeenCalledWith('prod', { allowPicker: true }); + }); + + it('never blocks profile creation when resolution defers (logged out)', async () => { + mockTryResolveProfileEnvironmentId.mockResolvedValue(false); + await runEnvAdd({ name: 'prod', apiKey: 'sk_live_abc' }); + // The profile write must land regardless of the resolution outcome. + expect(getConfig()?.environments.prod).toBeDefined(); + }); + it('does not include placeholder commands in missing-args recovery metadata', async () => { setOutputMode('json'); setInteractionMode({ mode: 'agent', source: 'env' }); @@ -225,6 +250,31 @@ describe('env commands', () => { await expect(runEnvSwitch('anything')).rejects.toThrow(CliExit); }); + it('attempts resolution when switching to a profile lacking an environmentId', async () => { + await runEnvAdd({ name: 'prod', apiKey: 'sk_live_abc' }); + await runEnvAdd({ name: 'sandbox', apiKey: 'sk_test_abc' }); + mockTryResolveProfileEnvironmentId.mockClear(); + await runEnvSwitch('sandbox'); + expect(mockTryResolveProfileEnvironmentId).toHaveBeenCalledWith('sandbox', { allowPicker: true }); + }); + + it('skips resolution when the target profile already stores an environmentId', async () => { + saveConfig({ + activeEnvironment: 'prod', + environments: { + prod: { name: 'prod', type: 'production', apiKey: 'sk_live_abc' }, + sandbox: { + name: 'sandbox', + type: 'sandbox', + apiKey: 'sk_test_abc', + environmentId: 'env_already', + }, + }, + }); + await runEnvSwitch('sandbox'); + expect(mockTryResolveProfileEnvironmentId).not.toHaveBeenCalled(); + }); + it('warns when WORKOS_API_KEY env var is set', async () => { const original = process.env.WORKOS_API_KEY; process.env.WORKOS_API_KEY = 'sk_test_override'; diff --git a/src/commands/env.ts b/src/commands/env.ts index 1cff02da..9505333d 100644 --- a/src/commands/env.ts +++ b/src/commands/env.ts @@ -13,6 +13,7 @@ import { UnclaimedEnvApiError, type UnclaimedEnvProvisionResult, } from '../lib/unclaimed-env-api.js'; +import { tryResolveProfileEnvironmentId } from '../lib/environment-target.js'; const ENV_NAME_REGEX = /^[a-z0-9\-_]+$/; @@ -100,6 +101,10 @@ export async function runEnvAdd(options: { if (isFirst) { clack.log.info(`Set as active environment`); } + // Best-effort dashboard environment resolution (clientId join or one-time + // picker). Never blocks profile creation — when logged out, resolution + // defers to first dashboard-command use. + await tryResolveProfileEnvironmentId(name, { allowPicker: true }); return; } @@ -122,6 +127,9 @@ export async function runEnvAdd(options: { } saveConfig(config); + // Best-effort dashboard environment resolution (join; picker only in human + // mode). Never blocks profile creation — defers to first dashboard use. + await tryResolveProfileEnvironmentId(name!, { allowPicker: true }); outputSuccess('Environment added', { name: name!, type, active: isFirst }); } @@ -273,6 +281,14 @@ export async function runEnvSwitch(name?: string): Promise { saveConfig(config); const env = config.environments[name]; + + // Switching to a profile that has never resolved its dashboard environment: + // attempt the clientId join (or the one-time picker in human mode) now. + // Best-effort — a logged-out switch defers resolution to first dashboard use. + if (!env.environmentId) { + await tryResolveProfileEnvironmentId(name, { allowPicker: true }); + } + const warnings = process.env.WORKOS_API_KEY ? [ { diff --git a/src/commands/environment.spec.ts b/src/commands/environment.spec.ts index 5ccbe979..91c319d1 100644 --- a/src/commands/environment.spec.ts +++ b/src/commands/environment.spec.ts @@ -19,6 +19,17 @@ vi.mock('../lib/dashboard-graphql.js', async (importActual) => { }; }); +// The resolver's own matrix lives in environment-target.spec.ts; commands only +// need to prove they thread its output into the request (variable + header). +const mockResolveEnvironmentTarget = vi.fn(); +vi.mock('../lib/environment-target.js', async (importActual) => { + const actual = await importActual(); + return { + ...actual, + resolveEnvironmentTarget: (...args: unknown[]) => mockResolveEnvironmentTarget(...args), + }; +}); + const { setOutputMode } = await import('../utils/output.js'); const { DashboardGraphqlError } = await import('../lib/dashboard-graphql.js'); const { CliExit } = await import('../utils/cli-exit.js'); @@ -30,6 +41,10 @@ describe('environment command', () => { beforeEach(() => { vi.clearAllMocks(); mockRequireCommandToken.mockResolvedValue('tok_123'); + mockResolveEnvironmentTarget.mockImplementation(async (_token: string, opts: { flagValue?: string }) => ({ + environmentId: opts.flagValue ?? 'env_profile', + source: opts.flagValue ? 'flag' : 'profile', + })); consoleOutput = []; vi.spyOn(console, 'log').mockImplementation((...args: unknown[]) => { consoleOutput.push(args.map(String).join(' ')); @@ -43,16 +58,33 @@ describe('environment command', () => { }); describe('create', () => { - it('maps name + sandbox to the createEnvironment input', async () => { + it('maps name + sandbox to the createEnvironment input and sends the resolved environment header', async () => { mockGraphqlRequest.mockResolvedValue({ createEnvironment: { environment: { id: 'env_1', name: 'Staging', sandbox: true } } }); await runEnvironmentCreate({ name: 'Staging', sandbox: true }); expect(mockGraphqlRequest).toHaveBeenCalledWith(expect.stringContaining('createEnvironment'), { token: 'tok_123', variables: { input: { name: 'Staging', isSandbox: true } }, + environmentId: 'env_profile', + }); + // Mutation: the resolver must have been asked to pre-validate. + expect(mockResolveEnvironmentTarget).toHaveBeenCalledWith('tok_123', { + flagValue: undefined, + forMutation: true, }); expect(consoleOutput.join('\n')).toContain('Staging'); }); + it('never issues the mutation when the target is stale/unresolved', async () => { + mockResolveEnvironmentTarget.mockRejectedValue( + new CliExit(1, { reason: 'validation_error', errorCode: 'environment_stale' }), + ); + await expect(runEnvironmentCreate({ name: 'Staging', sandbox: true })).rejects.toMatchObject({ + name: 'CliExit', + context: { errorCode: 'environment_stale' }, + }); + expect(mockGraphqlRequest).not.toHaveBeenCalled(); + }); + it('exits auth-required (code 4) when not logged in', async () => { // requireCommandToken never returns without a usable session: it throws // a structured exit-4 (see command-auth.spec.ts for the full matrix). @@ -92,12 +124,18 @@ describe('environment command', () => { }); describe('rename', () => { - it('maps environmentId + name to the renameEnvironment input', async () => { + it('maps environmentId + name to the renameEnvironment input and sends the header', async () => { mockGraphqlRequest.mockResolvedValue({ renameEnvironment: { environment: { id: 'env_1', name: 'Renamed' } } }); await runEnvironmentRename({ environmentId: 'env_1', name: 'Renamed' }); expect(mockGraphqlRequest).toHaveBeenCalledWith(expect.stringContaining('renameEnvironment'), { token: 'tok_123', variables: { input: { environmentId: 'env_1', name: 'Renamed' } }, + environmentId: 'env_1', + }); + // The explicit positional target is pre-validated like a flag value. + expect(mockResolveEnvironmentTarget).toHaveBeenCalledWith('tok_123', { + flagValue: 'env_1', + forMutation: true, }); expect(consoleOutput.join('\n')).toContain('Renamed'); }); diff --git a/src/commands/environment.ts b/src/commands/environment.ts index 2137122f..f6216ec9 100644 --- a/src/commands/environment.ts +++ b/src/commands/environment.ts @@ -16,6 +16,7 @@ import chalk from 'chalk'; import { requireCommandToken } from '../lib/command-auth.js'; import { dashboardGraphqlRequest } from '../lib/dashboard-graphql.js'; +import { resolveEnvironmentTarget } from '../lib/environment-target.js'; import { getOperation, resolveExecutableDocument, reportDashboardError } from '../catalog/operation.js'; import { isJsonMode, outputJson, outputSuccess } from '../utils/output.js'; @@ -28,17 +29,28 @@ interface EnvironmentNode { export interface EnvironmentCreateOptions { name: string; sandbox: boolean; + /** `--environment-id` override for this invocation. */ + environmentId?: string; } export async function runEnvironmentCreate(options: EnvironmentCreateOptions): Promise { const token = await requireCommandToken(); const op = getOperation('createEnvironment'); + // Environment-scoped mutation: the new environment's project placement + // derives from the request's environment context (CreateEnvironmentInput has + // no project field), so the target is resolved and pre-validated. + const target = await resolveEnvironmentTarget(token, { + flagValue: options.environmentId, + forMutation: op.kind === 'mutation', + }); + let data: { createEnvironment: { environment: EnvironmentNode } }; try { data = await dashboardGraphqlRequest(resolveExecutableDocument(op), { token, variables: { input: { name: options.name, isSandbox: options.sandbox } }, + environmentId: target.environmentId, }); } catch (error) { reportDashboardError(error); @@ -62,11 +74,20 @@ export async function runEnvironmentRename(options: EnvironmentRenameOptions): P const token = await requireCommandToken(); const op = getOperation('renameEnvironment'); + // Environment-scoped mutation: the explicit positional is the target — it is + // pre-validated against the team so a mistyped ID errors instead of hitting + // the server's silent production fallback. + const target = await resolveEnvironmentTarget(token, { + flagValue: options.environmentId, + forMutation: op.kind === 'mutation', + }); + let data: { renameEnvironment: { environment: EnvironmentNode } }; try { data = await dashboardGraphqlRequest(resolveExecutableDocument(op), { token, - variables: { input: { environmentId: options.environmentId, name: options.name } }, + variables: { input: { environmentId: target.environmentId, name: options.name } }, + environmentId: target.environmentId, }); } catch (error) { reportDashboardError(error); diff --git a/src/commands/login.spec.ts b/src/commands/login.spec.ts index dfd11893..ee2cc02a 100644 --- a/src/commands/login.spec.ts +++ b/src/commands/login.spec.ts @@ -59,6 +59,18 @@ vi.mock('./install-skill.js', () => ({ autoInstallSkills: vi.fn(), })); +// Best-effort environment resolution after provisioning — behavior is covered +// in environment-target.spec.ts; here we only assert the wiring (and keep the +// real resolver from issuing network requests). +const mockTryResolveProfileEnvironmentId = vi.fn(); +vi.mock('../lib/environment-target.js', async (importActual) => { + const actual = await importActual(); + return { + ...actual, + tryResolveProfileEnvironmentId: (...args: unknown[]) => mockTryResolveProfileEnvironmentId(...args), + }; +}); + vi.mock('../utils/output.js', () => ({ isJsonMode: vi.fn(() => false), exitWithError: vi.fn(), @@ -110,6 +122,7 @@ describe('login', () => { email: 'user@example.com', refreshToken: 'refresh_token', }); + mockTryResolveProfileEnvironmentId.mockResolvedValue(false); }); afterEach(() => { @@ -380,6 +393,24 @@ describe('login', () => { expect(nowUsing).toContain('user@example.com'); }); + it('attempts best-effort environment resolution for the provisioned staging profile', async () => { + setInteractionMode({ mode: 'human', source: 'env' }); + mockFetchStagingCredentials.mockResolvedValue({ clientId: 'client_user', apiKey: 'sk_test_user' }); + + await runLogin(); + + expect(mockTryResolveProfileEnvironmentId).toHaveBeenCalledWith('staging', { token: 'access_token' }); + }); + + it('skips environment resolution when provisioning failed', async () => { + setInteractionMode({ mode: 'human', source: 'env' }); + mockFetchStagingCredentials.mockRejectedValue(new Error('provisioning down')); + + await runLogin(); + + expect(mockTryResolveProfileEnvironmentId).not.toHaveBeenCalled(); + }); + it('switches the active env when the user confirms on a cross-account login', async () => { setInteractionMode({ mode: 'human', source: 'env' }); saveConfig({ diff --git a/src/commands/login.ts b/src/commands/login.ts index 592fa12e..95b02b9a 100644 --- a/src/commands/login.ts +++ b/src/commands/login.ts @@ -15,6 +15,7 @@ import { isAgentMode, isCiMode, isPromptAllowed } from '../utils/interaction-mod import { ExitCode, exitWithAuthRequired, exitWithCode } from '../utils/exit-codes.js'; import { requestDeviceCode, pollForToken, DeviceAuthTimeoutError } from '../lib/device-auth.js'; import { observeHostFailure } from '../lib/host-probe.js'; +import { tryResolveProfileEnvironmentId } from '../lib/environment-target.js'; /** * Best-effort skill install after a successful auth-login. @@ -238,6 +239,14 @@ export async function runLogin(): Promise { const account = { email: result.email, userId: result.userId }; const provision = await provisionStagingEnvironment(result.accessToken, account); + // Best-effort: stamp the provisioned profile with its dashboard environment + // ID (clientId join) so dashboard-plane commands target Staging instead of + // hitting the server's production fallback. Non-fatal — same posture as + // provisionStagingEnvironment; resolution defers to first use on failure. + if (provision.provisioned && provision.envName) { + await tryResolveProfileEnvironmentId(provision.envName, { token: result.accessToken }); + } + if (isJsonMode()) { outputJson({ status: 'ok', diff --git a/src/commands/project.spec.ts b/src/commands/project.spec.ts index 53b106eb..9785c69e 100644 --- a/src/commands/project.spec.ts +++ b/src/commands/project.spec.ts @@ -150,6 +150,14 @@ describe('project command', () => { expect(consoleOutput.join('\n')).toContain('No projects found.'); }); + it('sends NO environment header (team-scoped operation)', async () => { + mockGraphqlRequest.mockResolvedValue({ currentTeam: { id: 'team_1', projectsV2: [] } }); + await runProjectList(); + // Project lifecycle is team-level — an environment target must never ride + // along, or the guard would validate (and could reject) a spurious header. + expect(mockGraphqlRequest.mock.calls[0][1]).not.toHaveProperty('environmentId'); + }); + it('uses the curated (non-rotten) description, not the catalog rot string', () => { // teamProjectsV2's catalog description is wrong upstream ("Return the team // for the current dashboard session"). The curation override must win. diff --git a/src/commands/project.ts b/src/commands/project.ts index cc634a51..1d9ce359 100644 --- a/src/commands/project.ts +++ b/src/commands/project.ts @@ -6,6 +6,10 @@ * uses). `project create` provisions a project plus fresh environments, so it is * a `require-flag` operation: a non-interactive caller must pass `--yes` (the * "don't let a CI loop spawn many projects" guard). + * + * Every operation here is team-scoped (projects hang off the team, not an + * environment), so no environment header is sent (see + * `src/lib/environment-target.ts`). */ import chalk from 'chalk'; @@ -42,6 +46,7 @@ export async function runProjectCreate(options: ProjectCreateOptions): Promise { const op = getOperation('teamProjectsV2'); let data: { currentTeam: { id: string; projectsV2: ProjectListNode[] } | null }; + // Team-scoped operation: deliberately NO environment header (see environment-target.ts). try { data = await dashboardGraphqlRequest(resolveExecutableDocument(op), { token }); } catch (error) { diff --git a/src/commands/team.spec.ts b/src/commands/team.spec.ts index dd8659f6..76f880e7 100644 --- a/src/commands/team.spec.ts +++ b/src/commands/team.spec.ts @@ -100,6 +100,14 @@ describe('team command', () => { const out = JSON.parse(consoleOutput[0]); expect(out.members[0].id).toBe('uo_1'); }); + + it('sends NO environment header (team-scoped operation)', async () => { + mockGraphqlRequest.mockResolvedValue({ currentTeam: { memberships: [] } }); + await runTeamMembers(); + // Team-level ops must never carry an environment target — a spurious + // header would be validated (and could be rejected) by the guard. + expect(mockGraphqlRequest.mock.calls[0][1]).not.toHaveProperty('environmentId'); + }); }); describe('invite', () => { diff --git a/src/commands/team.ts b/src/commands/team.ts index b21ad528..79cc1296 100644 --- a/src/commands/team.ts +++ b/src/commands/team.ts @@ -8,6 +8,9 @@ * - `team remove` is destructive → `confirmDestructive` (prompt, or --yes). * - `team change-role` / `team set-mfa` are `require-flag` → non-interactive * callers must pass --yes (privilege / security-posture changes). + * + * Every operation here is team-scoped: none consults the environment context, + * so no environment header is sent (see `src/lib/environment-target.ts`). */ import chalk from 'chalk'; @@ -46,6 +49,7 @@ export async function runTeamMembers(): Promise { const op = getOperation('teamMemberships'); let data: { currentTeam: { memberships: MembershipNode[] } | null }; + // Team-scoped operation: deliberately NO environment header (see environment-target.ts). try { data = await dashboardGraphqlRequest(resolveExecutableDocument(op), { token }); } catch (error) { @@ -97,6 +101,7 @@ export async function runTeamInvite(options: TeamInviteOptions): Promise { | { __typename: 'UserAlreadyBelongsToAnotherTeam'; email: string } | { __typename: string }; }; + // Team-scoped operation: deliberately NO environment header (see environment-target.ts). try { data = await dashboardGraphqlRequest(resolveExecutableDocument(op), { token, @@ -151,6 +156,7 @@ export async function runTeamChangeRole(options: TeamChangeRoleOptions): Promise const op = getOperation('changeRole'); let data: { changeRole: { id: string; role: string | null } }; + // Team-scoped operation: deliberately NO environment header (see environment-target.ts). try { data = await dashboardGraphqlRequest(resolveExecutableDocument(op), { token, @@ -180,6 +186,7 @@ export async function runTeamRemove(options: TeamRemoveOptions): Promise { const token = await requireCommandToken(); const op = getOperation('removeUserFromTeam'); + // Team-scoped operation: deliberately NO environment header (see environment-target.ts). try { await dashboardGraphqlRequest(resolveExecutableDocument(op), { token, @@ -205,6 +212,7 @@ export async function runTeamResendInvite(options: TeamResendInviteOptions): Pro const op = getOperation('resendDashboardInvite'); let data: { resendDashboardInvite: { __typename: string } }; + // Team-scoped operation: deliberately NO environment header (see environment-target.ts). try { data = await dashboardGraphqlRequest(resolveExecutableDocument(op), { token, @@ -246,6 +254,7 @@ export async function runTeamUpdate(options: TeamUpdateOptions): Promise { | { __typename: 'InvalidTeamName'; team: { id: string } } | { __typename: string }; }; + // Team-scoped operation: deliberately NO environment header (see environment-target.ts). try { data = await dashboardGraphqlRequest(resolveExecutableDocument(op), { token, @@ -289,6 +298,7 @@ export async function runTeamSetMfa(options: TeamSetMfaOptions): Promise { let data: { updateTeamMfaRequirement: { __typename: string; team?: { id: string; isMfaRequired?: boolean | null } }; }; + // Team-scoped operation: deliberately NO environment header (see environment-target.ts). try { data = await dashboardGraphqlRequest(resolveExecutableDocument(op), { token, diff --git a/src/commands/whoami.spec.ts b/src/commands/whoami.spec.ts index 6bf5a248..35e6a62f 100644 --- a/src/commands/whoami.spec.ts +++ b/src/commands/whoami.spec.ts @@ -21,6 +21,17 @@ vi.mock('../lib/dashboard-graphql.js', async (importActual) => { }; }); +// The resolver has its own full matrix in environment-target.spec.ts — here we +// only assert the command threads its output into the request. +const mockResolveEnvironmentTarget = vi.fn(); +vi.mock('../lib/environment-target.js', async (importActual) => { + const actual = await importActual(); + return { + ...actual, + resolveEnvironmentTarget: (...args: unknown[]) => mockResolveEnvironmentTarget(...args), + }; +}); + const { setOutputMode } = await import('../utils/output.js'); const { DashboardGraphqlError } = await import('../lib/dashboard-graphql.js'); const { CliExit } = await import('../utils/cli-exit.js'); @@ -40,6 +51,10 @@ describe('whoami command', () => { beforeEach(() => { vi.clearAllMocks(); + mockResolveEnvironmentTarget.mockImplementation(async (_token: string, opts: { flagValue?: string }) => ({ + environmentId: opts.flagValue ?? 'env_profile', + source: opts.flagValue ? 'flag' : 'profile', + })); consoleOutput = []; consoleErrors = []; vi.spyOn(console, 'log').mockImplementation((...args: unknown[]) => { @@ -64,11 +79,39 @@ describe('whoami command', () => { expect(mockGraphqlRequest).not.toHaveBeenCalled(); }); - it('calls the dashboard GraphQL API with the bearer token', async () => { + it('calls the dashboard GraphQL API with the bearer token and the resolved environment', async () => { mockRequireCommandToken.mockResolvedValue('tok_123'); mockGraphqlRequest.mockResolvedValue(sampleData()); await runWhoami(); - expect(mockGraphqlRequest).toHaveBeenCalledWith(expect.stringContaining('workosCliWhoami'), { token: 'tok_123' }); + // Environment-scoped read: the header must reflect the active profile — + // never absent (the server would silently fall back to production). + expect(mockGraphqlRequest).toHaveBeenCalledWith(expect.stringContaining('workosCliWhoami'), { + token: 'tok_123', + environmentId: 'env_profile', + }); + expect(mockResolveEnvironmentTarget).toHaveBeenCalledWith('tok_123', { + flagValue: undefined, + forMutation: false, + }); + }); + + it('threads a --environment-id override into the request for one invocation', async () => { + mockRequireCommandToken.mockResolvedValue('tok_123'); + mockGraphqlRequest.mockResolvedValue(sampleData()); + await runWhoami({ environmentId: 'env_override' }); + expect(mockGraphqlRequest).toHaveBeenCalledWith(expect.any(String), { + token: 'tok_123', + environmentId: 'env_override', + }); + }); + + it('never issues the request when the target cannot be resolved', async () => { + mockRequireCommandToken.mockResolvedValue('tok_123'); + mockResolveEnvironmentTarget.mockRejectedValue( + new CliExit(1, { reason: 'validation_error', errorCode: 'environment_unresolved' }), + ); + await expect(runWhoami()).rejects.toMatchObject({ name: 'CliExit' }); + expect(mockGraphqlRequest).not.toHaveBeenCalled(); }); it('renders user, team, and environment in human mode', async () => { diff --git a/src/commands/whoami.ts b/src/commands/whoami.ts index 65fd81d3..71aca747 100644 --- a/src/commands/whoami.ts +++ b/src/commands/whoami.ts @@ -10,6 +10,7 @@ import chalk from 'chalk'; import { requireCommandToken } from '../lib/command-auth.js'; import { dashboardGraphqlRequest } from '../lib/dashboard-graphql.js'; +import { resolveEnvironmentTarget } from '../lib/environment-target.js'; import { reportDashboardError } from '../catalog/operation.js'; import { isJsonMode, outputJson } from '../utils/output.js'; @@ -61,14 +62,30 @@ interface WhoamiData { } | null; } -export async function runWhoami(): Promise { +export interface WhoamiOptions { + /** `--environment-id` override for this invocation. */ + environmentId?: string; +} + +export async function runWhoami(options: WhoamiOptions = {}): Promise { // Resolve a usable bearer, silently refreshing an expired access token when // a valid refresh token exists; exits 4 when no usable session remains. const token = await requireCommandToken(); + // Environment-scoped (read): send the resolved target so the displayed + // environment reflects the active profile, not the server's production + // fallback. + const target = await resolveEnvironmentTarget(token, { + flagValue: options.environmentId, + forMutation: false, + }); + let data: WhoamiData; try { - data = await dashboardGraphqlRequest(WHOAMI_QUERY, { token }); + data = await dashboardGraphqlRequest(WHOAMI_QUERY, { + token, + environmentId: target.environmentId, + }); } catch (error) { reportDashboardError(error); } diff --git a/src/lib/config-store.ts b/src/lib/config-store.ts index 1e1fc99f..bbae589c 100644 --- a/src/lib/config-store.ts +++ b/src/lib/config-store.ts @@ -24,6 +24,13 @@ interface BaseEnvironmentConfig { ownerEmail?: string; /** User ID of the account that owns this environment. Stamped alongside ownerEmail. */ ownerUserId?: string; + /** + * The WorkOS dashboard environment ID this profile targets (`environment_...`). + * Resolved by `resolveEnvironmentTarget()` (clientId join or explicit pick) and + * sent as the environment header on dashboard-plane requests so the server + * never silently falls back to the team's production environment. + */ + environmentId?: string; } export interface ClaimedEnvironmentConfig extends BaseEnvironmentConfig { @@ -252,6 +259,21 @@ export function setActiveEnvironment(name: string): void { saveConfig(config); } +/** + * Persist a resolved dashboard environment ID onto a stored profile. + * + * Setter used by environment-target resolution (clientId join, picker persist, + * and opportunistic healing). No-op when the profile does not exist or already + * stores the same ID — healing must never churn the keyring with no-op writes. + */ +export function setProfileEnvironmentId(envKey: string, environmentId: string): void { + const config = getConfig(); + const profile = config?.environments[envKey]; + if (!config || !profile || profile.environmentId === environmentId) return; + profile.environmentId = environmentId; + saveConfig(config); +} + /** Pick a non-colliding environments key: `base`, else `base-2`, `base-3`, … */ export function freshEnvKey(config: CliConfig, base: string): string { if (!config.environments[base]) return base; @@ -328,6 +350,7 @@ export function markEnvironmentClaimed(): void { ...(env.endpoint && { endpoint: env.endpoint }), ...(env.ownerEmail && { ownerEmail: env.ownerEmail }), ...(env.ownerUserId && { ownerUserId: env.ownerUserId }), + ...(env.environmentId && { environmentId: env.environmentId }), }; if (oldKey !== newKey) { diff --git a/src/lib/dashboard-graphql.ts b/src/lib/dashboard-graphql.ts index ab35bff2..64e8bc22 100644 --- a/src/lib/dashboard-graphql.ts +++ b/src/lib/dashboard-graphql.ts @@ -47,6 +47,15 @@ export interface DashboardGraphqlOptions { * Optional environment to operate in. The guard validates it against the * caller's own team and falls back to the team's production environment when * unset or unrecognized (sent as the `x-url-environment-id` header). + * + * CLI-side invariant: environment-scoped commands must always pass an ID + * resolved through `resolveEnvironmentTarget()` (environment-target.ts) — + * never omit it and never pass an unvalidated stored ID into a mutation. The + * server's silent production fallback means a missing/stale header misroutes + * the request rather than failing; the resolver is what turns that into a + * structured `environment_stale` / `environment_unresolved` error instead. + * Team-scoped operations (memberships, project lifecycle) deliberately send + * no environment header. */ environmentId?: string; } diff --git a/src/lib/environment-target.spec.ts b/src/lib/environment-target.spec.ts new file mode 100644 index 00000000..b9cb9a6f --- /dev/null +++ b/src/lib/environment-target.spec.ts @@ -0,0 +1,384 @@ +import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'; +import { mkdtempSync, rmSync } from 'node:fs'; +import { join } from 'node:path'; +import { tmpdir } from 'node:os'; + +// debug.ts derives its log dir from homedir() at import time — mock it before +// the homedir mock below swaps in a per-test temp dir (same as env.spec.ts). +vi.mock('../utils/debug.js', () => ({ + logWarn: vi.fn(), + logInfo: vi.fn(), + logError: vi.fn(), +})); + +const mockGraphqlRequest = vi.fn(); +const mockRefreshIfExpired = vi.fn(); + +vi.mock('./dashboard-graphql.js', async (importActual) => { + const actual = await importActual(); + return { + ...actual, + dashboardGraphqlRequest: (...args: unknown[]) => mockGraphqlRequest(...args), + }; +}); + +vi.mock('./command-auth.js', async (importActual) => { + const actual = await importActual(); + return { + ...actual, + refreshIfExpired: () => mockRefreshIfExpired(), + }; +}); + +vi.mock('../utils/clack.js', () => ({ + default: { + log: { success: vi.fn(), error: vi.fn(), info: vi.fn(), step: vi.fn(), warn: vi.fn() }, + select: vi.fn(), + isCancel: vi.fn(() => false), + }, +})); + +let testDir: string; + +vi.mock('node:os', async (importOriginal) => { + const original = await importOriginal(); + return { + ...original, + default: { ...original, homedir: () => testDir }, + homedir: () => testDir, + }; +}); + +const { getConfig, saveConfig, setInsecureConfigStorage, clearConfig } = await import('./config-store.js'); +const { resolveEnvironmentTarget, tryResolveProfileEnvironmentId } = await import('./environment-target.js'); +const { setInteractionMode, resetInteractionModeForTests } = await import('../utils/interaction-mode.js'); +const { CliExit } = await import('../utils/cli-exit.js'); +const clack = (await import('../utils/clack.js')).default; + +/** teamProjectsV2 response with the given environments spread over projects. */ +function teamData(environments: Array<{ id: string; name?: string; clientId?: string; sandbox?: boolean }>) { + return { + currentTeam: { + projectsV2: [{ environments: environments.map((env) => ({ name: env.id, sandbox: false, ...env })) }], + }, + }; +} + +function seedProfile(overrides: { environmentId?: string; clientId?: string } = {}, key = 'staging') { + saveConfig({ + activeEnvironment: key, + environments: { + [key]: { + name: key, + type: 'sandbox', + apiKey: 'sk_test_abc', + ...(overrides.clientId && { clientId: overrides.clientId }), + ...(overrides.environmentId && { environmentId: overrides.environmentId }), + }, + }, + }); +} + +describe('resolveEnvironmentTarget', () => { + let errorSpy: ReturnType; + + beforeEach(() => { + testDir = mkdtempSync(join(tmpdir(), 'env-target-test-')); + setInsecureConfigStorage(true); + resetInteractionModeForTests(); + vi.clearAllMocks(); + vi.mocked(clack.isCancel).mockReturnValue(false); + errorSpy = vi.spyOn(console, 'error').mockImplementation(() => {}); + }); + + afterEach(() => { + clearConfig(); + resetInteractionModeForTests(); + errorSpy.mockRestore(); + try { + rmSync(testDir, { recursive: true, force: true }); + } catch { + /* best effort */ + } + }); + + describe('flag precedence', () => { + it('returns the flag value for reads without any fetch', async () => { + seedProfile({ environmentId: 'env_stored' }); + const target = await resolveEnvironmentTarget('tok', { flagValue: 'env_flag', forMutation: false }); + expect(target).toEqual({ environmentId: 'env_flag', source: 'flag' }); + expect(mockGraphqlRequest).not.toHaveBeenCalled(); + }); + + it('validates a flag-supplied ID on mutations (valid case)', async () => { + mockGraphqlRequest.mockResolvedValue(teamData([{ id: 'env_flag' }])); + const target = await resolveEnvironmentTarget('tok', { flagValue: 'env_flag', forMutation: true }); + expect(target).toEqual({ environmentId: 'env_flag', source: 'flag' }); + expect(mockGraphqlRequest).toHaveBeenCalledTimes(1); + expect(mockGraphqlRequest).toHaveBeenCalledWith(expect.stringContaining('teamProjectsV2'), { token: 'tok' }); + }); + + it('exits environment_stale for a flag-supplied ID unknown to the team on mutations', async () => { + mockGraphqlRequest.mockResolvedValue(teamData([{ id: 'env_other' }])); + await expect( + resolveEnvironmentTarget('tok', { flagValue: 'env_typo', forMutation: true }), + ).rejects.toMatchObject({ name: 'CliExit', context: { errorCode: 'environment_stale' } }); + // Only the validation fetch was issued — never the operation itself. + expect(mockGraphqlRequest).toHaveBeenCalledTimes(1); + }); + }); + + describe('stored profile ID', () => { + it('trusts the stored ID for reads without any fetch', async () => { + seedProfile({ environmentId: 'env_stored' }); + const target = await resolveEnvironmentTarget('tok', { forMutation: false }); + expect(target).toEqual({ environmentId: 'env_stored', source: 'profile' }); + expect(mockGraphqlRequest).not.toHaveBeenCalled(); + }); + + it('proceeds with a stale stored ID on reads (documented trade-off: no fetch, no error)', async () => { + seedProfile({ environmentId: 'env_deleted' }); + const target = await resolveEnvironmentTarget('tok', { forMutation: false }); + expect(target.environmentId).toBe('env_deleted'); + expect(mockGraphqlRequest).not.toHaveBeenCalled(); + }); + + it('pre-validates the stored ID on mutations (valid case)', async () => { + seedProfile({ environmentId: 'env_stored' }); + mockGraphqlRequest.mockResolvedValue(teamData([{ id: 'env_stored' }])); + const target = await resolveEnvironmentTarget('tok', { forMutation: true }); + expect(target).toEqual({ environmentId: 'env_stored', source: 'profile' }); + expect(mockGraphqlRequest).toHaveBeenCalledTimes(1); + }); + + it('exits environment_stale on mutations when the stored ID is not in the team list', async () => { + seedProfile({ environmentId: 'env_deleted' }); + mockGraphqlRequest.mockResolvedValue(teamData([{ id: 'env_live' }])); + await expect(resolveEnvironmentTarget('tok', { forMutation: true })).rejects.toMatchObject({ + name: 'CliExit', + context: { errorCode: 'environment_stale' }, + }); + expect(mockGraphqlRequest).toHaveBeenCalledTimes(1); + }); + + it('names both remedies in the stale error without mentioning internals', async () => { + seedProfile({ environmentId: 'env_deleted' }); + mockGraphqlRequest.mockResolvedValue(teamData([{ id: 'env_live' }])); + await expect(resolveEnvironmentTarget('tok', { forMutation: true })).rejects.toThrow(CliExit); + const err = errorSpy.mock.calls.map((c) => c.map(String).join(' ')).join('\n'); + expect(err).toContain('--environment-id'); + expect(err).toContain('env switch'); + expect(err).not.toMatch(/graphql/i); + }); + + it('heals a stale stored ID via the clientId join and proceeds on mutations', async () => { + seedProfile({ environmentId: 'env_recreated_old', clientId: 'client_abc' }); + mockGraphqlRequest.mockResolvedValue(teamData([{ id: 'env_recreated_new', clientId: 'client_abc' }])); + const target = await resolveEnvironmentTarget('tok', { forMutation: true }); + expect(target).toEqual({ environmentId: 'env_recreated_new', source: 'profile' }); + expect(getConfig()?.environments.staging.environmentId).toBe('env_recreated_new'); + }); + }); + + describe('clientId join', () => { + it('joins the profile clientId against the team environments and persists the ID', async () => { + seedProfile({ clientId: 'client_abc' }); + mockGraphqlRequest.mockResolvedValue( + teamData([ + { id: 'env_other', clientId: 'client_other' }, + { id: 'env_joined', clientId: 'client_abc' }, + ]), + ); + const target = await resolveEnvironmentTarget('tok', { forMutation: false }); + expect(target).toEqual({ environmentId: 'env_joined', source: 'profile' }); + // Healing write: the profile gains the joined ID. + expect(getConfig()?.environments.staging.environmentId).toBe('env_joined'); + }); + + it('never guesses for a foreign profile whose clientId joins nothing (agent mode)', async () => { + setInteractionMode({ mode: 'agent', source: 'env' }); + seedProfile({ clientId: 'client_foreign' }); + mockGraphqlRequest.mockResolvedValue(teamData([{ id: 'env_a', clientId: 'client_abc' }])); + await expect(resolveEnvironmentTarget('tok', { forMutation: false })).rejects.toMatchObject({ + name: 'CliExit', + context: { errorCode: 'environment_unresolved' }, + }); + expect(getConfig()?.environments.staging.environmentId).toBeUndefined(); + }); + }); + + describe('picker (human mode)', () => { + it('prompts once and persists the choice to the active profile', async () => { + seedProfile({}); + mockGraphqlRequest.mockResolvedValue(teamData([{ id: 'env_a' }, { id: 'env_b', sandbox: true }])); + vi.mocked(clack.select).mockResolvedValue('env_b'); + const target = await resolveEnvironmentTarget('tok', { forMutation: false }); + expect(target).toEqual({ environmentId: 'env_b', source: 'picker' }); + expect(clack.select).toHaveBeenCalledTimes(1); + expect(getConfig()?.environments.staging.environmentId).toBe('env_b'); + }); + + it('exits cancelled (code 2) when the picker is dismissed', async () => { + seedProfile({}); + mockGraphqlRequest.mockResolvedValue(teamData([{ id: 'env_a' }])); + vi.mocked(clack.select).mockResolvedValue(Symbol('cancel')); + vi.mocked(clack.isCancel).mockReturnValue(true); + await expect(resolveEnvironmentTarget('tok', { forMutation: false })).rejects.toMatchObject({ + name: 'CliExit', + exitCode: 2, + }); + }); + + it('still resolves via picker with no active profile at all (nothing persisted)', async () => { + mockGraphqlRequest.mockResolvedValue(teamData([{ id: 'env_a' }])); + vi.mocked(clack.select).mockResolvedValue('env_a'); + const target = await resolveEnvironmentTarget('tok', { forMutation: false }); + expect(target).toEqual({ environmentId: 'env_a', source: 'picker' }); + expect(getConfig()).toBeNull(); + }); + }); + + describe('non-interactive unresolved', () => { + it('exits environment_unresolved in agent mode, naming both remedies', async () => { + setInteractionMode({ mode: 'agent', source: 'env' }); + seedProfile({}); + mockGraphqlRequest.mockResolvedValue(teamData([{ id: 'env_a' }])); + await expect(resolveEnvironmentTarget('tok', { forMutation: false })).rejects.toMatchObject({ + name: 'CliExit', + context: { errorCode: 'environment_unresolved' }, + }); + expect(clack.select).not.toHaveBeenCalled(); + const err = errorSpy.mock.calls.map((c) => c.map(String).join(' ')).join('\n'); + expect(err).toContain('--environment-id'); + expect(err).toContain('env switch'); + expect(err).not.toMatch(/graphql/i); + }); + }); + + describe('fetch failures and empty teams', () => { + it('exits environment_unresolved with transient wording when the fetch fails (read path — never proceeds headerless)', async () => { + seedProfile({ clientId: 'client_abc' }); + mockGraphqlRequest.mockRejectedValue(new Error('boom')); + await expect(resolveEnvironmentTarget('tok', { forMutation: false })).rejects.toMatchObject({ + name: 'CliExit', + context: { errorCode: 'environment_unresolved' }, + }); + const err = errorSpy.mock.calls.map((c) => c.map(String).join(' ')).join('\n'); + expect(err).toMatch(/network or server error/i); + expect(err).not.toMatch(/graphql/i); + }); + + it('exits environment_unresolved when the fetch fails on the mutation path', async () => { + seedProfile({ environmentId: 'env_stored' }); + mockGraphqlRequest.mockRejectedValue(new Error('boom')); + await expect(resolveEnvironmentTarget('tok', { forMutation: true })).rejects.toMatchObject({ + name: 'CliExit', + context: { errorCode: 'environment_unresolved' }, + }); + }); + + it('exits environment_unresolved with access guidance when the team has zero environments', async () => { + seedProfile({ clientId: 'client_abc' }); + mockGraphqlRequest.mockResolvedValue({ currentTeam: { projectsV2: [] } }); + await expect(resolveEnvironmentTarget('tok', { forMutation: false })).rejects.toMatchObject({ + name: 'CliExit', + context: { errorCode: 'environment_unresolved' }, + }); + const err = errorSpy.mock.calls.map((c) => c.map(String).join(' ')).join('\n'); + expect(err).toMatch(/access/i); + }); + }); +}); + +describe('tryResolveProfileEnvironmentId', () => { + beforeEach(() => { + testDir = mkdtempSync(join(tmpdir(), 'env-target-try-test-')); + setInsecureConfigStorage(true); + resetInteractionModeForTests(); + vi.clearAllMocks(); + vi.mocked(clack.isCancel).mockReturnValue(false); + mockRefreshIfExpired.mockResolvedValue({ accessToken: 'tok_refreshed', refreshed: false }); + }); + + afterEach(() => { + clearConfig(); + resetInteractionModeForTests(); + try { + rmSync(testDir, { recursive: true, force: true }); + } catch { + /* best effort */ + } + }); + + it('joins via clientId, persists, and reports success', async () => { + seedProfile({ clientId: 'client_abc' }); + mockGraphqlRequest.mockResolvedValue(teamData([{ id: 'env_joined', clientId: 'client_abc' }])); + await expect(tryResolveProfileEnvironmentId('staging')).resolves.toBe(true); + expect(getConfig()?.environments.staging.environmentId).toBe('env_joined'); + }); + + it('uses the provided token instead of refreshing a stored session', async () => { + seedProfile({ clientId: 'client_abc' }); + mockGraphqlRequest.mockResolvedValue(teamData([{ id: 'env_joined', clientId: 'client_abc' }])); + await expect(tryResolveProfileEnvironmentId('staging', { token: 'tok_given' })).resolves.toBe(true); + expect(mockRefreshIfExpired).not.toHaveBeenCalled(); + expect(mockGraphqlRequest).toHaveBeenCalledWith(expect.any(String), { token: 'tok_given' }); + }); + + it('is a no-op success when the profile already has an environmentId', async () => { + seedProfile({ environmentId: 'env_existing' }); + await expect(tryResolveProfileEnvironmentId('staging')).resolves.toBe(true); + expect(mockGraphqlRequest).not.toHaveBeenCalled(); + }); + + it('reports false without a usable session (logged out — defers to first use)', async () => { + seedProfile({ clientId: 'client_abc' }); + mockRefreshIfExpired.mockResolvedValue(null); + await expect(tryResolveProfileEnvironmentId('staging')).resolves.toBe(false); + expect(mockGraphqlRequest).not.toHaveBeenCalled(); + expect(getConfig()?.environments.staging.environmentId).toBeUndefined(); + }); + + it('swallows fetch failures (best-effort, never blocks the caller)', async () => { + seedProfile({ clientId: 'client_abc' }); + mockGraphqlRequest.mockRejectedValue(new Error('boom')); + await expect(tryResolveProfileEnvironmentId('staging')).resolves.toBe(false); + }); + + it('offers the picker when allowed in human mode and the join misses', async () => { + seedProfile({}); + mockGraphqlRequest.mockResolvedValue(teamData([{ id: 'env_a' }, { id: 'env_b' }])); + vi.mocked(clack.select).mockResolvedValue('env_a'); + await expect(tryResolveProfileEnvironmentId('staging', { allowPicker: true })).resolves.toBe(true); + expect(getConfig()?.environments.staging.environmentId).toBe('env_a'); + }); + + it('treats picker cancel as a skip, leaving the profile untouched', async () => { + seedProfile({}); + mockGraphqlRequest.mockResolvedValue(teamData([{ id: 'env_a' }])); + vi.mocked(clack.select).mockResolvedValue(Symbol('cancel')); + vi.mocked(clack.isCancel).mockReturnValue(true); + await expect(tryResolveProfileEnvironmentId('staging', { allowPicker: true })).resolves.toBe(false); + expect(getConfig()?.environments.staging.environmentId).toBeUndefined(); + }); + + it('never prompts when the picker is not allowed (env add non-interactive path)', async () => { + seedProfile({}); + mockGraphqlRequest.mockResolvedValue(teamData([{ id: 'env_a' }])); + await expect(tryResolveProfileEnvironmentId('staging')).resolves.toBe(false); + expect(clack.select).not.toHaveBeenCalled(); + }); + + it('never prompts in agent mode even when the picker is allowed', async () => { + setInteractionMode({ mode: 'agent', source: 'env' }); + seedProfile({}); + mockGraphqlRequest.mockResolvedValue(teamData([{ id: 'env_a' }])); + await expect(tryResolveProfileEnvironmentId('staging', { allowPicker: true })).resolves.toBe(false); + expect(clack.select).not.toHaveBeenCalled(); + }); + + it('reports false for a missing profile key', async () => { + await expect(tryResolveProfileEnvironmentId('missing')).resolves.toBe(false); + expect(mockGraphqlRequest).not.toHaveBeenCalled(); + }); +}); diff --git a/src/lib/environment-target.ts b/src/lib/environment-target.ts new file mode 100644 index 00000000..68a26c15 --- /dev/null +++ b/src/lib/environment-target.ts @@ -0,0 +1,260 @@ +/** + * Environment targeting for dashboard-plane (account-plane) commands. + * + * Under the REST plane the API key *is* the environment. Under the dashboard + * plane the server resolves the caller's team and — when no environment header + * is sent, or the sent ID isn't recognized as the team's — silently falls back + * to the team's production environment (see `dashboard-graphql.ts`). That + * fallback is exactly the hazard this module exists to kill: + * + * **Invariant: environment-scoped dashboard requests always carry an + * environment ID resolved through `resolveEnvironmentTarget()`, and a + * stored-but-unrecognized ID produces a structured error — never a request the + * server would misroute to production.** + * + * Resolution precedence mirrors the API-key chain (`api-key.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. + * + * Staleness tactic: **mutations pre-validate, reads trust.** A mutation + * validates the effective ID against a fresh fetch of the team's environments + * (one extra round trip on writes only); an unrecognized ID exits with + * `environment_stale` before any operation request is issued. Reads use the + * stored ID directly — worst case a read errors server-side or returns empty, + * but nothing is ever written to the wrong environment. Whenever the team's + * environments are fetched anyway, the active profile is opportunistically + * healed via its clientId join. + */ + +import clack from '../utils/clack.js'; +import { getConfig, getActiveEnvironment, setProfileEnvironmentId } from './config-store.js'; +import { dashboardGraphqlRequest } from './dashboard-graphql.js'; +import { getOperation, resolveExecutableDocument } from '../catalog/operation.js'; +import { refreshIfExpired } from './command-auth.js'; +import { exitWithError } from '../utils/output.js'; +import { isPromptAllowed } from '../utils/interaction-mode.js'; +import { formatWorkOSCommand } from '../utils/command-invocation.js'; +import { ExitCode, exitWithCode } from '../utils/exit-codes.js'; + +export interface EnvironmentTargetOptions { + /** `--environment-id` flag value (or an explicit positional target). */ + flagValue?: string; + /** Mutations pre-validate the effective ID; reads trust stored state. */ + forMutation: boolean; +} + +export interface EnvironmentTarget { + environmentId: string; + source: 'flag' | 'profile' | 'picker'; +} + +interface TeamEnvironment { + id: string; + name: string | null; + sandbox?: boolean | null; + clientId?: string | null; +} + +interface TeamProjectsData { + currentTeam: { + projectsV2: Array<{ environments: TeamEnvironment[] | null }> | null; + } | null; +} + +/** The two remedies every unresolved/stale message must name. */ +function remedies(): string { + return `Pass --environment-id, or run \`${formatWorkOSCommand('env switch')}\` to select an environment.`; +} + +/** + * Fetch the team's environments (projects → environments) with the caller's + * bearer. Throws the underlying transport/server error — callers decide + * whether that is fatal (`resolveEnvironmentTarget`) or best-effort + * (`tryResolveProfileEnvironmentId`). + */ +async function fetchTeamEnvironments(token: string): Promise { + const op = getOperation('teamProjectsV2'); + const data = await dashboardGraphqlRequest(resolveExecutableDocument(op), { token }); + const projects = data.currentTeam?.projectsV2 ?? []; + return projects.flatMap((project) => project.environments ?? []); +} + +/** + * Opportunistic healing: whenever the team's environments are fetched anyway, + * re-join the active profile's clientId and persist the environment ID if it + * changed (e.g. the environment was recreated). Profiles without a clientId + * are never touched. Note: a picker choice persisted onto a clientId-bearing + * profile (the foreign-profile fall-through, where the join missed) could be + * overridden here if that clientId later appears in the team's list — the + * join is then the fresher truth for this team, so that override is desired. + */ +function healActiveProfile(environments: TeamEnvironment[]): void { + const config = getConfig(); + if (!config?.activeEnvironment) return; + const profile = config.environments[config.activeEnvironment]; + if (!profile?.clientId) return; + const match = environments.find((env) => env.clientId === profile.clientId); + if (!match) return; + setProfileEnvironmentId(config.activeEnvironment, match.id); +} + +function exitStale(environmentId: string): never { + exitWithError({ + code: 'environment_stale', + message: + `Environment "${environmentId}" was not found in your WorkOS team — it may have been deleted or recreated. ` + + remedies(), + }); +} + +async function promptForEnvironment(environments: TeamEnvironment[]): Promise { + const choice = await clack.select({ + message: 'Select the WorkOS environment to target', + options: environments.map((env) => ({ + value: env.id, + label: `${env.name ?? env.id}${env.sandbox ? ' [Sandbox]' : ''}`, + hint: env.id, + })), + }); + if (clack.isCancel(choice)) return null; + return String(choice); +} + +/** + * Single choke point answering: which environment does this invocation target, + * and is that answer safe to act on? + * + * Never resolves to "no environment": when nothing can be determined it exits + * with a structured `environment_unresolved` error (proceeding without the + * header is forbidden — it would hit the server's silent production fallback). + */ +export async function resolveEnvironmentTarget( + token: string, + options: EnvironmentTargetOptions, +): Promise { + const flagValue = options.flagValue?.trim() || undefined; + + // Fast paths — reads trust explicit/stored state with no extra round trip. + if (!options.forMutation) { + if (flagValue) return { environmentId: flagValue, source: 'flag' }; + const profile = getActiveEnvironment(); + if (profile?.environmentId) return { environmentId: profile.environmentId, source: 'profile' }; + } + + // Everything past here needs the team's environment list (pre-validation, + // clientId join, or the picker). + let environments: TeamEnvironment[]; + try { + environments = await fetchTeamEnvironments(token); + } catch { + exitWithError({ + code: 'environment_unresolved', + message: `Could not resolve the target WorkOS environment (network or server error). Try again. ${remedies()}`, + }); + } + + if (environments.length === 0) { + exitWithError({ + code: 'environment_unresolved', + message: + 'No WorkOS environments are visible to this account. Check your team access in the WorkOS dashboard. ' + + remedies(), + }); + } + + // Heal before validating: a stale stored ID whose profile clientId still + // joins to a live environment is silently repaired instead of erroring. + healActiveProfile(environments); + + if (flagValue) { + // An explicit-but-mistyped ID hitting the server's silent fallback on a + // delete is exactly the hazard the invariant exists to kill — validate + // flag-supplied IDs like stored ones. + if (!environments.some((env) => env.id === flagValue)) { + exitStale(flagValue); + } + return { environmentId: flagValue, source: 'flag' }; + } + + // Re-read: healing above may have just stored a fresh ID. + const profile = getActiveEnvironment(); + if (profile?.environmentId) { + if (!environments.some((env) => env.id === profile.environmentId)) { + exitStale(profile.environmentId); + } + return { environmentId: profile.environmentId, source: 'profile' }; + } + + if (isPromptAllowed()) { + const choice = await promptForEnvironment(environments); + if (choice === null) exitWithCode(ExitCode.CANCELLED); + // Persist so the picker runs at most once per profile (no-op without an + // active profile — the choice then applies to this invocation only). + const config = getConfig(); + if (config?.activeEnvironment) { + setProfileEnvironmentId(config.activeEnvironment, choice); + } + return { environmentId: choice, source: 'picker' }; + } + + exitWithError({ + code: 'environment_unresolved', + message: `Could not determine which WorkOS environment to target. ${remedies()}`, + }); +} + +export interface TryResolveProfileOptions { + /** Bearer to use; when omitted, a stored session is refreshed if possible. */ + token?: string; + /** Offer the one-time picker in human mode when the clientId join misses. */ + allowPicker?: boolean; +} + +/** + * Best-effort resolution for a named profile, used by `env add`, `env switch`, + * and post-login staging provisioning. Joins via the profile's clientId; in + * human mode (and with `allowPicker`) falls back to the one-time picker. + * + * Never throws and never exits: profile creation/switch/login must succeed + * regardless — resolution defers to first dashboard-command use. + */ +export async function tryResolveProfileEnvironmentId( + envKey: string, + options: TryResolveProfileOptions = {}, +): Promise { + try { + const config = getConfig(); + const profile = config?.environments[envKey]; + if (!profile) return false; + if (profile.environmentId) return true; + + const token = options.token ?? (await refreshIfExpired())?.accessToken; + if (!token) return false; + + const environments = await fetchTeamEnvironments(token); + if (environments.length === 0) return false; + + if (profile.clientId) { + const match = environments.find((env) => env.clientId === profile.clientId); + if (match) { + setProfileEnvironmentId(envKey, match.id); + return true; + } + // A clientId that joins nothing usually means a foreign profile (an API + // key from another team) — never guess. Fall through to the picker in + // human mode; otherwise defer to first dashboard-command use. + } + + if (options.allowPicker && isPromptAllowed()) { + const choice = await promptForEnvironment(environments); + if (choice === null) return false; // cancel skips resolution, never aborts the caller + setProfileEnvironmentId(envKey, choice); + return true; + } + + return false; + } catch { + return false; + } +} diff --git a/src/utils/help-json.ts b/src/utils/help-json.ts index 54cda398..9afd2bf2 100644 --- a/src/utils/help-json.ts +++ b/src/utils/help-json.ts @@ -115,7 +115,16 @@ const commands: CommandSchema[] = [ { name: 'whoami', description: 'Show the authenticated user, team, and environment (dashboard session)', - options: [insecureStorageOpt], + options: [ + insecureStorageOpt, + { + name: 'environment-id', + type: 'string', + description: 'Environment ID to target (defaults to the active environment)', + required: false, + hidden: false, + }, + ], }, { name: 'environment', @@ -135,6 +144,13 @@ const commands: CommandSchema[] = [ default: false, hidden: false, }, + { + name: 'environment-id', + type: 'string', + description: 'Environment ID whose project receives the new environment (defaults to the active environment)', + required: false, + hidden: false, + }, ], }, { @@ -203,7 +219,7 @@ const commands: CommandSchema[] = [ name: 'list', description: 'List configured redirect URIs for an environment', options: [ - { name: 'environment-id', type: 'string', description: 'Environment ID', required: true, hidden: false }, + { name: 'environment-id', type: 'string', description: 'Environment ID (defaults to the active environment)', required: false, hidden: false }, { name: 'limit', type: 'number', description: 'Maximum number of URIs to return', required: false, hidden: false }, ], }, @@ -211,7 +227,7 @@ const commands: CommandSchema[] = [ name: 'set', description: 'Set the allowed redirect URIs for an environment (replaces the full list)', options: [ - { name: 'environment-id', type: 'string', description: 'Environment ID', required: true, hidden: false }, + { name: 'environment-id', type: 'string', description: 'Environment ID (defaults to the active environment)', required: false, hidden: false }, { name: 'uri', type: 'string', description: 'Redirect URI (repeatable)', required: true, hidden: false }, { name: 'default', type: 'string', description: 'Which URI to mark as the default', required: false, hidden: false }, { name: 'dry-run', type: 'boolean', description: 'Validate without saving', required: false, default: false, hidden: false }, @@ -226,13 +242,13 @@ const commands: CommandSchema[] = [ { name: 'get', description: 'Show the allowed web origins (CORS) for an environment', - options: [{ name: 'environment-id', type: 'string', description: 'Environment ID', required: true, hidden: false }], + options: [{ name: 'environment-id', type: 'string', description: 'Environment ID (defaults to the active environment)', required: false, hidden: false }], }, { name: 'set', description: 'Set the allowed web origins (CORS) for an environment (replaces the full list)', options: [ - { name: 'environment-id', type: 'string', description: 'Environment ID', required: true, hidden: false }, + { name: 'environment-id', type: 'string', description: 'Environment ID (defaults to the active environment)', required: false, hidden: false }, { name: 'origin', type: 'string', description: 'Web origin (repeatable)', required: true, hidden: false }, { name: 'dry-run', type: 'boolean', description: 'Validate without saving', required: false, default: false, hidden: false }, ], @@ -247,7 +263,7 @@ const commands: CommandSchema[] = [ name: 'list', description: 'List configured logout URIs for an environment', options: [ - { name: 'environment-id', type: 'string', description: 'Environment ID', required: true, hidden: false }, + { name: 'environment-id', type: 'string', description: 'Environment ID (defaults to the active environment)', required: false, hidden: false }, { name: 'limit', type: 'number', description: 'Maximum number of URIs to return', required: false, hidden: false }, ], }, @@ -255,7 +271,7 @@ const commands: CommandSchema[] = [ name: 'set', description: 'Set the allowed logout URIs for an environment (replaces the full list)', options: [ - { name: 'environment-id', type: 'string', description: 'Environment ID', required: true, hidden: false }, + { name: 'environment-id', type: 'string', description: 'Environment ID (defaults to the active environment)', required: false, hidden: false }, { name: 'uri', type: 'string', description: 'Logout URI (repeatable)', required: true, hidden: false }, { name: 'default', type: 'string', description: 'Which URI to mark as the default', required: false, hidden: false }, { name: 'dry-run', type: 'boolean', description: 'Validate without saving', required: false, default: false, hidden: false }, @@ -270,7 +286,7 @@ const commands: CommandSchema[] = [ { name: 'get', description: 'Show AuthKit branding (logos, theme) for an environment', - options: [{ name: 'environment-id', type: 'string', description: 'Environment ID', required: true, hidden: false }], + options: [{ name: 'environment-id', type: 'string', description: 'Environment ID (defaults to the active environment)', required: false, hidden: false }], }, ], }, From bfa922f666da3715e6a65e31eaa0e5e5b5e4a56c Mon Sep 17 00:00:00 2001 From: Nick Nisi Date: Wed, 22 Jul 2026 17:59:40 -0500 Subject: [PATCH 11/22] feat!: migrate organization + user commands to the dashboard account plane MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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). --- src/bin.ts | 160 ++++---- src/catalog/curation.ts | 40 +- src/catalog/manifest.ts | 102 ++++++ src/catalog/no-graphql-leak.spec.ts | 32 ++ src/commands/organization.spec.ts | 550 +++++++++++++++++++++------- src/commands/organization.ts | 478 +++++++++++++++++++----- src/commands/user.spec.ts | 481 +++++++++++++++++------- src/commands/user.ts | 413 +++++++++++++++++---- src/utils/help-json.spec.ts | 7 +- src/utils/help-json.ts | 58 +-- 10 files changed, 1764 insertions(+), 557 deletions(-) diff --git a/src/bin.ts b/src/bin.ts index 925b6d5a..d3bc2086 100644 --- a/src/bin.ts +++ b/src/bin.ts @@ -1183,13 +1183,7 @@ async function runCli(): Promise { }, ) .command(['organization', 'org'], 'Manage WorkOS organizations (create, update, get, list, delete)', (yargs) => { - yargs.options({ - ...insecureStorageOption, - 'api-key': { - type: 'string' as const, - describe: 'WorkOS API key (overrides environment config). Format: sk_live_* or sk_test_*', - }, - }); + yargs.options(insecureStorageOption); registerSubcommand( yargs, 'create [domains..]', @@ -1201,14 +1195,14 @@ async function runCli(): Promise { type: 'string', array: true, describe: 'Domains in format domain:state (state defaults to verified)', - }), + }) + .option('environment-id', { type: 'string', describe: 'Environment ID (defaults to the active environment)' }), async (argv) => { await applyInsecureStorage(argv.insecureStorage); - - const { resolveApiKey, resolveApiBaseUrl } = await import('./lib/api-key.js'); const { runOrgCreate } = await import('./commands/organization.js'); - const apiKey = resolveApiKey({ apiKey: argv.apiKey }); - await runOrgCreate(argv.name, (argv.domains as string[]) || [], apiKey, resolveApiBaseUrl()); + await runOrgCreate(argv.name, (argv.domains as string[]) || [], { + environmentId: argv.environmentId as string | undefined, + }); }, ); registerSubcommand( @@ -1220,28 +1214,30 @@ async function runCli(): Promise { .positional('orgId', { type: 'string', demandOption: true, describe: 'Organization ID' }) .positional('name', { type: 'string', demandOption: true, describe: 'Organization name' }) .positional('domain', { type: 'string', describe: 'Domain' }) - .positional('state', { type: 'string', describe: 'Domain state (verified or pending)' }), + .positional('state', { type: 'string', describe: 'Domain state (verified or pending)' }) + .option('environment-id', { type: 'string', describe: 'Environment ID (defaults to the active environment)' }), async (argv) => { await applyInsecureStorage(argv.insecureStorage); - - const { resolveApiKey, resolveApiBaseUrl } = await import('./lib/api-key.js'); const { runOrgUpdate } = await import('./commands/organization.js'); - const apiKey = resolveApiKey({ apiKey: argv.apiKey }); - await runOrgUpdate(argv.orgId, argv.name, apiKey, argv.domain, argv.state, resolveApiBaseUrl()); + await runOrgUpdate(argv.orgId, argv.name, { + domain: argv.domain, + state: argv.state, + environmentId: argv.environmentId as string | undefined, + }); }, ); registerSubcommand( yargs, 'get ', 'Get an organization by ID', - (y) => y.positional('orgId', { type: 'string', demandOption: true, describe: 'Organization ID' }), + (y) => + y + .positional('orgId', { type: 'string', demandOption: true, describe: 'Organization ID' }) + .option('environment-id', { type: 'string', describe: 'Environment ID (defaults to the active environment)' }), async (argv) => { await applyInsecureStorage(argv.insecureStorage); - - const { resolveApiKey, resolveApiBaseUrl } = await import('./lib/api-key.js'); const { runOrgGet } = await import('./commands/organization.js'); - const apiKey = resolveApiKey({ apiKey: argv.apiKey }); - await runOrgGet(argv.orgId, apiKey, resolveApiBaseUrl()); + await runOrgGet(argv.orgId, { environmentId: argv.environmentId as string | undefined }); }, ); registerSubcommand( @@ -1250,60 +1246,61 @@ async function runCli(): Promise { 'List organizations', (y) => y.options({ - domain: { type: 'string', describe: 'Filter by domain' }, + domain: { type: 'string', describe: 'Filter by domain (name/domain search)' }, limit: { type: 'number', describe: 'Limit number of results' }, before: { type: 'string', describe: 'Cursor for results before a specific item' }, after: { type: 'string', describe: 'Cursor for results after a specific item' }, order: { type: 'string', describe: 'Order of results (asc or desc)' }, + 'environment-id': { type: 'string', describe: 'Environment ID (defaults to the active environment)' }, }), async (argv) => { await applyInsecureStorage(argv.insecureStorage); - - const { resolveApiKey, resolveApiBaseUrl } = await import('./lib/api-key.js'); const { runOrgList } = await import('./commands/organization.js'); - const apiKey = resolveApiKey({ apiKey: argv.apiKey }); - await runOrgList( - { domain: argv.domain, limit: argv.limit, before: argv.before, after: argv.after, order: argv.order }, - apiKey, - resolveApiBaseUrl(), - ); + await runOrgList({ + domain: argv.domain, + limit: argv.limit, + before: argv.before, + after: argv.after, + order: argv.order, + environmentId: argv.environmentId as string | undefined, + }); }, ); registerSubcommand( yargs, 'delete ', 'Delete an organization', - (y) => y.positional('orgId', { type: 'string', demandOption: true, describe: 'Organization ID' }), + (y) => + y + .positional('orgId', { type: 'string', demandOption: true, describe: 'Organization ID' }) + .option('yes', { alias: 'y', type: 'boolean', default: false, describe: 'Skip the confirmation prompt' }) + .option('environment-id', { type: 'string', describe: 'Environment ID (defaults to the active environment)' }), async (argv) => { await applyInsecureStorage(argv.insecureStorage); - - const { resolveApiKey, resolveApiBaseUrl } = await import('./lib/api-key.js'); const { runOrgDelete } = await import('./commands/organization.js'); - const apiKey = resolveApiKey({ apiKey: argv.apiKey }); - await runOrgDelete(argv.orgId, apiKey, resolveApiBaseUrl()); + await runOrgDelete(argv.orgId, { + yes: argv.yes, + json: argv.json as boolean | undefined, + environmentId: argv.environmentId as string | undefined, + }); }, ); return yargs.demandCommand(1, 'Please specify an organization subcommand').strict(); }) - .command('user', 'Manage WorkOS users (get, list, update, delete)', (yargs) => { - yargs.options({ - ...insecureStorageOption, - 'api-key': { - type: 'string' as const, - describe: 'WorkOS API key (overrides environment config). Format: sk_live_* or sk_test_*', - }, - }); + .command('user', 'Manage AuthKit users (get, list, update, delete)', (yargs) => { + yargs.options(insecureStorageOption); registerSubcommand( yargs, 'get ', 'Get a user by ID', - (y) => y.positional('userId', { type: 'string', demandOption: true, describe: 'User ID' }), + (y) => + y + .positional('userId', { type: 'string', demandOption: true, describe: 'User ID' }) + .option('environment-id', { type: 'string', describe: 'Environment ID (defaults to the active environment)' }), async (argv) => { await applyInsecureStorage(argv.insecureStorage); - - const { resolveApiKey, resolveApiBaseUrl } = await import('./lib/api-key.js'); const { runUserGet } = await import('./commands/user.js'); - await runUserGet(argv.userId, resolveApiKey({ apiKey: argv.apiKey }), resolveApiBaseUrl()); + await runUserGet(argv.userId, { environmentId: argv.environmentId as string | undefined }); }, ); registerSubcommand( @@ -1312,30 +1309,24 @@ async function runCli(): Promise { 'List users', (y) => y.options({ - email: { type: 'string', describe: 'Filter by email' }, - organization: { type: 'string', describe: 'Filter by organization ID' }, + email: { type: 'string', describe: 'Filter by email (search)' }, limit: { type: 'number', describe: 'Limit number of results' }, before: { type: 'string', describe: 'Cursor for results before a specific item' }, after: { type: 'string', describe: 'Cursor for results after a specific item' }, order: { type: 'string', describe: 'Order of results (asc or desc)' }, + 'environment-id': { type: 'string', describe: 'Environment ID (defaults to the active environment)' }, }), async (argv) => { await applyInsecureStorage(argv.insecureStorage); - - const { resolveApiKey, resolveApiBaseUrl } = await import('./lib/api-key.js'); const { runUserList } = await import('./commands/user.js'); - await runUserList( - { - email: argv.email, - organization: argv.organization, - limit: argv.limit, - before: argv.before, - after: argv.after, - order: argv.order, - }, - resolveApiKey({ apiKey: argv.apiKey }), - resolveApiBaseUrl(), - ); + await runUserList({ + email: argv.email, + limit: argv.limit, + before: argv.before, + after: argv.after, + order: argv.order, + environmentId: argv.environmentId as string | undefined, + }); }, ); registerSubcommand( @@ -1346,40 +1337,41 @@ async function runCli(): Promise { y.positional('userId', { type: 'string', demandOption: true, describe: 'User ID' }).options({ 'first-name': { type: 'string', describe: 'First name' }, 'last-name': { type: 'string', describe: 'Last name' }, - 'email-verified': { type: 'boolean', describe: 'Email verification status' }, - password: { type: 'string', describe: 'New password' }, + email: { type: 'string', describe: 'New email address' }, + locale: { type: 'string', describe: 'Locale (e.g. en-US)' }, 'external-id': { type: 'string', describe: 'External ID' }, + 'environment-id': { type: 'string', describe: 'Environment ID (defaults to the active environment)' }, }), async (argv) => { await applyInsecureStorage(argv.insecureStorage); - - const { resolveApiKey, resolveApiBaseUrl } = await import('./lib/api-key.js'); const { runUserUpdate } = await import('./commands/user.js'); - await runUserUpdate( - argv.userId, - resolveApiKey({ apiKey: argv.apiKey }), - { - firstName: argv.firstName, - lastName: argv.lastName, - emailVerified: argv.emailVerified, - password: argv.password, - externalId: argv.externalId, - }, - resolveApiBaseUrl(), - ); + await runUserUpdate(argv.userId, { + firstName: argv.firstName, + lastName: argv.lastName, + email: argv.email, + locale: argv.locale, + externalId: argv.externalId, + environmentId: argv.environmentId as string | undefined, + }); }, ); registerSubcommand( yargs, 'delete ', 'Delete a user', - (y) => y.positional('userId', { type: 'string', demandOption: true, describe: 'User ID' }), + (y) => + y + .positional('userId', { type: 'string', demandOption: true, describe: 'User ID' }) + .option('yes', { alias: 'y', type: 'boolean', default: false, describe: 'Skip the confirmation prompt' }) + .option('environment-id', { type: 'string', describe: 'Environment ID (defaults to the active environment)' }), async (argv) => { await applyInsecureStorage(argv.insecureStorage); - - const { resolveApiKey, resolveApiBaseUrl } = await import('./lib/api-key.js'); const { runUserDelete } = await import('./commands/user.js'); - await runUserDelete(argv.userId, resolveApiKey({ apiKey: argv.apiKey }), resolveApiBaseUrl()); + await runUserDelete(argv.userId, { + yes: argv.yes, + json: argv.json as boolean | undefined, + environmentId: argv.environmentId as string | undefined, + }); }, ); return yargs.demandCommand(1, 'Please specify a user subcommand').strict(); diff --git a/src/catalog/curation.ts b/src/catalog/curation.ts index a053bb81..0d85d4b1 100644 --- a/src/catalog/curation.ts +++ b/src/catalog/curation.ts @@ -64,25 +64,41 @@ export const OVERRIDES: Record = { // `userland*` ops: the prefix is internal dashboard naming; the user-facing // noun is just "user". // - // DEFERRED/DROPPED (Phase 5, decided 2026-06-26). These were pre-staged for a - // catalog-driven AuthKit-user category that is NOT being built: the nouns below - // (`user list`, `user create`, `user delete`, ...) would collide head-on with - // the existing REST `user` command (src/commands/user.ts), which already lists, - // creates, updates, and deletes AuthKit users on the public API plane. Routing - // those through the internal dashboard plane would duplicate working commands - // and risk a naming leak. Do NOT add these to manifest.ts without first - // resolving that collision (see the contract's Out of Scope). + // LIVE since the resource migration (Phase 3, graphql-resource-migration): + // the REST `user` command (src/commands/user.ts) was replaced by these + // dashboard-plane ops, which resolved the old noun collision by replacement — + // the manifest now activates `userlandUsers` (user list), `userlandUser` + // (user get), `updateUserlandUser` (user update), and `deleteUserlandUser` + // (user delete). // - // `userlandUsers` is intentionally retained: no-graphql-leak.spec.ts uses it as - // the worked example proving the curation layer cleans a `userland`-leaking op - // name to a clean noun. Removing it would weaken that machinery test. + // Still INERT (present here, absent from manifest.ts): + // - `createUserlandUser`: the CLI's `user` command has never had a `create` + // subcommand and the migration deliberately does not add one. + // - the `*UserlandUserInvite` ops: invitations are the `invitation` command's + // turf (migrating in Phase 4) — do not activate these under the `user` noun. + // + // `userlandUsers` also remains the leak-spec worked example proving the + // curation layer cleans a `userland`-leaking op name to a clean noun — it is + // now load-bearing AND exemplary; never remove it. userlandUsers: { command: 'user list', describe: 'List AuthKit users in the current environment' }, + userlandUser: { command: 'user get', describe: 'Get an AuthKit user by ID' }, + updateUserlandUser: { command: 'user update', describe: "Update an AuthKit user's profile" }, createUserlandUser: { command: 'user create', describe: 'Create a user' }, - deleteUserlandUser: { command: 'user delete', describe: 'Delete a user' }, + deleteUserlandUser: { command: 'user delete', describe: 'Delete an AuthKit user' }, createUserlandUserInvite: { command: 'user invite', describe: 'Invite a user by email' }, resendUserlandUserInvite: { command: 'user invite resend', describe: 'Resend a pending user invitation' }, revokeUserlandUserInvite: { command: 'user invite revoke', describe: 'Revoke a pending user invitation' }, + // --- organization (resource migration Phase 3) --- + // Op names/descriptions are clean upstream, but every curated op still needs + // an override so the manifest's clean `command` noun is the single source of + // truth (the leak spec asserts meta.command === manifest entry.command). + organizations: { command: 'organization list', describe: 'List organizations in the current environment' }, + organization: { command: 'organization get', describe: 'Get an organization by ID' }, + createOrganization: { command: 'organization create', describe: 'Create an organization with optional domains' }, + updateOrganization: { command: 'organization update', describe: "Update an organization's name or domains" }, + deleteOrganization: { command: 'organization delete', describe: 'Delete an organization' }, + // --- Phase 4: AuthKit app config --- // These op names/descriptions are already clean (no leak), but each still needs // an override so resolveCommandMeta returns the manifest's clean noun (the leak diff --git a/src/catalog/manifest.ts b/src/catalog/manifest.ts index 15f8286e..e626375c 100644 --- a/src/catalog/manifest.ts +++ b/src/catalog/manifest.ts @@ -234,6 +234,108 @@ const MANIFEST: CommandJustification[] = [ destructive: false, ciPolicy: 'allow', }, + + // --- Resource migration: organization + user --- + // First resource commands moved off the API-key REST plane onto the dashboard + // account plane (graphql-resource-migration Phase 3). The command surface is + // unchanged (same subcommands); the backend and output shapes are new. Lists + // are single-page bounded reads with explicit pagination variables — `cheap`, + // not `bulk` (nothing fans out). Deletes carry the catalog `confirmation` + // phrase and are `destructive` (confirmDestructive: prompt or --yes; ciPolicy + // stays `allow` because the destructive gate already covers non-interactive). + { + command: 'organization list', + mapsTo: 'organizations', + audiences: ['human', 'agent', 'ci'], + useCase: 'List organizations in the active environment (scripting, discovery, audits)', + load: 'cheap', + mutation: false, + destructive: false, + ciPolicy: 'allow', + }, + { + command: 'organization get', + mapsTo: 'organization', + audiences: ['human', 'agent', 'ci'], + useCase: 'Inspect a single organization by ID', + load: 'cheap', + mutation: false, + destructive: false, + ciPolicy: 'allow', + }, + { + command: 'organization create', + mapsTo: 'createOrganization', + audiences: ['human', 'agent', 'ci'], + useCase: 'Provision an organization (with optional domains) from setup scripts or CI', + load: 'cheap', + mutation: true, + destructive: false, + ciPolicy: 'allow', + }, + { + command: 'organization update', + mapsTo: 'updateOrganization', + audiences: ['human', 'agent'], + useCase: "Update an organization's name or domains", + load: 'cheap', + mutation: true, + destructive: false, + ciPolicy: 'allow', + }, + { + command: 'organization delete', + mapsTo: 'deleteOrganization', + audiences: ['human', 'agent'], + useCase: 'Delete an organization (offboarding, test-tenant cleanup)', + load: 'cheap', + mutation: true, + // destructive: the catalog confirmation phrase warns the delete cascades to + // the organization's connections, directories, and users. + destructive: true, + ciPolicy: 'allow', + }, + { + command: 'user list', + mapsTo: 'userlandUsers', + audiences: ['human', 'agent', 'ci'], + useCase: 'List AuthKit users in the active environment (scripting, audits)', + load: 'cheap', + mutation: false, + destructive: false, + ciPolicy: 'allow', + }, + { + command: 'user get', + mapsTo: 'userlandUser', + audiences: ['human', 'agent', 'ci'], + useCase: 'Inspect a single AuthKit user by ID', + load: 'cheap', + mutation: false, + destructive: false, + ciPolicy: 'allow', + }, + { + command: 'user update', + mapsTo: 'updateUserlandUser', + audiences: ['human', 'agent'], + useCase: "Update an AuthKit user's profile (name, email, locale, external ID)", + load: 'cheap', + mutation: true, + destructive: false, + ciPolicy: 'allow', + }, + { + command: 'user delete', + mapsTo: 'deleteUserlandUser', + audiences: ['human', 'agent'], + useCase: 'Delete an AuthKit user (offboarding, test cleanup)', + load: 'cheap', + mutation: true, + // destructive: permanently deletes the end user (catalog confirmation). + destructive: true, + ciPolicy: 'allow', + }, ]; /** Returns the curated command allowlist. */ diff --git a/src/catalog/no-graphql-leak.spec.ts b/src/catalog/no-graphql-leak.spec.ts index eadb3ab8..73ff57fe 100644 --- a/src/catalog/no-graphql-leak.spec.ts +++ b/src/catalog/no-graphql-leak.spec.ts @@ -5,6 +5,7 @@ import { OVERRIDES, resolveCommandMeta, findLeaks, LEAK_PATTERN } from './curati import { dashboardErrorMessage } from './operation.js'; import { DASHBOARD_ERROR_MESSAGES } from '../lib/command-auth.js'; import { DashboardGraphqlError, type DashboardGraphqlErrorCode } from '../lib/dashboard-graphql.js'; +import { buildCommandTree, type CommandSchema, type HelpOutput } from '../utils/help-json.js'; import type { CatalogOperation } from './catalog-types.js'; const catalog = loadManagementCatalog(undefined, { includeFeatureFlagged: true }); @@ -67,6 +68,37 @@ describe('no GraphQL/userland leak in curated command metadata', () => { }); }); +describe('no GraphQL/userland leak in the help-json registry for migrated commands', () => { + // Data-driven off the manifest: every top-level noun that has at least one + // catalog-backed command gets its full help-json subtree scanned, so later + // migration phases inherit this coverage automatically by adding manifest + // entries — no per-phase test edits. + const migratedNouns = [...new Set(getManifest().map((entry) => entry.command.split(' ')[0]))]; + const tree = buildCommandTree() as HelpOutput; + + /** Every user-visible string in a registry subtree (names, descriptions, examples). */ + function collectStrings(schema: CommandSchema): string[] { + const strings = [schema.name, schema.description]; + for (const option of schema.options ?? []) strings.push(option.name, option.description); + for (const positional of schema.positionals ?? []) strings.push(positional.name, positional.description); + for (const example of schema.examples ?? []) strings.push(example); + for (const child of schema.commands ?? []) strings.push(...collectStrings(child)); + return strings; + } + + it('covers at least the Phase-3 migrated nouns', () => { + expect(migratedNouns).toEqual(expect.arrayContaining(['organization', 'user'])); + }); + + it.each(migratedNouns)('help-json subtree for "%s" is leak-free', (noun) => { + const entry = tree.commands.find((command) => command.name === noun); + expect(entry, `help-json registry entry for "${noun}" is missing`).toBeDefined(); + for (const value of collectStrings(entry!)) { + expect(LEAK_PATTERN.test(value), `${noun}: "${value}"`).toBe(false); + } + }); +}); + describe('no GraphQL/userland leak in dashboard-plane error copy', () => { it('every DASHBOARD_ERROR_MESSAGES constant is a clean, non-empty string', () => { const entries = Object.entries(DASHBOARD_ERROR_MESSAGES); diff --git a/src/commands/organization.spec.ts b/src/commands/organization.spec.ts index 6753b896..62d6105b 100644 --- a/src/commands/organization.spec.ts +++ b/src/commands/organization.spec.ts @@ -1,38 +1,140 @@ import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'; -// Mock the unified client -const mockSdk = { - organizations: { - createOrganization: vi.fn(), - updateOrganization: vi.fn(), - getOrganization: vi.fn(), - listOrganizations: vi.fn(), - deleteOrganization: vi.fn(), - }, -}; +const mockRequireCommandToken = vi.fn(); +const mockGraphqlRequest = vi.fn(); +const mockConfirm = vi.fn(); +const mockIsCancel = vi.fn(() => false); +const mockGetActiveEnvironment = vi.fn(); +const mockGetConfig = vi.fn(); + +vi.mock('../lib/command-auth.js', async (importActual) => { + const actual = await importActual(); + return { + ...actual, + requireCommandToken: () => mockRequireCommandToken(), + }; +}); + +vi.mock('../lib/dashboard-graphql.js', async (importActual) => { + const actual = await importActual(); + return { + ...actual, + dashboardGraphqlRequest: (...args: unknown[]) => mockGraphqlRequest(...args), + }; +}); -vi.mock('../lib/workos-client.js', () => ({ - createWorkOSClient: () => ({ sdk: mockSdk }), +vi.mock('../utils/clack.js', () => ({ + default: { + confirm: (...args: unknown[]) => mockConfirm(...args), + isCancel: (...args: unknown[]) => mockIsCancel(...args), + select: vi.fn(), + }, })); -const { setOutputMode } = await import('../utils/output.js'); +// The environment-target resolver runs REAL against the mocked wire + config +// store, so mutation tests can assert the pre-validation fetch +// (teamProjectsV2) happens before the operation request. +vi.mock('../lib/config-store.js', async (importActual) => { + const actual = await importActual(); + return { + ...actual, + getActiveEnvironment: () => mockGetActiveEnvironment(), + getConfig: () => mockGetConfig(), + setProfileEnvironmentId: vi.fn(), + }; +}); +const { setOutputMode } = await import('../utils/output.js'); +const { resetInteractionModeForTests, setInteractionMode } = await import('../utils/interaction-mode.js'); +const { DashboardGraphqlError } = await import('../lib/dashboard-graphql.js'); +const { CliExit } = await import('../utils/cli-exit.js'); const { runOrgCreate, runOrgUpdate, runOrgGet, runOrgList, runOrgDelete, parseDomainArgs } = await import('./organization.js'); -describe('organization commands', () => { +const TEAM_ENVIRONMENTS_PAYLOAD = { + currentTeam: { + projectsV2: [{ environments: [{ id: 'env_profile', name: 'Sandbox', sandbox: true, clientId: null }] }], + }, +}; + +/** + * Route the wire mock by document: the environment resolver's pre-validation + * fetch (`teamProjectsV2`) gets the team's environments; everything else gets + * the operation payload. + */ +function respondWith(payload: unknown): void { + mockGraphqlRequest.mockImplementation(async (doc: unknown) => { + if (String(doc).includes('teamProjectsV2')) return TEAM_ENVIRONMENTS_PAYLOAD; + return payload; + }); +} + +async function expectExit(promise: Promise, code: number): Promise { + try { + await promise; + } catch (err) { + if (err instanceof CliExit) { + expect(err.exitCode).toBe(code); + return err; + } + throw err; + } + throw new Error(`Expected CliExit(${code}) but promise resolved`); +} + +/** The authoritative curated organization JSON shape (documented contract). */ +const ORGANIZATION_SHAPE_KEYS = [ + 'id', + 'name', + 'createdAt', + 'usersCount', + 'allowProfilesOutsideOrganization', + 'externalId', + 'domains', + 'metadata', +]; + +const ORG_NODE = { + id: 'org_1', + name: 'FooCorp', + createdAt: '2026-01-01T00:00:00.000Z', + usersCount: 3, + allowProfilesOutsideOrganization: false, + externalId: null, + metadata: [], + domains: [{ id: 'dom_1', domain: 'foo.com', state: 'verified' }], + // Internal fields the curated shape must drop: + seeded: false, + stripeCustomerId: 'cus_internal', +}; + +describe('organization command', () => { let consoleOutput: string[]; beforeEach(() => { vi.clearAllMocks(); + resetInteractionModeForTests(); + setOutputMode('human'); + mockRequireCommandToken.mockResolvedValue('tok_123'); + mockConfirm.mockReset(); + mockIsCancel.mockReset(); + mockIsCancel.mockReturnValue(false); + mockGetActiveEnvironment.mockReturnValue({ apiKey: 'sk_ignored', environmentId: 'env_profile' }); + mockGetConfig.mockReturnValue({ + activeEnvironment: 'default', + environments: { default: { apiKey: 'sk_ignored', environmentId: 'env_profile' } }, + }); consoleOutput = []; vi.spyOn(console, 'log').mockImplementation((...args: unknown[]) => { consoleOutput.push(args.map(String).join(' ')); }); + vi.spyOn(console, 'error').mockImplementation(() => {}); }); afterEach(() => { vi.restoreAllMocks(); + resetInteractionModeForTests(); + setOutputMode('human'); }); describe('parseDomainArgs', () => { @@ -53,175 +155,347 @@ describe('organization commands', () => { it('returns empty array for no args', () => { expect(parseDomainArgs([])).toEqual([]); }); + + it('rejects an unknown state', async () => { + const err = await expectExit(Promise.resolve().then(() => parseDomainArgs(['foo.com:bogus'])), 1); + expect(err.context?.errorCode).toBe('invalid_argument'); + }); }); - describe('runOrgCreate', () => { - it('creates org with name only', async () => { - mockSdk.organizations.createOrganization.mockResolvedValue({ id: 'org_123', name: 'Test', domains: [] }); - await runOrgCreate('Test', [], 'sk_test'); - expect(mockSdk.organizations.createOrganization).toHaveBeenCalledWith({ name: 'Test' }); + describe('list', () => { + it('lists organizations in human mode from the active environment', async () => { + respondWith({ organizations: { data: [ORG_NODE], listMetadata: { before: null, after: null } } }); + await runOrgList({}); + const out = consoleOutput.join('\n'); + expect(out).toContain('FooCorp'); + expect(out).toContain('foo.com'); }); - it('creates org with domain data', async () => { - mockSdk.organizations.createOrganization.mockResolvedValue({ id: 'org_123', name: 'Test', domains: [] }); - await runOrgCreate('Test', ['foo.com:pending'], 'sk_test'); - expect(mockSdk.organizations.createOrganization).toHaveBeenCalledWith({ - name: 'Test', - domainData: [{ domain: 'foo.com', state: 'pending' }], + it('sends the resolved environment as variable AND header (read: no pre-validation fetch)', async () => { + respondWith({ organizations: { data: [], listMetadata: { before: null, after: null } } }); + await runOrgList({}); + expect(mockGraphqlRequest).toHaveBeenCalledTimes(1); + expect(mockGraphqlRequest).toHaveBeenCalledWith(expect.stringContaining('organizations'), { + token: 'tok_123', + variables: { environmentId: 'env_profile' }, + environmentId: 'env_profile', }); }); - it('outputs created message and JSON', async () => { - mockSdk.organizations.createOrganization.mockResolvedValue({ id: 'org_123', name: 'Test', domains: [] }); - await runOrgCreate('Test', [], 'sk_test'); - expect(consoleOutput.some((l) => l.includes('Created organization'))).toBe(true); + it('maps --domain to search and pagination flags to catalog variables', async () => { + respondWith({ organizations: { data: [], listMetadata: { before: null, after: null } } }); + await runOrgList({ domain: 'foo.com', limit: 5, after: 'cursor_a', order: 'desc' }); + expect(mockGraphqlRequest).toHaveBeenCalledWith(expect.stringContaining('organizations'), { + token: 'tok_123', + variables: { environmentId: 'env_profile', search: 'foo.com', limit: 5, after: 'cursor_a', order: 'Desc' }, + environmentId: 'env_profile', + }); }); - }); - describe('runOrgUpdate', () => { - it('updates org name', async () => { - mockSdk.organizations.updateOrganization.mockResolvedValue({ id: 'org_123', name: 'Updated' }); - await runOrgUpdate('org_123', 'Updated', 'sk_test'); - expect(mockSdk.organizations.updateOrganization).toHaveBeenCalledWith({ - organization: 'org_123', - name: 'Updated', + it('honors an --environment-id override', async () => { + respondWith({ organizations: { data: [], listMetadata: { before: null, after: null } } }); + await runOrgList({ environmentId: 'env_flag' }); + expect(mockGraphqlRequest).toHaveBeenCalledWith(expect.stringContaining('organizations'), { + token: 'tok_123', + variables: { environmentId: 'env_flag' }, + environmentId: 'env_flag', }); }); - it('updates org with domain data', async () => { - mockSdk.organizations.updateOrganization.mockResolvedValue({ id: 'org_123', name: 'Updated' }); - await runOrgUpdate('org_123', 'Updated', 'sk_test', 'foo.com', 'pending'); - expect(mockSdk.organizations.updateOrganization).toHaveBeenCalledWith({ - organization: 'org_123', - name: 'Updated', - domainData: [{ domain: 'foo.com', state: 'pending' }], - }); + it('rejects an invalid --order before any request', async () => { + const err = await expectExit(runOrgList({ order: 'sideways' }), 1); + expect(err.context?.errorCode).toBe('invalid_argument'); + expect(mockGraphqlRequest).not.toHaveBeenCalled(); + }); + + it('handles empty results', async () => { + respondWith({ organizations: { data: [], listMetadata: { before: null, after: null } } }); + await runOrgList({}); + expect(consoleOutput.some((l) => l.includes('No organizations found'))).toBe(true); + }); + + it('shows pagination cursors in human mode', async () => { + respondWith({ organizations: { data: [ORG_NODE], listMetadata: { before: 'cursor_b', after: 'cursor_a' } } }); + await runOrgList({}); + expect(consoleOutput.some((l) => l.includes('cursor_b'))).toBe(true); + expect(consoleOutput.some((l) => l.includes('cursor_a'))).toBe(true); + }); + + it('--json emits the documented curated shape (no list_metadata, no internal fields)', async () => { + setOutputMode('json'); + respondWith({ organizations: { data: [ORG_NODE], listMetadata: { before: null, after: 'cursor_a' } } }); + await runOrgList({}); + const raw = consoleOutput[0]; + expect(raw).not.toMatch(/graphql|userland|list_metadata/i); + const out = JSON.parse(raw); + expect(Object.keys(out)).toEqual(['organizations', 'pagination']); + expect(Object.keys(out.organizations[0])).toEqual(ORGANIZATION_SHAPE_KEYS); + expect(out.organizations[0]).not.toHaveProperty('stripeCustomerId'); + expect(out.organizations[0].domains[0]).toEqual({ id: 'dom_1', domain: 'foo.com', state: 'verified' }); + expect(out.pagination).toEqual({ before: null, after: 'cursor_a' }); }); }); - describe('runOrgGet', () => { - it('fetches and prints org as JSON', async () => { - mockSdk.organizations.getOrganization.mockResolvedValue({ id: 'org_123', name: 'Test', domains: [] }); - await runOrgGet('org_123', 'sk_test'); - expect(mockSdk.organizations.getOrganization).toHaveBeenCalledWith('org_123'); - expect(consoleOutput.some((l) => l.includes('org_123'))).toBe(true); + describe('get', () => { + it('fetches by ID with the environment header and renders fields', async () => { + respondWith({ organization: ORG_NODE }); + await runOrgGet('org_1'); + expect(mockGraphqlRequest).toHaveBeenCalledWith(expect.stringContaining('organization'), { + token: 'tok_123', + variables: { id: 'org_1' }, + environmentId: 'env_profile', + }); + expect(consoleOutput.join('\n')).toContain('FooCorp'); + }); + + it('--json emits { organization } in the curated shape', async () => { + setOutputMode('json'); + respondWith({ organization: ORG_NODE }); + await runOrgGet('org_1'); + const out = JSON.parse(consoleOutput[0]); + expect(Object.keys(out)).toEqual(['organization']); + expect(Object.keys(out.organization)).toEqual(ORGANIZATION_SHAPE_KEYS); + expect(out.organization.id).toBe('org_1'); + }); + + it('errors not_found when the organization does not exist', async () => { + respondWith({ organization: null }); + const err = await expectExit(runOrgGet('org_missing'), 1); + expect(err.context?.errorCode).toBe('not_found'); }); }); - describe('runOrgList', () => { - it('lists orgs in table format', async () => { - mockSdk.organizations.listOrganizations.mockResolvedValue({ - data: [ - { - id: 'org_123', - name: 'FooCorp', - domains: [{ id: 'd_1', domain: 'foo.com', state: 'verified' }], - }, - ], - listMetadata: { before: null, after: null }, + describe('create', () => { + const created = { + createOrganization: { + __typename: 'OrganizationCreated', + organization: { id: 'org_1', name: 'Test', domains: [] }, + }, + }; + + it('maps name + domains into the input and pre-validates the environment first', async () => { + respondWith(created); + await runOrgCreate('Test', ['foo.com', 'bar.com']); + // Mutation: the resolver fetches the team's environments BEFORE the op. + expect(mockGraphqlRequest.mock.calls.length).toBe(2); + expect(String(mockGraphqlRequest.mock.calls[0][0])).toContain('teamProjectsV2'); + expect(String(mockGraphqlRequest.mock.calls[1][0])).toContain('createOrganization'); + expect(mockGraphqlRequest.mock.calls[1][1]).toEqual({ + token: 'tok_123', + variables: { + input: { environmentId: 'env_profile', name: 'Test', domains: ['foo.com', 'bar.com'], domainsDeveloperVerified: true }, + }, + environmentId: 'env_profile', }); - await runOrgList({}, 'sk_test'); - expect(mockSdk.organizations.listOrganizations).toHaveBeenCalled(); - expect(consoleOutput.some((l) => l.includes('FooCorp'))).toBe(true); }); - it('passes filter params', async () => { - mockSdk.organizations.listOrganizations.mockResolvedValue({ - data: [], - listMetadata: { before: null, after: null }, + it('maps all-pending domains to domainsDeveloperVerified: false', async () => { + respondWith(created); + await runOrgCreate('Test', ['foo.com:pending']); + const variables = mockGraphqlRequest.mock.calls[1][1] as { + variables: { input: { domains: string[]; domainsDeveloperVerified: boolean } }; + }; + expect(variables.variables.input.domainsDeveloperVerified).toBe(false); + }); + + it('rejects mixed domain states before any request', async () => { + const err = await expectExit(runOrgCreate('Test', ['foo.com:verified', 'bar.com:pending']), 1); + expect(err.context?.errorCode).toBe('invalid_argument'); + expect(mockGraphqlRequest).not.toHaveBeenCalled(); + }); + + it('omits domain fields when no domains are given', async () => { + respondWith(created); + await runOrgCreate('Test', []); + const variables = mockGraphqlRequest.mock.calls[1][1] as { variables: { input: Record } }; + expect(variables.variables.input).toEqual({ environmentId: 'env_profile', name: 'Test' }); + }); + + it('reports success in human mode', async () => { + respondWith(created); + await runOrgCreate('Test', []); + const out = consoleOutput.join('\n'); + expect(out).toContain('Created organization'); + expect(out).toContain('org_1'); + }); + + it('errors when a domain is already in use', async () => { + respondWith({ + createOrganization: { + __typename: 'OrganizationDomainAlreadyInUse', + domain: 'foo.com', + organization: { id: 'org_2', name: 'Other' }, + }, }); - await runOrgList({ domain: 'foo.com', limit: 5, order: 'desc' }, 'sk_test'); - expect(mockSdk.organizations.listOrganizations).toHaveBeenCalledWith( - expect.objectContaining({ domains: ['foo.com'], limit: 5, order: 'desc' }), - ); + const err = await expectExit(runOrgCreate('Test', ['foo.com']), 1); + expect(err.context?.errorCode).toBe('domain_in_use'); }); - it('handles empty results', async () => { - mockSdk.organizations.listOrganizations.mockResolvedValue({ - data: [], - listMetadata: { before: null, after: null }, + it('errors on a consumer email domain', async () => { + respondWith({ createOrganization: { __typename: 'ConsumerDomainForbidden', domain: 'gmail.com' } }); + const err = await expectExit(runOrgCreate('Test', ['gmail.com']), 1); + expect(err.context?.errorCode).toBe('consumer_domain_forbidden'); + }); + + it('--json emits { organization } in the curated shape', async () => { + setOutputMode('json'); + respondWith(created); + await runOrgCreate('Test', []); + const out = JSON.parse(consoleOutput[0]); + expect(Object.keys(out)).toEqual(['organization']); + expect(Object.keys(out.organization)).toEqual(ORGANIZATION_SHAPE_KEYS); + expect(out.organization.name).toBe('Test'); + }); + }); + + describe('update', () => { + const updated = { + updateOrganization: { + __typename: 'UpdateOrganizationPayload', + organization: { id: 'org_1', name: 'Updated', domains: [{ id: 'dom_1', domain: 'foo.com', state: 'pending' }] }, + }, + }; + + it('sends flat variables (id, name) and pre-validates the environment first', async () => { + respondWith(updated); + await runOrgUpdate('org_1', 'Updated'); + expect(String(mockGraphqlRequest.mock.calls[0][0])).toContain('teamProjectsV2'); + expect(mockGraphqlRequest.mock.calls[1][1]).toEqual({ + token: 'tok_123', + variables: { id: 'org_1', name: 'Updated' }, + environmentId: 'env_profile', }); - await runOrgList({}, 'sk_test'); - expect(consoleOutput.some((l) => l.includes('No organizations found'))).toBe(true); }); - it('shows pagination cursors', async () => { - mockSdk.organizations.listOrganizations.mockResolvedValue({ - data: [{ id: 'org_1', name: 'Test', domains: [] }], - listMetadata: { before: 'cursor_b', after: 'cursor_a' }, + it('maps a domain + state onto domains/domainsDeveloperVerified', async () => { + respondWith(updated); + await runOrgUpdate('org_1', 'Updated', { domain: 'foo.com', state: 'pending' }); + const variables = mockGraphqlRequest.mock.calls[1][1] as { variables: Record }; + expect(variables.variables).toEqual({ + id: 'org_1', + name: 'Updated', + domains: ['foo.com'], + domainsDeveloperVerified: false, }); - await runOrgList({}, 'sk_test'); - expect(consoleOutput.some((l) => l.includes('cursor_b'))).toBe(true); - expect(consoleOutput.some((l) => l.includes('cursor_a'))).toBe(true); }); - }); - describe('runOrgDelete', () => { - it('deletes org and prints confirmation', async () => { - mockSdk.organizations.deleteOrganization.mockResolvedValue(undefined); - await runOrgDelete('org_123', 'sk_test'); - expect(mockSdk.organizations.deleteOrganization).toHaveBeenCalledWith('org_123'); - expect(consoleOutput.some((l) => l.includes('Deleted'))).toBe(true); - expect(consoleOutput.some((l) => l.includes('org_123'))).toBe(true); + it('errors when the external ID is already used', async () => { + respondWith({ updateOrganization: { __typename: 'ExternalIDAlreadyUsed', externalId: 'ext_1' } }); + const err = await expectExit(runOrgUpdate('org_1', 'Updated'), 1); + expect(err.context?.errorCode).toBe('external_id_in_use'); + }); + + it('reports success in human mode', async () => { + respondWith(updated); + await runOrgUpdate('org_1', 'Updated'); + expect(consoleOutput.join('\n')).toContain('Updated organization'); }); - }); - describe('JSON output mode', () => { - beforeEach(() => { + it('--json emits { organization } in the curated shape', async () => { setOutputMode('json'); + respondWith(updated); + await runOrgUpdate('org_1', 'Updated'); + const out = JSON.parse(consoleOutput[0]); + expect(Object.keys(out.organization)).toEqual(ORGANIZATION_SHAPE_KEYS); + expect(out.organization.name).toBe('Updated'); }); + }); + + describe('delete (destructive)', () => { + const deleted = { deleteOrganization: { organization: { id: 'org_1' } } }; - afterEach(() => { - setOutputMode('human'); + it('refuses in agent mode without --yes (exit 1, confirmation_required)', async () => { + setInteractionMode({ mode: 'agent', source: 'agent_env' }); + const err = await expectExit(runOrgDelete('org_1', { yes: false }), 1); + expect(err.context?.errorCode).toBe('confirmation_required'); + expect(mockGraphqlRequest).not.toHaveBeenCalled(); + }); + + it('refuses in CI mode without --yes', async () => { + setInteractionMode({ mode: 'ci', source: 'ci_env' }); + const err = await expectExit(runOrgDelete('org_1', { yes: false }), 1); + expect(err.context?.errorCode).toBe('confirmation_required'); + expect(mockGraphqlRequest).not.toHaveBeenCalled(); + }); + + it('proceeds non-interactively with --yes and sends the delete input', async () => { + setInteractionMode({ mode: 'agent', source: 'agent_env' }); + respondWith(deleted); + await runOrgDelete('org_1', { yes: true }); + expect(String(mockGraphqlRequest.mock.calls[0][0])).toContain('teamProjectsV2'); + expect(mockGraphqlRequest.mock.calls[1][1]).toEqual({ + token: 'tok_123', + variables: { input: { organizationId: 'org_1' } }, + environmentId: 'env_profile', + }); }); - it('runOrgCreate outputs JSON success', async () => { - mockSdk.organizations.createOrganization.mockResolvedValue({ id: 'org_123', name: 'Test', domains: [] }); - await runOrgCreate('Test', [], 'sk_test'); - const output = JSON.parse(consoleOutput[0]); - expect(output.status).toBe('ok'); - expect(output.message).toBe('Created organization'); - expect(output.data.id).toBe('org_123'); + it('prompts interactively and proceeds when confirmed', async () => { + setInteractionMode({ mode: 'human', source: 'default' }); + mockConfirm.mockResolvedValue(true); + respondWith(deleted); + await runOrgDelete('org_1', { yes: false }); + expect(mockConfirm).toHaveBeenCalledOnce(); + expect(mockGraphqlRequest).toHaveBeenCalled(); }); - it('runOrgGet outputs raw JSON', async () => { - mockSdk.organizations.getOrganization.mockResolvedValue({ id: 'org_123', name: 'Test', domains: [] }); - await runOrgGet('org_123', 'sk_test'); - const output = JSON.parse(consoleOutput[0]); - expect(output.id).toBe('org_123'); - expect(output.name).toBe('Test'); - expect(output).not.toHaveProperty('status'); + it('cancels (exit 2) when the interactive prompt is declined', async () => { + setInteractionMode({ mode: 'human', source: 'default' }); + mockConfirm.mockResolvedValue(false); + await expectExit(runOrgDelete('org_1', { yes: false }), 2); + expect(mockGraphqlRequest).not.toHaveBeenCalled(); }); - it('runOrgList outputs JSON with data and listMetadata', async () => { - mockSdk.organizations.listOrganizations.mockResolvedValue({ - data: [{ id: 'org_123', name: 'FooCorp', domains: [] }], - listMetadata: { before: null, after: 'cursor_a' }, + it('requires --yes in JSON mode even interactively', async () => { + setInteractionMode({ mode: 'human', source: 'default' }); + setOutputMode('json'); + const err = await expectExit(runOrgDelete('org_1', { yes: false }), 1); + expect(err.context?.errorCode).toBe('confirmation_required'); + expect(mockGraphqlRequest).not.toHaveBeenCalled(); + }); + + it('--json emits { deleted }', async () => { + setInteractionMode({ mode: 'agent', source: 'agent_env' }); + setOutputMode('json'); + respondWith(deleted); + await runOrgDelete('org_1', { yes: true }); + expect(JSON.parse(consoleOutput[0])).toEqual({ deleted: 'org_1' }); + }); + }); + + describe('shared failure modes', () => { + it('exits auth-required (code 4) when not logged in', async () => { + mockRequireCommandToken.mockImplementation(() => { + throw new CliExit(4, { reason: 'auth_required', errorCode: 'auth_required' }); }); - await runOrgList({}, 'sk_test'); - const output = JSON.parse(consoleOutput[0]); - expect(output.data).toHaveLength(1); - expect(output.data[0].id).toBe('org_123'); - expect(output.listMetadata.after).toBe('cursor_a'); + await expectExit(runOrgList({}), 4); + expect(mockGraphqlRequest).not.toHaveBeenCalled(); }); - it('runOrgList outputs empty data array for no results', async () => { - mockSdk.organizations.listOrganizations.mockResolvedValue({ - data: [], - listMetadata: { before: null, after: null }, + it('surfaces the gated-capability case on a 403 without naming GraphQL', async () => { + const consoleErrors: string[] = []; + vi.spyOn(console, 'error').mockImplementation((...args: unknown[]) => { + consoleErrors.push(args.map(String).join(' ')); }); - await runOrgList({}, 'sk_test'); - const output = JSON.parse(consoleOutput[0]); - expect(output.data).toEqual([]); - expect(output.listMetadata).toBeDefined(); - }); - - it('runOrgDelete outputs JSON success', async () => { - mockSdk.organizations.deleteOrganization.mockResolvedValue(undefined); - await runOrgDelete('org_123', 'sk_test'); - const output = JSON.parse(consoleOutput[0]); - expect(output.status).toBe('ok'); - expect(output.data.id).toBe('org_123'); + mockGraphqlRequest.mockRejectedValue( + new DashboardGraphqlError('The dashboard GraphQL API rejected this session (HTTP 403).', 'forbidden', 403), + ); + await expectExit(runOrgList({}), 1); + const err = consoleErrors.join('\n'); + expect(err).toMatch(/account-plane capability/i); + expect(err).not.toMatch(/graphql/i); + }); + + it('never calls the operation when the environment target cannot be resolved', async () => { + mockGetActiveEnvironment.mockReturnValue(undefined); + mockGetConfig.mockReturnValue(undefined); + setInteractionMode({ mode: 'agent', source: 'agent_env' }); + // Resolver falls through flag → profile → team fetch; the fetch fails. + mockGraphqlRequest.mockRejectedValue(new Error('network down')); + const err = await expectExit(runOrgList({}), 1); + expect(err.context?.errorCode).toBe('environment_unresolved'); + // Only the resolver's team fetch went out — never the operation. + expect(mockGraphqlRequest.mock.calls.every(([doc]) => String(doc).includes('teamProjectsV2'))).toBe(true); }); }); }); diff --git a/src/commands/organization.ts b/src/commands/organization.ts index f66e46b7..e3373143 100644 --- a/src/commands/organization.ts +++ b/src/commands/organization.ts @@ -1,133 +1,435 @@ +/** + * `workos organization` — organization lifecycle on the dashboard account plane. + * + * Migrated from the API-key REST SDK (graphql-resource-migration Phase 3): the + * subcommand surface (create/update/get/list/delete) is unchanged, but every + * operation now runs catalog-backed dashboard operations with the user's OAuth + * bearer — the same gated capability `team` / `authkit` use. Output shapes are + * new curated shapes (approved breaking change); the authoritative examples + * live in `organization.spec.ts`. + * + * Every operation here is environment-scoped: the target rides as the + * `x-url-environment-id` header (and, where the operation declares it, as a + * variable), resolved through `resolveEnvironmentTarget()`. Mutations + * pre-validate the resolved target; reads trust stored state. + * + * Safety posture per the manifest: `organization delete` is destructive + * (cascades to connections, directories, and users) → `confirmDestructive` + * (prompt, or --yes). + */ + import chalk from 'chalk'; -import type { DomainData } from '@workos-inc/node'; -import { createWorkOSClient } from '../lib/workos-client.js'; +import { requireCommandToken } from '../lib/command-auth.js'; +import { dashboardGraphqlRequest } from '../lib/dashboard-graphql.js'; +import { resolveEnvironmentTarget } from '../lib/environment-target.js'; +import { getOperation, resolveExecutableDocument, reportDashboardError } from '../catalog/operation.js'; +import { confirmDestructive } from '../catalog/confirm.js'; +import { isJsonMode, outputJson, outputSuccess, exitWithError } from '../utils/output.js'; import { formatTable } from '../utils/table.js'; -import { outputSuccess, outputJson, isJsonMode } from '../utils/output.js'; -import { createApiErrorHandler } from '../lib/api-error-handler.js'; -export function parseDomainArgs(args: string[]): DomainData[] { +const DOMAIN_STATES = ['verified', 'pending'] as const; +type DomainState = (typeof DOMAIN_STATES)[number]; + +export interface ParsedDomain { + domain: string; + state: DomainState; +} + +/** Parse `domain[:state]` positionals; state defaults to `verified`. */ +export function parseDomainArgs(args: string[]): ParsedDomain[] { return args.map((arg) => { const parts = arg.split(':'); - return { - domain: parts[0], - state: (parts[1] || 'verified') as DomainData['state'], - }; + const state = parts[1] || 'verified'; + if (!(DOMAIN_STATES as readonly string[]).includes(state)) { + exitWithError({ + code: 'invalid_argument', + message: `Invalid domain state "${state}" for "${parts[0]}". Allowed states: ${DOMAIN_STATES.join(', ')}.`, + }); + } + return { domain: parts[0], state: state as DomainState }; }); } -const handleApiError = createApiErrorHandler('Organization'); +/** + * The dashboard operation takes `domains: [String!]` plus a single + * `domainsDeveloperVerified: Boolean` covering the whole list, so per-domain + * states must agree: all `verified` → true, all `pending` → false, mixed → + * structured error (the old per-domain REST shape cannot be represented). + */ +function domainsDeveloperVerified(domains: ParsedDomain[]): boolean { + const states = new Set(domains.map((d) => d.state)); + if (states.size > 1) { + exitWithError({ + code: 'invalid_argument', + message: 'All domains must share the same state (verified or pending) — mixed states are not supported.', + }); + } + return !states.has('pending'); +} + +/** Map the CLI `--order asc|desc` flag onto the catalog's pagination enum. */ +function normalizeOrder(order: string | undefined): 'Asc' | 'Desc' | undefined { + if (order === undefined) return undefined; + const lower = order.toLowerCase(); + if (lower === 'asc') return 'Asc'; + if (lower === 'desc') return 'Desc'; + exitWithError({ code: 'invalid_argument', message: `Invalid --order "${order}". Allowed values: asc, desc.` }); +} -export async function runOrgCreate( - name: string, - domainArgs: string[], - apiKey: string, - baseUrl?: string, -): Promise { - const client = createWorkOSClient(apiKey, baseUrl); - const domains = parseDomainArgs(domainArgs); +interface OrganizationDomainNode { + id?: string | null; + domain: string; + state?: string | null; +} + +interface OrganizationNode { + id: string; + name?: string | null; + createdAt?: string | null; + usersCount?: number | null; + allowProfilesOutsideOrganization?: boolean | null; + externalId?: string | null; + metadata?: Array<{ key: string; value: string }> | null; + domains?: OrganizationDomainNode[] | null; +} + +/** + * The curated organization shape — the `--json` contract for every subcommand. + * camelCase, stable keys, no internal fields; see organization.spec.ts for the + * authoritative example. + */ +function shapeOrganization(org: OrganizationNode) { + return { + id: org.id, + name: org.name ?? null, + createdAt: org.createdAt ?? null, + usersCount: org.usersCount ?? null, + allowProfilesOutsideOrganization: org.allowProfilesOutsideOrganization ?? null, + externalId: org.externalId ?? null, + domains: (org.domains ?? []).map((d) => ({ id: d.id ?? null, domain: d.domain, state: d.state ?? null })), + metadata: org.metadata ?? [], + }; +} + +export interface OrgListOptions { + /** `--environment-id` override; defaults from the active profile. */ + environmentId?: string; + /** `--domain` filter — served by the dashboard search (matches name/domain). */ + domain?: string; + limit?: number; + before?: string; + after?: string; + order?: string; +} + +export async function runOrgList(options: OrgListOptions = {}): Promise { + const order = normalizeOrder(options.order); + const token = await requireCommandToken(); + const op = getOperation('organizations'); + + // Environment-scoped read: resolved target as variable + header. + const { environmentId } = await resolveEnvironmentTarget(token, { + flagValue: options.environmentId, + forMutation: op.kind === 'mutation', + }); + let data: { + organizations: { + data: OrganizationNode[]; + listMetadata: { before: string | null; after: string | null }; + } | null; + }; try { - const org = await client.sdk.organizations.createOrganization({ - name, - ...(domains.length > 0 && { domainData: domains }), + data = await dashboardGraphqlRequest(resolveExecutableDocument(op), { + token, + variables: { + environmentId, + ...(options.domain ? { search: options.domain } : {}), + ...(options.limit !== undefined ? { limit: options.limit } : {}), + ...(options.before ? { before: options.before } : {}), + ...(options.after ? { after: options.after } : {}), + ...(order ? { order } : {}), + }, + environmentId, }); - outputSuccess('Created organization', org); } catch (error) { - handleApiError(error); + reportDashboardError(error); + } + + const organizations = data.organizations?.data ?? []; + const pagination = { + before: data.organizations?.listMetadata?.before ?? null, + after: data.organizations?.listMetadata?.after ?? null, + }; + + if (isJsonMode()) { + outputJson({ organizations: organizations.map(shapeOrganization), pagination }); + return; + } + + if (organizations.length === 0) { + console.log('No organizations found.'); + return; + } + + const rows = organizations.map((org) => [ + org.id, + org.name ?? chalk.dim('—'), + (org.domains ?? []).map((d) => d.domain).join(', ') || chalk.dim('none'), + ]); + console.log(formatTable([{ header: 'ID' }, { header: 'Name' }, { header: 'Domains' }], rows)); + + const { before, after } = pagination; + if (before && after) { + console.log(chalk.dim(`Before: ${before} After: ${after}`)); + } else if (before) { + console.log(chalk.dim(`Before: ${before}`)); + } else if (after) { + console.log(chalk.dim(`After: ${after}`)); } } -export async function runOrgUpdate( - orgId: string, - name: string, - apiKey: string, - domain?: string, - state?: string, - baseUrl?: string, -): Promise { - const client = createWorkOSClient(apiKey, baseUrl); +export interface OrgGetOptions { + /** `--environment-id` override; defaults from the active profile. */ + environmentId?: string; +} + +export async function runOrgGet(orgId: string, options: OrgGetOptions = {}): Promise { + const token = await requireCommandToken(); + const op = getOperation('organization'); + + // Environment-scoped read: the op takes only `id`, but the target still + // rides as the environment header. + const { environmentId } = await resolveEnvironmentTarget(token, { + flagValue: options.environmentId, + forMutation: op.kind === 'mutation', + }); + let data: { organization: OrganizationNode | null }; try { - const org = await client.sdk.organizations.updateOrganization({ - organization: orgId, - name, - ...(domain && { domainData: [{ domain, state: (state || 'verified') as DomainData['state'] }] }), + data = await dashboardGraphqlRequest(resolveExecutableDocument(op), { + token, + variables: { id: orgId }, + environmentId, }); - outputSuccess('Updated organization', org); } catch (error) { - handleApiError(error); + reportDashboardError(error); + } + + if (!data.organization) { + exitWithError({ code: 'not_found', message: `Organization "${orgId}" was not found in this environment.` }); } + + const organization = shapeOrganization(data.organization); + if (isJsonMode()) { + outputJson({ organization }); + return; + } + + const fields: Array<[string, unknown]> = [ + ['ID', organization.id], + ['Name', organization.name], + ['Created', organization.createdAt], + ['Users', organization.usersCount], + ['External ID', organization.externalId], + ['Domains', organization.domains.map((d) => `${d.domain} (${d.state ?? 'unknown'})`).join(', ') || null], + ]; + for (const [label, value] of fields) { + if (value === null || value === undefined || value === '') continue; + console.log(`${chalk.bold(label)}: ${String(value)}`); + } + console.log(chalk.dim('Run with --json for the full record.')); +} + +export interface OrgCreateOptions { + /** `--environment-id` override; defaults from the active profile. */ + environmentId?: string; } -export async function runOrgGet(orgId: string, apiKey: string, baseUrl?: string): Promise { - const client = createWorkOSClient(apiKey, baseUrl); +export async function runOrgCreate(name: string, domainArgs: string[], options: OrgCreateOptions = {}): Promise { + const domains = parseDomainArgs(domainArgs); + const developerVerified = domains.length > 0 ? domainsDeveloperVerified(domains) : undefined; + + const token = await requireCommandToken(); + const op = getOperation('createOrganization'); + // Environment-scoped mutation: pre-validated resolved target, sent as both + // input field and environment header. + const { environmentId } = await resolveEnvironmentTarget(token, { + flagValue: options.environmentId, + forMutation: op.kind === 'mutation', + }); + + let data: { + createOrganization: + | { __typename: 'OrganizationCreated'; organization: OrganizationNode } + | { __typename: 'EnvironmentNotFound'; environmentId: string } + | { __typename: 'OrganizationDomainAlreadyInUse'; domain: string; organization: { id: string; name: string | null } } + | { __typename: 'ConsumerDomainForbidden'; domain: string } + | { __typename: 'ExternalIDAlreadyUsed'; externalId: string } + | { __typename: string }; + }; try { - const org = await client.sdk.organizations.getOrganization(orgId); - outputJson(org); + data = await dashboardGraphqlRequest(resolveExecutableDocument(op), { + token, + variables: { + input: { + environmentId, + name, + ...(domains.length > 0 + ? { domains: domains.map((d) => d.domain), domainsDeveloperVerified: developerVerified } + : {}), + }, + }, + environmentId, + }); } catch (error) { - handleApiError(error); + reportDashboardError(error); } + + const result = data.createOrganization; + if (result.__typename === 'EnvironmentNotFound') { + exitWithError({ + code: 'environment_not_found', + message: `Environment "${(result as { environmentId: string }).environmentId}" was not found.`, + }); + } + if (result.__typename === 'OrganizationDomainAlreadyInUse') { + const taken = result as { domain: string; organization: { id: string; name: string | null } }; + exitWithError({ + code: 'domain_in_use', + message: `Domain "${taken.domain}" is already in use by organization ${taken.organization.name ?? taken.organization.id}.`, + }); + } + if (result.__typename === 'ConsumerDomainForbidden') { + exitWithError({ + code: 'consumer_domain_forbidden', + message: `"${(result as { domain: string }).domain}" is a consumer email domain and cannot be added to an organization.`, + }); + } + if (result.__typename === 'ExternalIDAlreadyUsed') { + exitWithError({ + code: 'external_id_in_use', + message: `External ID "${(result as { externalId: string }).externalId}" is already in use.`, + }); + } + if (result.__typename !== 'OrganizationCreated' || !('organization' in result)) { + exitWithError({ code: 'unexpected_result', message: `Could not create organization "${name}".` }); + } + + const organization = shapeOrganization((result as { organization: OrganizationNode }).organization); + if (isJsonMode()) { + outputJson({ organization }); + return; + } + outputSuccess(`Created organization ${chalk.bold(organization.name ?? organization.id)}`); + console.log(chalk.dim(` id: ${organization.id}`)); } -export interface OrgListOptions { +export interface OrgUpdateOptions { + /** `--environment-id` override; defaults from the active profile. */ + environmentId?: string; domain?: string; - limit?: number; - before?: string; - after?: string; - order?: string; + state?: string; } -export async function runOrgList(options: OrgListOptions, apiKey: string, baseUrl?: string): Promise { - const client = createWorkOSClient(apiKey, baseUrl); +export async function runOrgUpdate(orgId: string, name: string, options: OrgUpdateOptions = {}): Promise { + const domains = options.domain ? parseDomainArgs([`${options.domain}:${options.state || 'verified'}`]) : []; + const token = await requireCommandToken(); + const op = getOperation('updateOrganization'); + + // Environment-scoped mutation: pre-validated resolved target as header (the + // op itself takes flat variables keyed by organization ID). + const { environmentId } = await resolveEnvironmentTarget(token, { + flagValue: options.environmentId, + forMutation: op.kind === 'mutation', + }); + + let data: { + updateOrganization: + | { __typename: 'UpdateOrganizationPayload'; organization: OrganizationNode } + | { __typename: 'ConsumerDomainForbidden'; domain: string } + | { __typename: 'ExternalIDAlreadyUsed'; externalId: string } + | { __typename: string }; + }; try { - const result = await client.sdk.organizations.listOrganizations({ - ...(options.domain && { domains: [options.domain] }), - limit: options.limit, - before: options.before, - after: options.after, - order: options.order as 'asc' | 'desc' | undefined, + data = await dashboardGraphqlRequest(resolveExecutableDocument(op), { + token, + variables: { + id: orgId, + name, + ...(domains.length > 0 + ? { domains: domains.map((d) => d.domain), domainsDeveloperVerified: domainsDeveloperVerified(domains) } + : {}), + }, + environmentId, }); + } catch (error) { + reportDashboardError(error); + } - if (isJsonMode()) { - outputJson({ data: result.data, listMetadata: result.listMetadata }); - return; - } - - if (result.data.length === 0) { - console.log('No organizations found.'); - return; - } + const result = data.updateOrganization; + if (result.__typename === 'ConsumerDomainForbidden') { + exitWithError({ + code: 'consumer_domain_forbidden', + message: `"${(result as { domain: string }).domain}" is a consumer email domain and cannot be added to an organization.`, + }); + } + if (result.__typename === 'ExternalIDAlreadyUsed') { + exitWithError({ + code: 'external_id_in_use', + message: `External ID "${(result as { externalId: string }).externalId}" is already in use.`, + }); + } + if (result.__typename !== 'UpdateOrganizationPayload' || !('organization' in result)) { + exitWithError({ code: 'unexpected_result', message: `Could not update organization "${orgId}".` }); + } - const rows = result.data.map((org) => [ - org.id, - org.name, - org.domains.map((d) => d.domain).join(', ') || chalk.dim('none'), - ]); - - console.log(formatTable([{ header: 'ID' }, { header: 'Name' }, { header: 'Domains' }], rows)); - - const { before, after } = result.listMetadata; - if (before && after) { - console.log(chalk.dim(`Before: ${before} After: ${after}`)); - } else if (before) { - console.log(chalk.dim(`Before: ${before}`)); - } else if (after) { - console.log(chalk.dim(`After: ${after}`)); - } - } catch (error) { - handleApiError(error); + const organization = shapeOrganization((result as { organization: OrganizationNode }).organization); + if (isJsonMode()) { + outputJson({ organization }); + return; } + outputSuccess(`Updated organization ${chalk.bold(organization.name ?? organization.id)}`); } -export async function runOrgDelete(orgId: string, apiKey: string, baseUrl?: string): Promise { - const client = createWorkOSClient(apiKey, baseUrl); +export interface OrgDeleteOptions { + /** `--environment-id` override; defaults from the active profile. */ + environmentId?: string; + yes?: boolean; + json?: boolean; +} + +export async function runOrgDelete(orgId: string, options: OrgDeleteOptions = {}): Promise { + const op = getOperation('deleteOrganization'); + // Destructive per the manifest; the consequence copy comes from the catalog's + // confirmation phrase ("permanently deletes the organization and cascades to + // its connections, directories, and users"). + const consequence = op.confirmation ? ` — this ${op.confirmation}` : ''; + await confirmDestructive(options, { action: `delete organization ${orgId}${consequence}` }); + + const token = await requireCommandToken(); + + // Environment-scoped mutation: pre-validated resolved target as header. + const { environmentId } = await resolveEnvironmentTarget(token, { + flagValue: options.environmentId, + forMutation: op.kind === 'mutation', + }); try { - await client.sdk.organizations.deleteOrganization(orgId); - outputSuccess('Deleted organization', { id: orgId }); + await dashboardGraphqlRequest(resolveExecutableDocument(op), { + token, + variables: { input: { organizationId: orgId } }, + environmentId, + }); } catch (error) { - handleApiError(error); + reportDashboardError(error); + } + + if (isJsonMode()) { + outputJson({ deleted: orgId }); + return; } + outputSuccess(`Deleted organization ${chalk.bold(orgId)}`); } diff --git a/src/commands/user.spec.ts b/src/commands/user.spec.ts index 593ba318..7a2e84a9 100644 --- a/src/commands/user.spec.ts +++ b/src/commands/user.spec.ts @@ -1,190 +1,411 @@ import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'; -// Mock the unified client -const mockSdk = { - userManagement: { - getUser: vi.fn(), - listUsers: vi.fn(), - updateUser: vi.fn(), - deleteUser: vi.fn(), - }, -}; +const mockRequireCommandToken = vi.fn(); +const mockGraphqlRequest = vi.fn(); +const mockConfirm = vi.fn(); +const mockIsCancel = vi.fn(() => false); +const mockGetActiveEnvironment = vi.fn(); +const mockGetConfig = vi.fn(); + +vi.mock('../lib/command-auth.js', async (importActual) => { + const actual = await importActual(); + return { + ...actual, + requireCommandToken: () => mockRequireCommandToken(), + }; +}); -vi.mock('../lib/workos-client.js', () => ({ - createWorkOSClient: () => ({ sdk: mockSdk }), +vi.mock('../lib/dashboard-graphql.js', async (importActual) => { + const actual = await importActual(); + return { + ...actual, + dashboardGraphqlRequest: (...args: unknown[]) => mockGraphqlRequest(...args), + }; +}); + +vi.mock('../utils/clack.js', () => ({ + default: { + confirm: (...args: unknown[]) => mockConfirm(...args), + isCancel: (...args: unknown[]) => mockIsCancel(...args), + select: vi.fn(), + }, })); -const { setOutputMode } = await import('../utils/output.js'); +// The environment-target resolver runs REAL against the mocked wire + config +// store, so mutation tests can assert the pre-validation fetch +// (teamProjectsV2) happens before the operation request. +vi.mock('../lib/config-store.js', async (importActual) => { + const actual = await importActual(); + return { + ...actual, + getActiveEnvironment: () => mockGetActiveEnvironment(), + getConfig: () => mockGetConfig(), + setProfileEnvironmentId: vi.fn(), + }; +}); +const { setOutputMode } = await import('../utils/output.js'); +const { resetInteractionModeForTests, setInteractionMode } = await import('../utils/interaction-mode.js'); +const { DashboardGraphqlError } = await import('../lib/dashboard-graphql.js'); +const { CliExit } = await import('../utils/cli-exit.js'); const { runUserGet, runUserList, runUserUpdate, runUserDelete } = await import('./user.js'); -describe('user commands', () => { +const TEAM_ENVIRONMENTS_PAYLOAD = { + currentTeam: { + projectsV2: [{ environments: [{ id: 'env_profile', name: 'Sandbox', sandbox: true, clientId: null }] }], + }, +}; + +/** + * Route the wire mock by document: the environment resolver's pre-validation + * fetch (`teamProjectsV2`) gets the team's environments; everything else gets + * the operation payload. + */ +function respondWith(payload: unknown): void { + mockGraphqlRequest.mockImplementation(async (doc: unknown) => { + if (String(doc).includes('teamProjectsV2')) return TEAM_ENVIRONMENTS_PAYLOAD; + return payload; + }); +} + +async function expectExit(promise: Promise, code: number): Promise { + try { + await promise; + } catch (err) { + if (err instanceof CliExit) { + expect(err.exitCode).toBe(code); + return err; + } + throw err; + } + throw new Error(`Expected CliExit(${code}) but promise resolved`); +} + +/** The authoritative curated user JSON shape (documented contract). */ +const USER_SHAPE_KEYS = [ + 'id', + 'email', + 'firstName', + 'lastName', + 'createdAt', + 'emailVerifiedAt', + 'lastSignedInAt', + 'sessionCount', + 'hasPassword', + 'locale', + 'externalId', + 'profilePictureUrl', + 'metadata', + 'identities', +]; + +const USER_NODE = { + id: 'user_1', + email: 'jane@example.com', + firstName: 'Jane', + lastName: 'Doe', + createdAt: '2026-01-01T00:00:00.000Z', + emailVerifiedAt: '2026-01-02T00:00:00.000Z', + lastSignedInAt: '2026-02-01T00:00:00.000Z', + sessionCount: 4, + hasPassword: true, + locale: 'en-US', + externalId: null, + profilePictureUrl: null, + metadata: [], + identities: { + data: [ + { + id: 'ident_1', + status: 'Active', + organization: { id: 'org_1', name: 'FooCorp' }, + roles: [{ id: 'role_1', name: 'member' }], + // Internal fields the curated shape must drop: + customAttributes: { internal: true }, + ssoProfile: null, + }, + ], + }, + // Internal fields the curated shape must drop: + googleOauthProfile: { id: 'oauth_1' }, + directoryUser: null, +}; + +describe('user command', () => { let consoleOutput: string[]; beforeEach(() => { vi.clearAllMocks(); + resetInteractionModeForTests(); + setOutputMode('human'); + mockRequireCommandToken.mockResolvedValue('tok_123'); + mockConfirm.mockReset(); + mockIsCancel.mockReset(); + mockIsCancel.mockReturnValue(false); + mockGetActiveEnvironment.mockReturnValue({ apiKey: 'sk_ignored', environmentId: 'env_profile' }); + mockGetConfig.mockReturnValue({ + activeEnvironment: 'default', + environments: { default: { apiKey: 'sk_ignored', environmentId: 'env_profile' } }, + }); consoleOutput = []; vi.spyOn(console, 'log').mockImplementation((...args: unknown[]) => { consoleOutput.push(args.map(String).join(' ')); }); + vi.spyOn(console, 'error').mockImplementation(() => {}); }); afterEach(() => { vi.restoreAllMocks(); + resetInteractionModeForTests(); + setOutputMode('human'); }); - describe('runUserGet', () => { - it('fetches and prints user as JSON', async () => { - mockSdk.userManagement.getUser.mockResolvedValue({ id: 'user_123', email: 'test@example.com' }); - await runUserGet('user_123', 'sk_test'); - expect(mockSdk.userManagement.getUser).toHaveBeenCalledWith('user_123'); - expect(consoleOutput.some((l) => l.includes('user_123'))).toBe(true); + describe('get', () => { + it('fetches by ID with the environment header and renders fields', async () => { + respondWith({ userlandUser: USER_NODE }); + await runUserGet('user_1'); + expect(mockGraphqlRequest).toHaveBeenCalledWith(expect.stringContaining('userlandUser'), { + token: 'tok_123', + variables: { id: 'user_1' }, + environmentId: 'env_profile', + }); + const out = consoleOutput.join('\n'); + expect(out).toContain('jane@example.com'); + expect(out).toContain('Jane Doe'); + }); + + it('--json emits { user } in the curated shape (auth factors, no internal fields)', async () => { + setOutputMode('json'); + respondWith({ + userlandUser: { + ...USER_NODE, + authenticationFactors: [{ id: 'fac_1', lastVerifiedAt: null, factorType: { __typename: 'Totp' } }], + }, + }); + await runUserGet('user_1'); + const raw = consoleOutput[0]; + expect(raw).not.toMatch(/graphql|userland|list_metadata/i); + const out = JSON.parse(raw); + expect(Object.keys(out)).toEqual(['user']); + expect(Object.keys(out.user)).toEqual([...USER_SHAPE_KEYS, 'authenticationFactors']); + expect(out.user).not.toHaveProperty('googleOauthProfile'); + expect(out.user.identities[0]).toEqual({ + id: 'ident_1', + status: 'Active', + organization: { id: 'org_1', name: 'FooCorp' }, + roles: [{ id: 'role_1', name: 'member' }], + }); + expect(out.user.authenticationFactors).toEqual([{ id: 'fac_1', lastVerifiedAt: null }]); + }); + + it('errors not_found when the user does not exist', async () => { + respondWith({ userlandUser: null }); + const err = await expectExit(runUserGet('user_missing'), 1); + expect(err.context?.errorCode).toBe('not_found'); }); }); - describe('runUserList', () => { - it('lists users in table format', async () => { - mockSdk.userManagement.listUsers.mockResolvedValue({ - data: [ - { - id: 'user_123', - email: 'test@example.com', - firstName: 'Test', - lastName: 'User', - emailVerified: true, - }, - ], - listMetadata: { before: null, after: null }, + describe('list', () => { + it('lists users in human mode with a Verified column', async () => { + respondWith({ userlandUsers: { data: [USER_NODE], listMetadata: { before: null, after: null } } }); + await runUserList({}); + const out = consoleOutput.join('\n'); + expect(out).toContain('jane@example.com'); + expect(out).toContain('Yes'); + }); + + it('sends the resolved environment as variable AND header (read: no pre-validation fetch)', async () => { + respondWith({ userlandUsers: { data: [], listMetadata: { before: null, after: null } } }); + await runUserList({}); + expect(mockGraphqlRequest).toHaveBeenCalledTimes(1); + expect(mockGraphqlRequest).toHaveBeenCalledWith(expect.stringContaining('userlandUsers'), { + token: 'tok_123', + variables: { environmentId: 'env_profile' }, + environmentId: 'env_profile', }); - await runUserList({}, 'sk_test'); - expect(consoleOutput.some((l) => l.includes('test@example.com'))).toBe(true); }); - it('passes filter params', async () => { - mockSdk.userManagement.listUsers.mockResolvedValue({ - data: [], - listMetadata: { before: null, after: null }, + it('maps --email to search and pagination flags to catalog variables', async () => { + respondWith({ userlandUsers: { data: [], listMetadata: { before: null, after: null } } }); + await runUserList({ email: 'jane@', limit: 5, before: 'cursor_b', order: 'asc' }); + expect(mockGraphqlRequest).toHaveBeenCalledWith(expect.stringContaining('userlandUsers'), { + token: 'tok_123', + variables: { environmentId: 'env_profile', search: 'jane@', limit: 5, before: 'cursor_b', order: 'Asc' }, + environmentId: 'env_profile', }); - await runUserList({ email: 'test@example.com', organization: 'org_123', limit: 5 }, 'sk_test'); - expect(mockSdk.userManagement.listUsers).toHaveBeenCalledWith( - expect.objectContaining({ email: 'test@example.com', organizationId: 'org_123', limit: 5 }), - ); }); - it('handles empty results', async () => { - mockSdk.userManagement.listUsers.mockResolvedValue({ - data: [], - listMetadata: { before: null, after: null }, + it('rejects an invalid --order before any request', async () => { + const err = await expectExit(runUserList({ order: 'sideways' }), 1); + expect(err.context?.errorCode).toBe('invalid_argument'); + expect(mockGraphqlRequest).not.toHaveBeenCalled(); + }); + + it('honors an --environment-id override', async () => { + respondWith({ userlandUsers: { data: [], listMetadata: { before: null, after: null } } }); + await runUserList({ environmentId: 'env_flag' }); + expect(mockGraphqlRequest).toHaveBeenCalledWith(expect.stringContaining('userlandUsers'), { + token: 'tok_123', + variables: { environmentId: 'env_flag' }, + environmentId: 'env_flag', }); - await runUserList({}, 'sk_test'); + }); + + it('handles empty results', async () => { + respondWith({ userlandUsers: { data: [], listMetadata: { before: null, after: null } } }); + await runUserList({}); expect(consoleOutput.some((l) => l.includes('No users found'))).toBe(true); }); - it('shows pagination cursors when present', async () => { - mockSdk.userManagement.listUsers.mockResolvedValue({ - data: [{ id: 'user_1', email: 'a@b.com', firstName: '', lastName: '', emailVerified: false }], - listMetadata: { before: 'cur_b', after: 'cur_a' }, - }); - await runUserList({}, 'sk_test'); - expect(consoleOutput.some((l) => l.includes('cur_b'))).toBe(true); + it('--json emits the documented curated shape (no list_metadata, no internal fields)', async () => { + setOutputMode('json'); + respondWith({ userlandUsers: { data: [USER_NODE], listMetadata: { before: null, after: 'cursor_a' } } }); + await runUserList({}); + const raw = consoleOutput[0]; + expect(raw).not.toMatch(/graphql|userland|list_metadata/i); + const out = JSON.parse(raw); + expect(Object.keys(out)).toEqual(['users', 'pagination']); + expect(Object.keys(out.users[0])).toEqual(USER_SHAPE_KEYS); + expect(out.users[0]).not.toHaveProperty('googleOauthProfile'); + expect(out.pagination).toEqual({ before: null, after: 'cursor_a' }); }); }); - describe('runUserUpdate', () => { - it('updates user with provided fields', async () => { - mockSdk.userManagement.updateUser.mockResolvedValue({ id: 'user_123', email: 'test@example.com' }); - await runUserUpdate('user_123', 'sk_test', { firstName: 'John', lastName: 'Doe' }); - expect(mockSdk.userManagement.updateUser).toHaveBeenCalledWith({ - userId: 'user_123', - firstName: 'John', - lastName: 'Doe', + describe('update', () => { + const updated = { + updateUserlandUser: { + __typename: 'UserlandUserUpdated', + userlandUser: { id: 'user_1', email: 'jane@example.com', firstName: 'Janet', lastName: 'Doe' }, + }, + }; + + it('requires at least one update flag', async () => { + const err = await expectExit(runUserUpdate('user_1', {}), 1); + expect(err.context?.errorCode).toBe('missing_argument'); + expect(mockGraphqlRequest).not.toHaveBeenCalled(); + }); + + it('maps flags into the input and pre-validates the environment first', async () => { + respondWith(updated); + await runUserUpdate('user_1', { firstName: 'Janet', locale: 'fr-FR' }); + // Mutation: the resolver fetches the team's environments BEFORE the op. + expect(mockGraphqlRequest.mock.calls.length).toBe(2); + expect(String(mockGraphqlRequest.mock.calls[0][0])).toContain('teamProjectsV2'); + expect(String(mockGraphqlRequest.mock.calls[1][0])).toContain('updateUserlandUser'); + expect(mockGraphqlRequest.mock.calls[1][1]).toEqual({ + token: 'tok_123', + variables: { input: { userlandUserId: 'user_1', firstName: 'Janet', locale: 'fr-FR' } }, + environmentId: 'env_profile', }); }); - it('sends only provided fields', async () => { - mockSdk.userManagement.updateUser.mockResolvedValue({ id: 'user_123' }); - await runUserUpdate('user_123', 'sk_test', { emailVerified: true }); - expect(mockSdk.userManagement.updateUser).toHaveBeenCalledWith({ - userId: 'user_123', - emailVerified: true, + it('errors not_found on a missing user', async () => { + respondWith({ updateUserlandUser: { __typename: 'UserlandUserNotFound' } }); + const err = await expectExit(runUserUpdate('user_1', { firstName: 'X' }), 1); + expect(err.context?.errorCode).toBe('not_found'); + }); + + it('errors cleanly on an email-change failure without echoing internal naming', async () => { + const consoleErrors: string[] = []; + vi.spyOn(console, 'error').mockImplementation((...args: unknown[]) => { + consoleErrors.push(args.map(String).join(' ')); }); + respondWith({ updateUserlandUser: { __typename: 'UserlandUserChangeEmailError', reason: 'UserlandEmailTaken' } }); + const err = await expectExit(runUserUpdate('user_1', { email: 'taken@example.com' }), 1); + expect(err.context?.errorCode).toBe('email_change_failed'); + expect(consoleErrors.join('\n')).not.toMatch(/graphql|userland/i); }); - }); - describe('runUserDelete', () => { - it('deletes user and prints confirmation', async () => { - mockSdk.userManagement.deleteUser.mockResolvedValue(undefined); - await runUserDelete('user_123', 'sk_test'); - expect(mockSdk.userManagement.deleteUser).toHaveBeenCalledWith('user_123'); - expect(consoleOutput.some((l) => l.includes('Deleted'))).toBe(true); - expect(consoleOutput.some((l) => l.includes('user_123'))).toBe(true); + it('errors when the external ID is already used', async () => { + respondWith({ updateUserlandUser: { __typename: 'ExternalIDAlreadyUsed', externalId: 'ext_1' } }); + const err = await expectExit(runUserUpdate('user_1', { externalId: 'ext_1' }), 1); + expect(err.context?.errorCode).toBe('external_id_in_use'); }); - }); - describe('JSON output mode', () => { - beforeEach(() => { + it('--json emits the updated { user } subset', async () => { setOutputMode('json'); + respondWith(updated); + await runUserUpdate('user_1', { firstName: 'Janet' }); + const out = JSON.parse(consoleOutput[0]); + expect(out).toEqual({ user: { id: 'user_1', email: 'jane@example.com', firstName: 'Janet', lastName: 'Doe' } }); + }); + }); + + describe('delete (destructive)', () => { + const deleted = { deleteUserlandUser: { __typename: 'UserlandUserDeleted' } }; + + it('refuses in agent mode without --yes (exit 1, confirmation_required)', async () => { + setInteractionMode({ mode: 'agent', source: 'agent_env' }); + const err = await expectExit(runUserDelete('user_1', { yes: false }), 1); + expect(err.context?.errorCode).toBe('confirmation_required'); + expect(mockGraphqlRequest).not.toHaveBeenCalled(); + }); + + it('proceeds non-interactively with --yes and sends the delete input', async () => { + setInteractionMode({ mode: 'ci', source: 'ci_env' }); + respondWith(deleted); + await runUserDelete('user_1', { yes: true }); + expect(String(mockGraphqlRequest.mock.calls[0][0])).toContain('teamProjectsV2'); + expect(mockGraphqlRequest.mock.calls[1][1]).toEqual({ + token: 'tok_123', + variables: { input: { userlandUserId: 'user_1' } }, + environmentId: 'env_profile', + }); }); - afterEach(() => { - setOutputMode('human'); + it('prompts interactively and proceeds when confirmed', async () => { + setInteractionMode({ mode: 'human', source: 'default' }); + mockConfirm.mockResolvedValue(true); + respondWith(deleted); + await runUserDelete('user_1', { yes: false }); + expect(mockConfirm).toHaveBeenCalledOnce(); + expect(mockGraphqlRequest).toHaveBeenCalled(); }); - it('runUserGet outputs raw JSON', async () => { - mockSdk.userManagement.getUser.mockResolvedValue({ id: 'user_123', email: 'test@example.com' }); - await runUserGet('user_123', 'sk_test'); - const output = JSON.parse(consoleOutput[0]); - expect(output.id).toBe('user_123'); - expect(output.email).toBe('test@example.com'); - expect(output).not.toHaveProperty('status'); + it('cancels (exit 2) when the interactive prompt is declined', async () => { + setInteractionMode({ mode: 'human', source: 'default' }); + mockConfirm.mockResolvedValue(false); + await expectExit(runUserDelete('user_1', { yes: false }), 2); + expect(mockGraphqlRequest).not.toHaveBeenCalled(); }); - it('runUserList outputs JSON with data and listMetadata', async () => { - mockSdk.userManagement.listUsers.mockResolvedValue({ - data: [ - { - id: 'user_123', - email: 'test@example.com', - firstName: 'Test', - lastName: 'User', - emailVerified: true, - }, - ], - listMetadata: { before: null, after: 'cursor_a' }, + it('errors not_found when the user does not exist', async () => { + setInteractionMode({ mode: 'agent', source: 'agent_env' }); + respondWith({ deleteUserlandUser: { __typename: 'UserlandUserNotFound' } }); + const err = await expectExit(runUserDelete('user_missing', { yes: true }), 1); + expect(err.context?.errorCode).toBe('not_found'); + }); + + it('--json emits { deleted }', async () => { + setInteractionMode({ mode: 'agent', source: 'agent_env' }); + setOutputMode('json'); + respondWith(deleted); + await runUserDelete('user_1', { yes: true }); + expect(JSON.parse(consoleOutput[0])).toEqual({ deleted: 'user_1' }); + }); + }); + + describe('shared failure modes', () => { + it('exits auth-required (code 4) when not logged in', async () => { + mockRequireCommandToken.mockImplementation(() => { + throw new CliExit(4, { reason: 'auth_required', errorCode: 'auth_required' }); }); - await runUserList({}, 'sk_test'); - const output = JSON.parse(consoleOutput[0]); - expect(output.data).toHaveLength(1); - expect(output.data[0].email).toBe('test@example.com'); - expect(output.listMetadata.after).toBe('cursor_a'); + await expectExit(runUserList({}), 4); + expect(mockGraphqlRequest).not.toHaveBeenCalled(); }); - it('runUserList outputs empty data array for no results', async () => { - mockSdk.userManagement.listUsers.mockResolvedValue({ - data: [], - listMetadata: { before: null, after: null }, + it('surfaces the gated-capability case on a 403 without naming GraphQL', async () => { + const consoleErrors: string[] = []; + vi.spyOn(console, 'error').mockImplementation((...args: unknown[]) => { + consoleErrors.push(args.map(String).join(' ')); }); - await runUserList({}, 'sk_test'); - const output = JSON.parse(consoleOutput[0]); - expect(output.data).toEqual([]); - expect(output.listMetadata).toBeDefined(); - }); - - it('runUserUpdate outputs JSON success', async () => { - mockSdk.userManagement.updateUser.mockResolvedValue({ id: 'user_123', email: 'test@example.com' }); - await runUserUpdate('user_123', 'sk_test', { firstName: 'John' }); - const output = JSON.parse(consoleOutput[0]); - expect(output.status).toBe('ok'); - expect(output.message).toBe('Updated user'); - expect(output.data.id).toBe('user_123'); - }); - - it('runUserDelete outputs JSON success', async () => { - mockSdk.userManagement.deleteUser.mockResolvedValue(undefined); - await runUserDelete('user_123', 'sk_test'); - const output = JSON.parse(consoleOutput[0]); - expect(output.status).toBe('ok'); - expect(output.data.id).toBe('user_123'); + mockGraphqlRequest.mockRejectedValue( + new DashboardGraphqlError('The dashboard GraphQL API rejected this session (HTTP 403).', 'forbidden', 403), + ); + await expectExit(runUserList({}), 1); + const err = consoleErrors.join('\n'); + expect(err).toMatch(/account-plane capability/i); + expect(err).not.toMatch(/graphql/i); }); }); }); diff --git a/src/commands/user.ts b/src/commands/user.ts index 67bba39f..840a7b29 100644 --- a/src/commands/user.ts +++ b/src/commands/user.ts @@ -1,126 +1,377 @@ +/** + * `workos user` — AuthKit user lifecycle on the dashboard account plane. + * + * Migrated from the API-key REST SDK (graphql-resource-migration Phase 3): the + * subcommand surface (get/list/update/delete — there is deliberately no + * `create`) is unchanged, but every operation now runs catalog-backed dashboard + * operations with the user's OAuth bearer. Output shapes are new curated shapes + * (approved breaking change); the authoritative examples live in + * `user.spec.ts`. + * + * Every operation here is environment-scoped: the target rides as the + * `x-url-environment-id` header (and, where the operation declares it, as a + * variable), resolved through `resolveEnvironmentTarget()`. Mutations + * pre-validate the resolved target; reads trust stored state. + * + * Safety posture per the manifest: `user delete` is destructive (permanently + * deletes the end user) → `confirmDestructive` (prompt, or --yes). + */ + import chalk from 'chalk'; -import { createWorkOSClient } from '../lib/workos-client.js'; +import { requireCommandToken } from '../lib/command-auth.js'; +import { dashboardGraphqlRequest } from '../lib/dashboard-graphql.js'; +import { resolveEnvironmentTarget } from '../lib/environment-target.js'; +import { getOperation, resolveExecutableDocument, reportDashboardError } from '../catalog/operation.js'; +import { confirmDestructive } from '../catalog/confirm.js'; +import { isJsonMode, outputJson, outputSuccess, exitWithError } from '../utils/output.js'; import { formatTable } from '../utils/table.js'; -import { outputSuccess, outputJson, isJsonMode } from '../utils/output.js'; -import { createApiErrorHandler } from '../lib/api-error-handler.js'; -const handleApiError = createApiErrorHandler('User'); +/** Map the CLI `--order asc|desc` flag onto the catalog's pagination enum. */ +function normalizeOrder(order: string | undefined): 'Asc' | 'Desc' | undefined { + if (order === undefined) return undefined; + const lower = order.toLowerCase(); + if (lower === 'asc') return 'Asc'; + if (lower === 'desc') return 'Desc'; + exitWithError({ code: 'invalid_argument', message: `Invalid --order "${order}". Allowed values: asc, desc.` }); +} + +interface IdentityNode { + id: string; + status?: string | null; + organization?: { id: string; name: string | null } | null; + roles?: Array<{ id: string; name: string | null }> | null; +} + +interface UserNode { + id: string; + email?: string | null; + firstName?: string | null; + lastName?: string | null; + createdAt?: string | null; + emailVerifiedAt?: string | null; + lastSignedInAt?: string | null; + sessionCount?: number | null; + hasPassword?: boolean | null; + locale?: string | null; + externalId?: string | null; + profilePictureUrl?: string | null; + metadata?: Array<{ key: string; value: string }> | null; + identities?: { data: IdentityNode[] } | null; + authenticationFactors?: Array<{ id: string; lastVerifiedAt?: string | null }> | null; +} + +/** + * The curated user shape — the `--json` contract for every subcommand. + * camelCase, stable keys, no internal fields; see user.spec.ts for the + * authoritative example. + */ +function shapeUser(user: UserNode) { + return { + id: user.id, + email: user.email ?? null, + firstName: user.firstName ?? null, + lastName: user.lastName ?? null, + createdAt: user.createdAt ?? null, + emailVerifiedAt: user.emailVerifiedAt ?? null, + lastSignedInAt: user.lastSignedInAt ?? null, + sessionCount: user.sessionCount ?? null, + hasPassword: user.hasPassword ?? null, + locale: user.locale ?? null, + externalId: user.externalId ?? null, + profilePictureUrl: user.profilePictureUrl ?? null, + metadata: user.metadata ?? [], + identities: (user.identities?.data ?? []).map((identity) => ({ + id: identity.id, + status: identity.status ?? null, + organization: identity.organization + ? { id: identity.organization.id, name: identity.organization.name ?? null } + : null, + roles: (identity.roles ?? []).map((role) => ({ id: role.id, name: role.name ?? null })), + })), + }; +} + +export interface UserGetOptions { + /** `--environment-id` override; defaults from the active profile. */ + environmentId?: string; +} + +export async function runUserGet(userId: string, options: UserGetOptions = {}): Promise { + const token = await requireCommandToken(); + const op = getOperation('userlandUser'); -export async function runUserGet(userId: string, apiKey: string, baseUrl?: string): Promise { - const client = createWorkOSClient(apiKey, baseUrl); + // Environment-scoped read: the op takes only `id`, but the target still + // rides as the environment header. + const { environmentId } = await resolveEnvironmentTarget(token, { + flagValue: options.environmentId, + forMutation: op.kind === 'mutation', + }); + let data: { userlandUser: UserNode | null }; try { - const user = await client.sdk.userManagement.getUser(userId); - outputJson(user); + data = await dashboardGraphqlRequest(resolveExecutableDocument(op), { + token, + variables: { id: userId }, + environmentId, + }); } catch (error) { - handleApiError(error); + reportDashboardError(error); + } + + if (!data.userlandUser) { + exitWithError({ code: 'not_found', message: `User "${userId}" was not found in this environment.` }); } + + const user = { + ...shapeUser(data.userlandUser), + authenticationFactors: (data.userlandUser.authenticationFactors ?? []).map((factor) => ({ + id: factor.id, + lastVerifiedAt: factor.lastVerifiedAt ?? null, + })), + }; + if (isJsonMode()) { + outputJson({ user }); + return; + } + + const fields: Array<[string, unknown]> = [ + ['ID', user.id], + ['Email', user.email], + ['Name', [user.firstName, user.lastName].filter(Boolean).join(' ') || null], + ['Verified', user.emailVerifiedAt ? 'Yes' : 'No'], + ['Created', user.createdAt], + ['Last sign-in', user.lastSignedInAt], + ['External ID', user.externalId], + ]; + for (const [label, value] of fields) { + if (value === null || value === undefined || value === '') continue; + console.log(`${chalk.bold(label)}: ${String(value)}`); + } + console.log(chalk.dim('Run with --json for the full record.')); } export interface UserListOptions { + /** `--environment-id` override; defaults from the active profile. */ + environmentId?: string; + /** `--email` filter — served by the dashboard search. */ email?: string; - organization?: string; limit?: number; before?: string; after?: string; order?: string; } -export async function runUserList(options: UserListOptions, apiKey: string, baseUrl?: string): Promise { - const client = createWorkOSClient(apiKey, baseUrl); +export async function runUserList(options: UserListOptions = {}): Promise { + const order = normalizeOrder(options.order); + const token = await requireCommandToken(); + const op = getOperation('userlandUsers'); + // Environment-scoped read: resolved target as variable + header. + const { environmentId } = await resolveEnvironmentTarget(token, { + flagValue: options.environmentId, + forMutation: op.kind === 'mutation', + }); + + let data: { + userlandUsers: { + data: UserNode[]; + listMetadata: { before: string | null; after: string | null }; + } | null; + }; try { - const result = await client.sdk.userManagement.listUsers({ - email: options.email, - organizationId: options.organization, - limit: options.limit, - before: options.before, - after: options.after, - order: options.order as 'asc' | 'desc' | undefined, + data = await dashboardGraphqlRequest(resolveExecutableDocument(op), { + token, + variables: { + environmentId, + ...(options.email ? { search: options.email } : {}), + ...(options.limit !== undefined ? { limit: options.limit } : {}), + ...(options.before ? { before: options.before } : {}), + ...(options.after ? { after: options.after } : {}), + ...(order ? { order } : {}), + }, + environmentId, }); + } catch (error) { + reportDashboardError(error); + } - if (isJsonMode()) { - outputJson({ data: result.data, listMetadata: result.listMetadata }); - return; - } + const users = data.userlandUsers?.data ?? []; + const pagination = { + before: data.userlandUsers?.listMetadata?.before ?? null, + after: data.userlandUsers?.listMetadata?.after ?? null, + }; - if (result.data.length === 0) { - console.log('No users found.'); - return; - } + if (isJsonMode()) { + outputJson({ users: users.map(shapeUser), pagination }); + return; + } - const rows = result.data.map((user) => [ - user.id, - user.email, - user.firstName || chalk.dim('-'), - user.lastName || chalk.dim('-'), - user.emailVerified ? 'Yes' : 'No', - ]); - - console.log( - formatTable( - [ - { header: 'ID' }, - { header: 'Email' }, - { header: 'First Name' }, - { header: 'Last Name' }, - { header: 'Verified' }, - ], - rows, - ), - ); - - const { before, after } = result.listMetadata; - if (before && after) { - console.log(chalk.dim(`Before: ${before} After: ${after}`)); - } else if (before) { - console.log(chalk.dim(`Before: ${before}`)); - } else if (after) { - console.log(chalk.dim(`After: ${after}`)); - } - } catch (error) { - handleApiError(error); + if (users.length === 0) { + console.log('No users found.'); + return; + } + + const rows = users.map((user) => [ + user.id, + user.email ?? chalk.dim('—'), + user.firstName || chalk.dim('-'), + user.lastName || chalk.dim('-'), + user.emailVerifiedAt ? 'Yes' : 'No', + ]); + console.log( + formatTable( + [{ header: 'ID' }, { header: 'Email' }, { header: 'First Name' }, { header: 'Last Name' }, { header: 'Verified' }], + rows, + ), + ); + + const { before, after } = pagination; + if (before && after) { + console.log(chalk.dim(`Before: ${before} After: ${after}`)); + } else if (before) { + console.log(chalk.dim(`Before: ${before}`)); + } else if (after) { + console.log(chalk.dim(`After: ${after}`)); } } export interface UserUpdateOptions { + /** `--environment-id` override; defaults from the active profile. */ + environmentId?: string; firstName?: string; lastName?: string; - emailVerified?: boolean; - password?: string; + email?: string; + locale?: string; externalId?: string; } -export async function runUserUpdate( - userId: string, - apiKey: string, - options: UserUpdateOptions, - baseUrl?: string, -): Promise { - const client = createWorkOSClient(apiKey, baseUrl); +export async function runUserUpdate(userId: string, options: UserUpdateOptions = {}): Promise { + const updates = { + ...(options.firstName !== undefined ? { firstName: options.firstName } : {}), + ...(options.lastName !== undefined ? { lastName: options.lastName } : {}), + ...(options.email !== undefined ? { email: options.email } : {}), + ...(options.locale !== undefined ? { locale: options.locale } : {}), + ...(options.externalId !== undefined ? { externalId: options.externalId } : {}), + }; + if (Object.keys(updates).length === 0) { + exitWithError({ + code: 'missing_argument', + message: 'Nothing to update. Pass at least one of --first-name, --last-name, --email, --locale, --external-id.', + }); + } + + const token = await requireCommandToken(); + const op = getOperation('updateUserlandUser'); + + // Environment-scoped mutation: pre-validated resolved target as header. + const { environmentId } = await resolveEnvironmentTarget(token, { + flagValue: options.environmentId, + forMutation: op.kind === 'mutation', + }); + let data: { + updateUserlandUser: + | { + __typename: 'UserlandUserUpdated'; + userlandUser: { id: string; email: string | null; firstName: string | null; lastName: string | null }; + } + | { __typename: 'UserlandUserNotFound' } + | { __typename: 'UserlandUserChangeEmailError'; reason: string } + | { __typename: 'ExternalIDAlreadyUsed'; externalId: string } + | { __typename: string }; + }; try { - const user = await client.sdk.userManagement.updateUser({ - userId, - ...(options.firstName !== undefined && { firstName: options.firstName }), - ...(options.lastName !== undefined && { lastName: options.lastName }), - ...(options.emailVerified !== undefined && { emailVerified: options.emailVerified }), - ...(options.password !== undefined && { password: options.password }), - ...(options.externalId !== undefined && { externalId: options.externalId }), + data = await dashboardGraphqlRequest(resolveExecutableDocument(op), { + token, + variables: { input: { userlandUserId: userId, ...updates } }, + environmentId, }); - outputSuccess('Updated user', user); } catch (error) { - handleApiError(error); + reportDashboardError(error); + } + + const result = data.updateUserlandUser; + if (result.__typename === 'UserlandUserNotFound') { + exitWithError({ code: 'not_found', message: `User "${userId}" was not found in this environment.` }); + } + if (result.__typename === 'UserlandUserChangeEmailError') { + // The server's reason is an internal enum; keep the copy clean rather than + // echoing internal naming. + exitWithError({ + code: 'email_change_failed', + message: `Could not change the email address for "${userId}". The new email may be invalid or already in use.`, + }); + } + if (result.__typename === 'ExternalIDAlreadyUsed') { + exitWithError({ + code: 'external_id_in_use', + message: `External ID "${(result as { externalId: string }).externalId}" is already in use.`, + }); } + if (result.__typename !== 'UserlandUserUpdated' || !('userlandUser' in result)) { + exitWithError({ code: 'unexpected_result', message: `Could not update user "${userId}".` }); + } + + const updated = ( + result as { + userlandUser: { id: string; email: string | null; firstName: string | null; lastName: string | null }; + } + ).userlandUser; + const user = { + id: updated.id, + email: updated.email ?? null, + firstName: updated.firstName ?? null, + lastName: updated.lastName ?? null, + }; + if (isJsonMode()) { + outputJson({ user }); + return; + } + outputSuccess(`Updated user ${chalk.bold(user.email ?? user.id)}`); } -export async function runUserDelete(userId: string, apiKey: string, baseUrl?: string): Promise { - const client = createWorkOSClient(apiKey, baseUrl); +export interface UserDeleteOptions { + /** `--environment-id` override; defaults from the active profile. */ + environmentId?: string; + yes?: boolean; + json?: boolean; +} + +export async function runUserDelete(userId: string, options: UserDeleteOptions = {}): Promise { + const op = getOperation('deleteUserlandUser'); + // Destructive per the manifest; the consequence copy comes from the catalog's + // confirmation phrase ("permanently deletes the end user"). + const consequence = op.confirmation ? ` — this ${op.confirmation}` : ''; + await confirmDestructive(options, { action: `delete user ${userId}${consequence}` }); + + const token = await requireCommandToken(); + // Environment-scoped mutation: pre-validated resolved target as header. + const { environmentId } = await resolveEnvironmentTarget(token, { + flagValue: options.environmentId, + forMutation: op.kind === 'mutation', + }); + + let data: { + deleteUserlandUser: { __typename: 'UserlandUserDeleted' } | { __typename: 'UserlandUserNotFound' } | { __typename: string }; + }; try { - await client.sdk.userManagement.deleteUser(userId); - outputSuccess('Deleted user', { id: userId }); + data = await dashboardGraphqlRequest(resolveExecutableDocument(op), { + token, + variables: { input: { userlandUserId: userId } }, + environmentId, + }); } catch (error) { - handleApiError(error); + reportDashboardError(error); + } + + if (data.deleteUserlandUser.__typename === 'UserlandUserNotFound') { + exitWithError({ code: 'not_found', message: `User "${userId}" was not found in this environment.` }); + } + + if (isJsonMode()) { + outputJson({ deleted: userId }); + return; } + outputSuccess(`Deleted user ${chalk.bold(userId)}`); } diff --git a/src/utils/help-json.spec.ts b/src/utils/help-json.spec.ts index 2b099ab0..ca59aeb9 100644 --- a/src/utils/help-json.spec.ts +++ b/src/utils/help-json.spec.ts @@ -213,11 +213,14 @@ describe('help-json', () => { expect(optNames).toEqual(expect.arrayContaining(['limit', 'before', 'after', 'order'])); }); - it('user list has email and organization filters', () => { + it('user list has email filter and environment override', () => { const user = buildCommandTree('user'); const list = user.commands!.find((c) => c.name === 'list'); const optNames = list!.options!.map((o) => o.name); - expect(optNames).toEqual(expect.arrayContaining(['email', 'organization'])); + // --organization was dropped in the dashboard-plane migration (no backing + // variable on the list operation); --environment-id targets the env. + expect(optNames).toEqual(expect.arrayContaining(['email', 'environment-id'])); + expect(optNames).not.toContain('organization'); }); }); }); diff --git a/src/utils/help-json.ts b/src/utils/help-json.ts index 9afd2bf2..51a4a585 100644 --- a/src/utils/help-json.ts +++ b/src/utils/help-json.ts @@ -66,6 +66,24 @@ const apiKeyOpt: OptionSchema = { hidden: false, }; +const environmentIdOpt: OptionSchema = { + name: 'environment-id', + type: 'string', + description: 'Environment ID (defaults to the active environment)', + required: false, + hidden: false, +}; + +const confirmYesOpt: OptionSchema = { + name: 'yes', + type: 'boolean', + description: 'Skip the confirmation prompt', + required: false, + default: false, + alias: 'y', + hidden: false, +}; + const paginationOpts: OptionSchema[] = [ { name: 'limit', type: 'number', description: 'Maximum number of results to return', required: false, hidden: false }, { @@ -714,8 +732,8 @@ const commands: CommandSchema[] = [ }, { name: 'organization', - description: 'Manage WorkOS organizations (create, update, get, list, delete)', - options: [insecureStorageOpt, apiKeyOpt], + description: 'Manage WorkOS organizations (create, update, get, list, delete) in the active environment', + options: [insecureStorageOpt], commands: [ { name: 'create', @@ -729,6 +747,7 @@ const commands: CommandSchema[] = [ required: false, }, ], + options: [environmentIdOpt], }, { name: 'update', @@ -739,11 +758,13 @@ const commands: CommandSchema[] = [ { name: 'domain', type: 'string', description: 'Domain to add or update', required: false }, { name: 'state', type: 'string', description: 'Domain state (verified or pending)', required: false }, ], + options: [environmentIdOpt], }, { name: 'get', description: 'Get an organization by its ID', positionals: [{ name: 'orgId', type: 'string', description: 'Organization ID (org_*)', required: true }], + options: [environmentIdOpt], }, { name: 'list', @@ -752,29 +773,32 @@ const commands: CommandSchema[] = [ { name: 'domain', type: 'string', - description: 'Filter organizations by domain', + description: 'Filter organizations by domain (name/domain search)', required: false, hidden: false, }, ...paginationOpts, + environmentIdOpt, ], }, { name: 'delete', description: 'Delete an organization by its ID', positionals: [{ name: 'orgId', type: 'string', description: 'Organization ID (org_*)', required: true }], + options: [confirmYesOpt, environmentIdOpt], }, ], }, { name: 'user', - description: 'Manage WorkOS user management users (get, list, update, delete)', - options: [insecureStorageOpt, apiKeyOpt], + description: 'Manage AuthKit users (get, list, update, delete) in the active environment', + options: [insecureStorageOpt], commands: [ { name: 'get', description: 'Get a user by their ID', positionals: [{ name: 'userId', type: 'string', description: 'User ID (user_*)', required: true }], + options: [environmentIdOpt], }, { name: 'list', @@ -783,35 +807,23 @@ const commands: CommandSchema[] = [ { name: 'email', type: 'string', - description: 'Filter users by email address', - required: false, - hidden: false, - }, - { - name: 'organization', - type: 'string', - description: 'Filter users by organization ID', + description: 'Filter users by email address (search)', required: false, hidden: false, }, ...paginationOpts, + environmentIdOpt, ], }, { name: 'update', - description: 'Update user properties (name, email verification, password, external ID)', + description: 'Update user properties (name, email, locale, external ID)', positionals: [{ name: 'userId', type: 'string', description: 'User ID (user_*)', required: true }], options: [ { name: 'first-name', type: 'string', description: 'First name', required: false, hidden: false }, { name: 'last-name', type: 'string', description: 'Last name', required: false, hidden: false }, - { - name: 'email-verified', - type: 'boolean', - description: 'Email verification status', - required: false, - hidden: false, - }, - { name: 'password', type: 'string', description: 'New password', required: false, hidden: false }, + { name: 'email', type: 'string', description: 'New email address', required: false, hidden: false }, + { name: 'locale', type: 'string', description: 'Locale (e.g. en-US)', required: false, hidden: false }, { name: 'external-id', type: 'string', @@ -819,12 +831,14 @@ const commands: CommandSchema[] = [ required: false, hidden: false, }, + environmentIdOpt, ], }, { name: 'delete', description: 'Delete a user by their ID', positionals: [{ name: 'userId', type: 'string', description: 'User ID (user_*)', required: true }], + options: [confirmYesOpt, environmentIdOpt], }, ], }, From fd75956dc808f6777eccb87fc9da3bd8194589dd Mon Sep 17 00:00:00 2001 From: Nick Nisi Date: Wed, 22 Jul 2026 18:31:38 -0500 Subject: [PATCH 12/22] feat!: migrate membership, invitation, session to the dashboard account 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. --- src/bin.ts | 261 ++++++------ src/catalog/curation.ts | 43 +- src/catalog/manifest.ts | 177 ++++++++ src/commands/invitation.spec.ts | 525 +++++++++++++++++------- src/commands/invitation.ts | 510 +++++++++++++++++++---- src/commands/membership.spec.ts | 684 ++++++++++++++++++++++--------- src/commands/membership.ts | 699 +++++++++++++++++++++++++++----- src/commands/session.spec.ts | 369 +++++++++++++---- src/commands/session.ts | 273 ++++++++++--- src/utils/help-json.ts | 174 ++++++-- 10 files changed, 2899 insertions(+), 816 deletions(-) diff --git a/src/bin.ts b/src/bin.ts index d3bc2086..8fda397b 100644 --- a/src/bin.ts +++ b/src/bin.ts @@ -1635,173 +1635,186 @@ async function runCli(): Promise { return yargs.demandCommand(1, 'Please specify a permission subcommand').strict(); }) .command('membership', 'Manage organization memberships', (yargs) => { - yargs.options({ ...insecureStorageOption, 'api-key': { type: 'string' as const, describe: 'WorkOS API key' } }); + yargs.options(insecureStorageOption); registerSubcommand( yargs, 'list', - 'List memberships', + 'List memberships by user or organization', (y) => y.options({ - org: { type: 'string' }, - user: { type: 'string' }, - limit: { type: 'number' }, - before: { type: 'string' }, - after: { type: 'string' }, - order: { type: 'string' }, + org: { type: 'string', describe: 'Organization ID (org_*)' }, + user: { type: 'string', describe: 'User ID (user_*)' }, + limit: { type: 'number', describe: 'Limit number of results (--org only)' }, + before: { type: 'string', describe: 'Cursor for results before a specific item (--org only)' }, + after: { type: 'string', describe: 'Cursor for results after a specific item (--org only)' }, + order: { type: 'string', describe: 'Order of results, asc or desc (--org only)' }, + 'environment-id': { type: 'string', describe: 'Environment ID (defaults to the active environment)' }, }), async (argv) => { await applyInsecureStorage(argv.insecureStorage); - - const { resolveApiKey, resolveApiBaseUrl } = await import('./lib/api-key.js'); const { runMembershipList } = await import('./commands/membership.js'); - await runMembershipList( - { - org: argv.org, - user: argv.user, - limit: argv.limit, - before: argv.before, - after: argv.after, - order: argv.order, - }, - resolveApiKey({ apiKey: argv.apiKey }), - resolveApiBaseUrl(), - ); + await runMembershipList({ + org: argv.org, + user: argv.user, + limit: argv.limit, + before: argv.before, + after: argv.after, + order: argv.order, + environmentId: argv.environmentId as string | undefined, + }); }, ); registerSubcommand( yargs, 'get ', 'Get a membership', - (y) => y.positional('id', { type: 'string', demandOption: true }), + (y) => + y + .positional('id', { type: 'string', demandOption: true, describe: 'Membership ID' }) + .option('environment-id', { type: 'string', describe: 'Environment ID (defaults to the active environment)' }), async (argv) => { await applyInsecureStorage(argv.insecureStorage); - - const { resolveApiKey, resolveApiBaseUrl } = await import('./lib/api-key.js'); const { runMembershipGet } = await import('./commands/membership.js'); - await runMembershipGet(argv.id, resolveApiKey({ apiKey: argv.apiKey }), resolveApiBaseUrl()); + await runMembershipGet(argv.id, { environmentId: argv.environmentId as string | undefined }); }, ); registerSubcommand( yargs, 'create', - 'Create a membership', + 'Add a user to an organization', (y) => y.options({ - org: { type: 'string', demandOption: true }, - user: { type: 'string', demandOption: true }, - role: { type: 'string' }, + org: { type: 'string', demandOption: true, describe: 'Organization ID (org_*)' }, + user: { type: 'string', demandOption: true, describe: 'User ID (user_*)' }, + role: { type: 'string', describe: 'Role ID (role_*) to assign' }, + 'environment-id': { type: 'string', describe: 'Environment ID (defaults to the active environment)' }, }), async (argv) => { await applyInsecureStorage(argv.insecureStorage); - - const { resolveApiKey, resolveApiBaseUrl } = await import('./lib/api-key.js'); const { runMembershipCreate } = await import('./commands/membership.js'); - await runMembershipCreate( - { org: argv.org, user: argv.user, role: argv.role }, - resolveApiKey({ apiKey: argv.apiKey }), - resolveApiBaseUrl(), - ); + await runMembershipCreate({ + org: argv.org, + user: argv.user, + role: argv.role, + environmentId: argv.environmentId as string | undefined, + }); }, ); registerSubcommand( yargs, 'update ', - 'Update a membership', - (y) => y.positional('id', { type: 'string', demandOption: true }).option('role', { type: 'string' }), + "Change a membership's role", + (y) => + y + .positional('id', { type: 'string', demandOption: true, describe: 'Membership ID' }) + .option('role', { type: 'string', describe: 'Role ID (role_*) to assign' }) + .option('yes', { alias: 'y', type: 'boolean', default: false, describe: 'Confirm the change (required non-interactively)' }) + .option('environment-id', { type: 'string', describe: 'Environment ID (defaults to the active environment)' }), async (argv) => { await applyInsecureStorage(argv.insecureStorage); - - const { resolveApiKey, resolveApiBaseUrl } = await import('./lib/api-key.js'); const { runMembershipUpdate } = await import('./commands/membership.js'); - await runMembershipUpdate(argv.id, argv.role, resolveApiKey({ apiKey: argv.apiKey }), resolveApiBaseUrl()); + await runMembershipUpdate(argv.id, { + role: argv.role, + yes: argv.yes, + json: argv.json as boolean | undefined, + environmentId: argv.environmentId as string | undefined, + }); }, ); registerSubcommand( yargs, 'delete ', - 'Delete a membership', - (y) => y.positional('id', { type: 'string', demandOption: true }), + 'Delete a membership (removes the user from the organization)', + (y) => + y + .positional('id', { type: 'string', demandOption: true, describe: 'Membership ID' }) + .option('yes', { alias: 'y', type: 'boolean', default: false, describe: 'Skip the confirmation prompt' }) + .option('environment-id', { type: 'string', describe: 'Environment ID (defaults to the active environment)' }), async (argv) => { await applyInsecureStorage(argv.insecureStorage); - - const { resolveApiKey, resolveApiBaseUrl } = await import('./lib/api-key.js'); const { runMembershipDelete } = await import('./commands/membership.js'); - await runMembershipDelete(argv.id, resolveApiKey({ apiKey: argv.apiKey }), resolveApiBaseUrl()); + await runMembershipDelete(argv.id, { + yes: argv.yes, + json: argv.json as boolean | undefined, + environmentId: argv.environmentId as string | undefined, + }); }, ); registerSubcommand( yargs, 'deactivate ', 'Deactivate a membership', - (y) => y.positional('id', { type: 'string', demandOption: true }), + (y) => + y + .positional('id', { type: 'string', demandOption: true, describe: 'Membership ID' }) + .option('yes', { alias: 'y', type: 'boolean', default: false, describe: 'Confirm the change (required non-interactively)' }) + .option('environment-id', { type: 'string', describe: 'Environment ID (defaults to the active environment)' }), async (argv) => { await applyInsecureStorage(argv.insecureStorage); - - const { resolveApiKey, resolveApiBaseUrl } = await import('./lib/api-key.js'); const { runMembershipDeactivate } = await import('./commands/membership.js'); - await runMembershipDeactivate(argv.id, resolveApiKey({ apiKey: argv.apiKey }), resolveApiBaseUrl()); + await runMembershipDeactivate(argv.id, { + yes: argv.yes, + json: argv.json as boolean | undefined, + environmentId: argv.environmentId as string | undefined, + }); }, ); registerSubcommand( yargs, 'reactivate ', 'Reactivate a membership', - (y) => y.positional('id', { type: 'string', demandOption: true }), + (y) => + y + .positional('id', { type: 'string', demandOption: true, describe: 'Membership ID' }) + .option('environment-id', { type: 'string', describe: 'Environment ID (defaults to the active environment)' }), async (argv) => { await applyInsecureStorage(argv.insecureStorage); - - const { resolveApiKey, resolveApiBaseUrl } = await import('./lib/api-key.js'); const { runMembershipReactivate } = await import('./commands/membership.js'); - await runMembershipReactivate(argv.id, resolveApiKey({ apiKey: argv.apiKey }), resolveApiBaseUrl()); + await runMembershipReactivate(argv.id, { environmentId: argv.environmentId as string | undefined }); }, ); return yargs.demandCommand(1, 'Please specify a membership subcommand').strict(); }) .command('invitation', 'Manage user invitations', (yargs) => { - yargs.options({ ...insecureStorageOption, 'api-key': { type: 'string' as const, describe: 'WorkOS API key' } }); + yargs.options(insecureStorageOption); registerSubcommand( yargs, 'list', 'List invitations', (y) => y.options({ - org: { type: 'string' }, - email: { type: 'string' }, - limit: { type: 'number' }, - before: { type: 'string' }, - after: { type: 'string' }, - order: { type: 'string' }, + org: { type: 'string', describe: 'Organization ID (org_*)' }, + email: { type: 'string', describe: 'Filter by email (search)' }, + limit: { type: 'number', describe: 'Limit number of results' }, + before: { type: 'string', describe: 'Cursor for results before a specific item' }, + after: { type: 'string', describe: 'Cursor for results after a specific item' }, + 'environment-id': { type: 'string', describe: 'Environment ID (defaults to the active environment)' }, }), async (argv) => { await applyInsecureStorage(argv.insecureStorage); - - const { resolveApiKey, resolveApiBaseUrl } = await import('./lib/api-key.js'); const { runInvitationList } = await import('./commands/invitation.js'); - await runInvitationList( - { - org: argv.org, - email: argv.email, - limit: argv.limit, - before: argv.before, - after: argv.after, - order: argv.order, - }, - resolveApiKey({ apiKey: argv.apiKey }), - resolveApiBaseUrl(), - ); + await runInvitationList({ + org: argv.org, + email: argv.email, + limit: argv.limit, + before: argv.before, + after: argv.after, + environmentId: argv.environmentId as string | undefined, + }); }, ); registerSubcommand( yargs, 'get ', - 'Get an invitation', - (y) => y.positional('id', { type: 'string', demandOption: true }), + 'Get an invitation (searches the most recent invitations)', + (y) => + y + .positional('id', { type: 'string', demandOption: true, describe: 'Invitation ID' }) + .option('environment-id', { type: 'string', describe: 'Environment ID (defaults to the active environment)' }), async (argv) => { await applyInsecureStorage(argv.insecureStorage); - - const { resolveApiKey, resolveApiBaseUrl } = await import('./lib/api-key.js'); const { runInvitationGet } = await import('./commands/invitation.js'); - await runInvitationGet(argv.id, resolveApiKey({ apiKey: argv.apiKey }), resolveApiBaseUrl()); + await runInvitationGet(argv.id, { environmentId: argv.environmentId as string | undefined }); }, ); registerSubcommand( @@ -1810,88 +1823,100 @@ async function runCli(): Promise { 'Send an invitation', (y) => y.options({ - email: { type: 'string', demandOption: true }, - org: { type: 'string' }, - role: { type: 'string' }, - 'expires-in-days': { type: 'number' }, + email: { type: 'string', demandOption: true, describe: 'Email address to invite' }, + org: { type: 'string', describe: 'Organization ID (org_*)' }, + role: { type: 'string', describe: 'Role ID (role_*) to assign on acceptance' }, + 'expires-in-days': { type: 'number', describe: 'Expiration in days (default 7)' }, + 'environment-id': { type: 'string', describe: 'Environment ID (defaults to the active environment)' }, }), async (argv) => { await applyInsecureStorage(argv.insecureStorage); - - const { resolveApiKey, resolveApiBaseUrl } = await import('./lib/api-key.js'); const { runInvitationSend } = await import('./commands/invitation.js'); - await runInvitationSend( - { email: argv.email, org: argv.org, role: argv.role, expiresInDays: argv.expiresInDays }, - resolveApiKey({ apiKey: argv.apiKey }), - resolveApiBaseUrl(), - ); + await runInvitationSend({ + email: argv.email, + org: argv.org, + role: argv.role, + expiresInDays: argv.expiresInDays, + environmentId: argv.environmentId as string | undefined, + }); }, ); registerSubcommand( yargs, 'revoke ', 'Revoke an invitation', - (y) => y.positional('id', { type: 'string', demandOption: true }), + (y) => + y + .positional('id', { type: 'string', demandOption: true, describe: 'Invitation ID' }) + .option('yes', { alias: 'y', type: 'boolean', default: false, describe: 'Skip the confirmation prompt' }) + .option('environment-id', { type: 'string', describe: 'Environment ID (defaults to the active environment)' }), async (argv) => { await applyInsecureStorage(argv.insecureStorage); - - const { resolveApiKey, resolveApiBaseUrl } = await import('./lib/api-key.js'); const { runInvitationRevoke } = await import('./commands/invitation.js'); - await runInvitationRevoke(argv.id, resolveApiKey({ apiKey: argv.apiKey }), resolveApiBaseUrl()); + await runInvitationRevoke(argv.id, { + yes: argv.yes, + json: argv.json as boolean | undefined, + environmentId: argv.environmentId as string | undefined, + }); }, ); registerSubcommand( yargs, 'resend ', 'Resend an invitation', - (y) => y.positional('id', { type: 'string', demandOption: true }), + (y) => + y + .positional('id', { type: 'string', demandOption: true, describe: 'Invitation ID' }) + .option('environment-id', { type: 'string', describe: 'Environment ID (defaults to the active environment)' }), async (argv) => { await applyInsecureStorage(argv.insecureStorage); - - const { resolveApiKey, resolveApiBaseUrl } = await import('./lib/api-key.js'); const { runInvitationResend } = await import('./commands/invitation.js'); - await runInvitationResend(argv.id, resolveApiKey({ apiKey: argv.apiKey }), resolveApiBaseUrl()); + await runInvitationResend(argv.id, { environmentId: argv.environmentId as string | undefined }); }, ); return yargs.demandCommand(1, 'Please specify an invitation subcommand').strict(); }) .command('session', 'Manage user sessions', (yargs) => { - yargs.options({ ...insecureStorageOption, 'api-key': { type: 'string' as const, describe: 'WorkOS API key' } }); + yargs.options(insecureStorageOption); registerSubcommand( yargs, 'list ', 'List sessions for a user', (y) => - y.positional('userId', { type: 'string', demandOption: true }).options({ - limit: { type: 'number' }, - before: { type: 'string' }, - after: { type: 'string' }, - order: { type: 'string' }, + y.positional('userId', { type: 'string', demandOption: true, describe: 'User ID (user_*)' }).options({ + limit: { type: 'number', describe: 'Limit number of results' }, + before: { type: 'string', describe: 'Cursor for results before a specific item' }, + after: { type: 'string', describe: 'Cursor for results after a specific item' }, + 'environment-id': { type: 'string', describe: 'Environment ID (defaults to the active environment)' }, }), async (argv) => { await applyInsecureStorage(argv.insecureStorage); - - const { resolveApiKey, resolveApiBaseUrl } = await import('./lib/api-key.js'); const { runSessionList } = await import('./commands/session.js'); - await runSessionList( - argv.userId, - { limit: argv.limit, before: argv.before, after: argv.after, order: argv.order }, - resolveApiKey({ apiKey: argv.apiKey }), - resolveApiBaseUrl(), - ); + await runSessionList(argv.userId, { + limit: argv.limit, + before: argv.before, + after: argv.after, + environmentId: argv.environmentId as string | undefined, + }); }, ); registerSubcommand( yargs, 'revoke ', 'Revoke a session', - (y) => y.positional('sessionId', { type: 'string', demandOption: true }), + (y) => + y + .positional('sessionId', { type: 'string', demandOption: true, describe: 'Session ID' }) + .option('yes', { alias: 'y', type: 'boolean', default: false, describe: 'Skip the confirmation prompt' }) + .option('environment-id', { type: 'string', describe: 'Environment ID (defaults to the active environment)' }), async (argv) => { await applyInsecureStorage(argv.insecureStorage); - - const { resolveApiKey, resolveApiBaseUrl } = await import('./lib/api-key.js'); const { runSessionRevoke } = await import('./commands/session.js'); - await runSessionRevoke(argv.sessionId, resolveApiKey({ apiKey: argv.apiKey }), resolveApiBaseUrl()); + await runSessionRevoke(argv.sessionId, { + yes: argv.yes, + json: argv.json as boolean | undefined, + environmentId: argv.environmentId as string | undefined, + }); }, ); return yargs.demandCommand(1, 'Please specify a session subcommand').strict(); diff --git a/src/catalog/curation.ts b/src/catalog/curation.ts index 0d85d4b1..901780b9 100644 --- a/src/catalog/curation.ts +++ b/src/catalog/curation.ts @@ -74,8 +74,6 @@ export const OVERRIDES: Record = { // Still INERT (present here, absent from manifest.ts): // - `createUserlandUser`: the CLI's `user` command has never had a `create` // subcommand and the migration deliberately does not add one. - // - the `*UserlandUserInvite` ops: invitations are the `invitation` command's - // turf (migrating in Phase 4) — do not activate these under the `user` noun. // // `userlandUsers` also remains the leak-spec worked example proving the // curation layer cleans a `userland`-leaking op name to a clean noun — it is @@ -85,9 +83,44 @@ export const OVERRIDES: Record = { updateUserlandUser: { command: 'user update', describe: "Update an AuthKit user's profile" }, createUserlandUser: { command: 'user create', describe: 'Create a user' }, deleteUserlandUser: { command: 'user delete', describe: 'Delete an AuthKit user' }, - createUserlandUserInvite: { command: 'user invite', describe: 'Invite a user by email' }, - resendUserlandUserInvite: { command: 'user invite resend', describe: 'Resend a pending user invitation' }, - revokeUserlandUserInvite: { command: 'user invite revoke', describe: 'Revoke a pending user invitation' }, + + // --- identity cluster (resource migration Phase 4) --- + // The invite overrides staged in Phase 3 under the `user invite*` nouns went + // LIVE here under the `invitation` command (their real owner) when the REST + // `invitation` command was replaced. Memberships and sessions follow the same + // pattern: every manifest-curated op gets an override so the manifest's clean + // `command` noun is the single source of truth, and every `userland*` name is + // hidden behind it. + // + // `membership list` and `invitation list` are each backed by two ops (by-user + // vs by-org, env-wide vs by-org) — both ops resolve to the same command noun. + userlandUserOrganizationMemberships: { + command: 'membership list', + describe: "List an AuthKit user's organization memberships", + }, + userlandUsersByOrg: { command: 'membership list', describe: "List an organization's members" }, + userlandUserOrganizationMembership: { command: 'membership get', describe: 'Get an organization membership by ID' }, + addUserlandUserToOrg: { command: 'membership create', describe: 'Add a user to an organization' }, + updateRoleOnOrganizationMembership: { + command: 'membership update', + describe: "Change the role on a user's organization membership", + }, + removeMemberFromOrganization: { command: 'membership delete', describe: 'Remove a user from an organization' }, + deactivateOrganizationMembership: { + command: 'membership deactivate', + describe: 'Deactivate an organization membership', + }, + reactivateOrganizationMembership: { + command: 'membership reactivate', + describe: 'Reactivate an inactive organization membership', + }, + userlandUserInvites: { command: 'invitation list', describe: 'List user invitations in the current environment' }, + userlandUserInvitesByOrg: { command: 'invitation list', describe: "List an organization's pending invitations" }, + createUserlandUserInvite: { command: 'invitation send', describe: 'Invite a user by email' }, + resendUserlandUserInvite: { command: 'invitation resend', describe: 'Resend a pending invitation' }, + revokeUserlandUserInvite: { command: 'invitation revoke', describe: 'Revoke a pending invitation' }, + userlandSessions: { command: 'session list', describe: "List an AuthKit user's sessions" }, + revokeUserlandSession: { command: 'session revoke', describe: 'Revoke a user session' }, // --- organization (resource migration Phase 3) --- // Op names/descriptions are clean upstream, but every curated op still needs diff --git a/src/catalog/manifest.ts b/src/catalog/manifest.ts index e626375c..7053e3a1 100644 --- a/src/catalog/manifest.ts +++ b/src/catalog/manifest.ts @@ -336,6 +336,183 @@ const MANIFEST: CommandJustification[] = [ destructive: true, ciPolicy: 'allow', }, + + // --- Resource migration: identity cluster (membership / invitation / session) --- + // graphql-resource-migration Phase 4. Same recipe as organization/user above: + // unchanged subcommand grammar, dashboard-plane backend, new curated shapes. + // + // Mapping notes: + // - `membership list` is backed by TWO ops: the by-user op filters only by + // user (no pagination), so the by-org path rides `userlandUsersByOrg` + // (org-filtered identities, full pagination). Both entries share the + // command noun. + // - `invitation list` likewise splits env-wide vs by-org across two ops. + // - `invitation get` has NO backing single-invite operation: it is a + // client-side filter over the `invitation list` env-wide op (capped, loud + // miss wording), so it deliberately has no manifest entry of its own. + // - `membership delete ` is a two-step: the op is keyed by org+user, so + // the handler first resolves the membership via the `membership get` op. + // - Role-changing / access-removing mutations follow the `team change-role` + // precedent: ciPolicy `require-flag`. Removals/revocations are + // `destructive` (confirmDestructive; ciPolicy stays `allow` because the + // destructive gate already covers non-interactive runs). + { + command: 'membership list', + mapsTo: 'userlandUserOrganizationMemberships', + audiences: ['human', 'agent', 'ci'], + useCase: "List an AuthKit user's organization memberships (audits, scripting)", + load: 'cheap', + mutation: false, + destructive: false, + ciPolicy: 'allow', + }, + { + command: 'membership list', + mapsTo: 'userlandUsersByOrg', + audiences: ['human', 'agent', 'ci'], + useCase: "List an organization's members with roles and status (audits, scripting)", + load: 'cheap', + mutation: false, + destructive: false, + ciPolicy: 'allow', + }, + { + command: 'membership get', + mapsTo: 'userlandUserOrganizationMembership', + audiences: ['human', 'agent', 'ci'], + useCase: 'Inspect a single organization membership by ID', + load: 'cheap', + mutation: false, + destructive: false, + ciPolicy: 'allow', + }, + { + command: 'membership create', + mapsTo: 'addUserlandUserToOrg', + audiences: ['human', 'agent', 'ci'], + useCase: 'Add a user to an organization (optionally with a role) from setup scripts or CI', + load: 'cheap', + mutation: true, + destructive: false, + ciPolicy: 'allow', + }, + { + command: 'membership update', + mapsTo: 'updateRoleOnOrganizationMembership', + audiences: ['human', 'agent'], + useCase: "Change the role on a user's organization membership", + load: 'cheap', + mutation: true, + destructive: false, + // require-flag: a privilege change; require explicit consent in + // non-interactive use (team change-role precedent). + ciPolicy: 'require-flag', + }, + { + command: 'membership delete', + mapsTo: 'removeMemberFromOrganization', + audiences: ['human', 'agent'], + useCase: 'Remove a user from an organization (offboarding)', + load: 'cheap', + mutation: true, + // destructive: removes the user's access to the organization. + destructive: true, + ciPolicy: 'allow', + }, + { + command: 'membership deactivate', + mapsTo: 'deactivateOrganizationMembership', + audiences: ['human', 'agent'], + useCase: "Deactivate a membership so the user loses access to the organization", + load: 'cheap', + mutation: true, + destructive: false, + // require-flag: an access removal (reversible via reactivate); require + // explicit consent in non-interactive use. + ciPolicy: 'require-flag', + }, + { + command: 'membership reactivate', + mapsTo: 'reactivateOrganizationMembership', + audiences: ['human', 'agent'], + useCase: 'Reactivate an inactive organization membership', + load: 'cheap', + mutation: true, + destructive: false, + ciPolicy: 'allow', + }, + { + command: 'invitation list', + mapsTo: 'userlandUserInvites', + audiences: ['human', 'agent', 'ci'], + useCase: 'List AuthKit user invitations in the active environment (audits, scripting)', + load: 'cheap', + mutation: false, + destructive: false, + ciPolicy: 'allow', + }, + { + command: 'invitation list', + mapsTo: 'userlandUserInvitesByOrg', + audiences: ['human', 'agent', 'ci'], + useCase: "List an organization's pending AuthKit user invitations", + load: 'cheap', + mutation: false, + destructive: false, + ciPolicy: 'allow', + }, + { + command: 'invitation send', + mapsTo: 'createUserlandUserInvite', + audiences: ['human', 'agent', 'ci'], + useCase: 'Invite a user by email (optionally into an organization with a role) during onboarding automation', + load: 'cheap', + mutation: true, + destructive: false, + ciPolicy: 'allow', + }, + { + command: 'invitation revoke', + mapsTo: 'revokeUserlandUserInvite', + audiences: ['human', 'agent'], + useCase: 'Revoke a pending invitation (mis-sent invites, offboarding)', + load: 'cheap', + mutation: true, + // destructive: invalidates the pending invite link. + destructive: true, + ciPolicy: 'allow', + }, + { + command: 'invitation resend', + mapsTo: 'resendUserlandUserInvite', + audiences: ['human', 'agent', 'ci'], + useCase: 'Resend the invitation email for a pending invite', + load: 'cheap', + mutation: true, + destructive: false, + ciPolicy: 'allow', + }, + { + command: 'session list', + mapsTo: 'userlandSessions', + audiences: ['human', 'agent', 'ci'], + useCase: "List an AuthKit user's authentication sessions (security review, debugging)", + load: 'cheap', + mutation: false, + destructive: false, + ciPolicy: 'allow', + }, + { + command: 'session revoke', + mapsTo: 'revokeUserlandSession', + audiences: ['human', 'agent'], + useCase: 'Revoke a single user session (compromised device, forced logout)', + load: 'cheap', + mutation: true, + // destructive: signs the session out; it can no longer authenticate. + destructive: true, + ciPolicy: 'allow', + }, ]; /** Returns the curated command allowlist. */ diff --git a/src/commands/invitation.spec.ts b/src/commands/invitation.spec.ts index 3060f6a5..2465196a 100644 --- a/src/commands/invitation.spec.ts +++ b/src/commands/invitation.spec.ts @@ -1,46 +1,115 @@ import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'; -// Mock the unified client -const mockSdk = { - userManagement: { - listInvitations: vi.fn(), - getInvitation: vi.fn(), - sendInvitation: vi.fn(), - revokeInvitation: vi.fn(), - resendInvitation: vi.fn(), - }, -}; +const mockRequireCommandToken = vi.fn(); +const mockGraphqlRequest = vi.fn(); +const mockConfirm = vi.fn(); +const mockIsCancel = vi.fn(() => false); +const mockGetActiveEnvironment = vi.fn(); +const mockGetConfig = vi.fn(); -vi.mock('../lib/workos-client.js', () => ({ - createWorkOSClient: () => ({ sdk: mockSdk }), +vi.mock('../lib/command-auth.js', async (importActual) => { + const actual = await importActual(); + return { + ...actual, + requireCommandToken: () => mockRequireCommandToken(), + }; +}); + +vi.mock('../lib/dashboard-graphql.js', async (importActual) => { + const actual = await importActual(); + return { + ...actual, + dashboardGraphqlRequest: (...args: unknown[]) => mockGraphqlRequest(...args), + }; +}); + +vi.mock('../utils/clack.js', () => ({ + default: { + confirm: (...args: unknown[]) => mockConfirm(...args), + isCancel: (...args: unknown[]) => mockIsCancel(...args), + select: vi.fn(), + }, })); -const { setOutputMode } = await import('../utils/output.js'); +// The environment-target resolver runs REAL against the mocked wire + config +// store, so mutation tests can assert the pre-validation fetch +// (teamProjectsV2) happens before the operation request. +vi.mock('../lib/config-store.js', async (importActual) => { + const actual = await importActual(); + return { + ...actual, + getActiveEnvironment: () => mockGetActiveEnvironment(), + getConfig: () => mockGetConfig(), + setProfileEnvironmentId: vi.fn(), + }; +}); -const { runInvitationList, runInvitationGet, runInvitationSend, runInvitationRevoke, runInvitationResend } = +const { setOutputMode } = await import('../utils/output.js'); +const { resetInteractionModeForTests, setInteractionMode } = await import('../utils/interaction-mode.js'); +const { DashboardGraphqlError } = await import('../lib/dashboard-graphql.js'); +const { CliExit } = await import('../utils/cli-exit.js'); +const { runInvitationList, runInvitationGet, runInvitationSend, runInvitationRevoke, runInvitationResend, INVITATION_GET_SCAN_LIMIT } = await import('./invitation.js'); -const mockInvitation = { - id: 'inv_123', - email: 'test@example.com', - state: 'pending', - organizationId: 'org_789', - expiresAt: '2024-02-01T00:00:00Z', - acceptedAt: null, - revokedAt: null, - inviterUserId: null, - acceptedUserId: null, - token: 'tok_abc', - acceptInvitationUrl: 'https://example.com/accept', - createdAt: '2024-01-01T00:00:00Z', - updatedAt: '2024-01-01T00:00:00Z', +const TEAM_ENVIRONMENTS_PAYLOAD = { + currentTeam: { + projectsV2: [{ environments: [{ id: 'env_profile', name: 'Sandbox', sandbox: true, clientId: null }] }], + }, +}; + +/** + * Route the wire mock by document: the environment resolver's pre-validation + * fetch (`teamProjectsV2`) gets the team's environments; everything else gets + * the operation payload. + */ +function respondWith(payload: unknown): void { + mockGraphqlRequest.mockImplementation(async (doc: unknown) => { + if (String(doc).includes('teamProjectsV2')) return TEAM_ENVIRONMENTS_PAYLOAD; + return payload; + }); +} + +async function expectExit(promise: Promise, code: number): Promise { + try { + await promise; + } catch (err) { + if (err instanceof CliExit) { + expect(err.exitCode).toBe(code); + return err; + } + throw err; + } + throw new Error(`Expected CliExit(${code}) but promise resolved`); +} + +/** The authoritative curated invitation JSON shape (documented contract). */ +const INVITATION_SHAPE_KEYS = ['id', 'email', 'state', 'createdAt', 'organization']; + +const INVITE_NODE = { + __typename: 'UserlandUserInvite', + id: 'invite_1', + createdAt: '2026-01-01T00:00:00.000Z', + inviteeEmail: 'jane@example.com', + state: 'Pending', + organization: { id: 'org_1', name: 'FooCorp' }, }; -describe('invitation commands', () => { +describe('invitation command', () => { let consoleOutput: string[]; beforeEach(() => { vi.clearAllMocks(); + resetInteractionModeForTests(); + setOutputMode('human'); + mockRequireCommandToken.mockResolvedValue('tok_123'); + mockConfirm.mockReset(); + mockIsCancel.mockReset(); + mockIsCancel.mockReturnValue(false); + mockGetActiveEnvironment.mockReturnValue({ apiKey: 'sk_ignored', environmentId: 'env_profile' }); + mockGetConfig.mockReturnValue({ + activeEnvironment: 'default', + environments: { default: { apiKey: 'sk_ignored', environmentId: 'env_profile' } }, + }); consoleOutput = []; vi.spyOn(console, 'log').mockImplementation((...args: unknown[]) => { consoleOutput.push(args.map(String).join(' ')); @@ -50,172 +119,322 @@ describe('invitation commands', () => { afterEach(() => { vi.restoreAllMocks(); + resetInteractionModeForTests(); + setOutputMode('human'); }); - describe('runInvitationList', () => { - it('lists invitations', async () => { - mockSdk.userManagement.listInvitations.mockResolvedValue({ - data: [mockInvitation], - listMetadata: { before: null, after: null }, + describe('list', () => { + it('lists env-wide with the environment as variable AND header (read: no pre-validation)', async () => { + respondWith({ userlandUserInvites: { data: [INVITE_NODE], listMetadata: { before: null, after: null } } }); + await runInvitationList({}); + expect(mockGraphqlRequest).toHaveBeenCalledTimes(1); + expect(mockGraphqlRequest).toHaveBeenCalledWith(expect.stringContaining('userlandUserInvites'), { + token: 'tok_123', + variables: { environmentId: 'env_profile' }, + environmentId: 'env_profile', }); - await runInvitationList({}, 'sk_test'); - expect(mockSdk.userManagement.listInvitations).toHaveBeenCalled(); - expect(consoleOutput.some((l) => l.includes('test@example.com'))).toBe(true); + const out = consoleOutput.join('\n'); + expect(out).toContain('invite_1'); + expect(out).toContain('jane@example.com'); }); - it('passes org filter', async () => { - mockSdk.userManagement.listInvitations.mockResolvedValue({ - data: [], - listMetadata: { before: null, after: null }, + it('maps --email to search and pagination flags to catalog variables', async () => { + respondWith({ userlandUserInvites: { data: [], listMetadata: { before: null, after: null } } }); + await runInvitationList({ email: 'jane@', limit: 5, after: 'cursor_a' }); + expect(mockGraphqlRequest).toHaveBeenCalledWith(expect.stringContaining('userlandUserInvites'), { + token: 'tok_123', + variables: { environmentId: 'env_profile', search: 'jane@', limit: 5, after: 'cursor_a' }, + environmentId: 'env_profile', }); - await runInvitationList({ org: 'org_789' }, 'sk_test'); - expect(mockSdk.userManagement.listInvitations).toHaveBeenCalledWith( - expect.objectContaining({ organizationId: 'org_789' }), - ); }); - it('handles empty results', async () => { - mockSdk.userManagement.listInvitations.mockResolvedValue({ - data: [], - listMetadata: { before: null, after: null }, + it('lists by org via the by-org operation', async () => { + respondWith({ + organization: { + userlandUserInvites: { + data: [{ ...INVITE_NODE, organization: undefined }], + listMetadata: { before: null, after: null }, + }, + }, }); - await runInvitationList({}, 'sk_test'); - expect(consoleOutput.some((l) => l.includes('No invitations found'))).toBe(true); + await runInvitationList({ org: 'org_1', limit: 5 }); + expect(mockGraphqlRequest).toHaveBeenCalledWith(expect.stringContaining('userlandUserInvitesByOrg'), { + token: 'tok_123', + variables: { organizationId: 'org_1', limit: 5 }, + environmentId: 'env_profile', + }); + expect(consoleOutput.join('\n')).toContain('invite_1'); }); - it('shows pagination cursors', async () => { - mockSdk.userManagement.listInvitations.mockResolvedValue({ - data: [mockInvitation], - listMetadata: { before: 'cursor_b', after: 'cursor_a' }, + it('errors not_found when the organization does not exist', async () => { + respondWith({ organization: null }); + const err = await expectExit(runInvitationList({ org: 'org_missing' }), 1); + expect(err.context?.errorCode).toBe('not_found'); + }); + + it('--json emits the documented curated shape (env-wide)', async () => { + setOutputMode('json'); + respondWith({ userlandUserInvites: { data: [INVITE_NODE], listMetadata: { before: null, after: 'cursor_a' } } }); + await runInvitationList({}); + const raw = consoleOutput[0]; + expect(raw).not.toMatch(/graphql|userland|list_metadata/i); + const out = JSON.parse(raw); + expect(Object.keys(out)).toEqual(['invitations', 'pagination']); + expect(Object.keys(out.invitations[0])).toEqual(INVITATION_SHAPE_KEYS); + expect(out.invitations[0]).toEqual({ + id: 'invite_1', + email: 'jane@example.com', + state: 'Pending', + createdAt: '2026-01-01T00:00:00.000Z', + organization: { id: 'org_1', name: 'FooCorp' }, }); - await runInvitationList({}, 'sk_test'); - expect(consoleOutput.some((l) => l.includes('cursor_b'))).toBe(true); - expect(consoleOutput.some((l) => l.includes('cursor_a'))).toBe(true); + expect(out.pagination).toEqual({ before: null, after: 'cursor_a' }); + }); + + it('--json by-org carries the requested org ID on each row', async () => { + setOutputMode('json'); + respondWith({ + organization: { + userlandUserInvites: { + data: [{ ...INVITE_NODE, organization: undefined }], + listMetadata: { before: null, after: null }, + }, + }, + }); + await runInvitationList({ org: 'org_1' }); + const out = JSON.parse(consoleOutput[0]); + expect(out.invitations[0].organization).toEqual({ id: 'org_1', name: null }); + }); + + it('handles empty results', async () => { + respondWith({ userlandUserInvites: { data: [], listMetadata: { before: null, after: null } } }); + await runInvitationList({}); + expect(consoleOutput.some((l) => l.includes('No invitations found'))).toBe(true); }); }); - describe('runInvitationGet', () => { - it('fetches and prints invitation as JSON', async () => { - mockSdk.userManagement.getInvitation.mockResolvedValue(mockInvitation); - await runInvitationGet('inv_123', 'sk_test'); - expect(mockSdk.userManagement.getInvitation).toHaveBeenCalledWith('inv_123'); - expect(consoleOutput.some((l) => l.includes('inv_123'))).toBe(true); + describe('get (client-side filter, capped)', () => { + it('scans one capped page and renders the match', async () => { + respondWith({ userlandUserInvites: { data: [INVITE_NODE], listMetadata: { before: null, after: null } } }); + await runInvitationGet('invite_1'); + expect(mockGraphqlRequest).toHaveBeenCalledTimes(1); + expect(mockGraphqlRequest).toHaveBeenCalledWith(expect.stringContaining('userlandUserInvites'), { + token: 'tok_123', + variables: { environmentId: 'env_profile', limit: INVITATION_GET_SCAN_LIMIT }, + environmentId: 'env_profile', + }); + expect(consoleOutput.join('\n')).toContain('jane@example.com'); + }); + + it('--json emits { invitation } in the curated shape', async () => { + setOutputMode('json'); + respondWith({ userlandUserInvites: { data: [INVITE_NODE], listMetadata: { before: null, after: null } } }); + await runInvitationGet('invite_1'); + const raw = consoleOutput[0]; + expect(raw).not.toMatch(/graphql|userland/i); + const out = JSON.parse(raw); + expect(Object.keys(out)).toEqual(['invitation']); + expect(Object.keys(out.invitation)).toEqual(INVITATION_SHAPE_KEYS); + }); + + it('reports a capped miss loudly (not found in the most recent N)', async () => { + const consoleErrors: string[] = []; + vi.spyOn(console, 'error').mockImplementation((...args: unknown[]) => { + consoleErrors.push(args.map(String).join(' ')); + }); + respondWith({ userlandUserInvites: { data: [INVITE_NODE], listMetadata: { before: null, after: 'more' } } }); + const err = await expectExit(runInvitationGet('invite_missing'), 1); + expect(err.context?.errorCode).toBe('not_found'); + expect(consoleErrors.join('\n')).toContain(`${INVITATION_GET_SCAN_LIMIT} most recent`); }); }); - describe('runInvitationSend', () => { - it('sends invitation with email', async () => { - mockSdk.userManagement.sendInvitation.mockResolvedValue(mockInvitation); - await runInvitationSend({ email: 'test@example.com' }, 'sk_test'); - expect(mockSdk.userManagement.sendInvitation).toHaveBeenCalledWith({ - email: 'test@example.com', + describe('send', () => { + const created = { + createUserlandUserInvite: { + __typename: 'UserlandUserInviteCreated', + userlandUserInvite: { id: 'invite_new' }, + }, + }; + + it('maps flags into the input, defaulting expiry, and pre-validates the environment first', async () => { + respondWith(created); + await runInvitationSend({ email: 'jane@example.com', org: 'org_1', role: 'role_1' }); + // Mutation: the resolver fetches the team's environments BEFORE the op. + expect(mockGraphqlRequest.mock.calls.length).toBe(2); + expect(String(mockGraphqlRequest.mock.calls[0][0])).toContain('teamProjectsV2'); + expect(String(mockGraphqlRequest.mock.calls[1][0])).toContain('createUserlandUserInvite'); + expect(mockGraphqlRequest.mock.calls[1][1]).toEqual({ + token: 'tok_123', + variables: { + input: { + environmentId: 'env_profile', + inviteeEmail: 'jane@example.com', + expiresInDays: 7, + organizationId: 'org_1', + roleId: 'role_1', + }, + }, + environmentId: 'env_profile', }); }); - it('sends invitation with all options', async () => { - mockSdk.userManagement.sendInvitation.mockResolvedValue(mockInvitation); - await runInvitationSend( - { email: 'test@example.com', org: 'org_789', role: 'admin', expiresInDays: 7 }, - 'sk_test', - ); - expect(mockSdk.userManagement.sendInvitation).toHaveBeenCalledWith({ - email: 'test@example.com', - organizationId: 'org_789', - roleSlug: 'admin', - expiresInDays: 7, + it('honors an explicit --expires-in-days', async () => { + respondWith(created); + await runInvitationSend({ email: 'jane@example.com', expiresInDays: 30 }); + expect(mockGraphqlRequest.mock.calls[1][1]).toEqual({ + token: 'tok_123', + variables: { + input: { environmentId: 'env_profile', inviteeEmail: 'jane@example.com', expiresInDays: 30 }, + }, + environmentId: 'env_profile', }); }); - it('outputs sent message', async () => { - mockSdk.userManagement.sendInvitation.mockResolvedValue(mockInvitation); - await runInvitationSend({ email: 'test@example.com' }, 'sk_test'); - expect(consoleOutput.some((l) => l.includes('Sent invitation'))).toBe(true); + it.each([ + ['CreateUserlandUserInviteUserAlreadyExists', 'already_exists'], + ['CreateUserlandUserInviteEmailAlreadyInvitedToEnvironment', 'already_invited'], + ['CreateUserlandUserInviteInvalidInviteeEmail', 'invalid_argument'], + ['CreateUserlandUserInviteInvalidRole', 'invalid_argument'], + ['CreateUserlandUserInviteExpiresInDaysTooLong', 'invalid_argument'], + ['OrganizationNotFound', 'not_found'], + ['EnvironmentNotFound', 'environment_not_found'], + ])('maps the %s variant to a clean %s error', async (typename, code) => { + const consoleErrors: string[] = []; + vi.spyOn(console, 'error').mockImplementation((...args: unknown[]) => { + consoleErrors.push(args.map(String).join(' ')); + }); + respondWith({ createUserlandUserInvite: { __typename: typename } }); + const err = await expectExit(runInvitationSend({ email: 'jane@example.com', org: 'org_1' }), 1); + expect(err.context?.errorCode).toBe(code); + expect(consoleErrors.join('\n')).not.toMatch(/graphql|userland/i); }); - }); - describe('runInvitationRevoke', () => { - it('revokes invitation', async () => { - const revoked = { ...mockInvitation, state: 'revoked' }; - mockSdk.userManagement.revokeInvitation.mockResolvedValue(revoked); - await runInvitationRevoke('inv_123', 'sk_test'); - expect(mockSdk.userManagement.revokeInvitation).toHaveBeenCalledWith('inv_123'); - expect(consoleOutput.some((l) => l.includes('Revoked invitation'))).toBe(true); + it('--json emits { invitation }', async () => { + setOutputMode('json'); + respondWith(created); + await runInvitationSend({ email: 'jane@example.com' }); + expect(JSON.parse(consoleOutput[0])).toEqual({ invitation: { id: 'invite_new' } }); }); }); - describe('runInvitationResend', () => { - it('resends invitation', async () => { - mockSdk.userManagement.resendInvitation.mockResolvedValue(mockInvitation); - await runInvitationResend('inv_123', 'sk_test'); - expect(mockSdk.userManagement.resendInvitation).toHaveBeenCalledWith('inv_123'); - expect(consoleOutput.some((l) => l.includes('Resent invitation'))).toBe(true); + describe('revoke (destructive)', () => { + const revoked = { + revokeUserlandUserInvite: { __typename: 'UserlandUserInviteRevoked', userlandUserInvite: { id: 'invite_1' } }, + }; + + it('refuses in agent mode without --yes (exit 1, confirmation_required)', async () => { + setInteractionMode({ mode: 'agent', source: 'agent_env' }); + const err = await expectExit(runInvitationRevoke('invite_1', { yes: false }), 1); + expect(err.context?.errorCode).toBe('confirmation_required'); + expect(mockGraphqlRequest).not.toHaveBeenCalled(); + }); + + it('proceeds non-interactively with --yes and sends the revoke input', async () => { + setInteractionMode({ mode: 'ci', source: 'ci_env' }); + respondWith(revoked); + await runInvitationRevoke('invite_1', { yes: true }); + expect(String(mockGraphqlRequest.mock.calls[0][0])).toContain('teamProjectsV2'); + expect(mockGraphqlRequest.mock.calls[1][1]).toEqual({ + token: 'tok_123', + variables: { input: { userlandUserInviteId: 'invite_1' } }, + environmentId: 'env_profile', + }); + }); + + it('prompts interactively and proceeds when confirmed', async () => { + setInteractionMode({ mode: 'human', source: 'default' }); + mockConfirm.mockResolvedValue(true); + respondWith(revoked); + await runInvitationRevoke('invite_1', { yes: false }); + expect(mockConfirm).toHaveBeenCalledOnce(); + expect(mockGraphqlRequest).toHaveBeenCalled(); + }); + + it('cancels (exit 2) when the interactive prompt is declined', async () => { + setInteractionMode({ mode: 'human', source: 'default' }); + mockConfirm.mockResolvedValue(false); + await expectExit(runInvitationRevoke('invite_1', { yes: false }), 2); + expect(mockGraphqlRequest).not.toHaveBeenCalled(); + }); + + it('errors not_found on a missing invitation', async () => { + respondWith({ revokeUserlandUserInvite: { __typename: 'UserlandUserInviteNotFound' } }); + const err = await expectExit(runInvitationRevoke('invite_missing', { yes: true }), 1); + expect(err.context?.errorCode).toBe('not_found'); }); - }); - describe('JSON output mode', () => { - beforeEach(() => { + it('errors invite_not_pending on a non-pending invitation', async () => { + respondWith({ revokeUserlandUserInvite: { __typename: 'UserlandUserInviteNotPending' } }); + const err = await expectExit(runInvitationRevoke('invite_1', { yes: true }), 1); + expect(err.context?.errorCode).toBe('invite_not_pending'); + }); + + it('--json emits { revoked }', async () => { + setInteractionMode({ mode: 'agent', source: 'agent_env' }); setOutputMode('json'); + respondWith(revoked); + await runInvitationRevoke('invite_1', { yes: true }); + expect(JSON.parse(consoleOutput[0])).toEqual({ revoked: 'invite_1' }); }); + }); + + describe('resend', () => { + const resent = { + resendUserlandUserInvite: { __typename: 'UserlandUserInviteResent', userlandUserInvite: { id: 'invite_1' } }, + }; - afterEach(() => { - setOutputMode('human'); + it('sends the resend input with the environment header', async () => { + respondWith(resent); + await runInvitationResend('invite_1'); + expect(mockGraphqlRequest.mock.calls[1][1]).toEqual({ + token: 'tok_123', + variables: { input: { userlandUserInviteId: 'invite_1' } }, + environmentId: 'env_profile', + }); + }); + + it('errors not_found on a missing invitation', async () => { + respondWith({ resendUserlandUserInvite: { __typename: 'UserlandUserInviteNotFound' } }); + const err = await expectExit(runInvitationResend('invite_missing'), 1); + expect(err.context?.errorCode).toBe('not_found'); + }); + + it('errors invite_not_pending on a non-pending invitation', async () => { + respondWith({ resendUserlandUserInvite: { __typename: 'UserlandUserInviteNotPending' } }); + const err = await expectExit(runInvitationResend('invite_1'), 1); + expect(err.context?.errorCode).toBe('invite_not_pending'); }); - it('runInvitationGet outputs raw JSON', async () => { - mockSdk.userManagement.getInvitation.mockResolvedValue(mockInvitation); - await runInvitationGet('inv_123', 'sk_test'); - const output = JSON.parse(consoleOutput[0]); - expect(output.id).toBe('inv_123'); - expect(output).not.toHaveProperty('status', 'ok'); + it('--json emits { resent }', async () => { + setOutputMode('json'); + respondWith(resent); + await runInvitationResend('invite_1'); + expect(JSON.parse(consoleOutput[0])).toEqual({ resent: 'invite_1' }); }); + }); - it('runInvitationList outputs JSON with data and listMetadata', async () => { - mockSdk.userManagement.listInvitations.mockResolvedValue({ - data: [mockInvitation], - listMetadata: { before: null, after: 'cursor_a' }, + describe('shared failure modes', () => { + it('exits auth-required (code 4) when not logged in', async () => { + mockRequireCommandToken.mockImplementation(() => { + throw new CliExit(4, { reason: 'auth_required', errorCode: 'auth_required' }); }); - await runInvitationList({}, 'sk_test'); - const output = JSON.parse(consoleOutput[0]); - expect(output.data).toHaveLength(1); - expect(output.data[0].id).toBe('inv_123'); - expect(output.listMetadata.after).toBe('cursor_a'); + await expectExit(runInvitationList({}), 4); + expect(mockGraphqlRequest).not.toHaveBeenCalled(); }); - it('runInvitationList outputs empty data array for no results', async () => { - mockSdk.userManagement.listInvitations.mockResolvedValue({ - data: [], - listMetadata: { before: null, after: null }, + it('surfaces the gated-capability case on a 403 without naming GraphQL', async () => { + const consoleErrors: string[] = []; + vi.spyOn(console, 'error').mockImplementation((...args: unknown[]) => { + consoleErrors.push(args.map(String).join(' ')); }); - await runInvitationList({}, 'sk_test'); - const output = JSON.parse(consoleOutput[0]); - expect(output.data).toEqual([]); - expect(output.listMetadata).toBeDefined(); - }); - - it('runInvitationSend outputs JSON success', async () => { - mockSdk.userManagement.sendInvitation.mockResolvedValue(mockInvitation); - await runInvitationSend({ email: 'test@example.com' }, 'sk_test'); - const output = JSON.parse(consoleOutput[0]); - expect(output.status).toBe('ok'); - expect(output.message).toBe('Sent invitation'); - expect(output.data.id).toBe('inv_123'); - }); - - it('runInvitationRevoke outputs JSON success', async () => { - const revoked = { ...mockInvitation, state: 'revoked' }; - mockSdk.userManagement.revokeInvitation.mockResolvedValue(revoked); - await runInvitationRevoke('inv_123', 'sk_test'); - const output = JSON.parse(consoleOutput[0]); - expect(output.status).toBe('ok'); - expect(output.message).toBe('Revoked invitation'); - }); - - it('runInvitationResend outputs JSON success', async () => { - mockSdk.userManagement.resendInvitation.mockResolvedValue(mockInvitation); - await runInvitationResend('inv_123', 'sk_test'); - const output = JSON.parse(consoleOutput[0]); - expect(output.status).toBe('ok'); - expect(output.message).toBe('Resent invitation'); + mockGraphqlRequest.mockRejectedValue( + new DashboardGraphqlError('The dashboard GraphQL API rejected this session (HTTP 403).', 'forbidden', 403), + ); + await expectExit(runInvitationList({}), 1); + const err = consoleErrors.join('\n'); + expect(err).toMatch(/account-plane capability/i); + expect(err).not.toMatch(/graphql/i); }); }); }); diff --git a/src/commands/invitation.ts b/src/commands/invitation.ts index fd07fc7a..2cc89df7 100644 --- a/src/commands/invitation.ts +++ b/src/commands/invitation.ts @@ -1,131 +1,483 @@ +/** + * `workos invitation` — AuthKit user-invitation lifecycle on the dashboard + * account plane. + * + * Migrated from the API-key REST SDK (graphql-resource-migration Phase 4): the + * subcommand surface (list/get/send/revoke/resend) is unchanged, but every + * operation now runs catalog-backed dashboard operations with the user's OAuth + * bearer. Output shapes are new curated shapes (approved breaking change); the + * authoritative examples live in `invitation.spec.ts`. + * + * Backend divergences from REST (all loud, none faked): + * - `list` splits across two operations: env-wide vs by-org (`--org`). + * `--email` maps to the search variable; `--order` has no backing variable + * and was dropped. + * - `get ` has no backing single-invitation operation: it client-side + * filters the most recent {@link INVITATION_GET_SCAN_LIMIT} invitations and + * reports a capped miss loudly. + * - `send --expires-in-days` is required server-side; the CLI defaults to 7 + * days (the REST default) when the flag is omitted. + * - `--role` takes a role ID (role_*), not a role slug. + * + * Safety posture per the manifest: `revoke` is destructive → + * `confirmDestructive` (prompt, or --yes). + */ + import chalk from 'chalk'; -import { createWorkOSClient } from '../lib/workos-client.js'; +import { requireCommandToken } from '../lib/command-auth.js'; +import { dashboardGraphqlRequest } from '../lib/dashboard-graphql.js'; +import { resolveEnvironmentTarget } from '../lib/environment-target.js'; +import { getOperation, resolveExecutableDocument, reportDashboardError } from '../catalog/operation.js'; +import { confirmDestructive } from '../catalog/confirm.js'; +import { isJsonMode, outputJson, outputSuccess, exitWithError } from '../utils/output.js'; import { formatTable } from '../utils/table.js'; -import { outputSuccess, outputJson, isJsonMode } from '../utils/output.js'; -import { createApiErrorHandler } from '../lib/api-error-handler.js'; -const handleApiError = createApiErrorHandler('Invitation'); +/** `invitation get` scans at most this many recent invitations (no fan-out). */ +export const INVITATION_GET_SCAN_LIMIT = 100; + +/** The server default for `--expires-in-days` (matches the REST default). */ +const DEFAULT_EXPIRES_IN_DAYS = 7; + +interface InvitationNode { + id: string; + inviteeEmail?: string | null; + state?: string | null; + createdAt?: string | null; + organization?: { id: string; name: string | null } | null; +} + +/** + * The curated invitation shape — the `--json` contract for every subcommand. + * camelCase, stable keys, no internal fields; see invitation.spec.ts for the + * authoritative example. + */ +function shapeInvitation(invitation: InvitationNode) { + return { + id: invitation.id, + email: invitation.inviteeEmail ?? null, + state: invitation.state ?? null, + createdAt: invitation.createdAt ?? null, + organization: invitation.organization + ? { id: invitation.organization.id, name: invitation.organization.name ?? null } + : null, + }; +} + +type ShapedInvitation = ReturnType; + +function renderInvitationTable(invitations: ShapedInvitation[]): void { + const rows = invitations.map((inv) => [ + inv.id, + inv.email ?? chalk.dim('-'), + inv.organization?.id ?? chalk.dim('-'), + inv.state ?? chalk.dim('-'), + inv.createdAt ?? chalk.dim('-'), + ]); + console.log( + formatTable( + [{ header: 'ID' }, { header: 'Email' }, { header: 'Org ID' }, { header: 'State' }, { header: 'Created' }], + rows, + ), + ); +} + +function renderPagination(pagination: { before: string | null; after: string | null }): void { + const { before, after } = pagination; + if (before && after) { + console.log(chalk.dim(`Before: ${before} After: ${after}`)); + } else if (before) { + console.log(chalk.dim(`Before: ${before}`)); + } else if (after) { + console.log(chalk.dim(`After: ${after}`)); + } +} + +interface InvitationPage { + invitations: ShapedInvitation[]; + pagination: { before: string | null; after: string | null }; +} + +/** Fetch one page of env-wide invitations (also backs `invitation get`). */ +async function fetchEnvironmentInvitations( + token: string, + environmentId: string, + variables: { search?: string; limit?: number; before?: string; after?: string }, +): Promise { + const op = getOperation('userlandUserInvites'); + let data: { + userlandUserInvites: { + data: InvitationNode[]; + listMetadata: { before: string | null; after: string | null }; + } | null; + }; + try { + data = await dashboardGraphqlRequest(resolveExecutableDocument(op), { + token, + variables: { environmentId, ...variables }, + environmentId, + }); + } catch (error) { + reportDashboardError(error); + } + return { + invitations: (data.userlandUserInvites?.data ?? []).map(shapeInvitation), + pagination: { + before: data.userlandUserInvites?.listMetadata?.before ?? null, + after: data.userlandUserInvites?.listMetadata?.after ?? null, + }, + }; +} export interface InvitationListOptions { + /** `--environment-id` override; defaults from the active profile. */ + environmentId?: string; org?: string; + /** `--email` filter — served by the dashboard search. */ email?: string; limit?: number; before?: string; after?: string; - order?: string; } -export async function runInvitationList( - options: InvitationListOptions, - apiKey: string, - baseUrl?: string, -): Promise { - const client = createWorkOSClient(apiKey, baseUrl); +export async function runInvitationList(options: InvitationListOptions = {}): Promise { + const token = await requireCommandToken(); + const pageVariables = { + ...(options.email ? { search: options.email } : {}), + ...(options.limit !== undefined ? { limit: options.limit } : {}), + ...(options.before ? { before: options.before } : {}), + ...(options.after ? { after: options.after } : {}), + }; - try { - const result = await client.sdk.userManagement.listInvitations({ - ...(options.org && { organizationId: options.org }), - ...(options.email && { email: options.email }), - limit: options.limit, - before: options.before, - after: options.after, - order: options.order as 'asc' | 'desc' | undefined, + let page: InvitationPage; + if (options.org) { + const op = getOperation('userlandUserInvitesByOrg'); + const { environmentId } = await resolveEnvironmentTarget(token, { + flagValue: options.environmentId, + forMutation: op.kind === 'mutation', }); - if (isJsonMode()) { - outputJson({ data: result.data, listMetadata: result.listMetadata }); - return; + let data: { + organization: { + userlandUserInvites: { + data: InvitationNode[]; + listMetadata: { before: string | null; after: string | null }; + } | null; + } | null; + }; + try { + data = await dashboardGraphqlRequest(resolveExecutableDocument(op), { + token, + variables: { organizationId: options.org, ...pageVariables }, + environmentId, + }); + } catch (error) { + reportDashboardError(error); } - if (result.data.length === 0) { - console.log('No invitations found.'); - return; + if (!data.organization) { + exitWithError({ + code: 'not_found', + message: `Organization "${options.org}" was not found in this environment.`, + }); } - const rows = result.data.map((inv) => [ - inv.id, - inv.email, - inv.organizationId ?? chalk.dim('-'), - inv.state, - inv.expiresAt, - ]); - - console.log( - formatTable( - [{ header: 'ID' }, { header: 'Email' }, { header: 'Org ID' }, { header: 'State' }, { header: 'Expires At' }], - rows, + // Per-invite organization is not part of the by-org payload — it is implied + // by the filter, so the curated shape carries the requested org ID. + page = { + invitations: (data.organization.userlandUserInvites?.data ?? []).map((invitation) => + shapeInvitation({ ...invitation, organization: { id: options.org as string, name: null } }), ), - ); - - const { before, after } = result.listMetadata; - if (before && after) { - console.log(chalk.dim(`Before: ${before} After: ${after}`)); - } else if (before) { - console.log(chalk.dim(`Before: ${before}`)); - } else if (after) { - console.log(chalk.dim(`After: ${after}`)); - } - } catch (error) { - handleApiError(error); + pagination: { + before: data.organization.userlandUserInvites?.listMetadata?.before ?? null, + after: data.organization.userlandUserInvites?.listMetadata?.after ?? null, + }, + }; + } else { + const op = getOperation('userlandUserInvites'); + const { environmentId } = await resolveEnvironmentTarget(token, { + flagValue: options.environmentId, + forMutation: op.kind === 'mutation', + }); + page = await fetchEnvironmentInvitations(token, environmentId, pageVariables); + } + + if (isJsonMode()) { + outputJson({ invitations: page.invitations, pagination: page.pagination }); + return; } + + if (page.invitations.length === 0) { + console.log('No invitations found.'); + return; + } + renderInvitationTable(page.invitations); + renderPagination(page.pagination); } -export async function runInvitationGet(id: string, apiKey: string, baseUrl?: string): Promise { - const client = createWorkOSClient(apiKey, baseUrl); +export interface InvitationGetOptions { + /** `--environment-id` override; defaults from the active profile. */ + environmentId?: string; +} - try { - const invitation = await client.sdk.userManagement.getInvitation(id); - outputJson(invitation); - } catch (error) { - handleApiError(error); +export async function runInvitationGet(id: string, options: InvitationGetOptions = {}): Promise { + const token = await requireCommandToken(); + const op = getOperation('userlandUserInvites'); + + const { environmentId } = await resolveEnvironmentTarget(token, { + flagValue: options.environmentId, + forMutation: op.kind === 'mutation', + }); + + // There is no single-invitation operation, so filter the most recent page + // client-side — capped, never an unbounded scan. + const page = await fetchEnvironmentInvitations(token, environmentId, { limit: INVITATION_GET_SCAN_LIMIT }); + const invitation = page.invitations.find((candidate) => candidate.id === id); + + if (!invitation) { + exitWithError({ + code: 'not_found', + message: `Invitation "${id}" was not found in the ${INVITATION_GET_SCAN_LIMIT} most recent invitations in this environment. Use \`invitation list\` to page through older invitations.`, + }); + } + + if (isJsonMode()) { + outputJson({ invitation }); + return; + } + + const fields: Array<[string, unknown]> = [ + ['ID', invitation.id], + ['Email', invitation.email], + ['Org ID', invitation.organization?.id ?? null], + ['State', invitation.state], + ['Created', invitation.createdAt], + ]; + for (const [label, value] of fields) { + if (value === null || value === undefined || value === '') continue; + console.log(`${chalk.bold(label)}: ${String(value)}`); } + console.log(chalk.dim('Run with --json for the full record.')); } export interface InvitationSendOptions { + /** `--environment-id` override; defaults from the active profile. */ + environmentId?: string; email: string; org?: string; + /** Role ID (role_*) to assign when the invite is accepted. */ role?: string; expiresInDays?: number; } -export async function runInvitationSend( - options: InvitationSendOptions, - apiKey: string, - baseUrl?: string, -): Promise { - const client = createWorkOSClient(apiKey, baseUrl); +export async function runInvitationSend(options: InvitationSendOptions): Promise { + const token = await requireCommandToken(); + const op = getOperation('createUserlandUserInvite'); + // Environment-scoped mutation: pre-validated resolved target, sent as both + // input field and environment header. + const { environmentId } = await resolveEnvironmentTarget(token, { + flagValue: options.environmentId, + forMutation: op.kind === 'mutation', + }); + + let data: { + createUserlandUserInvite: + | { __typename: 'UserlandUserInviteCreated'; userlandUserInvite: { id: string } } + | { __typename: string }; + }; try { - const invitation = await client.sdk.userManagement.sendInvitation({ - email: options.email, - ...(options.org && { organizationId: options.org }), - ...(options.role && { roleSlug: options.role }), - ...(options.expiresInDays !== undefined && { expiresInDays: options.expiresInDays }), + data = await dashboardGraphqlRequest(resolveExecutableDocument(op), { + token, + variables: { + input: { + environmentId, + inviteeEmail: options.email, + // Required server-side; default matches the REST plane's behavior. + expiresInDays: options.expiresInDays ?? DEFAULT_EXPIRES_IN_DAYS, + ...(options.org ? { organizationId: options.org } : {}), + ...(options.role ? { roleId: options.role } : {}), + }, + }, + environmentId, }); - outputSuccess('Sent invitation', invitation); } catch (error) { - handleApiError(error); + reportDashboardError(error); + } + + const result = data.createUserlandUserInvite; + // Business-error variants carry only their typename; map each to clean copy. + const sendErrors: Record = { + EnvironmentNotFound: { + code: 'environment_not_found', + message: 'The target environment was not found.', + }, + OrganizationNotFound: { + code: 'not_found', + message: `Organization "${options.org ?? ''}" was not found in this environment.`, + }, + UserlandUserNotFound: { + code: 'not_found', + message: 'The user for this invitation was not found in this environment.', + }, + CreateUserlandUserInviteExpiresInDaysTooLong: { + code: 'invalid_argument', + message: `--expires-in-days ${options.expiresInDays ?? DEFAULT_EXPIRES_IN_DAYS} is too long.`, + }, + CreateUserlandUserInviteExpiresInDaysTooShort: { + code: 'invalid_argument', + message: `--expires-in-days ${options.expiresInDays ?? DEFAULT_EXPIRES_IN_DAYS} is too short.`, + }, + CreateUserlandUserInviteUserAlreadyExists: { + code: 'already_exists', + message: `A user with the email ${options.email} already exists in this environment.`, + }, + CreateUserlandUserInviteUserAlreadyOrganizationMember: { + code: 'already_member', + message: `${options.email} is already a member of organization "${options.org ?? ''}".`, + }, + CreateUserlandUserInviteInvalidInviteeEmail: { + code: 'invalid_argument', + message: `"${options.email}" is not a valid email address.`, + }, + CreateUserlandUserInviteEmailAlreadyInvitedToEnvironment: { + code: 'already_invited', + message: `${options.email} already has a pending invitation in this environment.`, + }, + CreateUserlandUserInviteEmailAlreadyInvitedToOrganization: { + code: 'already_invited', + message: `${options.email} already has a pending invitation to organization "${options.org ?? ''}".`, + }, + CreateUserlandUserInviteInvalidRole: { + code: 'invalid_argument', + message: `Role "${options.role ?? ''}" is not valid for this invitation.`, + }, + }; + const sendError = sendErrors[result.__typename]; + if (sendError) { + exitWithError(sendError); + } + if (result.__typename !== 'UserlandUserInviteCreated' || !('userlandUserInvite' in result)) { + exitWithError({ code: 'unexpected_result', message: `Could not send an invitation to ${options.email}.` }); } + + const invitation = { id: (result as { userlandUserInvite: { id: string } }).userlandUserInvite.id }; + if (isJsonMode()) { + outputJson({ invitation }); + return; + } + outputSuccess(`Sent invitation to ${chalk.bold(options.email)}`); + console.log(chalk.dim(` id: ${invitation.id}`)); +} + +export interface InvitationRevokeOptions { + /** `--environment-id` override; defaults from the active profile. */ + environmentId?: string; + yes?: boolean; + json?: boolean; } -export async function runInvitationRevoke(id: string, apiKey: string, baseUrl?: string): Promise { - const client = createWorkOSClient(apiKey, baseUrl); +export async function runInvitationRevoke(id: string, options: InvitationRevokeOptions = {}): Promise { + // Destructive per the manifest: invalidates the pending invite link. + await confirmDestructive(options, { + action: `revoke invitation ${id} — the invite link can no longer be accepted`, + }); + const token = await requireCommandToken(); + const op = getOperation('revokeUserlandUserInvite'); + + // Environment-scoped mutation: pre-validated resolved target as header. + const { environmentId } = await resolveEnvironmentTarget(token, { + flagValue: options.environmentId, + forMutation: op.kind === 'mutation', + }); + + let data: { + revokeUserlandUserInvite: + | { __typename: 'UserlandUserInviteRevoked'; userlandUserInvite: { id: string } } + | { __typename: 'UserlandUserInviteNotFound' } + | { __typename: 'UserlandUserInviteNotPending' } + | { __typename: string }; + }; try { - const invitation = await client.sdk.userManagement.revokeInvitation(id); - outputSuccess('Revoked invitation', invitation); + data = await dashboardGraphqlRequest(resolveExecutableDocument(op), { + token, + variables: { input: { userlandUserInviteId: id } }, + environmentId, + }); } catch (error) { - handleApiError(error); + reportDashboardError(error); + } + + const result = data.revokeUserlandUserInvite; + if (result.__typename === 'UserlandUserInviteNotFound') { + exitWithError({ code: 'not_found', message: `Invitation "${id}" was not found in this environment.` }); + } + if (result.__typename === 'UserlandUserInviteNotPending') { + exitWithError({ + code: 'invite_not_pending', + message: `Invitation "${id}" is not pending; only pending invitations can be revoked.`, + }); } + if (result.__typename !== 'UserlandUserInviteRevoked') { + exitWithError({ code: 'unexpected_result', message: `Could not revoke invitation "${id}".` }); + } + + if (isJsonMode()) { + outputJson({ revoked: id }); + return; + } + outputSuccess(`Revoked invitation ${chalk.bold(id)}`); +} + +export interface InvitationResendOptions { + /** `--environment-id` override; defaults from the active profile. */ + environmentId?: string; } -export async function runInvitationResend(id: string, apiKey: string, baseUrl?: string): Promise { - const client = createWorkOSClient(apiKey, baseUrl); +export async function runInvitationResend(id: string, options: InvitationResendOptions = {}): Promise { + const token = await requireCommandToken(); + const op = getOperation('resendUserlandUserInvite'); + + // Environment-scoped mutation: pre-validated resolved target as header. + const { environmentId } = await resolveEnvironmentTarget(token, { + flagValue: options.environmentId, + forMutation: op.kind === 'mutation', + }); + let data: { + resendUserlandUserInvite: + | { __typename: 'UserlandUserInviteResent'; userlandUserInvite: { id: string } } + | { __typename: 'UserlandUserInviteNotFound' } + | { __typename: 'UserlandUserInviteNotPending' } + | { __typename: string }; + }; try { - const invitation = await client.sdk.userManagement.resendInvitation(id); - outputSuccess('Resent invitation', invitation); + data = await dashboardGraphqlRequest(resolveExecutableDocument(op), { + token, + variables: { input: { userlandUserInviteId: id } }, + environmentId, + }); } catch (error) { - handleApiError(error); + reportDashboardError(error); + } + + const result = data.resendUserlandUserInvite; + if (result.__typename === 'UserlandUserInviteNotFound') { + exitWithError({ code: 'not_found', message: `Invitation "${id}" was not found in this environment.` }); + } + if (result.__typename === 'UserlandUserInviteNotPending') { + exitWithError({ + code: 'invite_not_pending', + message: `Invitation "${id}" is not pending; only pending invitations can be resent.`, + }); + } + if (result.__typename !== 'UserlandUserInviteResent') { + exitWithError({ code: 'unexpected_result', message: `Could not resend invitation "${id}".` }); + } + + if (isJsonMode()) { + outputJson({ resent: id }); + return; } + outputSuccess(`Resent invitation ${chalk.bold(id)}`); } diff --git a/src/commands/membership.spec.ts b/src/commands/membership.spec.ts index 1d24ace1..e5f176b6 100644 --- a/src/commands/membership.spec.ts +++ b/src/commands/membership.spec.ts @@ -1,24 +1,53 @@ import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'; -// Mock the unified client -const mockSdk = { - userManagement: { - listOrganizationMemberships: vi.fn(), - getOrganizationMembership: vi.fn(), - createOrganizationMembership: vi.fn(), - updateOrganizationMembership: vi.fn(), - deleteOrganizationMembership: vi.fn(), - deactivateOrganizationMembership: vi.fn(), - reactivateOrganizationMembership: vi.fn(), - }, -}; +const mockRequireCommandToken = vi.fn(); +const mockGraphqlRequest = vi.fn(); +const mockConfirm = vi.fn(); +const mockIsCancel = vi.fn(() => false); +const mockGetActiveEnvironment = vi.fn(); +const mockGetConfig = vi.fn(); + +vi.mock('../lib/command-auth.js', async (importActual) => { + const actual = await importActual(); + return { + ...actual, + requireCommandToken: () => mockRequireCommandToken(), + }; +}); + +vi.mock('../lib/dashboard-graphql.js', async (importActual) => { + const actual = await importActual(); + return { + ...actual, + dashboardGraphqlRequest: (...args: unknown[]) => mockGraphqlRequest(...args), + }; +}); -vi.mock('../lib/workos-client.js', () => ({ - createWorkOSClient: () => ({ sdk: mockSdk }), +vi.mock('../utils/clack.js', () => ({ + default: { + confirm: (...args: unknown[]) => mockConfirm(...args), + isCancel: (...args: unknown[]) => mockIsCancel(...args), + select: vi.fn(), + }, })); -const { setOutputMode } = await import('../utils/output.js'); +// The environment-target resolver runs REAL against the mocked wire + config +// store, so mutation tests can assert the pre-validation fetch +// (teamProjectsV2) happens before the operation request. +vi.mock('../lib/config-store.js', async (importActual) => { + const actual = await importActual(); + return { + ...actual, + getActiveEnvironment: () => mockGetActiveEnvironment(), + getConfig: () => mockGetConfig(), + setProfileEnvironmentId: vi.fn(), + }; +}); +const { setOutputMode } = await import('../utils/output.js'); +const { resetInteractionModeForTests, setInteractionMode } = await import('../utils/interaction-mode.js'); +const { DashboardGraphqlError } = await import('../lib/dashboard-graphql.js'); +const { CliExit } = await import('../utils/cli-exit.js'); const { runMembershipList, runMembershipGet, @@ -28,25 +57,81 @@ const { runMembershipDeactivate, runMembershipReactivate, } = await import('./membership.js'); -const { CliExit } = await import('../utils/cli-exit.js'); -const mockMembership = { - id: 'om_123', - userId: 'user_456', - organizationId: 'org_789', - organizationName: 'FooCorp', - role: { slug: 'admin' }, - status: 'active', - createdAt: '2024-01-01T00:00:00Z', - updatedAt: '2024-01-01T00:00:00Z', - customAttributes: {}, +const TEAM_ENVIRONMENTS_PAYLOAD = { + currentTeam: { + projectsV2: [{ environments: [{ id: 'env_profile', name: 'Sandbox', sandbox: true, clientId: null }] }], + }, +}; + +/** + * Route the wire mock by document: the environment resolver's pre-validation + * fetch (`teamProjectsV2`) gets the team's environments; everything else gets + * the operation payload. + */ +function respondWith(payload: unknown): void { + mockGraphqlRequest.mockImplementation(async (doc: unknown) => { + if (String(doc).includes('teamProjectsV2')) return TEAM_ENVIRONMENTS_PAYLOAD; + return payload; + }); +} + +async function expectExit(promise: Promise, code: number): Promise { + try { + await promise; + } catch (err) { + if (err instanceof CliExit) { + expect(err.exitCode).toBe(code); + return err; + } + throw err; + } + throw new Error(`Expected CliExit(${code}) but promise resolved`); +} + +/** The authoritative curated membership JSON shape (documented contract). */ +const MEMBERSHIP_SHAPE_KEYS = [ + 'id', + 'userId', + 'organizationId', + 'status', + 'type', + 'role', + 'roles', + 'directoryUserId', + 'createdAt', + 'updatedAt', +]; + +const MEMBERSHIP_NODE = { + id: 'om_1', + type: 'Standard', + status: 'Active', + organizationId: 'org_1', + userlandUserId: 'user_1', + directoryUserId: null, + role: 'member', + roles: ['member'], + createdAt: '2026-01-01T00:00:00.000Z', + updatedAt: '2026-02-01T00:00:00.000Z', }; -describe('membership commands', () => { +describe('membership command', () => { let consoleOutput: string[]; beforeEach(() => { vi.clearAllMocks(); + resetInteractionModeForTests(); + setOutputMode('human'); + mockRequireCommandToken.mockResolvedValue('tok_123'); + mockConfirm.mockReset(); + mockIsCancel.mockReset(); + mockIsCancel.mockReturnValue(false); + mockGetActiveEnvironment.mockReturnValue({ apiKey: 'sk_ignored', environmentId: 'env_profile' }); + mockGetConfig.mockReturnValue({ + activeEnvironment: 'default', + environments: { default: { apiKey: 'sk_ignored', environmentId: 'env_profile' } }, + }); consoleOutput = []; vi.spyOn(console, 'log').mockImplementation((...args: unknown[]) => { consoleOutput.push(args.map(String).join(' ')); @@ -56,211 +141,448 @@ describe('membership commands', () => { afterEach(() => { vi.restoreAllMocks(); + resetInteractionModeForTests(); + setOutputMode('human'); }); - describe('runMembershipList', () => { - it('lists memberships by org', async () => { - mockSdk.userManagement.listOrganizationMemberships.mockResolvedValue({ - data: [mockMembership], - listMetadata: { before: null, after: null }, + describe('list', () => { + it('requires --org or --user', async () => { + const err = await expectExit(runMembershipList({}), 1); + expect(err.context?.errorCode).toBe('missing_argument'); + expect(mockGraphqlRequest).not.toHaveBeenCalled(); + }); + + it('rejects --org combined with --user', async () => { + const err = await expectExit(runMembershipList({ org: 'org_1', user: 'user_1' }), 1); + expect(err.context?.errorCode).toBe('invalid_argument'); + expect(mockGraphqlRequest).not.toHaveBeenCalled(); + }); + + it('lists by user with the environment header (no pagination variables)', async () => { + respondWith({ userlandUserOrganizationMemberships: { organizationMemberships: [MEMBERSHIP_NODE] } }); + await runMembershipList({ user: 'user_1' }); + expect(mockGraphqlRequest).toHaveBeenCalledTimes(1); + expect(mockGraphqlRequest).toHaveBeenCalledWith(expect.stringContaining('userlandUserOrganizationMemberships'), { + token: 'tok_123', + variables: { userlandUserId: 'user_1' }, + environmentId: 'env_profile', }); - await runMembershipList({ org: 'org_789' }, 'sk_test'); - expect(mockSdk.userManagement.listOrganizationMemberships).toHaveBeenCalledWith( - expect.objectContaining({ organizationId: 'org_789' }), - ); - expect(consoleOutput.some((l) => l.includes('om_123'))).toBe(true); + const out = consoleOutput.join('\n'); + expect(out).toContain('om_1'); + expect(out).toContain('org_1'); + }); + + it('rejects pagination/order flags on the by-user path (no backing variables)', async () => { + const err = await expectExit(runMembershipList({ user: 'user_1', limit: 5 }), 1); + expect(err.context?.errorCode).toBe('invalid_argument'); + expect(mockGraphqlRequest).not.toHaveBeenCalled(); }); - it('lists memberships by user', async () => { - mockSdk.userManagement.listOrganizationMemberships.mockResolvedValue({ - data: [mockMembership], - listMetadata: { before: null, after: null }, + it('lists by org via the org members operation, flattening identities to memberships', async () => { + setOutputMode('json'); + respondWith({ + organization: { + userlandUsers: { + data: [ + { + id: 'user_1', + identities: { + data: [ + { + id: 'om_1', + status: 'Active', + directoryUserId: null, + role: { id: 'role_1', name: 'member' }, + roles: [{ id: 'role_1', name: 'member' }], + organization: { id: 'org_1', name: 'FooCorp' }, + createdAt: '2026-01-01T00:00:00.000Z', + updatedAt: '2026-02-01T00:00:00.000Z', + // Internal fields the curated shape must drop: + ssoProfile: null, + customAttributes: { internal: true }, + }, + ], + }, + }, + ], + listMetadata: { before: null, after: 'cursor_a' }, + }, + }, }); - await runMembershipList({ user: 'user_456' }, 'sk_test'); - expect(mockSdk.userManagement.listOrganizationMemberships).toHaveBeenCalledWith( - expect.objectContaining({ userId: 'user_456' }), - ); + await runMembershipList({ org: 'org_1' }); + const raw = consoleOutput[0]; + expect(raw).not.toMatch(/graphql|userland|list_metadata/i); + const out = JSON.parse(raw); + expect(Object.keys(out)).toEqual(['memberships', 'pagination']); + expect(Object.keys(out.memberships[0])).toEqual(MEMBERSHIP_SHAPE_KEYS); + expect(out.memberships[0]).toMatchObject({ + id: 'om_1', + userId: 'user_1', + organizationId: 'org_1', + role: 'member', + roles: ['member'], + }); + expect(out.memberships[0]).not.toHaveProperty('customAttributes'); + expect(out.pagination).toEqual({ before: null, after: 'cursor_a' }); + }); + + it('maps by-org pagination flags to catalog variables', async () => { + respondWith({ + organization: { userlandUsers: { data: [], listMetadata: { before: null, after: null } } }, + }); + await runMembershipList({ org: 'org_1', limit: 5, after: 'cursor_a', order: 'desc' }); + expect(mockGraphqlRequest).toHaveBeenCalledWith(expect.stringContaining('userlandUsersByOrg'), { + token: 'tok_123', + variables: { organizationId: 'org_1', limit: 5, after: 'cursor_a', order: 'Desc' }, + environmentId: 'env_profile', + }); + }); + + it('errors not_found when the organization does not exist', async () => { + respondWith({ organization: null }); + const err = await expectExit(runMembershipList({ org: 'org_missing' }), 1); + expect(err.context?.errorCode).toBe('not_found'); }); - it('exits with error when neither --org nor --user provided', async () => { - await expect(runMembershipList({}, 'sk_test')).rejects.toThrow(CliExit); + it('--json emits the by-user curated shape with null pagination', async () => { + setOutputMode('json'); + respondWith({ userlandUserOrganizationMemberships: { organizationMemberships: [MEMBERSHIP_NODE] } }); + await runMembershipList({ user: 'user_1' }); + const raw = consoleOutput[0]; + expect(raw).not.toMatch(/graphql|userland|list_metadata/i); + const out = JSON.parse(raw); + expect(Object.keys(out)).toEqual(['memberships', 'pagination']); + expect(Object.keys(out.memberships[0])).toEqual(MEMBERSHIP_SHAPE_KEYS); + expect(out.memberships[0].userId).toBe('user_1'); + expect(out.pagination).toEqual({ before: null, after: null }); }); it('handles empty results', async () => { - mockSdk.userManagement.listOrganizationMemberships.mockResolvedValue({ - data: [], - listMetadata: { before: null, after: null }, - }); - await runMembershipList({ org: 'org_789' }, 'sk_test'); + respondWith({ userlandUserOrganizationMemberships: { organizationMemberships: [] } }); + await runMembershipList({ user: 'user_1' }); expect(consoleOutput.some((l) => l.includes('No memberships found'))).toBe(true); }); + }); - it('shows pagination cursors', async () => { - mockSdk.userManagement.listOrganizationMemberships.mockResolvedValue({ - data: [mockMembership], - listMetadata: { before: 'cursor_b', after: 'cursor_a' }, + describe('get', () => { + it('fetches by ID with the environment header and renders fields', async () => { + respondWith({ userlandUserOrganizationMembership: MEMBERSHIP_NODE }); + await runMembershipGet('om_1'); + expect(mockGraphqlRequest).toHaveBeenCalledWith(expect.stringContaining('userlandUserOrganizationMembership'), { + token: 'tok_123', + variables: { id: 'om_1' }, + environmentId: 'env_profile', }); - await runMembershipList({ org: 'org_789' }, 'sk_test'); - expect(consoleOutput.some((l) => l.includes('cursor_b'))).toBe(true); - expect(consoleOutput.some((l) => l.includes('cursor_a'))).toBe(true); + const out = consoleOutput.join('\n'); + expect(out).toContain('om_1'); + expect(out).toContain('user_1'); + }); + + it('--json emits { membership } in the curated shape', async () => { + setOutputMode('json'); + respondWith({ userlandUserOrganizationMembership: MEMBERSHIP_NODE }); + await runMembershipGet('om_1'); + const raw = consoleOutput[0]; + expect(raw).not.toMatch(/graphql|userland/i); + const out = JSON.parse(raw); + expect(Object.keys(out)).toEqual(['membership']); + expect(Object.keys(out.membership)).toEqual(MEMBERSHIP_SHAPE_KEYS); + expect(out.membership).toMatchObject({ id: 'om_1', userId: 'user_1', organizationId: 'org_1' }); }); - }); - describe('runMembershipGet', () => { - it('fetches and prints membership as JSON', async () => { - mockSdk.userManagement.getOrganizationMembership.mockResolvedValue(mockMembership); - await runMembershipGet('om_123', 'sk_test'); - expect(mockSdk.userManagement.getOrganizationMembership).toHaveBeenCalledWith('om_123'); - expect(consoleOutput.some((l) => l.includes('om_123'))).toBe(true); + it('errors not_found when the membership does not exist', async () => { + respondWith({ userlandUserOrganizationMembership: null }); + const err = await expectExit(runMembershipGet('om_missing'), 1); + expect(err.context?.errorCode).toBe('not_found'); }); }); - describe('runMembershipCreate', () => { - it('creates membership with org and user', async () => { - mockSdk.userManagement.createOrganizationMembership.mockResolvedValue(mockMembership); - await runMembershipCreate({ org: 'org_789', user: 'user_456' }, 'sk_test'); - expect(mockSdk.userManagement.createOrganizationMembership).toHaveBeenCalledWith({ - organizationId: 'org_789', - userId: 'user_456', + describe('create', () => { + const added = { addUserlandUserToOrganization: { __typename: 'UserlandUserAddedToOrganization' } }; + + it('maps flags into the input and pre-validates the environment first', async () => { + respondWith(added); + await runMembershipCreate({ org: 'org_1', user: 'user_1', role: 'role_1' }); + // Mutation: the resolver fetches the team's environments BEFORE the op. + expect(mockGraphqlRequest.mock.calls.length).toBe(2); + expect(String(mockGraphqlRequest.mock.calls[0][0])).toContain('teamProjectsV2'); + expect(String(mockGraphqlRequest.mock.calls[1][0])).toContain('addUserlandUserToOrg'); + expect(mockGraphqlRequest.mock.calls[1][1]).toEqual({ + token: 'tok_123', + variables: { input: { organizationId: 'org_1', userlandUserId: 'user_1', roleId: 'role_1' } }, + environmentId: 'env_profile', }); }); - it('creates membership with role', async () => { - mockSdk.userManagement.createOrganizationMembership.mockResolvedValue(mockMembership); - await runMembershipCreate({ org: 'org_789', user: 'user_456', role: 'admin' }, 'sk_test'); - expect(mockSdk.userManagement.createOrganizationMembership).toHaveBeenCalledWith({ - organizationId: 'org_789', - userId: 'user_456', - roleSlug: 'admin', + it('omits roleId when --role is not passed', async () => { + respondWith(added); + await runMembershipCreate({ org: 'org_1', user: 'user_1' }); + expect(mockGraphqlRequest.mock.calls[1][1]).toEqual({ + token: 'tok_123', + variables: { input: { organizationId: 'org_1', userlandUserId: 'user_1' } }, + environmentId: 'env_profile', }); }); - it('outputs created message', async () => { - mockSdk.userManagement.createOrganizationMembership.mockResolvedValue(mockMembership); - await runMembershipCreate({ org: 'org_789', user: 'user_456' }, 'sk_test'); - expect(consoleOutput.some((l) => l.includes('Created membership'))).toBe(true); + it('errors not_found when the organization is missing', async () => { + respondWith({ addUserlandUserToOrganization: { __typename: 'OrganizationNotFound', organizationId: 'org_1' } }); + const err = await expectExit(runMembershipCreate({ org: 'org_1', user: 'user_1' }), 1); + expect(err.context?.errorCode).toBe('not_found'); }); - }); - describe('runMembershipUpdate', () => { - it('updates membership role', async () => { - mockSdk.userManagement.updateOrganizationMembership.mockResolvedValue(mockMembership); - await runMembershipUpdate('om_123', 'editor', 'sk_test'); - expect(mockSdk.userManagement.updateOrganizationMembership).toHaveBeenCalledWith('om_123', { - roleSlug: 'editor', + it('errors already_invited without echoing internal naming', async () => { + const consoleErrors: string[] = []; + vi.spyOn(console, 'error').mockImplementation((...args: unknown[]) => { + consoleErrors.push(args.map(String).join(' ')); }); + respondWith({ + addUserlandUserToOrganization: { + __typename: 'UserlandUserAlreadyInvited', + userlandUserId: 'user_1', + organizationId: 'org_1', + }, + }); + const err = await expectExit(runMembershipCreate({ org: 'org_1', user: 'user_1' }), 1); + expect(err.context?.errorCode).toBe('already_invited'); + expect(consoleErrors.join('\n')).not.toMatch(/graphql|userland/i); }); - }); - describe('runMembershipDelete', () => { - it('deletes membership and prints confirmation', async () => { - mockSdk.userManagement.deleteOrganizationMembership.mockResolvedValue(undefined); - await runMembershipDelete('om_123', 'sk_test'); - expect(mockSdk.userManagement.deleteOrganizationMembership).toHaveBeenCalledWith('om_123'); - expect(consoleOutput.some((l) => l.includes('Deleted'))).toBe(true); - expect(consoleOutput.some((l) => l.includes('om_123'))).toBe(true); + it('--json emits { added }', async () => { + setOutputMode('json'); + respondWith(added); + await runMembershipCreate({ org: 'org_1', user: 'user_1' }); + expect(JSON.parse(consoleOutput[0])).toEqual({ added: { organizationId: 'org_1', userId: 'user_1' } }); }); }); - describe('runMembershipDeactivate', () => { - it('deactivates membership', async () => { - const deactivated = { ...mockMembership, status: 'inactive' }; - mockSdk.userManagement.deactivateOrganizationMembership.mockResolvedValue(deactivated); - await runMembershipDeactivate('om_123', 'sk_test'); - expect(mockSdk.userManagement.deactivateOrganizationMembership).toHaveBeenCalledWith('om_123'); - expect(consoleOutput.some((l) => l.includes('Deactivated membership'))).toBe(true); + describe('update (require-flag)', () => { + const updated = { + updateRoleOnOrganizationMembership: { + __typename: 'RoleOnOrganizationMembershipUpdated', + organizationMembership: { ...MEMBERSHIP_NODE, role: 'admin', roles: ['admin'] }, + }, + }; + + it('requires --role', async () => { + const err = await expectExit(runMembershipUpdate('om_1', {}), 1); + expect(err.context?.errorCode).toBe('missing_argument'); + expect(mockGraphqlRequest).not.toHaveBeenCalled(); + }); + + it('refuses in agent mode without --yes (privilege change)', async () => { + setInteractionMode({ mode: 'agent', source: 'agent_env' }); + const err = await expectExit(runMembershipUpdate('om_1', { role: 'role_admin' }), 1); + expect(err.context?.errorCode).toBe('confirmation_required'); + expect(mockGraphqlRequest).not.toHaveBeenCalled(); + }); + + it('proceeds non-interactively with --yes and sends the update input', async () => { + setInteractionMode({ mode: 'ci', source: 'ci_env' }); + respondWith(updated); + await runMembershipUpdate('om_1', { role: 'role_admin', yes: true }); + expect(String(mockGraphqlRequest.mock.calls[0][0])).toContain('teamProjectsV2'); + expect(mockGraphqlRequest.mock.calls[1][1]).toEqual({ + token: 'tok_123', + variables: { input: { organizationMembershipId: 'om_1', roleId: 'role_admin' } }, + environmentId: 'env_profile', + }); + }); + + it('proceeds without --yes for an interactive human (no prompt)', async () => { + setInteractionMode({ mode: 'human', source: 'default' }); + respondWith(updated); + await runMembershipUpdate('om_1', { role: 'role_admin' }); + expect(mockConfirm).not.toHaveBeenCalled(); + expect(mockGraphqlRequest).toHaveBeenCalled(); + }); + + it('errors not_found when the role is missing', async () => { + respondWith({ updateRoleOnOrganizationMembership: { __typename: 'RoleNotFound', roleId: 'role_missing' } }); + const err = await expectExit(runMembershipUpdate('om_1', { role: 'role_missing', yes: true }), 1); + expect(err.context?.errorCode).toBe('not_found'); + }); + + it('errors not_found when the membership is missing', async () => { + respondWith({ + updateRoleOnOrganizationMembership: { + __typename: 'UserlandUserOrganizationMembershipNotFound', + message: 'internal', + }, + }); + const err = await expectExit(runMembershipUpdate('om_1', { role: 'role_admin', yes: true }), 1); + expect(err.context?.errorCode).toBe('not_found'); + }); + + it('--json emits the updated { membership }', async () => { + setOutputMode('json'); + respondWith(updated); + await runMembershipUpdate('om_1', { role: 'role_admin', yes: true }); + const out = JSON.parse(consoleOutput[0]); + expect(out.membership).toMatchObject({ id: 'om_1', role: 'admin' }); }); }); - describe('runMembershipReactivate', () => { - it('reactivates membership', async () => { - mockSdk.userManagement.reactivateOrganizationMembership.mockResolvedValue(mockMembership); - await runMembershipReactivate('om_123', 'sk_test'); - expect(mockSdk.userManagement.reactivateOrganizationMembership).toHaveBeenCalledWith('om_123'); - expect(consoleOutput.some((l) => l.includes('Reactivated membership'))).toBe(true); + describe('delete (destructive, two-step)', () => { + function respondForDelete(removePayload: unknown, lookupPayload?: unknown): void { + mockGraphqlRequest.mockImplementation(async (doc: unknown) => { + const text = String(doc); + if (text.includes('teamProjectsV2')) return TEAM_ENVIRONMENTS_PAYLOAD; + if (text.includes('removeMemberFromOrganization')) return removePayload; + return lookupPayload ?? { userlandUserOrganizationMembership: MEMBERSHIP_NODE }; + }); + } + + const removed = { removeUserlandUserFromOrganization: { __typename: 'UserlandUserRemovedFromOrganization' } }; + + it('refuses in agent mode without --yes (exit 1, confirmation_required)', async () => { + setInteractionMode({ mode: 'agent', source: 'agent_env' }); + const err = await expectExit(runMembershipDelete('om_1', { yes: false }), 1); + expect(err.context?.errorCode).toBe('confirmation_required'); + expect(mockGraphqlRequest).not.toHaveBeenCalled(); + }); + + it('resolves the membership first, then removes by org+user', async () => { + setInteractionMode({ mode: 'ci', source: 'ci_env' }); + respondForDelete(removed); + await runMembershipDelete('om_1', { yes: true }); + const docs = mockGraphqlRequest.mock.calls.map((call) => String(call[0])); + expect(docs[0]).toContain('teamProjectsV2'); + expect(docs[1]).toContain('userlandUserOrganizationMembership'); + expect(docs[2]).toContain('removeMemberFromOrganization'); + expect(mockGraphqlRequest.mock.calls[2][1]).toEqual({ + token: 'tok_123', + variables: { input: { organizationId: 'org_1', userlandUserId: 'user_1' } }, + environmentId: 'env_profile', + }); + }); + + it('errors not_found when the membership lookup misses (no remove attempted)', async () => { + setInteractionMode({ mode: 'ci', source: 'ci_env' }); + respondForDelete(removed, { userlandUserOrganizationMembership: null }); + const err = await expectExit(runMembershipDelete('om_missing', { yes: true }), 1); + expect(err.context?.errorCode).toBe('not_found'); + const docs = mockGraphqlRequest.mock.calls.map((call) => String(call[0])); + expect(docs.some((doc) => doc.includes('removeMemberFromOrganization'))).toBe(false); + }); + + it('prompts interactively and proceeds when confirmed', async () => { + setInteractionMode({ mode: 'human', source: 'default' }); + mockConfirm.mockResolvedValue(true); + respondForDelete(removed); + await runMembershipDelete('om_1', { yes: false }); + expect(mockConfirm).toHaveBeenCalledOnce(); + expect(mockGraphqlRequest).toHaveBeenCalled(); + }); + + it('cancels (exit 2) when the interactive prompt is declined', async () => { + setInteractionMode({ mode: 'human', source: 'default' }); + mockConfirm.mockResolvedValue(false); + await expectExit(runMembershipDelete('om_1', { yes: false }), 2); + expect(mockGraphqlRequest).not.toHaveBeenCalled(); + }); + + it('--json emits { deleted }', async () => { + setInteractionMode({ mode: 'agent', source: 'agent_env' }); + setOutputMode('json'); + respondForDelete(removed); + await runMembershipDelete('om_1', { yes: true }); + expect(JSON.parse(consoleOutput[0])).toEqual({ deleted: 'om_1' }); }); }); - describe('JSON output mode', () => { - beforeEach(() => { + describe('deactivate (require-flag) / reactivate', () => { + it('deactivate refuses in agent mode without --yes', async () => { + setInteractionMode({ mode: 'agent', source: 'agent_env' }); + const err = await expectExit(runMembershipDeactivate('om_1', {}), 1); + expect(err.context?.errorCode).toBe('confirmation_required'); + expect(mockGraphqlRequest).not.toHaveBeenCalled(); + }); + + it('deactivate proceeds with --yes and sends the membership ID input', async () => { + setInteractionMode({ mode: 'ci', source: 'ci_env' }); + respondWith({ + deactivateUserlandUserOrganizationMembership: { + __typename: 'UserlandUserOrganizationMembershipDeactivated', + }, + }); + await runMembershipDeactivate('om_1', { yes: true }); + expect(mockGraphqlRequest.mock.calls[1][1]).toEqual({ + token: 'tok_123', + variables: { input: { userlandUserOrganizationMembershipId: 'om_1' } }, + environmentId: 'env_profile', + }); + }); + + it('deactivate --json emits { deactivated }', async () => { setOutputMode('json'); + respondWith({ + deactivateUserlandUserOrganizationMembership: { + __typename: 'UserlandUserOrganizationMembershipDeactivated', + }, + }); + await runMembershipDeactivate('om_1', { yes: true }); + expect(JSON.parse(consoleOutput[0])).toEqual({ deactivated: 'om_1' }); }); - afterEach(() => { - setOutputMode('human'); + it('reactivate needs no flag and sends the membership ID input', async () => { + setInteractionMode({ mode: 'ci', source: 'ci_env' }); + respondWith({ + reactivateUserlandUserOrganizationMembership: { + __typename: 'UserlandUserOrganizationMembershipReactivated', + }, + }); + await runMembershipReactivate('om_1'); + expect(mockGraphqlRequest.mock.calls[1][1]).toEqual({ + token: 'tok_123', + variables: { input: { userlandUserOrganizationMembershipId: 'om_1' } }, + environmentId: 'env_profile', + }); }); - it('runMembershipGet outputs raw JSON', async () => { - mockSdk.userManagement.getOrganizationMembership.mockResolvedValue(mockMembership); - await runMembershipGet('om_123', 'sk_test'); - const output = JSON.parse(consoleOutput[0]); - expect(output.id).toBe('om_123'); - expect(output).not.toHaveProperty('status', 'ok'); + it('reactivate errors not_found on a missing membership', async () => { + respondWith({ + reactivateUserlandUserOrganizationMembership: { + __typename: 'UserlandUserOrganizationMembershipNotFound', + message: 'internal', + }, + }); + const err = await expectExit(runMembershipReactivate('om_missing'), 1); + expect(err.context?.errorCode).toBe('not_found'); }); - it('runMembershipList outputs JSON with data and listMetadata', async () => { - mockSdk.userManagement.listOrganizationMemberships.mockResolvedValue({ - data: [mockMembership], - listMetadata: { before: null, after: 'cursor_a' }, + it('reactivate --json emits { reactivated }', async () => { + setOutputMode('json'); + respondWith({ + reactivateUserlandUserOrganizationMembership: { + __typename: 'UserlandUserOrganizationMembershipReactivated', + }, }); - await runMembershipList({ org: 'org_789' }, 'sk_test'); - const output = JSON.parse(consoleOutput[0]); - expect(output.data).toHaveLength(1); - expect(output.data[0].id).toBe('om_123'); - expect(output.listMetadata.after).toBe('cursor_a'); + await runMembershipReactivate('om_1'); + expect(JSON.parse(consoleOutput[0])).toEqual({ reactivated: 'om_1' }); }); + }); - it('runMembershipList outputs empty data array for no results', async () => { - mockSdk.userManagement.listOrganizationMemberships.mockResolvedValue({ - data: [], - listMetadata: { before: null, after: null }, + describe('shared failure modes', () => { + it('exits auth-required (code 4) when not logged in', async () => { + mockRequireCommandToken.mockImplementation(() => { + throw new CliExit(4, { reason: 'auth_required', errorCode: 'auth_required' }); }); - await runMembershipList({ org: 'org_789' }, 'sk_test'); - const output = JSON.parse(consoleOutput[0]); - expect(output.data).toEqual([]); - expect(output.listMetadata).toBeDefined(); - }); - - it('runMembershipCreate outputs JSON success', async () => { - mockSdk.userManagement.createOrganizationMembership.mockResolvedValue(mockMembership); - await runMembershipCreate({ org: 'org_789', user: 'user_456' }, 'sk_test'); - const output = JSON.parse(consoleOutput[0]); - expect(output.status).toBe('ok'); - expect(output.message).toBe('Created membership'); - expect(output.data.id).toBe('om_123'); - }); - - it('runMembershipUpdate outputs JSON success', async () => { - mockSdk.userManagement.updateOrganizationMembership.mockResolvedValue(mockMembership); - await runMembershipUpdate('om_123', 'admin', 'sk_test'); - const output = JSON.parse(consoleOutput[0]); - expect(output.status).toBe('ok'); - expect(output.data.id).toBe('om_123'); - }); - - it('runMembershipDelete outputs JSON success', async () => { - mockSdk.userManagement.deleteOrganizationMembership.mockResolvedValue(undefined); - await runMembershipDelete('om_123', 'sk_test'); - const output = JSON.parse(consoleOutput[0]); - expect(output.status).toBe('ok'); - expect(output.data.id).toBe('om_123'); - }); - - it('runMembershipDeactivate outputs JSON success', async () => { - const deactivated = { ...mockMembership, status: 'inactive' }; - mockSdk.userManagement.deactivateOrganizationMembership.mockResolvedValue(deactivated); - await runMembershipDeactivate('om_123', 'sk_test'); - const output = JSON.parse(consoleOutput[0]); - expect(output.status).toBe('ok'); - expect(output.message).toBe('Deactivated membership'); - }); - - it('runMembershipReactivate outputs JSON success', async () => { - mockSdk.userManagement.reactivateOrganizationMembership.mockResolvedValue(mockMembership); - await runMembershipReactivate('om_123', 'sk_test'); - const output = JSON.parse(consoleOutput[0]); - expect(output.status).toBe('ok'); - expect(output.message).toBe('Reactivated membership'); + await expectExit(runMembershipList({ user: 'user_1' }), 4); + expect(mockGraphqlRequest).not.toHaveBeenCalled(); + }); + + it('surfaces the gated-capability case on a 403 without naming GraphQL', async () => { + const consoleErrors: string[] = []; + vi.spyOn(console, 'error').mockImplementation((...args: unknown[]) => { + consoleErrors.push(args.map(String).join(' ')); + }); + mockGraphqlRequest.mockRejectedValue( + new DashboardGraphqlError('The dashboard GraphQL API rejected this session (HTTP 403).', 'forbidden', 403), + ); + await expectExit(runMembershipList({ user: 'user_1' }), 1); + const err = consoleErrors.join('\n'); + expect(err).toMatch(/account-plane capability/i); + expect(err).not.toMatch(/graphql/i); }); }); }); diff --git a/src/commands/membership.ts b/src/commands/membership.ts index 4e223421..f463e6b1 100644 --- a/src/commands/membership.ts +++ b/src/commands/membership.ts @@ -1,12 +1,130 @@ +/** + * `workos membership` — organization-membership lifecycle on the dashboard + * account plane. + * + * Migrated from the API-key REST SDK (graphql-resource-migration Phase 4): the + * subcommand surface (list/get/create/update/delete/deactivate/reactivate) is + * unchanged, but every operation now runs catalog-backed dashboard operations + * with the user's OAuth bearer. Output shapes are new curated shapes (approved + * breaking change); the authoritative examples live in `membership.spec.ts`. + * + * Backend divergences from REST (all loud, none faked): + * - `list` is backed by two operations: the by-user operation filters only by + * user and has no pagination; the by-org path supports pagination and + * ordering. Combining `--org` and `--user` is no longer supported. + * - `delete ` is keyed server-side by organization + user, so the handler + * first resolves the membership by ID (one extra read) to preserve the + * frozen `delete ` grammar. + * - `--role` takes a role ID (role_*), not a role slug. + * + * Safety posture per the manifest: `delete` is destructive → + * `confirmDestructive` (prompt, or --yes); `update` (privilege change) and + * `deactivate` (access removal) are `require-flag` → non-interactive callers + * must pass --yes. + */ + import chalk from 'chalk'; -import { createWorkOSClient } from '../lib/workos-client.js'; +import { requireCommandToken } from '../lib/command-auth.js'; +import { dashboardGraphqlRequest } from '../lib/dashboard-graphql.js'; +import { resolveEnvironmentTarget } from '../lib/environment-target.js'; +import { getOperation, resolveExecutableDocument, reportDashboardError } from '../catalog/operation.js'; +import { confirmDestructive, requireConfirmationFlag } from '../catalog/confirm.js'; +import { isJsonMode, outputJson, outputSuccess, exitWithError } from '../utils/output.js'; import { formatTable } from '../utils/table.js'; -import { outputSuccess, outputJson, isJsonMode, exitWithError } from '../utils/output.js'; -import { createApiErrorHandler } from '../lib/api-error-handler.js'; -const handleApiError = createApiErrorHandler('Membership'); +/** Map the CLI `--order asc|desc` flag onto the catalog's pagination enum. */ +function normalizeOrder(order: string | undefined): 'Asc' | 'Desc' | undefined { + if (order === undefined) return undefined; + const lower = order.toLowerCase(); + if (lower === 'asc') return 'Asc'; + if (lower === 'desc') return 'Desc'; + exitWithError({ code: 'invalid_argument', message: `Invalid --order "${order}". Allowed values: asc, desc.` }); +} + +/** + * `role`/`roles` come back as unselected scalars on the membership record but + * as `{ id, name }` objects on the by-org identity path — normalize both to a + * plain string so the curated shape is stable. + */ +function normalizeRoleValue(role: unknown): string | null { + if (role == null) return null; + if (typeof role === 'string') return role; + if (typeof role === 'object') { + const candidate = role as { slug?: unknown; name?: unknown; id?: unknown }; + for (const key of ['slug', 'name', 'id'] as const) { + if (typeof candidate[key] === 'string') return candidate[key] as string; + } + } + return null; +} + +function normalizeRolesValue(roles: unknown): string[] { + if (!Array.isArray(roles)) return []; + return roles.map(normalizeRoleValue).filter((role): role is string => role !== null); +} + +interface MembershipNode { + id: string; + type?: string | null; + status?: string | null; + organizationId?: string | null; + userlandUserId?: string | null; + directoryUserId?: string | null; + role?: unknown; + roles?: unknown; + createdAt?: string | null; + updatedAt?: string | null; +} + +/** + * The curated membership shape — the `--json` contract for every subcommand. + * camelCase, stable keys, no internal fields; see membership.spec.ts for the + * authoritative example. + */ +function shapeMembership(membership: MembershipNode) { + return { + id: membership.id, + userId: membership.userlandUserId ?? null, + organizationId: membership.organizationId ?? null, + status: membership.status ?? null, + type: membership.type ?? null, + role: normalizeRoleValue(membership.role), + roles: normalizeRolesValue(membership.roles), + directoryUserId: membership.directoryUserId ?? null, + createdAt: membership.createdAt ?? null, + updatedAt: membership.updatedAt ?? null, + }; +} + +type ShapedMembership = ReturnType; + +function renderMembershipTable(memberships: ShapedMembership[]): void { + const rows = memberships.map((m) => [ + m.id, + m.userId ?? chalk.dim('-'), + m.organizationId ?? chalk.dim('-'), + m.role ?? (m.roles.length > 0 ? m.roles.join(', ') : chalk.dim('-')), + m.status ?? chalk.dim('-'), + m.createdAt ?? chalk.dim('-'), + ]); + console.log( + formatTable( + [ + { header: 'ID' }, + { header: 'User ID' }, + { header: 'Org ID' }, + { header: 'Role' }, + { header: 'Status' }, + { header: 'Created' }, + ], + rows, + ), + ); +} export interface MembershipListOptions { + /** `--environment-id` override; defaults from the active profile. */ + environmentId?: string; org?: string; user?: string; limit?: number; @@ -15,159 +133,534 @@ export interface MembershipListOptions { order?: string; } -export async function runMembershipList( - options: MembershipListOptions, - apiKey: string, - baseUrl?: string, -): Promise { +interface IdentityNode { + id: string; + status?: string | null; + directoryUserId?: string | null; + role?: unknown; + roles?: unknown; + organization?: { id: string; name: string | null } | null; + createdAt?: string | null; + updatedAt?: string | null; +} + +export async function runMembershipList(options: MembershipListOptions = {}): Promise { if (!options.org && !options.user) { exitWithError({ - code: 'missing_args', - message: 'At least one of --org or --user is required.', + code: 'missing_argument', + message: 'One of --org or --user is required.', + }); + } + if (options.org && options.user) { + exitWithError({ + code: 'invalid_argument', + message: 'Pass either --org or --user, not both.', }); } - const client = createWorkOSClient(apiKey, baseUrl); + if (options.user) { + // The by-user operation supports no pagination or ordering — refuse the + // flags loudly rather than silently ignoring them. + const unsupported = (['limit', 'before', 'after', 'order'] as const).filter( + (flag) => options[flag] !== undefined, + ); + if (unsupported.length > 0) { + exitWithError({ + code: 'invalid_argument', + message: `${unsupported.map((flag) => `--${flag}`).join(', ')} ${ + unsupported.length === 1 ? 'is' : 'are' + } only supported with --org. Listing by user returns all memberships.`, + }); + } + await listMembershipsByUser(options.user, options); + return; + } + await listMembershipsByOrg(options.org as string, options); +} + +async function listMembershipsByUser(userId: string, options: MembershipListOptions): Promise { + const token = await requireCommandToken(); + const op = getOperation('userlandUserOrganizationMemberships'); + + // Environment-scoped read: the op takes only the user ID, but the target + // still rides as the environment header. + const { environmentId } = await resolveEnvironmentTarget(token, { + flagValue: options.environmentId, + forMutation: op.kind === 'mutation', + }); + + let data: { + userlandUserOrganizationMemberships: { organizationMemberships: MembershipNode[] } | null; + }; try { - const result = await client.sdk.userManagement.listOrganizationMemberships({ - ...(options.org && { organizationId: options.org }), - ...(options.user && { userId: options.user }), - limit: options.limit, - before: options.before, - after: options.after, - order: options.order as 'asc' | 'desc' | undefined, - } as Parameters[0]); - - if (isJsonMode()) { - outputJson({ data: result.data, listMetadata: result.listMetadata }); - return; - } + data = await dashboardGraphqlRequest(resolveExecutableDocument(op), { + token, + variables: { userlandUserId: userId }, + environmentId, + }); + } catch (error) { + reportDashboardError(error); + } - if (result.data.length === 0) { - console.log('No memberships found.'); - return; - } + const memberships = (data.userlandUserOrganizationMemberships?.organizationMemberships ?? []).map(shapeMembership); - const rows = result.data.map((m) => [ - m.id, - m.userId, - m.organizationId, - m.role?.slug ?? chalk.dim('-'), - m.status, - m.createdAt, - ]); - - console.log( - formatTable( - [ - { header: 'ID' }, - { header: 'User ID' }, - { header: 'Org ID' }, - { header: 'Role' }, - { header: 'Status' }, - { header: 'Created' }, - ], - rows, - ), - ); + if (isJsonMode()) { + outputJson({ memberships, pagination: { before: null, after: null } }); + return; + } + if (memberships.length === 0) { + console.log('No memberships found.'); + return; + } + renderMembershipTable(memberships); +} - const { before, after } = result.listMetadata; - if (before && after) { - console.log(chalk.dim(`Before: ${before} After: ${after}`)); - } else if (before) { - console.log(chalk.dim(`Before: ${before}`)); - } else if (after) { - console.log(chalk.dim(`After: ${after}`)); - } +async function listMembershipsByOrg(orgId: string, options: MembershipListOptions): Promise { + const order = normalizeOrder(options.order); + const token = await requireCommandToken(); + const op = getOperation('userlandUsersByOrg'); + + const { environmentId } = await resolveEnvironmentTarget(token, { + flagValue: options.environmentId, + forMutation: op.kind === 'mutation', + }); + + let data: { + organization: { + userlandUsers: { + data: Array<{ id: string; identities?: { data: IdentityNode[] } | null }>; + listMetadata: { before: string | null; after: string | null }; + } | null; + } | null; + }; + try { + data = await dashboardGraphqlRequest(resolveExecutableDocument(op), { + token, + variables: { + organizationId: orgId, + ...(options.limit !== undefined ? { limit: options.limit } : {}), + ...(options.before ? { before: options.before } : {}), + ...(options.after ? { after: options.after } : {}), + ...(order ? { order } : {}), + }, + environmentId, + }); } catch (error) { - handleApiError(error); + reportDashboardError(error); + } + + if (!data.organization) { + exitWithError({ code: 'not_found', message: `Organization "${orgId}" was not found in this environment.` }); + } + + // The by-org path returns the organization's users, each carrying its + // org-filtered membership records (identities) — flatten those into the same + // curated membership shape as the by-user path. + const users = data.organization.userlandUsers?.data ?? []; + const memberships = users.flatMap((user) => + (user.identities?.data ?? []).map((identity) => + shapeMembership({ + id: identity.id, + status: identity.status, + organizationId: identity.organization?.id ?? orgId, + userlandUserId: user.id, + directoryUserId: identity.directoryUserId, + role: identity.role, + roles: identity.roles, + createdAt: identity.createdAt, + updatedAt: identity.updatedAt, + }), + ), + ); + const pagination = { + before: data.organization.userlandUsers?.listMetadata?.before ?? null, + after: data.organization.userlandUsers?.listMetadata?.after ?? null, + }; + + if (isJsonMode()) { + outputJson({ memberships, pagination }); + return; + } + if (memberships.length === 0) { + console.log('No memberships found.'); + return; } + renderMembershipTable(memberships); + + const { before, after } = pagination; + if (before && after) { + console.log(chalk.dim(`Before: ${before} After: ${after}`)); + } else if (before) { + console.log(chalk.dim(`Before: ${before}`)); + } else if (after) { + console.log(chalk.dim(`After: ${after}`)); + } +} + +export interface MembershipGetOptions { + /** `--environment-id` override; defaults from the active profile. */ + environmentId?: string; } -export async function runMembershipGet(id: string, apiKey: string, baseUrl?: string): Promise { - const client = createWorkOSClient(apiKey, baseUrl); +export async function runMembershipGet(id: string, options: MembershipGetOptions = {}): Promise { + const token = await requireCommandToken(); + const op = getOperation('userlandUserOrganizationMembership'); + const { environmentId } = await resolveEnvironmentTarget(token, { + flagValue: options.environmentId, + forMutation: op.kind === 'mutation', + }); + + let data: { userlandUserOrganizationMembership: MembershipNode | null }; try { - const membership = await client.sdk.userManagement.getOrganizationMembership(id); - outputJson(membership); + data = await dashboardGraphqlRequest(resolveExecutableDocument(op), { + token, + variables: { id }, + environmentId, + }); } catch (error) { - handleApiError(error); + reportDashboardError(error); + } + + if (!data.userlandUserOrganizationMembership) { + exitWithError({ code: 'not_found', message: `Membership "${id}" was not found in this environment.` }); } + + const membership = shapeMembership(data.userlandUserOrganizationMembership); + if (isJsonMode()) { + outputJson({ membership }); + return; + } + + const fields: Array<[string, unknown]> = [ + ['ID', membership.id], + ['User ID', membership.userId], + ['Org ID', membership.organizationId], + ['Role', membership.role ?? (membership.roles.length > 0 ? membership.roles.join(', ') : null)], + ['Status', membership.status], + ['Created', membership.createdAt], + ]; + for (const [label, value] of fields) { + if (value === null || value === undefined || value === '') continue; + console.log(`${chalk.bold(label)}: ${String(value)}`); + } + console.log(chalk.dim('Run with --json for the full record.')); } export interface MembershipCreateOptions { + /** `--environment-id` override; defaults from the active profile. */ + environmentId?: string; org: string; user: string; + /** Role ID (role_*) to assign on the new membership. */ role?: string; } -export async function runMembershipCreate( - options: MembershipCreateOptions, - apiKey: string, - baseUrl?: string, -): Promise { - const client = createWorkOSClient(apiKey, baseUrl); +export async function runMembershipCreate(options: MembershipCreateOptions): Promise { + const token = await requireCommandToken(); + const op = getOperation('addUserlandUserToOrg'); + // Environment-scoped mutation: pre-validated resolved target as header. + const { environmentId } = await resolveEnvironmentTarget(token, { + flagValue: options.environmentId, + forMutation: op.kind === 'mutation', + }); + + let data: { + addUserlandUserToOrganization: + | { __typename: 'UserlandUserAddedToOrganization' } + | { __typename: 'OrganizationNotFound'; organizationId: string } + | { __typename: 'UserlandUserNotFound'; userlandUserId: string } + | { __typename: 'UserlandUserAlreadyInvited'; userlandUserId: string; organizationId: string } + | { __typename: string }; + }; try { - const membership = await client.sdk.userManagement.createOrganizationMembership({ - organizationId: options.org, - userId: options.user, - ...(options.role && { roleSlug: options.role }), + data = await dashboardGraphqlRequest(resolveExecutableDocument(op), { + token, + variables: { + input: { + organizationId: options.org, + userlandUserId: options.user, + ...(options.role ? { roleId: options.role } : {}), + }, + }, + environmentId, }); - outputSuccess('Created membership', membership); } catch (error) { - handleApiError(error); + reportDashboardError(error); + } + + const result = data.addUserlandUserToOrganization; + if (result.__typename === 'OrganizationNotFound') { + exitWithError({ + code: 'not_found', + message: `Organization "${options.org}" was not found in this environment.`, + }); + } + if (result.__typename === 'UserlandUserNotFound') { + exitWithError({ code: 'not_found', message: `User "${options.user}" was not found in this environment.` }); + } + if (result.__typename === 'UserlandUserAlreadyInvited') { + exitWithError({ + code: 'already_invited', + message: `User "${options.user}" already has a pending invitation to organization "${options.org}".`, + }); + } + if (result.__typename !== 'UserlandUserAddedToOrganization') { + exitWithError({ + code: 'unexpected_result', + message: `Could not add user "${options.user}" to organization "${options.org}".`, + }); + } + + // The success variant carries no membership record, so the output reports the + // pair that was linked rather than inventing one. + if (isJsonMode()) { + outputJson({ added: { organizationId: options.org, userId: options.user } }); + return; } + outputSuccess(`Added user ${chalk.bold(options.user)} to organization ${chalk.bold(options.org)}`); +} + +export interface MembershipUpdateOptions { + /** `--environment-id` override; defaults from the active profile. */ + environmentId?: string; + /** Role ID (role_*) to assign. */ + role?: string; + yes?: boolean; + json?: boolean; } -export async function runMembershipUpdate( - id: string, - role: string | undefined, - apiKey: string, - baseUrl?: string, -): Promise { - const client = createWorkOSClient(apiKey, baseUrl); +export async function runMembershipUpdate(id: string, options: MembershipUpdateOptions = {}): Promise { + if (!options.role) { + exitWithError({ code: 'missing_argument', message: 'Nothing to update. Pass --role with the new role ID.' }); + } + + // require-flag: a privilege change; non-interactive callers must pass --yes. + await requireConfirmationFlag(options, { action: `change the role on membership ${id}` }); + const token = await requireCommandToken(); + const op = getOperation('updateRoleOnOrganizationMembership'); + + // Environment-scoped mutation: pre-validated resolved target as header. + const { environmentId } = await resolveEnvironmentTarget(token, { + flagValue: options.environmentId, + forMutation: op.kind === 'mutation', + }); + + let data: { + updateRoleOnOrganizationMembership: + | { __typename: 'RoleOnOrganizationMembershipUpdated'; organizationMembership: MembershipNode } + | { __typename: 'RoleNotFound'; roleId: string } + | { __typename: 'UserlandUserOrganizationMembershipNotFound'; message: string } + | { __typename: string }; + }; try { - const membership = await client.sdk.userManagement.updateOrganizationMembership(id, { - ...(role && { roleSlug: role }), + data = await dashboardGraphqlRequest(resolveExecutableDocument(op), { + token, + variables: { input: { organizationMembershipId: id, roleId: options.role } }, + environmentId, }); - outputSuccess('Updated membership', membership); } catch (error) { - handleApiError(error); + reportDashboardError(error); + } + + const result = data.updateRoleOnOrganizationMembership; + if (result.__typename === 'RoleNotFound') { + exitWithError({ + code: 'not_found', + message: `Role "${(result as { roleId: string }).roleId}" was not found in this environment.`, + }); + } + if (result.__typename === 'UserlandUserOrganizationMembershipNotFound') { + exitWithError({ code: 'not_found', message: `Membership "${id}" was not found in this environment.` }); + } + if (result.__typename !== 'RoleOnOrganizationMembershipUpdated' || !('organizationMembership' in result)) { + exitWithError({ code: 'unexpected_result', message: `Could not update membership "${id}".` }); } + + const membership = shapeMembership((result as { organizationMembership: MembershipNode }).organizationMembership); + if (isJsonMode()) { + outputJson({ membership }); + return; + } + outputSuccess(`Updated membership ${chalk.bold(membership.id)}`); +} + +export interface MembershipDeleteOptions { + /** `--environment-id` override; defaults from the active profile. */ + environmentId?: string; + yes?: boolean; + json?: boolean; } -export async function runMembershipDelete(id: string, apiKey: string, baseUrl?: string): Promise { - const client = createWorkOSClient(apiKey, baseUrl); +export async function runMembershipDelete(id: string, options: MembershipDeleteOptions = {}): Promise { + // Destructive per the manifest: removes the user's access to the organization. + await confirmDestructive(options, { + action: `delete membership ${id} — this removes the user from the organization`, + }); + + const token = await requireCommandToken(); + const removeOp = getOperation('removeMemberFromOrganization'); + + // Environment-scoped mutation: pre-validated resolved target as header. + const { environmentId } = await resolveEnvironmentTarget(token, { + flagValue: options.environmentId, + forMutation: removeOp.kind === 'mutation', + }); + + // The remove operation is keyed by organization + user, not by membership ID, + // so resolve the membership first to preserve the frozen `delete ` grammar. + const getOp = getOperation('userlandUserOrganizationMembership'); + let lookup: { userlandUserOrganizationMembership: MembershipNode | null }; + try { + lookup = await dashboardGraphqlRequest(resolveExecutableDocument(getOp), { + token, + variables: { id }, + environmentId, + }); + } catch (error) { + reportDashboardError(error); + } + const membership = lookup.userlandUserOrganizationMembership; + if (!membership || !membership.organizationId || !membership.userlandUserId) { + exitWithError({ code: 'not_found', message: `Membership "${id}" was not found in this environment.` }); + } + + let data: { + removeUserlandUserFromOrganization: + | { __typename: 'UserlandUserRemovedFromOrganization' } + | { __typename: 'OrganizationNotFound'; organizationId: string } + | { __typename: 'UserlandUserNotFound'; userlandUserId: string } + | { __typename: 'UserlandUserOrganizationMembershipNotFound'; message: string } + | { __typename: string }; + }; try { - await client.sdk.userManagement.deleteOrganizationMembership(id); - outputSuccess('Deleted membership', { id }); + data = await dashboardGraphqlRequest(resolveExecutableDocument(removeOp), { + token, + variables: { + input: { organizationId: membership.organizationId, userlandUserId: membership.userlandUserId }, + }, + environmentId, + }); } catch (error) { - handleApiError(error); + reportDashboardError(error); + } + + const result = data.removeUserlandUserFromOrganization; + if ( + result.__typename === 'OrganizationNotFound' || + result.__typename === 'UserlandUserNotFound' || + result.__typename === 'UserlandUserOrganizationMembershipNotFound' + ) { + exitWithError({ code: 'not_found', message: `Membership "${id}" was not found in this environment.` }); + } + if (result.__typename !== 'UserlandUserRemovedFromOrganization') { + exitWithError({ code: 'unexpected_result', message: `Could not delete membership "${id}".` }); + } + + if (isJsonMode()) { + outputJson({ deleted: id }); + return; } + outputSuccess(`Deleted membership ${chalk.bold(id)}`); } -export async function runMembershipDeactivate(id: string, apiKey: string, baseUrl?: string): Promise { - const client = createWorkOSClient(apiKey, baseUrl); +export interface MembershipDeactivateOptions { + /** `--environment-id` override; defaults from the active profile. */ + environmentId?: string; + yes?: boolean; + json?: boolean; +} + +export async function runMembershipDeactivate(id: string, options: MembershipDeactivateOptions = {}): Promise { + // require-flag: an access removal; non-interactive callers must pass --yes. + await requireConfirmationFlag(options, { action: `deactivate membership ${id}` }); + + const token = await requireCommandToken(); + const op = getOperation('deactivateOrganizationMembership'); + + // Environment-scoped mutation: pre-validated resolved target as header. + const { environmentId } = await resolveEnvironmentTarget(token, { + flagValue: options.environmentId, + forMutation: op.kind === 'mutation', + }); + let data: { + deactivateUserlandUserOrganizationMembership: + | { __typename: 'UserlandUserOrganizationMembershipDeactivated' } + | { __typename: 'UserlandUserOrganizationMembershipNotFound'; message: string } + | { __typename: string }; + }; try { - const membership = await client.sdk.userManagement.deactivateOrganizationMembership(id); - outputSuccess('Deactivated membership', membership); + data = await dashboardGraphqlRequest(resolveExecutableDocument(op), { + token, + variables: { input: { userlandUserOrganizationMembershipId: id } }, + environmentId, + }); } catch (error) { - handleApiError(error); + reportDashboardError(error); + } + + const result = data.deactivateUserlandUserOrganizationMembership; + if (result.__typename === 'UserlandUserOrganizationMembershipNotFound') { + exitWithError({ code: 'not_found', message: `Membership "${id}" was not found in this environment.` }); + } + if (result.__typename !== 'UserlandUserOrganizationMembershipDeactivated') { + exitWithError({ code: 'unexpected_result', message: `Could not deactivate membership "${id}".` }); } + + if (isJsonMode()) { + outputJson({ deactivated: id }); + return; + } + outputSuccess(`Deactivated membership ${chalk.bold(id)}`); +} + +export interface MembershipReactivateOptions { + /** `--environment-id` override; defaults from the active profile. */ + environmentId?: string; } -export async function runMembershipReactivate(id: string, apiKey: string, baseUrl?: string): Promise { - const client = createWorkOSClient(apiKey, baseUrl); +export async function runMembershipReactivate(id: string, options: MembershipReactivateOptions = {}): Promise { + const token = await requireCommandToken(); + const op = getOperation('reactivateOrganizationMembership'); + // Environment-scoped mutation: pre-validated resolved target as header. + const { environmentId } = await resolveEnvironmentTarget(token, { + flagValue: options.environmentId, + forMutation: op.kind === 'mutation', + }); + + let data: { + reactivateUserlandUserOrganizationMembership: + | { __typename: 'UserlandUserOrganizationMembershipReactivated' } + | { __typename: 'UserlandUserOrganizationMembershipNotFound'; message: string } + | { __typename: string }; + }; try { - const membership = await client.sdk.userManagement.reactivateOrganizationMembership(id); - outputSuccess('Reactivated membership', membership); + data = await dashboardGraphqlRequest(resolveExecutableDocument(op), { + token, + variables: { input: { userlandUserOrganizationMembershipId: id } }, + environmentId, + }); } catch (error) { - handleApiError(error); + reportDashboardError(error); + } + + const result = data.reactivateUserlandUserOrganizationMembership; + if (result.__typename === 'UserlandUserOrganizationMembershipNotFound') { + exitWithError({ code: 'not_found', message: `Membership "${id}" was not found in this environment.` }); + } + if (result.__typename !== 'UserlandUserOrganizationMembershipReactivated') { + exitWithError({ code: 'unexpected_result', message: `Could not reactivate membership "${id}".` }); + } + + if (isJsonMode()) { + outputJson({ reactivated: id }); + return; } + outputSuccess(`Reactivated membership ${chalk.bold(id)}`); } diff --git a/src/commands/session.spec.ts b/src/commands/session.spec.ts index 969e6846..ea21bb71 100644 --- a/src/commands/session.spec.ts +++ b/src/commands/session.spec.ts @@ -1,39 +1,132 @@ import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'; -// Mock the unified client -const mockSdk = { - userManagement: { - listSessions: vi.fn(), - revokeSession: vi.fn(), - }, -}; +const mockRequireCommandToken = vi.fn(); +const mockGraphqlRequest = vi.fn(); +const mockConfirm = vi.fn(); +const mockIsCancel = vi.fn(() => false); +const mockGetActiveEnvironment = vi.fn(); +const mockGetConfig = vi.fn(); + +vi.mock('../lib/command-auth.js', async (importActual) => { + const actual = await importActual(); + return { + ...actual, + requireCommandToken: () => mockRequireCommandToken(), + }; +}); + +vi.mock('../lib/dashboard-graphql.js', async (importActual) => { + const actual = await importActual(); + return { + ...actual, + dashboardGraphqlRequest: (...args: unknown[]) => mockGraphqlRequest(...args), + }; +}); -vi.mock('../lib/workos-client.js', () => ({ - createWorkOSClient: () => ({ sdk: mockSdk }), +vi.mock('../utils/clack.js', () => ({ + default: { + confirm: (...args: unknown[]) => mockConfirm(...args), + isCancel: (...args: unknown[]) => mockIsCancel(...args), + select: vi.fn(), + }, })); -const { setOutputMode } = await import('../utils/output.js'); +// The environment-target resolver runs REAL against the mocked wire + config +// store, so mutation tests can assert the pre-validation fetch +// (teamProjectsV2) happens before the operation request. +vi.mock('../lib/config-store.js', async (importActual) => { + const actual = await importActual(); + return { + ...actual, + getActiveEnvironment: () => mockGetActiveEnvironment(), + getConfig: () => mockGetConfig(), + setProfileEnvironmentId: vi.fn(), + }; +}); +const { setOutputMode } = await import('../utils/output.js'); +const { resetInteractionModeForTests, setInteractionMode } = await import('../utils/interaction-mode.js'); +const { DashboardGraphqlError } = await import('../lib/dashboard-graphql.js'); +const { CliExit } = await import('../utils/cli-exit.js'); const { runSessionList, runSessionRevoke } = await import('./session.js'); -const mockSession = { - id: 'session_123', - userId: 'user_456', - ipAddress: '192.168.1.1', +const TEAM_ENVIRONMENTS_PAYLOAD = { + currentTeam: { + projectsV2: [{ environments: [{ id: 'env_profile', name: 'Sandbox', sandbox: true, clientId: null }] }], + }, +}; + +/** + * Route the wire mock by document: the environment resolver's pre-validation + * fetch (`teamProjectsV2`) gets the team's environments; everything else gets + * the operation payload. + */ +function respondWith(payload: unknown): void { + mockGraphqlRequest.mockImplementation(async (doc: unknown) => { + if (String(doc).includes('teamProjectsV2')) return TEAM_ENVIRONMENTS_PAYLOAD; + return payload; + }); +} + +async function expectExit(promise: Promise, code: number): Promise { + try { + await promise; + } catch (err) { + if (err instanceof CliExit) { + expect(err.exitCode).toBe(code); + return err; + } + throw err; + } + throw new Error(`Expected CliExit(${code}) but promise resolved`); +} + +/** The authoritative curated session JSON shape (documented contract). */ +const SESSION_SHAPE_KEYS = [ + 'id', + 'status', + 'createdAt', + 'updatedAt', + 'expiresAt', + 'endedAt', + 'ipAddress', + 'userAgent', + 'provider', + 'organization', + 'impersonator', +]; + +const SESSION_NODE = { + __typename: 'UserlandSession', + id: 'session_1', + createdAt: '2026-01-01T00:00:00.000Z', + updatedAt: '2026-01-02T00:00:00.000Z', + ipAddress: '203.0.113.7', userAgent: 'Mozilla/5.0', - authMethod: 'password', - status: 'active', - expiresAt: '2024-02-01T00:00:00Z', - endedAt: null, - createdAt: '2024-01-01T00:00:00Z', - updatedAt: '2024-01-01T00:00:00Z', + provider: 'Password', + impersonator: null, + impersonationReason: null, + organization: { id: 'org_1', name: 'FooCorp' }, + application: { id: 'app_1', name: 'Web' }, + state: { __typename: 'UserlandSessionIssued', expiresAt: '2026-03-01T00:00:00.000Z' }, }; -describe('session commands', () => { +describe('session command', () => { let consoleOutput: string[]; beforeEach(() => { vi.clearAllMocks(); + resetInteractionModeForTests(); + setOutputMode('human'); + mockRequireCommandToken.mockResolvedValue('tok_123'); + mockConfirm.mockReset(); + mockIsCancel.mockReset(); + mockIsCancel.mockReturnValue(false); + mockGetActiveEnvironment.mockReturnValue({ apiKey: 'sk_ignored', environmentId: 'env_profile' }); + mockGetConfig.mockReturnValue({ + activeEnvironment: 'default', + environments: { default: { apiKey: 'sk_ignored', environmentId: 'env_profile' } }, + }); consoleOutput = []; vi.spyOn(console, 'log').mockImplementation((...args: unknown[]) => { consoleOutput.push(args.map(String).join(' ')); @@ -43,97 +136,201 @@ describe('session commands', () => { afterEach(() => { vi.restoreAllMocks(); + resetInteractionModeForTests(); + setOutputMode('human'); }); - describe('runSessionList', () => { - it('lists sessions for a user', async () => { - mockSdk.userManagement.listSessions.mockResolvedValue({ - data: [mockSession], - listMetadata: { before: null, after: null }, + describe('list', () => { + it('lists sessions with the environment header (read: no pre-validation fetch)', async () => { + respondWith({ + userlandUser: { + id: 'user_1', + sessions: { data: [SESSION_NODE], listMetadata: { before: null, after: null } }, + }, + }); + await runSessionList('user_1', {}); + expect(mockGraphqlRequest).toHaveBeenCalledTimes(1); + expect(mockGraphqlRequest).toHaveBeenCalledWith(expect.stringContaining('userlandSessions'), { + token: 'tok_123', + variables: { userId: 'user_1' }, + environmentId: 'env_profile', + }); + const out = consoleOutput.join('\n'); + expect(out).toContain('session_1'); + expect(out).toContain('203.0.113.7'); + expect(out).toContain('active'); + }); + + it('maps pagination flags to catalog variables', async () => { + respondWith({ + userlandUser: { id: 'user_1', sessions: { data: [], listMetadata: { before: null, after: null } } }, + }); + await runSessionList('user_1', { limit: 5, after: 'cursor_a' }); + expect(mockGraphqlRequest).toHaveBeenCalledWith(expect.stringContaining('userlandSessions'), { + token: 'tok_123', + variables: { userId: 'user_1', limit: 5, after: 'cursor_a' }, + environmentId: 'env_profile', + }); + }); + + it('honors an --environment-id override', async () => { + respondWith({ + userlandUser: { id: 'user_1', sessions: { data: [], listMetadata: { before: null, after: null } } }, }); - await runSessionList('user_456', {}, 'sk_test'); - expect(mockSdk.userManagement.listSessions).toHaveBeenCalledWith('user_456', expect.any(Object)); - expect(consoleOutput.some((l) => l.includes('session_123'))).toBe(true); + await runSessionList('user_1', { environmentId: 'env_flag' }); + expect(mockGraphqlRequest).toHaveBeenCalledWith(expect.stringContaining('userlandSessions'), { + token: 'tok_123', + variables: { userId: 'user_1' }, + environmentId: 'env_flag', + }); + }); + + it('errors not_found when the user does not exist', async () => { + respondWith({ userlandUser: null }); + const err = await expectExit(runSessionList('user_missing', {}), 1); + expect(err.context?.errorCode).toBe('not_found'); }); it('handles empty results', async () => { - mockSdk.userManagement.listSessions.mockResolvedValue({ - data: [], - listMetadata: { before: null, after: null }, + respondWith({ + userlandUser: { id: 'user_1', sessions: { data: [], listMetadata: { before: null, after: null } } }, }); - await runSessionList('user_456', {}, 'sk_test'); + await runSessionList('user_1', {}); expect(consoleOutput.some((l) => l.includes('No sessions found'))).toBe(true); }); - it('shows pagination cursors', async () => { - mockSdk.userManagement.listSessions.mockResolvedValue({ - data: [mockSession], - listMetadata: { before: 'cursor_b', after: 'cursor_a' }, + it('--json emits the documented curated shape with derived status', async () => { + setOutputMode('json'); + respondWith({ + userlandUser: { + id: 'user_1', + sessions: { + data: [ + SESSION_NODE, + { + ...SESSION_NODE, + id: 'session_2', + state: { + __typename: 'UserlandSessionRevoked', + expiresAt: '2026-03-01T00:00:00.000Z', + endedAt: '2026-02-01T00:00:00.000Z', + }, + }, + ], + listMetadata: { before: null, after: 'cursor_a' }, + }, + }, + }); + await runSessionList('user_1', {}); + const raw = consoleOutput[0]; + expect(raw).not.toMatch(/graphql|userland|list_metadata/i); + const out = JSON.parse(raw); + expect(Object.keys(out)).toEqual(['sessions', 'pagination']); + expect(Object.keys(out.sessions[0])).toEqual(SESSION_SHAPE_KEYS); + expect(out.sessions[0]).toMatchObject({ + id: 'session_1', + status: 'active', + expiresAt: '2026-03-01T00:00:00.000Z', + endedAt: null, + organization: { id: 'org_1', name: 'FooCorp' }, + }); + expect(out.sessions[1]).toMatchObject({ + id: 'session_2', + status: 'revoked', + endedAt: '2026-02-01T00:00:00.000Z', }); - await runSessionList('user_456', {}, 'sk_test'); - expect(consoleOutput.some((l) => l.includes('cursor_b'))).toBe(true); - expect(consoleOutput.some((l) => l.includes('cursor_a'))).toBe(true); + expect(out.pagination).toEqual({ before: null, after: 'cursor_a' }); }); + }); + + describe('revoke (destructive)', () => { + const revoked = { revokeUserlandSession: { sessionId: 'session_1' } }; - it('displays user agent and IP in table', async () => { - mockSdk.userManagement.listSessions.mockResolvedValue({ - data: [mockSession], - listMetadata: { before: null, after: null }, + it('refuses in agent mode without --yes (exit 1, confirmation_required)', async () => { + setInteractionMode({ mode: 'agent', source: 'agent_env' }); + const err = await expectExit(runSessionRevoke('session_1', { yes: false }), 1); + expect(err.context?.errorCode).toBe('confirmation_required'); + expect(mockGraphqlRequest).not.toHaveBeenCalled(); + }); + + it('refuses in CI mode without --yes', async () => { + setInteractionMode({ mode: 'ci', source: 'ci_env' }); + const err = await expectExit(runSessionRevoke('session_1', {}), 1); + expect(err.context?.errorCode).toBe('confirmation_required'); + expect(mockGraphqlRequest).not.toHaveBeenCalled(); + }); + + it('proceeds non-interactively with --yes, pre-validating the environment first', async () => { + setInteractionMode({ mode: 'ci', source: 'ci_env' }); + respondWith(revoked); + await runSessionRevoke('session_1', { yes: true }); + // Mutation: the resolver fetches the team's environments BEFORE the op. + expect(String(mockGraphqlRequest.mock.calls[0][0])).toContain('teamProjectsV2'); + expect(String(mockGraphqlRequest.mock.calls[1][0])).toContain('revokeUserlandSession'); + expect(mockGraphqlRequest.mock.calls[1][1]).toEqual({ + token: 'tok_123', + variables: { input: { sessionId: 'session_1' } }, + environmentId: 'env_profile', }); - await runSessionList('user_456', {}, 'sk_test'); - expect(consoleOutput.some((l) => l.includes('Mozilla/5.0'))).toBe(true); - expect(consoleOutput.some((l) => l.includes('192.168.1.1'))).toBe(true); }); - }); - describe('runSessionRevoke', () => { - it('revokes session and prints confirmation', async () => { - mockSdk.userManagement.revokeSession.mockResolvedValue(undefined); - await runSessionRevoke('session_123', 'sk_test'); - expect(mockSdk.userManagement.revokeSession).toHaveBeenCalledWith({ sessionId: 'session_123' }); - expect(consoleOutput.some((l) => l.includes('Revoked session'))).toBe(true); - expect(consoleOutput.some((l) => l.includes('session_123'))).toBe(true); + it('prompts interactively and proceeds when confirmed', async () => { + setInteractionMode({ mode: 'human', source: 'default' }); + mockConfirm.mockResolvedValue(true); + respondWith(revoked); + await runSessionRevoke('session_1', { yes: false }); + expect(mockConfirm).toHaveBeenCalledOnce(); + expect(mockGraphqlRequest).toHaveBeenCalled(); }); - }); - describe('JSON output mode', () => { - beforeEach(() => { - setOutputMode('json'); + it('cancels (exit 2) when the interactive prompt is declined', async () => { + setInteractionMode({ mode: 'human', source: 'default' }); + mockConfirm.mockResolvedValue(false); + await expectExit(runSessionRevoke('session_1', { yes: false }), 2); + expect(mockGraphqlRequest).not.toHaveBeenCalled(); }); - afterEach(() => { - setOutputMode('human'); + it('errors revoke_failed when the result carries no session (unknown/miss variant)', async () => { + const consoleErrors: string[] = []; + vi.spyOn(console, 'error').mockImplementation((...args: unknown[]) => { + consoleErrors.push(args.map(String).join(' ')); + }); + respondWith({ revokeUserlandSession: {} }); + const err = await expectExit(runSessionRevoke('session_missing', { yes: true }), 1); + expect(err.context?.errorCode).toBe('revoke_failed'); + expect(consoleErrors.join('\n')).not.toMatch(/graphql|userland/i); }); - it('runSessionList outputs JSON with data and listMetadata', async () => { - mockSdk.userManagement.listSessions.mockResolvedValue({ - data: [mockSession], - listMetadata: { before: null, after: 'cursor_a' }, + it('--json emits { revoked }', async () => { + setInteractionMode({ mode: 'agent', source: 'agent_env' }); + setOutputMode('json'); + respondWith(revoked); + await runSessionRevoke('session_1', { yes: true }); + expect(JSON.parse(consoleOutput[0])).toEqual({ revoked: 'session_1' }); + }); + }); + + describe('shared failure modes', () => { + it('exits auth-required (code 4) when not logged in', async () => { + mockRequireCommandToken.mockImplementation(() => { + throw new CliExit(4, { reason: 'auth_required', errorCode: 'auth_required' }); }); - await runSessionList('user_456', {}, 'sk_test'); - const output = JSON.parse(consoleOutput[0]); - expect(output.data).toHaveLength(1); - expect(output.data[0].id).toBe('session_123'); - expect(output.listMetadata.after).toBe('cursor_a'); + await expectExit(runSessionList('user_1', {}), 4); + expect(mockGraphqlRequest).not.toHaveBeenCalled(); }); - it('runSessionList outputs empty data array for no results', async () => { - mockSdk.userManagement.listSessions.mockResolvedValue({ - data: [], - listMetadata: { before: null, after: null }, + it('surfaces the gated-capability case on a 403 without naming GraphQL', async () => { + const consoleErrors: string[] = []; + vi.spyOn(console, 'error').mockImplementation((...args: unknown[]) => { + consoleErrors.push(args.map(String).join(' ')); }); - await runSessionList('user_456', {}, 'sk_test'); - const output = JSON.parse(consoleOutput[0]); - expect(output.data).toEqual([]); - expect(output.listMetadata).toBeDefined(); - }); - - it('runSessionRevoke outputs JSON success', async () => { - mockSdk.userManagement.revokeSession.mockResolvedValue(undefined); - await runSessionRevoke('session_123', 'sk_test'); - const output = JSON.parse(consoleOutput[0]); - expect(output.status).toBe('ok'); - expect(output.data.id).toBe('session_123'); + mockGraphqlRequest.mockRejectedValue( + new DashboardGraphqlError('The dashboard GraphQL API rejected this session (HTTP 403).', 'forbidden', 403), + ); + await expectExit(runSessionList('user_1', {}), 1); + const err = consoleErrors.join('\n'); + expect(err).toMatch(/account-plane capability/i); + expect(err).not.toMatch(/graphql/i); }); }); }); diff --git a/src/commands/session.ts b/src/commands/session.ts index 684b0c9e..0197e61e 100644 --- a/src/commands/session.ts +++ b/src/commands/session.ts @@ -1,85 +1,232 @@ +/** + * `workos session` — AuthKit user-session inspection and revocation on the + * dashboard account plane. + * + * Migrated from the API-key REST SDK (graphql-resource-migration Phase 4): the + * subcommand surface (list/revoke) is unchanged, but both operations now run + * catalog-backed dashboard operations with the user's OAuth bearer. Output + * shapes are new curated shapes (approved breaking change); the authoritative + * examples live in `session.spec.ts`. + * + * Backend divergences from REST (all loud, none faked): + * - `list --order` has no backing variable and was dropped. + * - The catalog also exposes a revoke-ALL-sessions operation; it is + * deliberately NOT surfaced (the subcommand grammar is frozen). + * + * Safety posture per the manifest: `revoke` is destructive → + * `confirmDestructive` (prompt, or --yes). + */ + import chalk from 'chalk'; -import { createWorkOSClient } from '../lib/workos-client.js'; +import { requireCommandToken } from '../lib/command-auth.js'; +import { dashboardGraphqlRequest } from '../lib/dashboard-graphql.js'; +import { resolveEnvironmentTarget } from '../lib/environment-target.js'; +import { getOperation, resolveExecutableDocument, reportDashboardError } from '../catalog/operation.js'; +import { confirmDestructive } from '../catalog/confirm.js'; +import { isJsonMode, outputJson, outputSuccess, exitWithError } from '../utils/output.js'; import { formatTable } from '../utils/table.js'; -import { outputSuccess, outputJson, isJsonMode } from '../utils/output.js'; -import { createApiErrorHandler } from '../lib/api-error-handler.js'; -const handleApiError = createApiErrorHandler('Session'); +interface SessionStateNode { + __typename?: string; + expiresAt?: string | null; + endedAt?: string | null; +} + +interface SessionNode { + id: string; + createdAt?: string | null; + updatedAt?: string | null; + ipAddress?: string | null; + userAgent?: string | null; + provider?: string | null; + impersonator?: { id: string; email: string | null; firstName: string | null; lastName: string | null } | null; + impersonationReason?: string | null; + organization?: { id: string; name: string | null } | null; + application?: { id: string; name: string | null } | null; + state?: SessionStateNode | null; +} + +/** + * Map the state variant to a clean status word. Never echo the raw typename — + * it carries internal naming. + */ +const SESSION_STATUS: Record = { + UserlandSessionIssued: 'active', + UserlandSessionExpired: 'expired', + UserlandSessionRevoked: 'revoked', +}; + +/** + * The curated session shape — the `--json` contract for every subcommand. + * camelCase, stable keys, no internal fields; see session.spec.ts for the + * authoritative example. + */ +function shapeSession(session: SessionNode) { + const state = session.state ?? null; + return { + id: session.id, + status: state?.__typename ? (SESSION_STATUS[state.__typename] ?? null) : null, + createdAt: session.createdAt ?? null, + updatedAt: session.updatedAt ?? null, + expiresAt: state?.expiresAt ?? null, + endedAt: state?.endedAt ?? null, + ipAddress: session.ipAddress ?? null, + userAgent: session.userAgent ?? null, + provider: session.provider ?? null, + organization: session.organization + ? { id: session.organization.id, name: session.organization.name ?? null } + : null, + impersonator: session.impersonator + ? { + id: session.impersonator.id, + email: session.impersonator.email ?? null, + firstName: session.impersonator.firstName ?? null, + lastName: session.impersonator.lastName ?? null, + } + : null, + }; +} export interface SessionListOptions { + /** `--environment-id` override; defaults from the active profile. */ + environmentId?: string; limit?: number; before?: string; after?: string; - order?: string; } -export async function runSessionList( - userId: string, - options: SessionListOptions, - apiKey: string, - baseUrl?: string, -): Promise { - const client = createWorkOSClient(apiKey, baseUrl); +export async function runSessionList(userId: string, options: SessionListOptions = {}): Promise { + const token = await requireCommandToken(); + const op = getOperation('userlandSessions'); + + // Environment-scoped read: the op takes the user ID, and the target rides as + // the environment header. + const { environmentId } = await resolveEnvironmentTarget(token, { + flagValue: options.environmentId, + forMutation: op.kind === 'mutation', + }); + let data: { + userlandUser: { + id: string; + sessions: { + data: SessionNode[]; + listMetadata: { before: string | null; after: string | null }; + } | null; + } | null; + }; try { - const result = await client.sdk.userManagement.listSessions(userId, { - limit: options.limit, - before: options.before, - after: options.after, - order: options.order as 'asc' | 'desc' | undefined, + data = await dashboardGraphqlRequest(resolveExecutableDocument(op), { + token, + variables: { + userId, + ...(options.limit !== undefined ? { limit: options.limit } : {}), + ...(options.before ? { before: options.before } : {}), + ...(options.after ? { after: options.after } : {}), + }, + environmentId, }); - - if (isJsonMode()) { - outputJson({ data: result.data, listMetadata: result.listMetadata }); - return; - } - - if (result.data.length === 0) { - console.log('No sessions found.'); - return; - } - - const rows = result.data.map((s) => [ - s.id, - s.userAgent ?? chalk.dim('-'), - s.ipAddress ?? chalk.dim('-'), - s.createdAt, - s.expiresAt, - ]); - - console.log( - formatTable( - [ - { header: 'ID' }, - { header: 'User Agent' }, - { header: 'IP Address' }, - { header: 'Created' }, - { header: 'Expires' }, - ], - rows, - ), - ); - - const { before, after } = result.listMetadata; - if (before && after) { - console.log(chalk.dim(`Before: ${before} After: ${after}`)); - } else if (before) { - console.log(chalk.dim(`Before: ${before}`)); - } else if (after) { - console.log(chalk.dim(`After: ${after}`)); - } } catch (error) { - handleApiError(error); + reportDashboardError(error); + } + + if (!data.userlandUser) { + exitWithError({ code: 'not_found', message: `User "${userId}" was not found in this environment.` }); + } + + const sessions = (data.userlandUser.sessions?.data ?? []).map(shapeSession); + const pagination = { + before: data.userlandUser.sessions?.listMetadata?.before ?? null, + after: data.userlandUser.sessions?.listMetadata?.after ?? null, + }; + + if (isJsonMode()) { + outputJson({ sessions, pagination }); + return; + } + + if (sessions.length === 0) { + console.log('No sessions found.'); + return; + } + + const rows = sessions.map((s) => [ + s.id, + s.status ?? chalk.dim('-'), + s.userAgent ?? chalk.dim('-'), + s.ipAddress ?? chalk.dim('-'), + s.createdAt ?? chalk.dim('-'), + s.expiresAt ?? chalk.dim('-'), + ]); + console.log( + formatTable( + [ + { header: 'ID' }, + { header: 'Status' }, + { header: 'User Agent' }, + { header: 'IP Address' }, + { header: 'Created' }, + { header: 'Expires' }, + ], + rows, + ), + ); + + const { before, after } = pagination; + if (before && after) { + console.log(chalk.dim(`Before: ${before} After: ${after}`)); + } else if (before) { + console.log(chalk.dim(`Before: ${before}`)); + } else if (after) { + console.log(chalk.dim(`After: ${after}`)); } } -export async function runSessionRevoke(sessionId: string, apiKey: string, baseUrl?: string): Promise { - const client = createWorkOSClient(apiKey, baseUrl); +export interface SessionRevokeOptions { + /** `--environment-id` override; defaults from the active profile. */ + environmentId?: string; + yes?: boolean; + json?: boolean; +} + +export async function runSessionRevoke(sessionId: string, options: SessionRevokeOptions = {}): Promise { + // Destructive per the manifest: the session can no longer authenticate. + await confirmDestructive(options, { + action: `revoke session ${sessionId} — it can no longer be used to sign in`, + }); + + const token = await requireCommandToken(); + const op = getOperation('revokeUserlandSession'); + // Environment-scoped mutation: pre-validated resolved target as header. + const { environmentId } = await resolveEnvironmentTarget(token, { + flagValue: options.environmentId, + forMutation: op.kind === 'mutation', + }); + + // The operation's result selects only the success variant's sessionId (no + // typename), so success is detected by its presence. + let data: { revokeUserlandSession: { sessionId?: string } | null }; try { - await client.sdk.userManagement.revokeSession({ sessionId }); - outputSuccess('Revoked session', { id: sessionId }); + data = await dashboardGraphqlRequest(resolveExecutableDocument(op), { + token, + variables: { input: { sessionId } }, + environmentId, + }); } catch (error) { - handleApiError(error); + reportDashboardError(error); + } + + if (!data.revokeUserlandSession?.sessionId) { + exitWithError({ + code: 'revoke_failed', + message: `Could not revoke session "${sessionId}". It may not exist or may already be revoked.`, + }); + } + + if (isJsonMode()) { + outputJson({ revoked: sessionId }); + return; } + outputSuccess(`Revoked session ${chalk.bold(sessionId)}`); } diff --git a/src/utils/help-json.ts b/src/utils/help-json.ts index 51a4a585..686e452e 100644 --- a/src/utils/help-json.ts +++ b/src/utils/help-json.ts @@ -84,6 +84,19 @@ const confirmYesOpt: OptionSchema = { hidden: false, }; +// require-flag ops never prompt interactively (see `requireConfirmationFlag`); +// the flag only gates non-interactive callers, so the copy differs from the +// destructive-prompt `confirmYesOpt`. Matches `team change-role`/`set-mfa`. +const requireFlagYesOpt: OptionSchema = { + name: 'yes', + type: 'boolean', + description: 'Confirm in non-interactive mode', + required: false, + default: false, + alias: 'y', + hidden: false, +}; + const paginationOpts: OptionSchema[] = [ { name: 'limit', type: 'number', description: 'Maximum number of results to return', required: false, hidden: false }, { @@ -963,117 +976,222 @@ const commands: CommandSchema[] = [ }, { name: 'membership', - description: 'Manage organization memberships', - options: [insecureStorageOpt, apiKeyOpt], + description: 'Manage organization memberships in the active environment', + options: [insecureStorageOpt], commands: [ { name: 'list', - description: 'List memberships', + description: 'List memberships by user or organization', options: [ - { name: 'org', type: 'string', description: 'Filter by organization ID', required: false, hidden: false }, - { name: 'user', type: 'string', description: 'Filter by user ID', required: false, hidden: false }, - ...paginationOpts, + { name: 'org', type: 'string', description: 'Organization ID (org_*)', required: false, hidden: false }, + { name: 'user', type: 'string', description: 'User ID (user_*)', required: false, hidden: false }, + { + name: 'limit', + type: 'number', + description: 'Maximum number of results to return (--org only)', + required: false, + hidden: false, + }, + { + name: 'before', + type: 'string', + description: 'Pagination cursor for results before a specific item (--org only)', + required: false, + hidden: false, + }, + { + name: 'after', + type: 'string', + description: 'Pagination cursor for results after a specific item (--org only)', + required: false, + hidden: false, + }, + { + name: 'order', + type: 'string', + description: 'Sort order, asc or desc (--org only)', + required: false, + choices: ['asc', 'desc'], + hidden: false, + }, + environmentIdOpt, ], }, { name: 'get', - description: 'Get a membership', + description: 'Get a membership by its ID', positionals: [{ name: 'id', type: 'string', description: 'Membership ID', required: true }], + options: [environmentIdOpt], }, { name: 'create', - description: 'Create a membership', + description: 'Add a user to an organization', options: [ - { name: 'org', type: 'string', description: 'Organization ID', required: true, hidden: false }, - { name: 'user', type: 'string', description: 'User ID', required: true, hidden: false }, - { name: 'role', type: 'string', description: 'Role slug', required: false, hidden: false }, + { name: 'org', type: 'string', description: 'Organization ID (org_*)', required: true, hidden: false }, + { name: 'user', type: 'string', description: 'User ID (user_*)', required: true, hidden: false }, + { + name: 'role', + type: 'string', + description: 'Role ID (role_*) to assign', + required: false, + hidden: false, + }, + environmentIdOpt, ], }, { name: 'update', - description: 'Update a membership', + description: "Change a membership's role", positionals: [{ name: 'id', type: 'string', description: 'Membership ID', required: true }], - options: [{ name: 'role', type: 'string', description: 'New role slug', required: false, hidden: false }], + options: [ + { + name: 'role', + type: 'string', + description: 'Role ID (role_*) to assign', + required: false, + hidden: false, + }, + requireFlagYesOpt, + environmentIdOpt, + ], }, { name: 'delete', - description: 'Delete a membership', + description: 'Delete a membership (removes the user from the organization)', positionals: [{ name: 'id', type: 'string', description: 'Membership ID', required: true }], + options: [confirmYesOpt, environmentIdOpt], }, { name: 'deactivate', description: 'Deactivate a membership', positionals: [{ name: 'id', type: 'string', description: 'Membership ID', required: true }], + options: [requireFlagYesOpt, environmentIdOpt], }, { name: 'reactivate', description: 'Reactivate a membership', positionals: [{ name: 'id', type: 'string', description: 'Membership ID', required: true }], + options: [environmentIdOpt], }, ], }, { name: 'invitation', - description: 'Manage user invitations', - options: [insecureStorageOpt, apiKeyOpt], + description: 'Manage user invitations in the active environment', + options: [insecureStorageOpt], commands: [ { name: 'list', description: 'List invitations', options: [ - { name: 'org', type: 'string', description: 'Filter by organization ID', required: false, hidden: false }, - { name: 'email', type: 'string', description: 'Filter by email', required: false, hidden: false }, - ...paginationOpts, + { name: 'org', type: 'string', description: 'Organization ID (org_*)', required: false, hidden: false }, + { name: 'email', type: 'string', description: 'Filter by email (search)', required: false, hidden: false }, + { + name: 'limit', + type: 'number', + description: 'Maximum number of results to return', + required: false, + hidden: false, + }, + { + name: 'before', + type: 'string', + description: 'Pagination cursor for results before a specific item', + required: false, + hidden: false, + }, + { + name: 'after', + type: 'string', + description: 'Pagination cursor for results after a specific item', + required: false, + hidden: false, + }, + environmentIdOpt, ], }, { name: 'get', - description: 'Get an invitation', + description: 'Get an invitation (searches the most recent invitations)', positionals: [{ name: 'id', type: 'string', description: 'Invitation ID', required: true }], + options: [environmentIdOpt], }, { name: 'send', description: 'Send an invitation', options: [ - { name: 'email', type: 'string', description: 'Email address', required: true, hidden: false }, - { name: 'org', type: 'string', description: 'Organization ID', required: false, hidden: false }, - { name: 'role', type: 'string', description: 'Role slug', required: false, hidden: false }, + { name: 'email', type: 'string', description: 'Email address to invite', required: true, hidden: false }, + { name: 'org', type: 'string', description: 'Organization ID (org_*)', required: false, hidden: false }, + { + name: 'role', + type: 'string', + description: 'Role ID (role_*) to assign on acceptance', + required: false, + hidden: false, + }, { name: 'expires-in-days', type: 'number', - description: 'Expiration in days', + description: 'Expiration in days (default 7)', required: false, hidden: false, }, + environmentIdOpt, ], }, { name: 'revoke', description: 'Revoke an invitation', positionals: [{ name: 'id', type: 'string', description: 'Invitation ID', required: true }], + options: [confirmYesOpt, environmentIdOpt], }, { name: 'resend', description: 'Resend an invitation', positionals: [{ name: 'id', type: 'string', description: 'Invitation ID', required: true }], + options: [environmentIdOpt], }, ], }, { name: 'session', - description: 'Manage user sessions', - options: [insecureStorageOpt, apiKeyOpt], + description: 'Manage user sessions in the active environment', + options: [insecureStorageOpt], commands: [ { name: 'list', description: 'List sessions for a user', - positionals: [{ name: 'userId', type: 'string', description: 'User ID', required: true }], - options: [...paginationOpts], + positionals: [{ name: 'userId', type: 'string', description: 'User ID (user_*)', required: true }], + options: [ + { + name: 'limit', + type: 'number', + description: 'Maximum number of results to return', + required: false, + hidden: false, + }, + { + name: 'before', + type: 'string', + description: 'Pagination cursor for results before a specific item', + required: false, + hidden: false, + }, + { + name: 'after', + type: 'string', + description: 'Pagination cursor for results after a specific item', + required: false, + hidden: false, + }, + environmentIdOpt, + ], }, { name: 'revoke', description: 'Revoke a session', positionals: [{ name: 'sessionId', type: 'string', description: 'Session ID', required: true }], + options: [confirmYesOpt, environmentIdOpt], }, ], }, From f4b55e0c89f35f3c4abd7b2c75c8edb5b07c191b Mon Sep 17 00:00:00 2001 From: Nick Nisi Date: Wed, 22 Jul 2026 19:31:28 -0500 Subject: [PATCH 13/22] feat!: migrate role, permission, feature-flag to the dashboard account plane MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- src/bin.ts | 322 +++++------ src/catalog/curation.ts | 25 + src/catalog/manifest.ts | 157 ++++++ src/commands/feature-flag.spec.ts | 568 ++++++++++++++++---- src/commands/feature-flag.ts | 475 +++++++++++++--- src/commands/permission.spec.ts | 643 +++++++++++++++------- src/commands/permission.ts | 400 +++++++++++--- src/commands/role.spec.ts | 862 ++++++++++++++++++++++-------- src/commands/role.ts | 612 ++++++++++++++++----- src/utils/help-json.ts | 48 +- 10 files changed, 3138 insertions(+), 974 deletions(-) diff --git a/src/bin.ts b/src/bin.ts index 8fda397b..49db09af 100644 --- a/src/bin.ts +++ b/src/bin.ts @@ -117,6 +117,10 @@ if (hasJsonFlag && (rawArgs.includes('--help') || rawArgs.includes('-h'))) { const { buildCommandTree, extractHelpJsonCommand } = await import('./utils/help-json.js'); const command = extractHelpJsonCommand(rawArgs); outputJson(buildCommandTree(command)); + // Writes to a piped stdout are asynchronous: exiting immediately truncates + // anything past the 64KiB pipe buffer (the full command tree is larger). + // Queue an empty write and exit only once everything before it has flushed. + await new Promise((resolve) => process.stdout.write('', () => resolve())); process.exit(0); } @@ -1380,33 +1384,32 @@ async function runCli(): Promise { .command('role', 'Manage WorkOS roles (environment and organization-scoped)', (yargs) => { yargs.options({ ...insecureStorageOption, - 'api-key': { type: 'string' as const, describe: 'WorkOS API key' }, - org: { type: 'string' as const, describe: 'Organization ID (for org-scoped roles)' }, + org: { type: 'string' as const, describe: 'Organization ID (for organization roles)' }, }); registerSubcommand( yargs, 'list', 'List roles', - (y) => y, + (y) => + y.option('environment-id', { type: 'string', describe: 'Environment ID (defaults to the active environment)' }), async (argv) => { await applyInsecureStorage(argv.insecureStorage); - - const { resolveApiKey, resolveApiBaseUrl } = await import('./lib/api-key.js'); const { runRoleList } = await import('./commands/role.js'); - await runRoleList(argv.org, resolveApiKey({ apiKey: argv.apiKey }), resolveApiBaseUrl()); + await runRoleList({ org: argv.org, environmentId: argv.environmentId as string | undefined }); }, ); registerSubcommand( yargs, 'get ', 'Get a role by slug', - (y) => y.positional('slug', { type: 'string', demandOption: true }), + (y) => + y + .positional('slug', { type: 'string', demandOption: true }) + .option('environment-id', { type: 'string', describe: 'Environment ID (defaults to the active environment)' }), async (argv) => { await applyInsecureStorage(argv.insecureStorage); - - const { resolveApiKey, resolveApiBaseUrl } = await import('./lib/api-key.js'); const { runRoleGet } = await import('./commands/role.js'); - await runRoleGet(argv.slug, argv.org, resolveApiKey({ apiKey: argv.apiKey }), resolveApiBaseUrl()); + await runRoleGet(argv.slug, { org: argv.org, environmentId: argv.environmentId as string | undefined }); }, ); registerSubcommand( @@ -1418,18 +1421,21 @@ async function runCli(): Promise { slug: { type: 'string', demandOption: true, describe: 'Role slug' }, name: { type: 'string', demandOption: true, describe: 'Role name' }, description: { type: 'string', describe: 'Role description' }, + yes: { alias: 'y', type: 'boolean', default: false, describe: 'Confirm the change (required non-interactively)' }, + 'environment-id': { type: 'string', describe: 'Environment ID (defaults to the active environment)' }, }), async (argv) => { await applyInsecureStorage(argv.insecureStorage); - - const { resolveApiKey, resolveApiBaseUrl } = await import('./lib/api-key.js'); const { runRoleCreate } = await import('./commands/role.js'); - await runRoleCreate( - { slug: argv.slug, name: argv.name, description: argv.description }, - argv.org, - resolveApiKey({ apiKey: argv.apiKey }), - resolveApiBaseUrl(), - ); + await runRoleCreate({ + slug: argv.slug, + name: argv.name, + description: argv.description, + org: argv.org, + yes: argv.yes, + json: argv.json as boolean | undefined, + environmentId: argv.environmentId as string | undefined, + }); }, ); registerSubcommand( @@ -1437,34 +1443,46 @@ async function runCli(): Promise { 'update ', 'Update a role', (y) => - y - .positional('slug', { type: 'string', demandOption: true }) - .options({ name: { type: 'string' }, description: { type: 'string' } }), + y.positional('slug', { type: 'string', demandOption: true }).options({ + name: { type: 'string' }, + description: { type: 'string' }, + yes: { alias: 'y', type: 'boolean', default: false, describe: 'Confirm the change (required non-interactively)' }, + 'environment-id': { type: 'string', describe: 'Environment ID (defaults to the active environment)' }, + }), async (argv) => { await applyInsecureStorage(argv.insecureStorage); - - const { resolveApiKey, resolveApiBaseUrl } = await import('./lib/api-key.js'); const { runRoleUpdate } = await import('./commands/role.js'); - await runRoleUpdate( - argv.slug, - { name: argv.name, description: argv.description }, - argv.org, - resolveApiKey({ apiKey: argv.apiKey }), - resolveApiBaseUrl(), - ); + await runRoleUpdate(argv.slug, { + name: argv.name, + description: argv.description, + org: argv.org, + yes: argv.yes, + json: argv.json as boolean | undefined, + environmentId: argv.environmentId as string | undefined, + }); }, ); registerSubcommand( yargs, 'delete ', 'Delete an org-scoped role (requires --org)', - (y) => y.positional('slug', { type: 'string', demandOption: true }).demandOption('org'), + (y) => + y + .positional('slug', { type: 'string', demandOption: true }) + .demandOption('org') + .options({ + yes: { alias: 'y', type: 'boolean', default: false, describe: 'Skip the confirmation prompt' }, + 'environment-id': { type: 'string', describe: 'Environment ID (defaults to the active environment)' }, + }), async (argv) => { await applyInsecureStorage(argv.insecureStorage); - - const { resolveApiKey, resolveApiBaseUrl } = await import('./lib/api-key.js'); const { runRoleDelete } = await import('./commands/role.js'); - await runRoleDelete(argv.slug, argv.org!, resolveApiKey({ apiKey: argv.apiKey }), resolveApiBaseUrl()); + await runRoleDelete(argv.slug, { + org: argv.org!, + yes: argv.yes, + json: argv.json as boolean | undefined, + environmentId: argv.environmentId as string | undefined, + }); }, ); registerSubcommand( @@ -1472,23 +1490,20 @@ async function runCli(): Promise { 'set-permissions ', 'Set all permissions on a role (replaces existing)', (y) => - y.positional('slug', { type: 'string', demandOption: true }).option('permissions', { - type: 'string', - demandOption: true, - describe: 'Comma-separated permission slugs', + y.positional('slug', { type: 'string', demandOption: true }).options({ + permissions: { type: 'string', demandOption: true, describe: 'Comma-separated permission slugs' }, + yes: { alias: 'y', type: 'boolean', default: false, describe: 'Confirm the change (required non-interactively)' }, + 'environment-id': { type: 'string', describe: 'Environment ID (defaults to the active environment)' }, }), async (argv) => { await applyInsecureStorage(argv.insecureStorage); - - const { resolveApiKey, resolveApiBaseUrl } = await import('./lib/api-key.js'); const { runRoleSetPermissions } = await import('./commands/role.js'); - await runRoleSetPermissions( - argv.slug, - argv.permissions.split(','), - argv.org, - resolveApiKey({ apiKey: argv.apiKey }), - resolveApiBaseUrl(), - ); + await runRoleSetPermissions(argv.slug, argv.permissions.split(','), { + org: argv.org, + yes: argv.yes, + json: argv.json as boolean | undefined, + environmentId: argv.environmentId as string | undefined, + }); }, ); registerSubcommand( @@ -1498,19 +1513,20 @@ async function runCli(): Promise { (y) => y .positional('slug', { type: 'string', demandOption: true }) - .positional('permissionSlug', { type: 'string', demandOption: true }), + .positional('permissionSlug', { type: 'string', demandOption: true }) + .options({ + yes: { alias: 'y', type: 'boolean', default: false, describe: 'Confirm the change (required non-interactively)' }, + 'environment-id': { type: 'string', describe: 'Environment ID (defaults to the active environment)' }, + }), async (argv) => { await applyInsecureStorage(argv.insecureStorage); - - const { resolveApiKey, resolveApiBaseUrl } = await import('./lib/api-key.js'); const { runRoleAddPermission } = await import('./commands/role.js'); - await runRoleAddPermission( - argv.slug, - argv.permissionSlug, - argv.org, - resolveApiKey({ apiKey: argv.apiKey }), - resolveApiBaseUrl(), - ); + await runRoleAddPermission(argv.slug, argv.permissionSlug, { + org: argv.org, + yes: argv.yes, + json: argv.json as boolean | undefined, + environmentId: argv.environmentId as string | undefined, + }); }, ); registerSubcommand( @@ -1521,59 +1537,50 @@ async function runCli(): Promise { y .positional('slug', { type: 'string', demandOption: true }) .positional('permissionSlug', { type: 'string', demandOption: true }) - .demandOption('org'), + .demandOption('org') + .options({ + yes: { alias: 'y', type: 'boolean', default: false, describe: 'Confirm the change (required non-interactively)' }, + 'environment-id': { type: 'string', describe: 'Environment ID (defaults to the active environment)' }, + }), async (argv) => { await applyInsecureStorage(argv.insecureStorage); - - const { resolveApiKey, resolveApiBaseUrl } = await import('./lib/api-key.js'); const { runRoleRemovePermission } = await import('./commands/role.js'); - await runRoleRemovePermission( - argv.slug, - argv.permissionSlug, - argv.org!, - resolveApiKey({ apiKey: argv.apiKey }), - resolveApiBaseUrl(), - ); + await runRoleRemovePermission(argv.slug, argv.permissionSlug, { + org: argv.org!, + yes: argv.yes, + json: argv.json as boolean | undefined, + environmentId: argv.environmentId as string | undefined, + }); }, ); return yargs.demandCommand(1, 'Please specify a role subcommand').strict(); }) .command('permission', 'Manage WorkOS permissions', (yargs) => { - yargs.options({ ...insecureStorageOption, 'api-key': { type: 'string' as const, describe: 'WorkOS API key' } }); + yargs.options(insecureStorageOption); registerSubcommand( yargs, 'list', 'List permissions', (y) => - y.options({ - limit: { type: 'number' }, - before: { type: 'string' }, - after: { type: 'string' }, - order: { type: 'string' }, - }), + y.option('environment-id', { type: 'string', describe: 'Environment ID (defaults to the active environment)' }), async (argv) => { await applyInsecureStorage(argv.insecureStorage); - - const { resolveApiKey, resolveApiBaseUrl } = await import('./lib/api-key.js'); const { runPermissionList } = await import('./commands/permission.js'); - await runPermissionList( - { limit: argv.limit, before: argv.before, after: argv.after, order: argv.order }, - resolveApiKey({ apiKey: argv.apiKey }), - resolveApiBaseUrl(), - ); + await runPermissionList({ environmentId: argv.environmentId as string | undefined }); }, ); registerSubcommand( yargs, 'get ', 'Get a permission', - (y) => y.positional('slug', { type: 'string', demandOption: true }), + (y) => + y + .positional('slug', { type: 'string', demandOption: true }) + .option('environment-id', { type: 'string', describe: 'Environment ID (defaults to the active environment)' }), async (argv) => { await applyInsecureStorage(argv.insecureStorage); - - const { resolveApiKey, resolveApiBaseUrl } = await import('./lib/api-key.js'); const { runPermissionGet } = await import('./commands/permission.js'); - await runPermissionGet(argv.slug, resolveApiKey({ apiKey: argv.apiKey }), resolveApiBaseUrl()); + await runPermissionGet(argv.slug, { environmentId: argv.environmentId as string | undefined }); }, ); registerSubcommand( @@ -1582,20 +1589,23 @@ async function runCli(): Promise { 'Create a permission', (y) => y.options({ - slug: { type: 'string', demandOption: true }, - name: { type: 'string', demandOption: true }, - description: { type: 'string' }, + slug: { type: 'string', demandOption: true, describe: 'Permission slug' }, + name: { type: 'string', demandOption: true, describe: 'Permission name' }, + description: { type: 'string', describe: 'Permission description' }, + yes: { alias: 'y', type: 'boolean', default: false, describe: 'Confirm the change (required non-interactively)' }, + 'environment-id': { type: 'string', describe: 'Environment ID (defaults to the active environment)' }, }), async (argv) => { await applyInsecureStorage(argv.insecureStorage); - - const { resolveApiKey, resolveApiBaseUrl } = await import('./lib/api-key.js'); const { runPermissionCreate } = await import('./commands/permission.js'); - await runPermissionCreate( - { slug: argv.slug, name: argv.name, description: argv.description }, - resolveApiKey({ apiKey: argv.apiKey }), - resolveApiBaseUrl(), - ); + await runPermissionCreate({ + slug: argv.slug, + name: argv.name, + description: argv.description, + yes: argv.yes, + json: argv.json as boolean | undefined, + environmentId: argv.environmentId as string | undefined, + }); }, ); registerSubcommand( @@ -1603,33 +1613,41 @@ async function runCli(): Promise { 'update ', 'Update a permission', (y) => - y - .positional('slug', { type: 'string', demandOption: true }) - .options({ name: { type: 'string' }, description: { type: 'string' } }), + y.positional('slug', { type: 'string', demandOption: true }).options({ + name: { type: 'string' }, + description: { type: 'string' }, + yes: { alias: 'y', type: 'boolean', default: false, describe: 'Confirm the change (required non-interactively)' }, + 'environment-id': { type: 'string', describe: 'Environment ID (defaults to the active environment)' }, + }), async (argv) => { await applyInsecureStorage(argv.insecureStorage); - - const { resolveApiKey, resolveApiBaseUrl } = await import('./lib/api-key.js'); const { runPermissionUpdate } = await import('./commands/permission.js'); - await runPermissionUpdate( - argv.slug, - { name: argv.name, description: argv.description }, - resolveApiKey({ apiKey: argv.apiKey }), - resolveApiBaseUrl(), - ); + await runPermissionUpdate(argv.slug, { + name: argv.name, + description: argv.description, + yes: argv.yes, + json: argv.json as boolean | undefined, + environmentId: argv.environmentId as string | undefined, + }); }, ); registerSubcommand( yargs, 'delete ', 'Delete a permission', - (y) => y.positional('slug', { type: 'string', demandOption: true }), + (y) => + y.positional('slug', { type: 'string', demandOption: true }).options({ + yes: { alias: 'y', type: 'boolean', default: false, describe: 'Skip the confirmation prompt' }, + 'environment-id': { type: 'string', describe: 'Environment ID (defaults to the active environment)' }, + }), async (argv) => { await applyInsecureStorage(argv.insecureStorage); - - const { resolveApiKey, resolveApiBaseUrl } = await import('./lib/api-key.js'); const { runPermissionDelete } = await import('./commands/permission.js'); - await runPermissionDelete(argv.slug, resolveApiKey({ apiKey: argv.apiKey }), resolveApiBaseUrl()); + await runPermissionDelete(argv.slug, { + yes: argv.yes, + json: argv.json as boolean | undefined, + environmentId: argv.environmentId as string | undefined, + }); }, ); return yargs.demandCommand(1, 'Please specify a permission subcommand').strict(); @@ -2274,109 +2292,105 @@ async function runCli(): Promise { return yargs.demandCommand(1, 'Please specify an audit-log subcommand').strict(); }) .command('feature-flag', 'Manage feature flags', (yargs) => { - yargs.options({ ...insecureStorageOption, 'api-key': { type: 'string' as const, describe: 'WorkOS API key' } }); + yargs.options(insecureStorageOption); registerSubcommand( yargs, 'list', 'List feature flags', (y) => y.options({ - limit: { type: 'number' }, - before: { type: 'string' }, - after: { type: 'string' }, - order: { type: 'string' }, + limit: { type: 'number', describe: 'Limit number of results' }, + before: { type: 'string', describe: 'Cursor for results before a specific item' }, + after: { type: 'string', describe: 'Cursor for results after a specific item' }, + order: { type: 'string', describe: 'Order of results, asc or desc' }, + 'environment-id': { type: 'string', describe: 'Environment ID (defaults to the active environment)' }, }), async (argv) => { await applyInsecureStorage(argv.insecureStorage); - - const { resolveApiKey, resolveApiBaseUrl } = await import('./lib/api-key.js'); const { runFeatureFlagList } = await import('./commands/feature-flag.js'); - await runFeatureFlagList( - { limit: argv.limit, before: argv.before, after: argv.after, order: argv.order }, - resolveApiKey({ apiKey: argv.apiKey }), - resolveApiBaseUrl(), - ); + await runFeatureFlagList({ + limit: argv.limit, + before: argv.before, + after: argv.after, + order: argv.order, + environmentId: argv.environmentId as string | undefined, + }); }, ); registerSubcommand( yargs, 'get ', 'Get a feature flag', - (y) => y.positional('slug', { type: 'string', demandOption: true }), + (y) => + y + .positional('slug', { type: 'string', demandOption: true }) + .option('environment-id', { type: 'string', describe: 'Environment ID (defaults to the active environment)' }), async (argv) => { await applyInsecureStorage(argv.insecureStorage); - - const { resolveApiKey, resolveApiBaseUrl } = await import('./lib/api-key.js'); const { runFeatureFlagGet } = await import('./commands/feature-flag.js'); - await runFeatureFlagGet(argv.slug, resolveApiKey({ apiKey: argv.apiKey }), resolveApiBaseUrl()); + await runFeatureFlagGet(argv.slug, { environmentId: argv.environmentId as string | undefined }); }, ); registerSubcommand( yargs, 'enable ', 'Enable a feature flag', - (y) => y.positional('slug', { type: 'string', demandOption: true }), + (y) => + y + .positional('slug', { type: 'string', demandOption: true }) + .option('environment-id', { type: 'string', describe: 'Environment ID (defaults to the active environment)' }), async (argv) => { await applyInsecureStorage(argv.insecureStorage); - - const { resolveApiKey, resolveApiBaseUrl } = await import('./lib/api-key.js'); const { runFeatureFlagEnable } = await import('./commands/feature-flag.js'); - await runFeatureFlagEnable(argv.slug, resolveApiKey({ apiKey: argv.apiKey }), resolveApiBaseUrl()); + await runFeatureFlagEnable(argv.slug, { environmentId: argv.environmentId as string | undefined }); }, ); registerSubcommand( yargs, 'disable ', 'Disable a feature flag', - (y) => y.positional('slug', { type: 'string', demandOption: true }), + (y) => + y + .positional('slug', { type: 'string', demandOption: true }) + .option('environment-id', { type: 'string', describe: 'Environment ID (defaults to the active environment)' }), async (argv) => { await applyInsecureStorage(argv.insecureStorage); - - const { resolveApiKey, resolveApiBaseUrl } = await import('./lib/api-key.js'); const { runFeatureFlagDisable } = await import('./commands/feature-flag.js'); - await runFeatureFlagDisable(argv.slug, resolveApiKey({ apiKey: argv.apiKey }), resolveApiBaseUrl()); + await runFeatureFlagDisable(argv.slug, { environmentId: argv.environmentId as string | undefined }); }, ); registerSubcommand( yargs, 'add-target ', - 'Add a target to a feature flag', + 'Add a target (user_* or org_*) to a feature flag', (y) => y .positional('slug', { type: 'string', demandOption: true }) - .positional('targetId', { type: 'string', demandOption: true }), + .positional('targetId', { type: 'string', demandOption: true }) + .option('environment-id', { type: 'string', describe: 'Environment ID (defaults to the active environment)' }), async (argv) => { await applyInsecureStorage(argv.insecureStorage); - - const { resolveApiKey, resolveApiBaseUrl } = await import('./lib/api-key.js'); const { runFeatureFlagAddTarget } = await import('./commands/feature-flag.js'); - await runFeatureFlagAddTarget( - argv.slug, - argv.targetId, - resolveApiKey({ apiKey: argv.apiKey }), - resolveApiBaseUrl(), - ); + await runFeatureFlagAddTarget(argv.slug, argv.targetId, { + environmentId: argv.environmentId as string | undefined, + }); }, ); registerSubcommand( yargs, 'remove-target ', - 'Remove a target from a feature flag', + 'Remove a target (user_* or org_*) from a feature flag', (y) => y .positional('slug', { type: 'string', demandOption: true }) - .positional('targetId', { type: 'string', demandOption: true }), + .positional('targetId', { type: 'string', demandOption: true }) + .option('environment-id', { type: 'string', describe: 'Environment ID (defaults to the active environment)' }), async (argv) => { await applyInsecureStorage(argv.insecureStorage); - - const { resolveApiKey, resolveApiBaseUrl } = await import('./lib/api-key.js'); const { runFeatureFlagRemoveTarget } = await import('./commands/feature-flag.js'); - await runFeatureFlagRemoveTarget( - argv.slug, - argv.targetId, - resolveApiKey({ apiKey: argv.apiKey }), - resolveApiBaseUrl(), - ); + await runFeatureFlagRemoveTarget(argv.slug, argv.targetId, { + environmentId: argv.environmentId as string | undefined, + }); }, ); return yargs.demandCommand(1, 'Please specify a feature-flag subcommand').strict(); diff --git a/src/catalog/curation.ts b/src/catalog/curation.ts index 901780b9..bebeadec 100644 --- a/src/catalog/curation.ts +++ b/src/catalog/curation.ts @@ -132,6 +132,31 @@ export const OVERRIDES: Record = { updateOrganization: { command: 'organization update', describe: "Update an organization's name or domains" }, deleteOrganization: { command: 'organization delete', describe: 'Delete an organization' }, + // --- authorization cluster (resource migration Phase 5) --- + // Op names are clean upstream, but several descriptions are rotten (the + // vendored docstrings concatenate the sub-queries: "List roles defined in an + // environment ... Return the role configuration for an environment"), and + // every manifest-curated op needs an override so the manifest's clean + // `command` noun is the single source of truth. One op ↔ one command noun: + // multi-subcommand ops (`updateRole`, `updateFlagEnvironment`, the list ops + // that back get/lookup steps) are keyed under their primary command — see the + // manifest comment. + roles: { command: 'role list', describe: 'List roles in the current environment' }, + rolesForOrganization: { command: 'role list', describe: "List an organization's assignable roles" }, + createRole: { command: 'role create', describe: 'Create an environment or organization role' }, + updateRole: { command: 'role update', describe: "Update a role's name, description, or permissions" }, + deleteRole: { command: 'role delete', describe: 'Delete an organization role' }, + permissions: { command: 'permission list', describe: 'List permissions in the current environment' }, + createPermission: { command: 'permission create', describe: 'Create a permission' }, + updatePermission: { command: 'permission update', describe: "Update a custom permission's name or description" }, + deletePermission: { command: 'permission delete', describe: 'Delete a custom permission' }, + flags: { command: 'feature-flag list', describe: 'List feature flags in the current project' }, + flagBySlug: { command: 'feature-flag get', describe: 'Get a feature flag by slug' }, + updateFlagEnvironment: { + command: 'feature-flag enable', + describe: "Update a feature flag's state and targeting in the current environment", + }, + // --- Phase 4: AuthKit app config --- // These op names/descriptions are already clean (no leak), but each still needs // an override so resolveCommandMeta returns the manifest's clean noun (the leak diff --git a/src/catalog/manifest.ts b/src/catalog/manifest.ts index 7053e3a1..90d6bc86 100644 --- a/src/catalog/manifest.ts +++ b/src/catalog/manifest.ts @@ -513,6 +513,163 @@ const MANIFEST: CommandJustification[] = [ destructive: true, ciPolicy: 'allow', }, + + // --- Resource migration: authorization cluster (role / permission / feature-flag) --- + // graphql-resource-migration Phase 5. Same recipe as the earlier clusters: + // unchanged subcommand grammar, dashboard-plane backend, new curated shapes. + // + // Mapping notes: + // - `role list` is backed by TWO ops (environment-scoped vs organization-scoped + // listing); both entries share the command noun. + // - `role get` has no single-role operation: it is a client-side filter over + // the scope's list op, so it deliberately has no manifest entry of its own + // (invitation-get precedent). The same list ops back the slug→ID resolution + // step of every role mutation. + // - `role set-permissions` / `role add-permission` / `role remove-permission` + // ride the `role update` op: the backing mutation carries the role's full + // permission set, so the handlers read-merge-write over it. One op, one + // manifest entry — the `role update` entry's ciPolicy governs all four. + // - `permission get/update/delete` resolve slugs via the `permission list` op. + // - `feature-flag get` also backs the lookup step of every flag mutation, and + // `feature-flag enable` / `disable` / `add-target` / `remove-target` all ride + // the same per-environment update op (one op, one manifest entry). + // - Privilege-shaping mutations (role/permission create+update, and the + // permission-assignment subcommands riding `role update`) follow the + // `team change-role` precedent: ciPolicy `require-flag`. Deletes are + // `destructive` (confirmDestructive; ciPolicy stays `allow` because the + // destructive gate already covers non-interactive runs). Feature-flag + // mutations are reversible configuration toggles, not privilege changes — + // ciPolicy `allow`. + { + command: 'role list', + mapsTo: 'roles', + audiences: ['human', 'agent', 'ci'], + useCase: 'List the roles defined in the active environment (audits, scripting)', + load: 'cheap', + mutation: false, + destructive: false, + ciPolicy: 'allow', + }, + { + command: 'role list', + mapsTo: 'rolesForOrganization', + audiences: ['human', 'agent', 'ci'], + useCase: "List the roles assignable within an organization (audits, scripting)", + load: 'cheap', + mutation: false, + destructive: false, + ciPolicy: 'allow', + }, + { + command: 'role create', + mapsTo: 'createRole', + audiences: ['human', 'agent', 'ci'], + useCase: 'Create an environment or organization role from setup scripts or CI', + load: 'cheap', + mutation: true, + destructive: false, + // require-flag: expands the privilege surface; require explicit consent in + // non-interactive use (team change-role precedent). + ciPolicy: 'require-flag', + }, + { + command: 'role update', + mapsTo: 'updateRole', + audiences: ['human', 'agent'], + useCase: "Update a role's name, description, or assigned permissions", + load: 'cheap', + mutation: true, + destructive: false, + // require-flag: a privilege change (also gates set-permissions / + // add-permission / remove-permission, which ride this op). + ciPolicy: 'require-flag', + }, + { + command: 'role delete', + mapsTo: 'deleteRole', + audiences: ['human', 'agent'], + useCase: 'Delete an organization-scoped role (cleanup, offboarding)', + load: 'cheap', + mutation: true, + // destructive: permanently deletes the role; members lose it. + destructive: true, + ciPolicy: 'allow', + }, + { + command: 'permission list', + mapsTo: 'permissions', + audiences: ['human', 'agent', 'ci'], + useCase: 'List the permissions defined in the active environment (audits, scripting)', + load: 'cheap', + mutation: false, + destructive: false, + ciPolicy: 'allow', + }, + { + command: 'permission create', + mapsTo: 'createPermission', + audiences: ['human', 'agent', 'ci'], + useCase: 'Create a permission during RBAC setup automation', + load: 'cheap', + mutation: true, + destructive: false, + // require-flag: expands the privilege surface (team change-role precedent). + ciPolicy: 'require-flag', + }, + { + command: 'permission update', + mapsTo: 'updatePermission', + audiences: ['human', 'agent'], + useCase: "Update a custom permission's name or description", + load: 'cheap', + mutation: true, + destructive: false, + // require-flag: a privilege-surface change (team change-role precedent). + ciPolicy: 'require-flag', + }, + { + command: 'permission delete', + mapsTo: 'deletePermission', + audiences: ['human', 'agent'], + useCase: 'Delete a custom permission (cleanup)', + load: 'cheap', + mutation: true, + // destructive: permanently removes the permission from every role using it. + destructive: true, + ciPolicy: 'allow', + }, + { + command: 'feature-flag list', + mapsTo: 'flags', + audiences: ['human', 'agent', 'ci'], + useCase: "List the project's feature flags with the active environment's state", + load: 'cheap', + mutation: false, + destructive: false, + ciPolicy: 'allow', + }, + { + command: 'feature-flag get', + mapsTo: 'flagBySlug', + audiences: ['human', 'agent', 'ci'], + useCase: 'Inspect a feature flag by slug (also backs the lookup step of flag mutations)', + load: 'cheap', + mutation: false, + destructive: false, + ciPolicy: 'allow', + }, + { + command: 'feature-flag enable', + mapsTo: 'updateFlagEnvironment', + audiences: ['human', 'agent', 'ci'], + useCase: "Toggle a flag or adjust its targeting in the active environment (enable/disable/add-target/remove-target all ride this op)", + load: 'cheap', + mutation: true, + destructive: false, + // allow: reversible configuration toggle, not a privilege change; flipping + // flags from CI/scripts is the intended automation. + ciPolicy: 'allow', + }, ]; /** Returns the curated command allowlist. */ diff --git a/src/commands/feature-flag.spec.ts b/src/commands/feature-flag.spec.ts index 2912bf67..2c842d2b 100644 --- a/src/commands/feature-flag.spec.ts +++ b/src/commands/feature-flag.spec.ts @@ -1,22 +1,53 @@ import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'; -const mockSdk = { - featureFlags: { - listFeatureFlags: vi.fn(), - getFeatureFlag: vi.fn(), - enableFeatureFlag: vi.fn(), - disableFeatureFlag: vi.fn(), - addFlagTarget: vi.fn(), - removeFlagTarget: vi.fn(), - }, -}; +const mockRequireCommandToken = vi.fn(); +const mockGraphqlRequest = vi.fn(); +const mockConfirm = vi.fn(); +const mockIsCancel = vi.fn(() => false); +const mockGetActiveEnvironment = vi.fn(); +const mockGetConfig = vi.fn(); + +vi.mock('../lib/command-auth.js', async (importActual) => { + const actual = await importActual(); + return { + ...actual, + requireCommandToken: () => mockRequireCommandToken(), + }; +}); + +vi.mock('../lib/dashboard-graphql.js', async (importActual) => { + const actual = await importActual(); + return { + ...actual, + dashboardGraphqlRequest: (...args: unknown[]) => mockGraphqlRequest(...args), + }; +}); -vi.mock('../lib/workos-client.js', () => ({ - createWorkOSClient: () => ({ sdk: mockSdk }), +vi.mock('../utils/clack.js', () => ({ + default: { + confirm: (...args: unknown[]) => mockConfirm(...args), + isCancel: (...args: unknown[]) => mockIsCancel(...args), + select: vi.fn(), + }, })); -const { setOutputMode } = await import('../utils/output.js'); +// The environment-target resolver runs REAL against the mocked wire + config +// store, so mutation tests can assert the pre-validation fetch +// (teamProjectsV2) happens before the operation request. +vi.mock('../lib/config-store.js', async (importActual) => { + const actual = await importActual(); + return { + ...actual, + getActiveEnvironment: () => mockGetActiveEnvironment(), + getConfig: () => mockGetConfig(), + setProfileEnvironmentId: vi.fn(), + }; +}); +const { setOutputMode } = await import('../utils/output.js'); +const { resetInteractionModeForTests, setInteractionMode } = await import('../utils/interaction-mode.js'); +const { DashboardGraphqlError } = await import('../lib/dashboard-graphql.js'); +const { CliExit } = await import('../utils/cli-exit.js'); const { runFeatureFlagList, runFeatureFlagGet, @@ -26,160 +57,463 @@ const { runFeatureFlagRemoveTarget, } = await import('./feature-flag.js'); -const mockFlag = { - id: 'ff_123', - slug: 'coffee', - name: 'Coffee Feature', - description: 'Enables coffee', - enabled: true, - defaultValue: false, - tags: [], - createdAt: '2024-01-01T00:00:00Z', - updatedAt: '2024-01-01T00:00:00Z', +// Projects carry IDs so the flag commands can derive the active environment's +// project (the flag operations are project-scoped). +const TEAM_ENVIRONMENTS_PAYLOAD = { + currentTeam: { + projectsV2: [ + { id: 'proj_1', environments: [{ id: 'env_profile', name: 'Sandbox', sandbox: true, clientId: null }] }, + { id: 'proj_2', environments: [{ id: 'env_other', name: 'Other', sandbox: false, clientId: null }] }, + ], + }, +}; + +const FLAG_ENVIRONMENT_NODE = { + id: 'fe_1', + environmentId: 'env_profile', + flagId: 'flag_1', + flagEnabled: false, + defaultEnabled: true, + accessType: 'SOME', + organizations: [{ id: 'org_1', name: 'FooCorp' }], + users: [{ id: 'user_1', email: 'a@example.com', firstName: 'A', lastName: 'B' }], + uniqueUsersCount: 1, +}; + +const FLAG_NODE = { + id: 'flag_1', + name: 'Beta', + slug: 'beta', + description: 'Beta feature', + projectId: 'proj_1', + owner: null, + flagEnvironments: [ + FLAG_ENVIRONMENT_NODE, + // A different environment's state the curated shapes must NOT report: + { id: 'fe_2', environmentId: 'env_other', flagId: 'flag_1', flagEnabled: true }, + ], + createdAt: '2026-01-01T00:00:00.000Z', + updatedAt: '2026-02-01T00:00:00.000Z', + tags: [{ id: 'tag_1', name: 'beta-wave' }], +}; + +interface RouteMap { + list?: unknown; + flagBySlug?: unknown; + updateFlagEnvironment?: unknown; +} + +/** + * Route the wire mock by document: `teamProjectsV2` serves BOTH the + * environment resolver's pre-validation fetch and the project derivation; + * everything else gets its configured payload. + */ +function respondWith(routes: RouteMap): void { + mockGraphqlRequest.mockImplementation(async (doc: unknown) => { + const text = String(doc); + if (text.includes('teamProjectsV2')) return TEAM_ENVIRONMENTS_PAYLOAD; + if (text.includes('mutation updateFlagEnvironment')) return routes.updateFlagEnvironment; + if (text.includes('flagBySlug')) return routes.flagBySlug; + if (text.includes('flagsForProject')) return routes.list; + throw new Error(`Unrouted document in test: ${text.slice(0, 80)}`); + }); +} + +async function expectExit(promise: Promise, code: number): Promise { + try { + await promise; + } catch (err) { + if (err instanceof CliExit) { + expect(err.exitCode).toBe(code); + return err; + } + throw err; + } + throw new Error(`Expected CliExit(${code}) but promise resolved`); +} + +/** The authoritative curated flag JSON shapes (documented contract). */ +const FLAG_SHAPE_KEYS = ['id', 'slug', 'name', 'description', 'enabled', 'createdAt', 'updatedAt']; +const FLAG_DETAIL_SHAPE_KEYS = [ + ...FLAG_SHAPE_KEYS, + 'defaultEnabled', + 'accessType', + 'organizationTargets', + 'userTargets', + 'tags', +]; + +const UPDATED_PAYLOAD = { + updateFlagEnvironment: { + __typename: 'FlagEnvironmentUpdated', + flagEnvironment: { ...FLAG_ENVIRONMENT_NODE, flagEnabled: true }, + }, +}; + +const LIST_PAYLOAD = { + flagsForProject: { data: [FLAG_NODE], listMetadata: { before: null, after: 'cursor_a' } }, }; -describe('feature-flag commands', () => { +/** Every subcommand's happy-path invocation, for the shared contract matrices. */ +const SUBCOMMANDS: Array<{ name: string; routes: RouteMap; run: () => Promise }> = [ + { name: 'list', routes: { list: LIST_PAYLOAD }, run: () => runFeatureFlagList({}) }, + { name: 'get', routes: { flagBySlug: { flagBySlug: FLAG_NODE } }, run: () => runFeatureFlagGet('beta') }, + { + name: 'enable', + routes: { flagBySlug: { flagBySlug: FLAG_NODE }, updateFlagEnvironment: UPDATED_PAYLOAD }, + run: () => runFeatureFlagEnable('beta'), + }, + { + name: 'disable', + routes: { flagBySlug: { flagBySlug: FLAG_NODE }, updateFlagEnvironment: UPDATED_PAYLOAD }, + run: () => runFeatureFlagDisable('beta'), + }, + { + name: 'add-target', + routes: { flagBySlug: { flagBySlug: FLAG_NODE }, updateFlagEnvironment: UPDATED_PAYLOAD }, + run: () => runFeatureFlagAddTarget('beta', 'user_2'), + }, + { + name: 'remove-target', + routes: { flagBySlug: { flagBySlug: FLAG_NODE }, updateFlagEnvironment: UPDATED_PAYLOAD }, + run: () => runFeatureFlagRemoveTarget('beta', 'user_1'), + }, +]; + +describe('feature-flag command', () => { let consoleOutput: string[]; beforeEach(() => { vi.clearAllMocks(); + resetInteractionModeForTests(); + setOutputMode('human'); + mockRequireCommandToken.mockResolvedValue('tok_123'); + mockConfirm.mockReset(); + mockIsCancel.mockReset(); + mockIsCancel.mockReturnValue(false); + mockGetActiveEnvironment.mockReturnValue({ apiKey: 'sk_ignored', environmentId: 'env_profile' }); + mockGetConfig.mockReturnValue({ + activeEnvironment: 'default', + environments: { default: { apiKey: 'sk_ignored', environmentId: 'env_profile' } }, + }); consoleOutput = []; vi.spyOn(console, 'log').mockImplementation((...args: unknown[]) => { consoleOutput.push(args.map(String).join(' ')); }); + vi.spyOn(console, 'error').mockImplementation(() => {}); }); afterEach(() => { vi.restoreAllMocks(); + resetInteractionModeForTests(); + setOutputMode('human'); }); - describe('runFeatureFlagList', () => { - it('lists flags in table', async () => { - mockSdk.featureFlags.listFeatureFlags.mockResolvedValue({ - data: [mockFlag], - listMetadata: { before: null, after: null }, + describe('list', () => { + it('derives the project from the active environment, then lists flags', async () => { + respondWith({ list: LIST_PAYLOAD }); + await runFeatureFlagList({}); + const docs = mockGraphqlRequest.mock.calls.map((call) => String(call[0])); + expect(docs[0]).toContain('teamProjectsV2'); + expect(docs[1]).toContain('flagsForProject'); + expect(mockGraphqlRequest.mock.calls[1][1]).toEqual({ + token: 'tok_123', + variables: { projectId: 'proj_1' }, + environmentId: 'env_profile', + }); + const out = consoleOutput.join('\n'); + expect(out).toContain('beta'); + // The ACTIVE environment's state (fe_1) is disabled, even though another + // environment has the flag on. + expect(out).toContain('No'); + }); + + it('maps pagination flags to catalog variables', async () => { + respondWith({ list: { flagsForProject: { data: [], listMetadata: { before: null, after: null } } } }); + await runFeatureFlagList({ limit: 5, after: 'cursor_a', order: 'desc' }); + expect(mockGraphqlRequest.mock.calls[1][1]).toEqual({ + token: 'tok_123', + variables: { projectId: 'proj_1', limit: 5, after: 'cursor_a', order: 'Desc' }, + environmentId: 'env_profile', }); - await runFeatureFlagList({}, 'sk_test'); - expect(consoleOutput.some((l) => l.includes('coffee'))).toBe(true); - expect(consoleOutput.some((l) => l.includes('Coffee Feature'))).toBe(true); }); - it('passes pagination params', async () => { - mockSdk.featureFlags.listFeatureFlags.mockResolvedValue({ - data: [], - listMetadata: { before: null, after: null }, + it('rejects an invalid --order value', async () => { + const err = await expectExit(runFeatureFlagList({ order: 'sideways' }), 1); + expect(err.context?.errorCode).toBe('invalid_argument'); + expect(mockGraphqlRequest).not.toHaveBeenCalled(); + }); + + it('--json emits the curated shape with the active environment state', async () => { + setOutputMode('json'); + respondWith({ list: LIST_PAYLOAD }); + await runFeatureFlagList({}); + const raw = consoleOutput[0]; + expect(raw).not.toMatch(/graphql|userland|flagEnvironments|list_metadata/i); + const out = JSON.parse(raw); + expect(Object.keys(out)).toEqual(['flags', 'pagination']); + expect(Object.keys(out.flags[0])).toEqual(FLAG_SHAPE_KEYS); + expect(out.flags[0]).toMatchObject({ slug: 'beta', enabled: false }); + expect(out.pagination).toEqual({ before: null, after: 'cursor_a' }); + }); + + it('errors environment_stale when the environment joins no project', async () => { + mockGetActiveEnvironment.mockReturnValue({ apiKey: 'sk_ignored', environmentId: 'env_gone' }); + mockGetConfig.mockReturnValue({ + activeEnvironment: 'default', + environments: { default: { apiKey: 'sk_ignored', environmentId: 'env_gone' } }, }); - await runFeatureFlagList({ limit: 5, order: 'desc' }, 'sk_test'); - expect(mockSdk.featureFlags.listFeatureFlags).toHaveBeenCalledWith( - expect.objectContaining({ limit: 5, order: 'desc' }), - ); + respondWith({}); + const err = await expectExit(runFeatureFlagList({}), 1); + expect(err.context?.errorCode).toBe('environment_stale'); }); it('handles empty results', async () => { - mockSdk.featureFlags.listFeatureFlags.mockResolvedValue({ - data: [], - listMetadata: { before: null, after: null }, + respondWith({ list: { flagsForProject: { data: [], listMetadata: { before: null, after: null } } } }); + await runFeatureFlagList({}); + expect(consoleOutput.some((line) => line.includes('No feature flags found'))).toBe(true); + }); + }); + + describe('get', () => { + it('fetches by slug within the derived project', async () => { + respondWith({ flagBySlug: { flagBySlug: FLAG_NODE } }); + await runFeatureFlagGet('beta'); + expect(mockGraphqlRequest.mock.calls[1][1]).toEqual({ + token: 'tok_123', + variables: { projectId: 'proj_1', slug: 'beta' }, + environmentId: 'env_profile', }); - await runFeatureFlagList({}, 'sk_test'); - expect(consoleOutput.some((l) => l.includes('No feature flags found'))).toBe(true); + const out = consoleOutput.join('\n'); + expect(out).toContain('beta'); + expect(out).toContain('No'); // active environment's state }); - it('shows pagination cursors', async () => { - mockSdk.featureFlags.listFeatureFlags.mockResolvedValue({ - data: [mockFlag], - listMetadata: { before: 'cursor_b', after: 'cursor_a' }, + it('--json emits { flag } with the active environment targeting', async () => { + setOutputMode('json'); + respondWith({ flagBySlug: { flagBySlug: FLAG_NODE } }); + await runFeatureFlagGet('beta'); + const raw = consoleOutput[0]; + expect(raw).not.toMatch(/graphql|userland|flagEnvironments/i); + const out = JSON.parse(raw); + expect(Object.keys(out)).toEqual(['flag']); + expect(Object.keys(out.flag)).toEqual(FLAG_DETAIL_SHAPE_KEYS); + expect(out.flag).toMatchObject({ + slug: 'beta', + enabled: false, + defaultEnabled: true, + accessType: 'SOME', + organizationTargets: [{ id: 'org_1', name: 'FooCorp' }], + userTargets: [{ id: 'user_1', email: 'a@example.com' }], + tags: ['beta-wave'], }); - await runFeatureFlagList({}, 'sk_test'); - expect(consoleOutput.some((l) => l.includes('cursor_b'))).toBe(true); - expect(consoleOutput.some((l) => l.includes('cursor_a'))).toBe(true); }); - }); - describe('runFeatureFlagGet', () => { - it('fetches flag by slug', async () => { - mockSdk.featureFlags.getFeatureFlag.mockResolvedValue(mockFlag); - await runFeatureFlagGet('coffee', 'sk_test'); - expect(mockSdk.featureFlags.getFeatureFlag).toHaveBeenCalledWith('coffee'); - expect(consoleOutput.some((l) => l.includes('coffee'))).toBe(true); + it('errors not_found when the flag does not exist', async () => { + respondWith({ flagBySlug: { flagBySlug: null } }); + const err = await expectExit(runFeatureFlagGet('missing'), 1); + expect(err.context?.errorCode).toBe('not_found'); }); }); - describe('runFeatureFlagEnable', () => { - it('enables flag', async () => { - mockSdk.featureFlags.enableFeatureFlag.mockResolvedValue({ ...mockFlag, enabled: true }); - await runFeatureFlagEnable('coffee', 'sk_test'); - expect(mockSdk.featureFlags.enableFeatureFlag).toHaveBeenCalledWith('coffee'); - expect(consoleOutput.some((l) => l.includes('Enabled feature flag'))).toBe(true); + describe('enable / disable (read-merge-write)', () => { + it('enable fetches the flag and sends the FULL current state with flagEnabled true', async () => { + respondWith({ flagBySlug: { flagBySlug: FLAG_NODE }, updateFlagEnvironment: UPDATED_PAYLOAD }); + await runFeatureFlagEnable('beta'); + const docs = mockGraphqlRequest.mock.calls.map((call) => String(call[0])); + // Mutation: resolver pre-validation, project derivation, lookup, update. + expect(docs[0]).toContain('teamProjectsV2'); + expect(docs.at(-2)).toContain('flagBySlug'); + expect(docs.at(-1)).toContain('mutation updateFlagEnvironment'); + // The update REPLACES target lists server-side — the full current + // targeting must ride along or it would be silently cleared. + expect(mockGraphqlRequest.mock.calls.at(-1)![1]).toEqual({ + token: 'tok_123', + variables: { + input: { + flagEnvironmentId: 'fe_1', + flagEnabled: true, + defaultEnabled: true, + accessType: 'SOME', + organizationIds: ['org_1'], + userIds: ['user_1'], + }, + }, + environmentId: 'env_profile', + }); }); - }); - describe('runFeatureFlagDisable', () => { - it('disables flag', async () => { - mockSdk.featureFlags.disableFeatureFlag.mockResolvedValue({ ...mockFlag, enabled: false }); - await runFeatureFlagDisable('coffee', 'sk_test'); - expect(mockSdk.featureFlags.disableFeatureFlag).toHaveBeenCalledWith('coffee'); - expect(consoleOutput.some((l) => l.includes('Disabled feature flag'))).toBe(true); + it('enable prints a success message in human mode', async () => { + respondWith({ flagBySlug: { flagBySlug: FLAG_NODE }, updateFlagEnvironment: UPDATED_PAYLOAD }); + await runFeatureFlagEnable('beta'); + expect(consoleOutput.join('\n')).toContain('Enabled feature flag'); }); - }); - describe('runFeatureFlagAddTarget', () => { - it('adds target with slug and targetId', async () => { - mockSdk.featureFlags.addFlagTarget.mockResolvedValue(undefined); - await runFeatureFlagAddTarget('coffee', 'user_123', 'sk_test'); - expect(mockSdk.featureFlags.addFlagTarget).toHaveBeenCalledWith({ slug: 'coffee', targetId: 'user_123' }); - expect(consoleOutput.some((l) => l.includes('Added target'))).toBe(true); + it('disable prints a success message in human mode', async () => { + respondWith({ flagBySlug: { flagBySlug: FLAG_NODE }, updateFlagEnvironment: UPDATED_PAYLOAD }); + await runFeatureFlagDisable('beta'); + expect(consoleOutput.join('\n')).toContain('Disabled feature flag'); }); - }); - describe('runFeatureFlagRemoveTarget', () => { - it('removes target with slug and targetId', async () => { - mockSdk.featureFlags.removeFlagTarget.mockResolvedValue(undefined); - await runFeatureFlagRemoveTarget('coffee', 'user_123', 'sk_test'); - expect(mockSdk.featureFlags.removeFlagTarget).toHaveBeenCalledWith({ slug: 'coffee', targetId: 'user_123' }); - expect(consoleOutput.some((l) => l.includes('Removed target'))).toBe(true); + it('errors not_found on the FlagEnvironmentNotFound mutation variant', async () => { + respondWith({ + flagBySlug: { flagBySlug: FLAG_NODE }, + updateFlagEnvironment: { + updateFlagEnvironment: { __typename: 'FlagEnvironmentNotFound', flagEnvironmentId: 'fe_1' }, + }, + }); + const err = await expectExit(runFeatureFlagEnable('beta'), 1); + expect(err.context?.errorCode).toBe('not_found'); + }); + + it('enable --json emits { enabled }', async () => { + setOutputMode('json'); + respondWith({ flagBySlug: { flagBySlug: FLAG_NODE }, updateFlagEnvironment: UPDATED_PAYLOAD }); + await runFeatureFlagEnable('beta'); + expect(JSON.parse(consoleOutput[0])).toEqual({ enabled: 'beta' }); + }); + + it('disable sends flagEnabled false, preserving targeting', async () => { + setOutputMode('json'); + respondWith({ flagBySlug: { flagBySlug: FLAG_NODE }, updateFlagEnvironment: UPDATED_PAYLOAD }); + await runFeatureFlagDisable('beta'); + const input = (mockGraphqlRequest.mock.calls.at(-1)![1] as { variables: { input: Record } }) + .variables.input; + expect(input).toMatchObject({ + flagEnvironmentId: 'fe_1', + flagEnabled: false, + organizationIds: ['org_1'], + userIds: ['user_1'], + }); + expect(JSON.parse(consoleOutput[0])).toEqual({ disabled: 'beta' }); + }); + + it('errors not_found when the flag has no state in the active environment', async () => { + respondWith({ flagBySlug: { flagBySlug: { ...FLAG_NODE, flagEnvironments: [] } } }); + const err = await expectExit(runFeatureFlagEnable('beta'), 1); + expect(err.context?.errorCode).toBe('not_found'); + const docs = mockGraphqlRequest.mock.calls.map((call) => String(call[0])); + expect(docs.some((doc) => doc.includes('mutation updateFlagEnvironment'))).toBe(false); + }); + + it('errors not_found when the flag does not exist', async () => { + respondWith({ flagBySlug: { flagBySlug: null } }); + const err = await expectExit(runFeatureFlagDisable('missing'), 1); + expect(err.context?.errorCode).toBe('not_found'); }); }); - describe('JSON output mode', () => { - beforeEach(() => setOutputMode('json')); - afterEach(() => setOutputMode('human')); + describe('add-target / remove-target (prefix-typed, read-merge-write)', () => { + it('add-target user_* merges into userIds', async () => { + respondWith({ flagBySlug: { flagBySlug: FLAG_NODE }, updateFlagEnvironment: UPDATED_PAYLOAD }); + await runFeatureFlagAddTarget('beta', 'user_2'); + const input = (mockGraphqlRequest.mock.calls.at(-1)![1] as { variables: { input: Record } }) + .variables.input; + expect(input).toMatchObject({ + flagEnabled: false, // preserved, not toggled + organizationIds: ['org_1'], + userIds: ['user_1', 'user_2'], + }); + }); - it('list outputs { data, listMetadata }', async () => { - mockSdk.featureFlags.listFeatureFlags.mockResolvedValue({ - data: [mockFlag], - listMetadata: { before: null, after: 'cursor_a' }, + it('add-target org_* merges into organizationIds', async () => { + setOutputMode('json'); + respondWith({ flagBySlug: { flagBySlug: FLAG_NODE }, updateFlagEnvironment: UPDATED_PAYLOAD }); + await runFeatureFlagAddTarget('beta', 'org_2'); + const input = (mockGraphqlRequest.mock.calls.at(-1)![1] as { variables: { input: Record } }) + .variables.input; + expect(input).toMatchObject({ + organizationIds: ['org_1', 'org_2'], + userIds: ['user_1'], }); - await runFeatureFlagList({}, 'sk_test'); - const output = JSON.parse(consoleOutput[0]); - expect(output.data).toHaveLength(1); - expect(output.data[0].slug).toBe('coffee'); - expect(output.listMetadata.after).toBe('cursor_a'); + expect(JSON.parse(consoleOutput[0])).toEqual({ added: { flag: 'beta', targetId: 'org_2' } }); + }); + + it('add-target is idempotent for an existing target', async () => { + respondWith({ flagBySlug: { flagBySlug: FLAG_NODE }, updateFlagEnvironment: UPDATED_PAYLOAD }); + await runFeatureFlagAddTarget('beta', 'user_1'); + const input = (mockGraphqlRequest.mock.calls.at(-1)![1] as { variables: { input: Record } }) + .variables.input; + expect(input).toMatchObject({ userIds: ['user_1'] }); + }); + + it('rejects a target ID with an unknown prefix before any request', async () => { + const err = await expectExit(runFeatureFlagAddTarget('beta', 'conn_123'), 1); + expect(err.context?.errorCode).toBe('invalid_argument'); + expect(mockGraphqlRequest).not.toHaveBeenCalled(); }); - it('list outputs empty data array for no results', async () => { - mockSdk.featureFlags.listFeatureFlags.mockResolvedValue({ - data: [], - listMetadata: { before: null, after: null }, + it('remove-target filters the list', async () => { + setOutputMode('json'); + respondWith({ flagBySlug: { flagBySlug: FLAG_NODE }, updateFlagEnvironment: UPDATED_PAYLOAD }); + await runFeatureFlagRemoveTarget('beta', 'user_1'); + const input = (mockGraphqlRequest.mock.calls.at(-1)![1] as { variables: { input: Record } }) + .variables.input; + expect(input).toMatchObject({ userIds: [], organizationIds: ['org_1'] }); + expect(JSON.parse(consoleOutput[0])).toEqual({ removed: { flag: 'beta', targetId: 'user_1' } }); + }); + + it('add-target prints a success message in human mode', async () => { + respondWith({ flagBySlug: { flagBySlug: FLAG_NODE }, updateFlagEnvironment: UPDATED_PAYLOAD }); + await runFeatureFlagAddTarget('beta', 'user_2'); + expect(consoleOutput.join('\n')).toContain('Added target'); + }); + + it('remove-target prints a success message in human mode', async () => { + respondWith({ flagBySlug: { flagBySlug: FLAG_NODE }, updateFlagEnvironment: UPDATED_PAYLOAD }); + await runFeatureFlagRemoveTarget('beta', 'user_1'); + expect(consoleOutput.join('\n')).toContain('Removed target'); + }); + + it('remove-target errors not_found when the flag has no state in this environment', async () => { + respondWith({ flagBySlug: { flagBySlug: { ...FLAG_NODE, flagEnvironments: [] } } }); + const err = await expectExit(runFeatureFlagRemoveTarget('beta', 'user_1'), 1); + expect(err.context?.errorCode).toBe('not_found'); + }); + + it('remove-target errors not_found when the target is not assigned', async () => { + respondWith({ flagBySlug: { flagBySlug: FLAG_NODE } }); + const err = await expectExit(runFeatureFlagRemoveTarget('beta', 'user_9'), 1); + expect(err.context?.errorCode).toBe('not_found'); + const docs = mockGraphqlRequest.mock.calls.map((call) => String(call[0])); + expect(docs.some((doc) => doc.includes('mutation updateFlagEnvironment'))).toBe(false); + }); + }); + + describe('shared failure modes (every subcommand)', () => { + it.each(SUBCOMMANDS)('$name exits auth-required (code 4) when not logged in', async ({ run }) => { + mockRequireCommandToken.mockImplementation(() => { + throw new CliExit(4, { reason: 'auth_required', errorCode: 'auth_required' }); }); - await runFeatureFlagList({}, 'sk_test'); - const output = JSON.parse(consoleOutput[0]); - expect(output.data).toEqual([]); + await expectExit(run(), 4); + expect(mockGraphqlRequest).not.toHaveBeenCalled(); }); - it('get outputs raw JSON', async () => { - mockSdk.featureFlags.getFeatureFlag.mockResolvedValue(mockFlag); - await runFeatureFlagGet('coffee', 'sk_test'); - const output = JSON.parse(consoleOutput[0]); - expect(output.slug).toBe('coffee'); - expect(output).not.toHaveProperty('status'); + it.each(SUBCOMMANDS)('$name sends the environment header on its operation request', async ({ run, routes }) => { + respondWith(routes); + await run(); + expect(mockGraphqlRequest).toHaveBeenCalled(); + expect(mockGraphqlRequest.mock.calls.at(-1)![1]).toMatchObject({ + token: 'tok_123', + environmentId: 'env_profile', + }); }); - it('enable outputs JSON success', async () => { - mockSdk.featureFlags.enableFeatureFlag.mockResolvedValue({ ...mockFlag, enabled: true }); - await runFeatureFlagEnable('coffee', 'sk_test'); - const output = JSON.parse(consoleOutput[0]); - expect(output.status).toBe('ok'); - expect(output.message).toBe('Enabled feature flag'); + it.each(SUBCOMMANDS)('$name surfaces a 403 without naming GraphQL', async ({ run }) => { + const consoleErrors: string[] = []; + vi.spyOn(console, 'error').mockImplementation((...args: unknown[]) => { + consoleErrors.push(args.map(String).join(' ')); + }); + // The team projects fetch stays healthy (resolver pre-validation and + // project derivation); the flag operation is what the gate rejects. + mockGraphqlRequest.mockImplementation(async (doc: unknown) => { + if (String(doc).includes('teamProjectsV2')) return TEAM_ENVIRONMENTS_PAYLOAD; + throw new DashboardGraphqlError('The dashboard GraphQL API rejected this session (HTTP 403).', 'forbidden', 403); + }); + await expectExit(run(), 1); + const err = consoleErrors.join('\n'); + expect(err).toMatch(/account-plane capability/i); + expect(err).not.toMatch(/graphql/i); }); }); }); diff --git a/src/commands/feature-flag.ts b/src/commands/feature-flag.ts index 06e4efcb..cd9f7862 100644 --- a/src/commands/feature-flag.ts +++ b/src/commands/feature-flag.ts @@ -1,128 +1,445 @@ +/** + * `workos feature-flag` — feature-flag management on the dashboard account + * plane. + * + * Migrated from the API-key REST SDK (graphql-resource-migration Phase 5): the + * subcommand surface (list/get/enable/disable/add-target/remove-target) is + * unchanged, but every operation now runs catalog-backed dashboard operations + * with the user's OAuth bearer. Output shapes are new curated shapes (approved + * breaking change); the authoritative examples live in `feature-flag.spec.ts`. + * + * Backend divergences from REST (all loud, none faked): + * - The backing queries are PROJECT-scoped, so every subcommand first derives + * the active environment's project from the team's project list (one extra + * read). + * - Flag state is per-environment server-side; the curated shapes report the + * ACTIVE environment's state (`enabled`, targeting). A flag with no state in + * the target environment errors `not_found` on enable/disable/targeting. + * - The per-environment update mutation REPLACES the flag's target lists, so + * enable/disable/add-target/remove-target read-merge-write: they fetch the + * flag first and always send the full current targeting alongside the change. + * - Targets are typed by ID prefix (`user_` / `org_`), mirroring the REST + * target endpoint's contract; other prefixes are refused loudly. + * + * Safety posture per the manifest: all mutations are reversible configuration + * toggles (ciPolicy `allow`) — no confirmation gates, matching REST behavior. + */ + import chalk from 'chalk'; -import { createWorkOSClient } from '../lib/workos-client.js'; +import { requireCommandToken } from '../lib/command-auth.js'; +import { dashboardGraphqlRequest } from '../lib/dashboard-graphql.js'; +import { resolveEnvironmentTarget } from '../lib/environment-target.js'; +import { getOperation, resolveExecutableDocument, reportDashboardError } from '../catalog/operation.js'; +import { isJsonMode, outputJson, outputSuccess, exitWithError } from '../utils/output.js'; import { formatTable } from '../utils/table.js'; -import { outputSuccess, outputJson, isJsonMode } from '../utils/output.js'; -import { createApiErrorHandler } from '../lib/api-error-handler.js'; +import { formatWorkOSCommand } from '../utils/command-invocation.js'; + +/** Map the CLI `--order asc|desc` flag onto the catalog's pagination enum. */ +function normalizeOrder(order: string | undefined): 'Asc' | 'Desc' | undefined { + if (order === undefined) return undefined; + const lower = order.toLowerCase(); + if (lower === 'asc') return 'Asc'; + if (lower === 'desc') return 'Desc'; + exitWithError({ code: 'invalid_argument', message: `Invalid --order "${order}". Allowed values: asc, desc.` }); +} + +interface FlagEnvironmentNode { + id: string; + environmentId: string; + flagEnabled?: boolean | null; + defaultEnabled?: boolean | null; + accessType?: string | null; + organizations?: Array<{ id: string; name?: string | null }> | null; + users?: Array<{ id: string; email?: string | null }> | null; +} + +interface FlagNode { + id: string; + name?: string | null; + slug: string; + description?: string | null; + flagEnvironments?: FlagEnvironmentNode[] | null; + createdAt?: string | null; + updatedAt?: string | null; + tags?: Array<{ id: string; name?: string | null }> | null; +} + +function environmentStateFor(flag: FlagNode, environmentId: string): FlagEnvironmentNode | undefined { + return (flag.flagEnvironments ?? []).find((state) => state.environmentId === environmentId); +} + +/** + * The curated flag list shape — `enabled` is the ACTIVE environment's state. + * camelCase, stable keys, no internal fields; see feature-flag.spec.ts for the + * authoritative example. + */ +function shapeFlag(flag: FlagNode, environmentId: string) { + return { + id: flag.id, + slug: flag.slug, + name: flag.name ?? null, + description: flag.description ?? null, + enabled: environmentStateFor(flag, environmentId)?.flagEnabled ?? false, + createdAt: flag.createdAt ?? null, + updatedAt: flag.updatedAt ?? null, + }; +} + +/** The curated `get` shape: the list shape plus the active environment's targeting. */ +function shapeFlagDetail(flag: FlagNode, environmentId: string) { + const state = environmentStateFor(flag, environmentId); + return { + ...shapeFlag(flag, environmentId), + defaultEnabled: state?.defaultEnabled ?? false, + accessType: state?.accessType ?? null, + organizationTargets: (state?.organizations ?? []).map((org) => ({ id: org.id, name: org.name ?? null })), + userTargets: (state?.users ?? []).map((user) => ({ id: user.id, email: user.email ?? null })), + tags: (flag.tags ?? []).map((tag) => tag.name ?? tag.id), + }; +} + +interface TeamProjectsData { + currentTeam: { + projectsV2: Array<{ id: string; environments: Array<{ id: string }> | null }> | null; + } | null; +} -const handleApiError = createApiErrorHandler('FeatureFlag'); +/** + * The flag operations are project-scoped: derive the project that owns the + * resolved environment from the team's project list. A resolved environment + * that joins no project means the stored ID went stale — same remedy as the + * environment resolver's staleness error. + */ +async function resolveProjectId(token: string, environmentId: string): Promise { + const op = getOperation('teamProjectsV2'); + let data: TeamProjectsData; + try { + // Team-scoped read: deliberately no environment header. + data = await dashboardGraphqlRequest(resolveExecutableDocument(op), { token }); + } catch (error) { + reportDashboardError(error); + } + + const projects = data.currentTeam?.projectsV2 ?? []; + const project = projects.find((candidate) => + (candidate.environments ?? []).some((environment) => environment.id === environmentId), + ); + if (!project) { + exitWithError({ + code: 'environment_stale', + message: + `Environment "${environmentId}" was not found in your WorkOS team — it may have been deleted or recreated. ` + + `Pass --environment-id, or run \`${formatWorkOSCommand('env switch')}\` to select an environment.`, + }); + } + return project.id; +} export interface FeatureFlagListOptions { + /** `--environment-id` override; defaults from the active profile. */ + environmentId?: string; limit?: number; before?: string; after?: string; order?: string; } -export async function runFeatureFlagList( - options: FeatureFlagListOptions, - apiKey: string, - baseUrl?: string, -): Promise { - const client = createWorkOSClient(apiKey, baseUrl); +export async function runFeatureFlagList(options: FeatureFlagListOptions = {}): Promise { + const order = normalizeOrder(options.order); + const token = await requireCommandToken(); + const op = getOperation('flags'); + const { environmentId } = await resolveEnvironmentTarget(token, { + flagValue: options.environmentId, + forMutation: op.kind === 'mutation', + }); + const projectId = await resolveProjectId(token, environmentId); + + let data: { + flagsForProject: { + data: FlagNode[]; + listMetadata: { before: string | null; after: string | null }; + } | null; + }; try { - const result = await client.sdk.featureFlags.listFeatureFlags({ - limit: options.limit, - before: options.before, - after: options.after, - order: options.order as 'asc' | 'desc' | undefined, + data = await dashboardGraphqlRequest(resolveExecutableDocument(op), { + token, + variables: { + projectId, + ...(options.limit !== undefined ? { limit: options.limit } : {}), + ...(options.before ? { before: options.before } : {}), + ...(options.after ? { after: options.after } : {}), + ...(order ? { order } : {}), + }, + environmentId, }); - - if (isJsonMode()) { - outputJson({ data: result.data, listMetadata: result.listMetadata }); - return; - } - - if (result.data.length === 0) { - console.log('No feature flags found.'); - return; - } - - const rows = result.data.map((flag) => [ - flag.slug, - flag.name ?? chalk.dim('-'), - flag.enabled ? chalk.green('Yes') : chalk.red('No'), - flag.description ?? chalk.dim('-'), - ]); - - console.log( - formatTable([{ header: 'Slug' }, { header: 'Name' }, { header: 'Enabled' }, { header: 'Description' }], rows), - ); - - const { before, after } = result.listMetadata; - if (before && after) { - console.log(chalk.dim(`Before: ${before} After: ${after}`)); - } else if (before) { - console.log(chalk.dim(`Before: ${before}`)); - } else if (after) { - console.log(chalk.dim(`After: ${after}`)); - } } catch (error) { - handleApiError(error); + reportDashboardError(error); + } + + const flags = (data.flagsForProject?.data ?? []).map((flag) => shapeFlag(flag, environmentId)); + const pagination = { + before: data.flagsForProject?.listMetadata?.before ?? null, + after: data.flagsForProject?.listMetadata?.after ?? null, + }; + + if (isJsonMode()) { + outputJson({ flags, pagination }); + return; + } + if (flags.length === 0) { + console.log('No feature flags found.'); + return; + } + + const rows = flags.map((flag) => [ + flag.slug, + flag.name ?? chalk.dim('-'), + flag.enabled ? chalk.green('Yes') : chalk.red('No'), + flag.description ?? chalk.dim('-'), + ]); + console.log( + formatTable([{ header: 'Slug' }, { header: 'Name' }, { header: 'Enabled' }, { header: 'Description' }], rows), + ); + + const { before, after } = pagination; + if (before && after) { + console.log(chalk.dim(`Before: ${before} After: ${after}`)); + } else if (before) { + console.log(chalk.dim(`Before: ${before}`)); + } else if (after) { + console.log(chalk.dim(`After: ${after}`)); } } -export async function runFeatureFlagGet(slug: string, apiKey: string, baseUrl?: string): Promise { - const client = createWorkOSClient(apiKey, baseUrl); +export interface FeatureFlagEnvironmentOptions { + /** `--environment-id` override; defaults from the active profile. */ + environmentId?: string; +} +/** Fetch a flag by slug within the resolved environment's project, or exit not_found. */ +async function requireFlagBySlug( + token: string, + environmentId: string, + projectId: string, + slug: string, +): Promise { + const op = getOperation('flagBySlug'); + let data: { flagBySlug: FlagNode | null }; try { - const result = await client.sdk.featureFlags.getFeatureFlag(slug); - outputJson(result); + data = await dashboardGraphqlRequest(resolveExecutableDocument(op), { + token, + variables: { projectId, slug }, + environmentId, + }); } catch (error) { - handleApiError(error); + reportDashboardError(error); } + if (!data.flagBySlug) { + exitWithError({ code: 'not_found', message: `Feature flag "${slug}" was not found.` }); + } + return data.flagBySlug; } -export async function runFeatureFlagEnable(slug: string, apiKey: string, baseUrl?: string): Promise { - const client = createWorkOSClient(apiKey, baseUrl); +export async function runFeatureFlagGet(slug: string, options: FeatureFlagEnvironmentOptions = {}): Promise { + const token = await requireCommandToken(); - try { - const result = await client.sdk.featureFlags.enableFeatureFlag(slug); - outputSuccess('Enabled feature flag', result); - } catch (error) { - handleApiError(error); + const { environmentId } = await resolveEnvironmentTarget(token, { + flagValue: options.environmentId, + forMutation: false, + }); + const projectId = await resolveProjectId(token, environmentId); + const flag = shapeFlagDetail(await requireFlagBySlug(token, environmentId, projectId, slug), environmentId); + + if (isJsonMode()) { + outputJson({ flag }); + return; } + + const fields: Array<[string, unknown]> = [ + ['Slug', flag.slug], + ['Name', flag.name], + ['Enabled', flag.enabled ? 'Yes' : 'No'], + ['Description', flag.description], + ['Access type', flag.accessType], + ['Organization targets', flag.organizationTargets.length > 0 ? flag.organizationTargets.map((t) => t.id).join(', ') : null], + ['User targets', flag.userTargets.length > 0 ? flag.userTargets.map((t) => t.id).join(', ') : null], + ['Created', flag.createdAt], + ]; + for (const [label, value] of fields) { + if (value === null || value === undefined || value === '') continue; + console.log(`${chalk.bold(label)}: ${String(value)}`); + } + console.log(chalk.dim('Run with --json for the full record.')); +} + +/** + * Resolve the flag's state record for the target environment, or exit + * not_found (the flag exists but has no state in this environment). + */ +function requireEnvironmentState(flag: FlagNode, slug: string, environmentId: string): FlagEnvironmentNode { + const state = environmentStateFor(flag, environmentId); + if (!state) { + exitWithError({ + code: 'not_found', + message: `Feature flag "${slug}" is not configured in this environment.`, + }); + } + return state; } -export async function runFeatureFlagDisable(slug: string, apiKey: string, baseUrl?: string): Promise { - const client = createWorkOSClient(apiKey, baseUrl); +/** + * Issue the per-environment update. The mutation REQUIRES the full state + * (enabled/default/access type) and REPLACES the target lists — callers merge + * their change into the freshly fetched state and pass everything back. + */ +async function executeFlagEnvironmentUpdate( + token: string, + environmentId: string, + slug: string, + state: FlagEnvironmentNode, + overrides: { flagEnabled?: boolean; organizationIds?: string[]; userIds?: string[] }, +): Promise { + const op = getOperation('updateFlagEnvironment'); + let data: { + updateFlagEnvironment: + | { __typename: 'FlagEnvironmentUpdated'; flagEnvironment: FlagEnvironmentNode } + | { __typename: 'FlagEnvironmentNotFound'; flagEnvironmentId: string } + | { __typename: string }; + }; try { - const result = await client.sdk.featureFlags.disableFeatureFlag(slug); - outputSuccess('Disabled feature flag', result); + data = await dashboardGraphqlRequest(resolveExecutableDocument(op), { + token, + variables: { + input: { + flagEnvironmentId: state.id, + flagEnabled: overrides.flagEnabled ?? state.flagEnabled ?? false, + defaultEnabled: state.defaultEnabled ?? false, + accessType: state.accessType ?? 'NONE', + organizationIds: overrides.organizationIds ?? (state.organizations ?? []).map((org) => org.id), + userIds: overrides.userIds ?? (state.users ?? []).map((user) => user.id), + }, + }, + environmentId, + }); } catch (error) { - handleApiError(error); + reportDashboardError(error); + } + + const result = data.updateFlagEnvironment; + if (result.__typename === 'FlagEnvironmentNotFound') { + exitWithError({ code: 'not_found', message: `Feature flag "${slug}" is not configured in this environment.` }); + } + if (result.__typename !== 'FlagEnvironmentUpdated') { + exitWithError({ code: 'unexpected_result', message: `Could not update feature flag "${slug}".` }); + } +} + +async function setFlagEnabled( + slug: string, + enabled: boolean, + options: FeatureFlagEnvironmentOptions, +): Promise { + const token = await requireCommandToken(); + + // Environment-scoped mutation: pre-validated resolved target as header. + const { environmentId } = await resolveEnvironmentTarget(token, { + flagValue: options.environmentId, + forMutation: true, + }); + const projectId = await resolveProjectId(token, environmentId); + const flag = await requireFlagBySlug(token, environmentId, projectId, slug); + const state = requireEnvironmentState(flag, slug, environmentId); + + await executeFlagEnvironmentUpdate(token, environmentId, slug, state, { flagEnabled: enabled }); + + if (isJsonMode()) { + outputJson(enabled ? { enabled: slug } : { disabled: slug }); + return; } + outputSuccess(`${enabled ? 'Enabled' : 'Disabled'} feature flag ${chalk.bold(slug)}`); +} + +export async function runFeatureFlagEnable(slug: string, options: FeatureFlagEnvironmentOptions = {}): Promise { + await setFlagEnabled(slug, true, options); +} + +export async function runFeatureFlagDisable(slug: string, options: FeatureFlagEnvironmentOptions = {}): Promise { + await setFlagEnabled(slug, false, options); +} + +/** Targets are typed by ID prefix, mirroring the REST target endpoint's contract. */ +function targetListKey(targetId: string): 'organizationIds' | 'userIds' { + if (targetId.startsWith('user_')) return 'userIds'; + if (targetId.startsWith('org_')) return 'organizationIds'; + exitWithError({ + code: 'invalid_argument', + message: `Invalid target ID "${targetId}". Targets must be a user (user_*) or an organization (org_*).`, + }); } export async function runFeatureFlagAddTarget( slug: string, targetId: string, - apiKey: string, - baseUrl?: string, + options: FeatureFlagEnvironmentOptions = {}, ): Promise { - const client = createWorkOSClient(apiKey, baseUrl); + const key = targetListKey(targetId); + const token = await requireCommandToken(); - try { - await client.sdk.featureFlags.addFlagTarget({ slug, targetId }); - outputSuccess('Added target to feature flag', { slug, targetId }); - } catch (error) { - handleApiError(error); + const { environmentId } = await resolveEnvironmentTarget(token, { + flagValue: options.environmentId, + forMutation: true, + }); + const projectId = await resolveProjectId(token, environmentId); + const flag = await requireFlagBySlug(token, environmentId, projectId, slug); + const state = requireEnvironmentState(flag, slug, environmentId); + + // The mutation replaces the lists — merge (deduped) into the current one. + const current = + key === 'userIds' ? (state.users ?? []).map((user) => user.id) : (state.organizations ?? []).map((org) => org.id); + const merged = [...new Set([...current, targetId])]; + + await executeFlagEnvironmentUpdate(token, environmentId, slug, state, { [key]: merged }); + + if (isJsonMode()) { + outputJson({ added: { flag: slug, targetId } }); + return; } + outputSuccess(`Added target ${chalk.bold(targetId)} to feature flag ${chalk.bold(slug)}`); } export async function runFeatureFlagRemoveTarget( slug: string, targetId: string, - apiKey: string, - baseUrl?: string, + options: FeatureFlagEnvironmentOptions = {}, ): Promise { - const client = createWorkOSClient(apiKey, baseUrl); + const key = targetListKey(targetId); + const token = await requireCommandToken(); - try { - await client.sdk.featureFlags.removeFlagTarget({ slug, targetId }); - outputSuccess('Removed target from feature flag', { slug, targetId }); - } catch (error) { - handleApiError(error); + const { environmentId } = await resolveEnvironmentTarget(token, { + flagValue: options.environmentId, + forMutation: true, + }); + const projectId = await resolveProjectId(token, environmentId); + const flag = await requireFlagBySlug(token, environmentId, projectId, slug); + const state = requireEnvironmentState(flag, slug, environmentId); + + const current = + key === 'userIds' ? (state.users ?? []).map((user) => user.id) : (state.organizations ?? []).map((org) => org.id); + if (!current.includes(targetId)) { + exitWithError({ + code: 'not_found', + message: `Feature flag "${slug}" has no target "${targetId}" in this environment.`, + }); + } + const remaining = current.filter((candidate) => candidate !== targetId); + + await executeFlagEnvironmentUpdate(token, environmentId, slug, state, { [key]: remaining }); + + if (isJsonMode()) { + outputJson({ removed: { flag: slug, targetId } }); + return; } + outputSuccess(`Removed target ${chalk.bold(targetId)} from feature flag ${chalk.bold(slug)}`); } diff --git a/src/commands/permission.spec.ts b/src/commands/permission.spec.ts index 2fc00581..a6ccd68b 100644 --- a/src/commands/permission.spec.ts +++ b/src/commands/permission.spec.ts @@ -1,261 +1,528 @@ import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'; -// Mock the unified client -const mockSdk = { - authorization: { - listPermissions: vi.fn(), - getPermission: vi.fn(), - createPermission: vi.fn(), - updatePermission: vi.fn(), - deletePermission: vi.fn(), - }, -}; +const mockRequireCommandToken = vi.fn(); +const mockGraphqlRequest = vi.fn(); +const mockConfirm = vi.fn(); +const mockIsCancel = vi.fn(() => false); +const mockGetActiveEnvironment = vi.fn(); +const mockGetConfig = vi.fn(); + +vi.mock('../lib/command-auth.js', async (importActual) => { + const actual = await importActual(); + return { + ...actual, + requireCommandToken: () => mockRequireCommandToken(), + }; +}); + +vi.mock('../lib/dashboard-graphql.js', async (importActual) => { + const actual = await importActual(); + return { + ...actual, + dashboardGraphqlRequest: (...args: unknown[]) => mockGraphqlRequest(...args), + }; +}); -vi.mock('../lib/workos-client.js', () => ({ - createWorkOSClient: () => ({ sdk: mockSdk }), +vi.mock('../utils/clack.js', () => ({ + default: { + confirm: (...args: unknown[]) => mockConfirm(...args), + isCancel: (...args: unknown[]) => mockIsCancel(...args), + select: vi.fn(), + }, })); -const { setOutputMode } = await import('../utils/output.js'); +// The environment-target resolver runs REAL against the mocked wire + config +// store, so mutation tests can assert the pre-validation fetch +// (teamProjectsV2) happens before the operation request. +vi.mock('../lib/config-store.js', async (importActual) => { + const actual = await importActual(); + return { + ...actual, + getActiveEnvironment: () => mockGetActiveEnvironment(), + getConfig: () => mockGetConfig(), + setProfileEnvironmentId: vi.fn(), + }; +}); +const { setOutputMode } = await import('../utils/output.js'); +const { resetInteractionModeForTests, setInteractionMode } = await import('../utils/interaction-mode.js'); +const { DashboardGraphqlError } = await import('../lib/dashboard-graphql.js'); +const { CliExit } = await import('../utils/cli-exit.js'); const { runPermissionList, runPermissionGet, runPermissionCreate, runPermissionUpdate, runPermissionDelete } = await import('./permission.js'); -describe('permission commands', () => { +const TEAM_ENVIRONMENTS_PAYLOAD = { + currentTeam: { + projectsV2: [{ environments: [{ id: 'env_profile', name: 'Sandbox', sandbox: true, clientId: null }] }], + }, +}; + +const PERMISSION_NODE = { + id: 'perm_1', + name: 'Read users', + slug: 'users:read', + description: 'Read user records', + system: false, + createdAt: '2026-01-01T00:00:00.000Z', + updatedAt: '2026-02-01T00:00:00.000Z', + // Internal fields the curated shape must drop: + environmentId: 'env_profile', + isEnabledForApiKeys: true, +}; + +const SYSTEM_PERMISSION_NODE = { + ...PERMISSION_NODE, + id: 'perm_sys', + slug: 'system:admin', + name: 'System admin', + system: true, +}; + +interface RouteMap { + list?: unknown; + createPermission?: unknown; + updatePermission?: unknown; + deletePermission?: unknown; +} + +/** + * Route the wire mock by document: the environment resolver's pre-validation + * fetch (`teamProjectsV2`) gets the team's environments; mutations and the + * list lookup get their configured payloads. + */ +function respondWith(routes: RouteMap): void { + mockGraphqlRequest.mockImplementation(async (doc: unknown) => { + const text = String(doc); + if (text.includes('teamProjectsV2')) return TEAM_ENVIRONMENTS_PAYLOAD; + if (text.includes('mutation createPermission')) return routes.createPermission; + if (text.includes('mutation updatePermission')) return routes.updatePermission; + if (text.includes('mutation deletePermission')) return routes.deletePermission; + if (text.includes('permissionsForEnvironment')) return routes.list; + throw new Error(`Unrouted document in test: ${text.slice(0, 80)}`); + }); +} + +async function expectExit(promise: Promise, code: number): Promise { + try { + await promise; + } catch (err) { + if (err instanceof CliExit) { + expect(err.exitCode).toBe(code); + return err; + } + throw err; + } + throw new Error(`Expected CliExit(${code}) but promise resolved`); +} + +/** The authoritative curated permission JSON shape (documented contract). */ +const PERMISSION_SHAPE_KEYS = ['id', 'slug', 'name', 'description', 'system', 'createdAt', 'updatedAt']; + +const LIST_PAYLOAD = { permissionsForEnvironment: { permissions: [PERMISSION_NODE, SYSTEM_PERMISSION_NODE] } }; +const CREATED_PAYLOAD = { createPermission: { __typename: 'PermissionCreated', permission: PERMISSION_NODE } }; +const UPDATED_PAYLOAD = { + updatePermission: { __typename: 'PermissionUpdated', permission: { ...PERMISSION_NODE, name: 'New name' } }, +}; +const DELETED_PAYLOAD = { deletePermission: { __typename: 'PermissionDeleted', permissionId: 'perm_1' } }; + +/** Every subcommand's happy-path invocation, for the shared contract matrices. */ +const SUBCOMMANDS: Array<{ name: string; routes: RouteMap; run: () => Promise }> = [ + { name: 'list', routes: { list: LIST_PAYLOAD }, run: () => runPermissionList({}) }, + { name: 'get', routes: { list: LIST_PAYLOAD }, run: () => runPermissionGet('users:read') }, + { + name: 'create', + routes: { createPermission: CREATED_PAYLOAD }, + run: () => runPermissionCreate({ slug: 'users:read', name: 'Read users', yes: true }), + }, + { + name: 'update', + routes: { list: LIST_PAYLOAD, updatePermission: UPDATED_PAYLOAD }, + run: () => runPermissionUpdate('users:read', { name: 'New name', yes: true }), + }, + { + name: 'delete', + routes: { list: LIST_PAYLOAD, deletePermission: DELETED_PAYLOAD }, + run: () => runPermissionDelete('users:read', { yes: true }), + }, +]; + +/** The require-flag-gated subcommands (privilege-surface changes). */ +const GATED: Array<{ name: string; routes: RouteMap; run: (yes: boolean) => Promise }> = [ + { + name: 'create', + routes: { createPermission: CREATED_PAYLOAD }, + run: (yes) => runPermissionCreate({ slug: 'users:read', name: 'Read users', yes }), + }, + { + name: 'update', + routes: { list: LIST_PAYLOAD, updatePermission: UPDATED_PAYLOAD }, + run: (yes) => runPermissionUpdate('users:read', { name: 'New name', yes }), + }, +]; + +describe('permission command', () => { let consoleOutput: string[]; beforeEach(() => { vi.clearAllMocks(); + resetInteractionModeForTests(); + setOutputMode('human'); + mockRequireCommandToken.mockResolvedValue('tok_123'); + mockConfirm.mockReset(); + mockIsCancel.mockReset(); + mockIsCancel.mockReturnValue(false); + mockGetActiveEnvironment.mockReturnValue({ apiKey: 'sk_ignored', environmentId: 'env_profile' }); + mockGetConfig.mockReturnValue({ + activeEnvironment: 'default', + environments: { default: { apiKey: 'sk_ignored', environmentId: 'env_profile' } }, + }); consoleOutput = []; vi.spyOn(console, 'log').mockImplementation((...args: unknown[]) => { consoleOutput.push(args.map(String).join(' ')); }); + vi.spyOn(console, 'error').mockImplementation(() => {}); }); afterEach(() => { vi.restoreAllMocks(); + resetInteractionModeForTests(); + setOutputMode('human'); }); - describe('runPermissionList', () => { - it('lists permissions in table format', async () => { - mockSdk.authorization.listPermissions.mockResolvedValue({ - data: [ - { - id: 'perm_123', - slug: 'read-users', - name: 'Read Users', - description: 'Can read user data', - createdAt: '2024-01-01T00:00:00Z', - }, - ], - listMetadata: { before: null, after: null }, + describe('list', () => { + it('lists permissions with the environment as variable and header', async () => { + respondWith({ list: LIST_PAYLOAD }); + await runPermissionList({}); + expect(mockGraphqlRequest).toHaveBeenCalledTimes(1); + expect(mockGraphqlRequest).toHaveBeenCalledWith(expect.stringContaining('permissionsForEnvironment'), { + token: 'tok_123', + variables: { id: 'env_profile' }, + environmentId: 'env_profile', }); - await runPermissionList({}, 'sk_test'); - expect(mockSdk.authorization.listPermissions).toHaveBeenCalled(); - expect(consoleOutput.some((l) => l.includes('read-users'))).toBe(true); - expect(consoleOutput.some((l) => l.includes('Read Users'))).toBe(true); + expect(consoleOutput.join('\n')).toContain('users:read'); }); - it('passes pagination params', async () => { - mockSdk.authorization.listPermissions.mockResolvedValue({ - data: [], - listMetadata: { before: null, after: null }, - }); - await runPermissionList({ limit: 5, order: 'desc', after: 'cursor_a' }, 'sk_test'); - expect(mockSdk.authorization.listPermissions).toHaveBeenCalledWith( - expect.objectContaining({ limit: 5, order: 'desc', after: 'cursor_a' }), - ); + it('--json emits the curated shape', async () => { + setOutputMode('json'); + respondWith({ list: LIST_PAYLOAD }); + await runPermissionList({}); + const raw = consoleOutput[0]; + expect(raw).not.toMatch(/graphql|userland|isEnabledForApiKeys/i); + const out = JSON.parse(raw); + expect(Object.keys(out)).toEqual(['permissions']); + expect(Object.keys(out.permissions[0])).toEqual(PERMISSION_SHAPE_KEYS); + expect(out.permissions[0]).toMatchObject({ slug: 'users:read', system: false }); }); it('handles empty results', async () => { - mockSdk.authorization.listPermissions.mockResolvedValue({ - data: [], - listMetadata: { before: null, after: null }, - }); - await runPermissionList({}, 'sk_test'); - expect(consoleOutput.some((l) => l.includes('No permissions found'))).toBe(true); - }); - - it('shows pagination cursors', async () => { - mockSdk.authorization.listPermissions.mockResolvedValue({ - data: [ - { - id: 'perm_1', - slug: 'read', - name: 'Read', - description: null, - createdAt: '2024-01-01T00:00:00Z', - }, - ], - listMetadata: { before: 'cursor_b', after: 'cursor_a' }, - }); - await runPermissionList({}, 'sk_test'); - expect(consoleOutput.some((l) => l.includes('cursor_b'))).toBe(true); - expect(consoleOutput.some((l) => l.includes('cursor_a'))).toBe(true); + respondWith({ list: { permissionsForEnvironment: { permissions: [] } } }); + await runPermissionList({}); + expect(consoleOutput.some((line) => line.includes('No permissions found'))).toBe(true); }); }); - describe('runPermissionGet', () => { - it('fetches and prints permission as JSON', async () => { - mockSdk.authorization.getPermission.mockResolvedValue({ - id: 'perm_123', - slug: 'read-users', - name: 'Read Users', - description: 'Can read user data', - }); - await runPermissionGet('read-users', 'sk_test'); - expect(mockSdk.authorization.getPermission).toHaveBeenCalledWith('read-users'); - expect(consoleOutput.some((l) => l.includes('perm_123'))).toBe(true); + describe('get', () => { + it('client-filters the list by slug and renders fields', async () => { + respondWith({ list: LIST_PAYLOAD }); + await runPermissionGet('users:read'); + const out = consoleOutput.join('\n'); + expect(out).toContain('users:read'); + expect(out).toContain('Read users'); + }); + + it('--json emits { permission } in the curated shape', async () => { + setOutputMode('json'); + respondWith({ list: LIST_PAYLOAD }); + await runPermissionGet('users:read'); + const out = JSON.parse(consoleOutput[0]); + expect(Object.keys(out)).toEqual(['permission']); + expect(Object.keys(out.permission)).toEqual(PERMISSION_SHAPE_KEYS); + }); + + it('errors not_found when the slug does not match', async () => { + respondWith({ list: LIST_PAYLOAD }); + const err = await expectExit(runPermissionGet('missing'), 1); + expect(err.context?.errorCode).toBe('not_found'); }); }); - describe('runPermissionCreate', () => { - it('creates permission with slug and name', async () => { - mockSdk.authorization.createPermission.mockResolvedValue({ - id: 'perm_123', - slug: 'read-users', - name: 'Read Users', + describe('create', () => { + const created = CREATED_PAYLOAD; + + it('pre-validates the environment, then sends the create input', async () => { + setInteractionMode({ mode: 'ci', source: 'ci_env' }); + respondWith({ createPermission: created }); + await runPermissionCreate({ slug: 'users:read', name: 'Read users', description: 'd', yes: true }); + expect(String(mockGraphqlRequest.mock.calls[0][0])).toContain('teamProjectsV2'); + expect(mockGraphqlRequest.mock.calls[1][1]).toEqual({ + token: 'tok_123', + variables: { input: { slug: 'users:read', name: 'Read users', description: 'd' } }, + environmentId: 'env_profile', }); - await runPermissionCreate({ slug: 'read-users', name: 'Read Users' }, 'sk_test'); - expect(mockSdk.authorization.createPermission).toHaveBeenCalledWith({ - slug: 'read-users', - name: 'Read Users', + }); + + it('errors already_exists on a duplicate slug', async () => { + respondWith({ + createPermission: { createPermission: { __typename: 'PermissionAlreadyExists', slug: 'users:read' } }, }); + const err = await expectExit(runPermissionCreate({ slug: 'users:read', name: 'Read users', yes: true }), 1); + expect(err.context?.errorCode).toBe('already_exists'); }); - it('includes description when provided', async () => { - mockSdk.authorization.createPermission.mockResolvedValue({ - id: 'perm_123', - slug: 'read-users', - name: 'Read Users', + it('errors invalid_argument on an invalid slug', async () => { + respondWith({ + createPermission: { createPermission: { __typename: 'PermissionSlugInvalid', slug: 'bad slug' } }, }); - await runPermissionCreate({ slug: 'read-users', name: 'Read Users', description: 'Desc' }, 'sk_test'); - expect(mockSdk.authorization.createPermission).toHaveBeenCalledWith({ - slug: 'read-users', - name: 'Read Users', - description: 'Desc', + const err = await expectExit(runPermissionCreate({ slug: 'bad slug', name: 'Bad', yes: true }), 1); + expect(err.context?.errorCode).toBe('invalid_argument'); + }); + + it('prints a success message in human mode', async () => { + respondWith({ createPermission: created }); + await runPermissionCreate({ slug: 'users:read', name: 'Read users', yes: true }); + expect(consoleOutput.join('\n')).toContain('Created permission'); + }); + + it('--json emits the created { permission }', async () => { + setOutputMode('json'); + respondWith({ createPermission: created }); + await runPermissionCreate({ slug: 'users:read', name: 'Read users', yes: true }); + const out = JSON.parse(consoleOutput[0]); + expect(Object.keys(out.permission)).toEqual(PERMISSION_SHAPE_KEYS); + }); + }); + + describe('update (read-merge-write)', () => { + const updated = UPDATED_PAYLOAD; + + it('requires --name or --description', async () => { + const err = await expectExit(runPermissionUpdate('users:read', {}), 1); + expect(err.context?.errorCode).toBe('missing_argument'); + expect(mockGraphqlRequest).not.toHaveBeenCalled(); + }); + + it('merges the current name/description into the ID-keyed input', async () => { + setInteractionMode({ mode: 'ci', source: 'ci_env' }); + respondWith({ list: LIST_PAYLOAD, updatePermission: updated }); + await runPermissionUpdate('users:read', { name: 'New name', yes: true }); + const docs = mockGraphqlRequest.mock.calls.map((call) => String(call[0])); + expect(docs[0]).toContain('teamProjectsV2'); + expect(docs[1]).toContain('permissionsForEnvironment'); + expect(docs[2]).toContain('mutation updatePermission'); + // The current description rides along: an omitted description would be + // cleared server-side. + expect(mockGraphqlRequest.mock.calls[2][1]).toEqual({ + token: 'tok_123', + variables: { input: { permissionId: 'perm_1', name: 'New name', description: 'Read user records' } }, + environmentId: 'env_profile', }); }); - it('outputs created message', async () => { - mockSdk.authorization.createPermission.mockResolvedValue({ - id: 'perm_123', - slug: 'read-users', - name: 'Read Users', + it('refuses to modify a system permission', async () => { + setInteractionMode({ mode: 'ci', source: 'ci_env' }); + respondWith({ list: LIST_PAYLOAD }); + const err = await expectExit(runPermissionUpdate('system:admin', { name: 'X', yes: true }), 1); + expect(err.context?.errorCode).toBe('invalid_argument'); + const docs = mockGraphqlRequest.mock.calls.map((call) => String(call[0])); + expect(docs.some((doc) => doc.includes('mutation updatePermission'))).toBe(false); + }); + + it('errors not_found when the slug does not resolve', async () => { + setInteractionMode({ mode: 'ci', source: 'ci_env' }); + respondWith({ list: { permissionsForEnvironment: { permissions: [] } } }); + const err = await expectExit(runPermissionUpdate('missing', { name: 'X', yes: true }), 1); + expect(err.context?.errorCode).toBe('not_found'); + }); + + it('errors not_found on the PermissionNotFound mutation variant', async () => { + respondWith({ + list: LIST_PAYLOAD, + updatePermission: { updatePermission: { __typename: 'PermissionNotFound', permissionId: 'perm_1' } }, }); - await runPermissionCreate({ slug: 'read-users', name: 'Read Users' }, 'sk_test'); - expect(consoleOutput.some((l) => l.includes('Created permission'))).toBe(true); + const err = await expectExit(runPermissionUpdate('users:read', { name: 'X', yes: true }), 1); + expect(err.context?.errorCode).toBe('not_found'); + }); + + it('prints a success message in human mode', async () => { + respondWith({ list: LIST_PAYLOAD, updatePermission: updated }); + await runPermissionUpdate('users:read', { name: 'New name', yes: true }); + expect(consoleOutput.join('\n')).toContain('Updated permission'); + }); + + it('--json emits the updated { permission }', async () => { + setOutputMode('json'); + respondWith({ list: LIST_PAYLOAD, updatePermission: updated }); + await runPermissionUpdate('users:read', { name: 'New name', yes: true }); + const out = JSON.parse(consoleOutput[0]); + expect(out.permission).toMatchObject({ slug: 'users:read', name: 'New name' }); }); }); - describe('runPermissionUpdate', () => { - it('updates permission with provided fields', async () => { - mockSdk.authorization.updatePermission.mockResolvedValue({ - id: 'perm_123', - slug: 'read-users', - name: 'Updated Name', + describe('delete (destructive, two-step)', () => { + const deleted = DELETED_PAYLOAD; + + it('refuses in agent mode without --yes (exit 1, confirmation_required)', async () => { + setInteractionMode({ mode: 'agent', source: 'agent_env' }); + const err = await expectExit(runPermissionDelete('users:read', { yes: false }), 1); + expect(err.context?.errorCode).toBe('confirmation_required'); + expect(mockGraphqlRequest).not.toHaveBeenCalled(); + }); + + it('refuses in CI mode without --yes', async () => { + setInteractionMode({ mode: 'ci', source: 'ci_env' }); + const err = await expectExit(runPermissionDelete('users:read', { yes: false }), 1); + expect(err.context?.errorCode).toBe('confirmation_required'); + expect(mockGraphqlRequest).not.toHaveBeenCalled(); + }); + + it('refuses in JSON mode without --yes (keeps stdout machine-readable)', async () => { + setInteractionMode({ mode: 'human', source: 'default' }); + setOutputMode('json'); + const err = await expectExit(runPermissionDelete('users:read', { yes: false }), 1); + expect(err.context?.errorCode).toBe('confirmation_required'); + expect(mockGraphqlRequest).not.toHaveBeenCalled(); + }); + + it('errors not_found on the PermissionNotFound mutation variant', async () => { + setInteractionMode({ mode: 'ci', source: 'ci_env' }); + respondWith({ + list: LIST_PAYLOAD, + deletePermission: { deletePermission: { __typename: 'PermissionNotFound', permissionId: 'perm_1' } }, }); - await runPermissionUpdate('read-users', { name: 'Updated Name' }, 'sk_test'); - expect(mockSdk.authorization.updatePermission).toHaveBeenCalledWith('read-users', { name: 'Updated Name' }); + const err = await expectExit(runPermissionDelete('users:read', { yes: true }), 1); + expect(err.context?.errorCode).toBe('not_found'); + }); + + it('prints a success message in human mode', async () => { + setInteractionMode({ mode: 'human', source: 'default' }); + mockConfirm.mockResolvedValue(true); + respondWith({ list: LIST_PAYLOAD, deletePermission: deleted }); + await runPermissionDelete('users:read', { yes: false }); + expect(consoleOutput.join('\n')).toContain('Deleted permission'); }); - it('sends only provided fields', async () => { - mockSdk.authorization.updatePermission.mockResolvedValue({ - id: 'perm_123', - slug: 'read-users', - name: 'Read Users', - description: 'New desc', + it('resolves the slug first, then deletes by permission ID', async () => { + setInteractionMode({ mode: 'ci', source: 'ci_env' }); + respondWith({ list: LIST_PAYLOAD, deletePermission: deleted }); + await runPermissionDelete('users:read', { yes: true }); + const docs = mockGraphqlRequest.mock.calls.map((call) => String(call[0])); + expect(docs[0]).toContain('teamProjectsV2'); + expect(docs[1]).toContain('permissionsForEnvironment'); + expect(docs[2]).toContain('mutation deletePermission'); + expect(mockGraphqlRequest.mock.calls[2][1]).toEqual({ + token: 'tok_123', + variables: { input: { permissionId: 'perm_1' } }, + environmentId: 'env_profile', }); - await runPermissionUpdate('read-users', { description: 'New desc' }, 'sk_test'); - expect(mockSdk.authorization.updatePermission).toHaveBeenCalledWith('read-users', { description: 'New desc' }); }); - }); - describe('runPermissionDelete', () => { - it('deletes permission and prints confirmation', async () => { - mockSdk.authorization.deletePermission.mockResolvedValue(undefined); - await runPermissionDelete('read-users', 'sk_test'); - expect(mockSdk.authorization.deletePermission).toHaveBeenCalledWith('read-users'); - expect(consoleOutput.some((l) => l.includes('Deleted'))).toBe(true); - expect(consoleOutput.some((l) => l.includes('read-users'))).toBe(true); + it('refuses to delete a system permission', async () => { + setInteractionMode({ mode: 'ci', source: 'ci_env' }); + respondWith({ list: LIST_PAYLOAD }); + const err = await expectExit(runPermissionDelete('system:admin', { yes: true }), 1); + expect(err.context?.errorCode).toBe('invalid_argument'); + const docs = mockGraphqlRequest.mock.calls.map((call) => String(call[0])); + expect(docs.some((doc) => doc.includes('mutation deletePermission'))).toBe(false); + }); + + it('prompts interactively and proceeds when confirmed', async () => { + setInteractionMode({ mode: 'human', source: 'default' }); + mockConfirm.mockResolvedValue(true); + respondWith({ list: LIST_PAYLOAD, deletePermission: deleted }); + await runPermissionDelete('users:read', { yes: false }); + expect(mockConfirm).toHaveBeenCalledOnce(); + expect(mockGraphqlRequest).toHaveBeenCalled(); + }); + + it('cancels (exit 2) when the interactive prompt is declined', async () => { + setInteractionMode({ mode: 'human', source: 'default' }); + mockConfirm.mockResolvedValue(false); + await expectExit(runPermissionDelete('users:read', { yes: false }), 2); + expect(mockGraphqlRequest).not.toHaveBeenCalled(); + }); + + it('--json emits { deleted }', async () => { + setInteractionMode({ mode: 'agent', source: 'agent_env' }); + setOutputMode('json'); + respondWith({ list: LIST_PAYLOAD, deletePermission: deleted }); + await runPermissionDelete('users:read', { yes: true }); + expect(JSON.parse(consoleOutput[0])).toEqual({ deleted: 'users:read' }); }); }); - describe('JSON output mode', () => { - beforeEach(() => { + describe('require-flag matrix (privilege-surface changes)', () => { + it.each(GATED)('$name refuses in agent mode without --yes', async ({ run }) => { + setInteractionMode({ mode: 'agent', source: 'agent_env' }); + const err = await expectExit(run(false), 1); + expect(err.context?.errorCode).toBe('confirmation_required'); + expect(mockGraphqlRequest).not.toHaveBeenCalled(); + }); + + it.each(GATED)('$name refuses in CI mode without --yes', async ({ run }) => { + setInteractionMode({ mode: 'ci', source: 'ci_env' }); + const err = await expectExit(run(false), 1); + expect(err.context?.errorCode).toBe('confirmation_required'); + expect(mockGraphqlRequest).not.toHaveBeenCalled(); + }); + + it.each(GATED)('$name refuses in JSON mode without --yes', async ({ run }) => { + setInteractionMode({ mode: 'human', source: 'default' }); setOutputMode('json'); + const err = await expectExit(run(false), 1); + expect(err.context?.errorCode).toBe('confirmation_required'); + expect(mockGraphqlRequest).not.toHaveBeenCalled(); }); - afterEach(() => { - setOutputMode('human'); + it.each(GATED)('$name proceeds without --yes for an interactive human (no prompt)', async ({ run, routes }) => { + setInteractionMode({ mode: 'human', source: 'default' }); + respondWith(routes); + await run(false); + expect(mockConfirm).not.toHaveBeenCalled(); + expect(mockGraphqlRequest).toHaveBeenCalled(); }); - it('runPermissionCreate outputs JSON success', async () => { - mockSdk.authorization.createPermission.mockResolvedValue({ - id: 'perm_123', - slug: 'read-users', - name: 'Read Users', - }); - await runPermissionCreate({ slug: 'read-users', name: 'Read Users' }, 'sk_test'); - const output = JSON.parse(consoleOutput[0]); - expect(output.status).toBe('ok'); - expect(output.message).toBe('Created permission'); - expect(output.data.id).toBe('perm_123'); - }); - - it('runPermissionGet outputs raw JSON', async () => { - mockSdk.authorization.getPermission.mockResolvedValue({ - id: 'perm_123', - slug: 'read-users', - name: 'Read Users', - }); - await runPermissionGet('read-users', 'sk_test'); - const output = JSON.parse(consoleOutput[0]); - expect(output.id).toBe('perm_123'); - expect(output.slug).toBe('read-users'); - expect(output).not.toHaveProperty('status'); + it.each(GATED)('$name proceeds in CI with --yes', async ({ run, routes }) => { + setInteractionMode({ mode: 'ci', source: 'ci_env' }); + respondWith(routes); + await run(true); + expect(mockGraphqlRequest).toHaveBeenCalled(); }); + }); - it('runPermissionList outputs JSON with data and listMetadata', async () => { - mockSdk.authorization.listPermissions.mockResolvedValue({ - data: [{ id: 'perm_123', slug: 'read-users', name: 'Read Users' }], - listMetadata: { before: null, after: 'cursor_a' }, + describe('shared failure modes (every subcommand)', () => { + it.each(SUBCOMMANDS)('$name exits auth-required (code 4) when not logged in', async ({ run }) => { + mockRequireCommandToken.mockImplementation(() => { + throw new CliExit(4, { reason: 'auth_required', errorCode: 'auth_required' }); }); - await runPermissionList({}, 'sk_test'); - const output = JSON.parse(consoleOutput[0]); - expect(output.data).toHaveLength(1); - expect(output.data[0].id).toBe('perm_123'); - expect(output.listMetadata.after).toBe('cursor_a'); + await expectExit(run(), 4); + expect(mockGraphqlRequest).not.toHaveBeenCalled(); }); - it('runPermissionList outputs empty data array for no results', async () => { - mockSdk.authorization.listPermissions.mockResolvedValue({ - data: [], - listMetadata: { before: null, after: null }, + it.each(SUBCOMMANDS)('$name sends the environment header on its operation request', async ({ run, routes }) => { + respondWith(routes); + await run(); + expect(mockGraphqlRequest).toHaveBeenCalled(); + expect(mockGraphqlRequest.mock.calls.at(-1)![1]).toMatchObject({ + token: 'tok_123', + environmentId: 'env_profile', }); - await runPermissionList({}, 'sk_test'); - const output = JSON.parse(consoleOutput[0]); - expect(output.data).toEqual([]); - expect(output.listMetadata).toBeDefined(); }); - it('runPermissionUpdate outputs JSON success', async () => { - mockSdk.authorization.updatePermission.mockResolvedValue({ - id: 'perm_123', - slug: 'read-users', - name: 'Updated', + it.each(SUBCOMMANDS)('$name surfaces a 403 without naming GraphQL', async ({ run }) => { + const consoleErrors: string[] = []; + vi.spyOn(console, 'error').mockImplementation((...args: unknown[]) => { + consoleErrors.push(args.map(String).join(' ')); + }); + // The resolver's own pre-validation fetch stays healthy; the OPERATION + // request is what the capability gate rejects. + mockGraphqlRequest.mockImplementation(async (doc: unknown) => { + if (String(doc).includes('teamProjectsV2')) return TEAM_ENVIRONMENTS_PAYLOAD; + throw new DashboardGraphqlError('The dashboard GraphQL API rejected this session (HTTP 403).', 'forbidden', 403); }); - await runPermissionUpdate('read-users', { name: 'Updated' }, 'sk_test'); - const output = JSON.parse(consoleOutput[0]); - expect(output.status).toBe('ok'); - expect(output.data.name).toBe('Updated'); - }); - - it('runPermissionDelete outputs JSON success', async () => { - mockSdk.authorization.deletePermission.mockResolvedValue(undefined); - await runPermissionDelete('read-users', 'sk_test'); - const output = JSON.parse(consoleOutput[0]); - expect(output.status).toBe('ok'); - expect(output.data.slug).toBe('read-users'); + await expectExit(run(), 1); + const err = consoleErrors.join('\n'); + expect(err).toMatch(/account-plane capability/i); + expect(err).not.toMatch(/graphql/i); }); }); }); diff --git a/src/commands/permission.ts b/src/commands/permission.ts index 46af96a8..4d20d481 100644 --- a/src/commands/permission.ts +++ b/src/commands/permission.ts @@ -1,134 +1,356 @@ +/** + * `workos permission` — RBAC permission management on the dashboard account + * plane. + * + * Migrated from the API-key REST SDK (graphql-resource-migration Phase 5): the + * subcommand surface (list/get/create/update/delete) is unchanged, but every + * operation now runs catalog-backed dashboard operations with the user's OAuth + * bearer. Output shapes are new curated shapes (approved breaking change); the + * authoritative examples live in `permission.spec.ts`. + * + * Backend divergences from REST (all loud, none faked): + * - The list operation has NO pagination or ordering variables, so the + * `--limit/--before/--after/--order` flags are removed (it returns the + * environment's full permission set). + * - The mutations are ID-keyed while the frozen grammar is slug-keyed, so + * update/delete first resolve the slug via the list operation. + * - The update mutation REQUIRES a name and CLEARS the description when it is + * omitted, so updates read-merge-write the current name/description. + * - System permissions are immutable server-side; update/delete refuse them + * loudly before issuing the mutation. + * + * Safety posture per the manifest: `delete` is destructive → + * `confirmDestructive` (prompt, or --yes); create/update are privilege-surface + * changes → `require-flag` (non-interactive callers must pass --yes). + */ + import chalk from 'chalk'; -import { createWorkOSClient } from '../lib/workos-client.js'; +import { requireCommandToken } from '../lib/command-auth.js'; +import { dashboardGraphqlRequest } from '../lib/dashboard-graphql.js'; +import { resolveEnvironmentTarget } from '../lib/environment-target.js'; +import { getOperation, resolveExecutableDocument, reportDashboardError } from '../catalog/operation.js'; +import { confirmDestructive, requireConfirmationFlag } from '../catalog/confirm.js'; +import { isJsonMode, outputJson, outputSuccess, exitWithError } from '../utils/output.js'; import { formatTable } from '../utils/table.js'; -import { outputSuccess, outputJson, isJsonMode } from '../utils/output.js'; -import { createApiErrorHandler } from '../lib/api-error-handler.js'; -const handleApiError = createApiErrorHandler('Permission'); +interface PermissionNode { + id: string; + name: string; + slug: string; + description?: string | null; + system?: boolean | null; + createdAt?: string | null; + updatedAt?: string | null; +} -export interface PermissionListOptions { - limit?: number; - before?: string; - after?: string; - order?: string; +/** + * The curated permission shape — the `--json` contract for every subcommand. + * camelCase, stable keys, no internal fields; see permission.spec.ts for the + * authoritative example. + */ +function shapePermission(permission: PermissionNode) { + return { + id: permission.id, + slug: permission.slug, + name: permission.name, + description: permission.description ?? null, + system: permission.system ?? false, + createdAt: permission.createdAt ?? null, + updatedAt: permission.updatedAt ?? null, + }; } -export async function runPermissionList( - options: PermissionListOptions, - apiKey: string, - baseUrl?: string, -): Promise { - const client = createWorkOSClient(apiKey, baseUrl); +type ShapedPermission = ReturnType; +/** Fetch the environment's full permission set (the op is unpaginated). */ +async function fetchPermissions(token: string, environmentId: string): Promise { + const op = getOperation('permissions'); + let data: { permissionsForEnvironment: { permissions: PermissionNode[] } | null }; try { - const result = await client.sdk.authorization.listPermissions({ - limit: options.limit, - before: options.before, - after: options.after, - order: options.order as 'asc' | 'desc' | undefined, + data = await dashboardGraphqlRequest(resolveExecutableDocument(op), { + token, + variables: { id: environmentId }, + environmentId, }); - - if (isJsonMode()) { - outputJson({ data: result.data, listMetadata: result.listMetadata }); - return; - } - - if (result.data.length === 0) { - console.log('No permissions found.'); - return; - } - - const rows = result.data.map((perm) => [ - perm.slug, - perm.name, - perm.description || chalk.dim('-'), - new Date(perm.createdAt).toLocaleDateString(), - ]); - - console.log( - formatTable([{ header: 'Slug' }, { header: 'Name' }, { header: 'Description' }, { header: 'Created' }], rows), - ); - - const { before, after } = result.listMetadata; - if (before && after) { - console.log(chalk.dim(`Before: ${before} After: ${after}`)); - } else if (before) { - console.log(chalk.dim(`Before: ${before}`)); - } else if (after) { - console.log(chalk.dim(`After: ${after}`)); - } } catch (error) { - handleApiError(error); + reportDashboardError(error); } + return data.permissionsForEnvironment?.permissions ?? []; } -export async function runPermissionGet(slug: string, apiKey: string, baseUrl?: string): Promise { - const client = createWorkOSClient(apiKey, baseUrl); +/** Resolve a slug within the environment's permission list, or exit not_found. */ +async function requirePermissionBySlug(token: string, environmentId: string, slug: string): Promise { + const permissions = await fetchPermissions(token, environmentId); + const permission = permissions.find((candidate) => candidate.slug === slug); + if (!permission) { + exitWithError({ code: 'not_found', message: `Permission "${slug}" was not found in this environment.` }); + } + return permission; +} - try { - const permission = await client.sdk.authorization.getPermission(slug); - outputJson(permission); - } catch (error) { - handleApiError(error); +/** System permissions are immutable server-side — refuse before the mutation. */ +function refuseSystemPermission(permission: PermissionNode, slug: string, action: string): void { + if (permission.system) { + exitWithError({ + code: 'invalid_argument', + message: `Permission "${slug}" is a system permission and cannot be ${action}.`, + }); } } -export interface PermissionCreateOptions { +export interface PermissionEnvironmentOptions { + /** `--environment-id` override; defaults from the active profile. */ + environmentId?: string; +} + +export async function runPermissionList(options: PermissionEnvironmentOptions = {}): Promise { + const token = await requireCommandToken(); + + // Environment-scoped read: the op takes the resolved ID as a variable and it + // rides as the environment header. + const { environmentId } = await resolveEnvironmentTarget(token, { + flagValue: options.environmentId, + forMutation: false, + }); + + const permissions = (await fetchPermissions(token, environmentId)).map(shapePermission); + + if (isJsonMode()) { + outputJson({ permissions }); + return; + } + if (permissions.length === 0) { + console.log('No permissions found.'); + return; + } + + const rows = permissions.map((permission) => [ + permission.slug, + permission.name, + permission.description ?? chalk.dim('-'), + permission.createdAt ?? chalk.dim('-'), + ]); + console.log( + formatTable([{ header: 'Slug' }, { header: 'Name' }, { header: 'Description' }, { header: 'Created' }], rows), + ); +} + +export async function runPermissionGet(slug: string, options: PermissionEnvironmentOptions = {}): Promise { + const token = await requireCommandToken(); + + const { environmentId } = await resolveEnvironmentTarget(token, { + flagValue: options.environmentId, + forMutation: false, + }); + + const permission = shapePermission(await requirePermissionBySlug(token, environmentId, slug)); + + if (isJsonMode()) { + outputJson({ permission }); + return; + } + + const fields: Array<[string, unknown]> = [ + ['Slug', permission.slug], + ['Name', permission.name], + ['Description', permission.description], + ['System', permission.system ? 'yes' : null], + ['Created', permission.createdAt], + ]; + for (const [label, value] of fields) { + if (value === null || value === undefined || value === '') continue; + console.log(`${chalk.bold(label)}: ${String(value)}`); + } + console.log(chalk.dim('Run with --json for the full record.')); +} + +export interface PermissionCreateOptions extends PermissionEnvironmentOptions { slug: string; name: string; description?: string; + yes?: boolean; + json?: boolean; } -export async function runPermissionCreate( - options: PermissionCreateOptions, - apiKey: string, - baseUrl?: string, -): Promise { - const client = createWorkOSClient(apiKey, baseUrl); +export async function runPermissionCreate(options: PermissionCreateOptions): Promise { + // require-flag: expands the privilege surface; non-interactive callers must pass --yes. + await requireConfirmationFlag(options, { action: `create permission ${options.slug}` }); + + const token = await requireCommandToken(); + const op = getOperation('createPermission'); + // Environment-scoped mutation: pre-validated resolved target as header (the + // create input carries no environment field — the header IS the scope). + const { environmentId } = await resolveEnvironmentTarget(token, { + flagValue: options.environmentId, + forMutation: op.kind === 'mutation', + }); + + let data: { + createPermission: + | { __typename: 'PermissionCreated'; permission: PermissionNode } + | { __typename: 'PermissionAlreadyExists'; slug: string } + | { __typename: 'PermissionSlugInvalid'; slug: string } + | { __typename: 'EnvironmentNotFound'; environmentId: string } + | { __typename: string }; + }; try { - const permission = await client.sdk.authorization.createPermission({ - slug: options.slug, - name: options.name, - ...(options.description && { description: options.description }), + data = await dashboardGraphqlRequest(resolveExecutableDocument(op), { + token, + variables: { + input: { + slug: options.slug, + name: options.name, + ...(options.description !== undefined ? { description: options.description } : {}), + }, + }, + environmentId, }); - outputSuccess('Created permission', permission); } catch (error) { - handleApiError(error); + reportDashboardError(error); + } + + const result = data.createPermission; + if (result.__typename === 'PermissionAlreadyExists') { + exitWithError({ code: 'already_exists', message: `A permission with slug "${options.slug}" already exists.` }); + } + if (result.__typename === 'PermissionSlugInvalid') { + exitWithError({ code: 'invalid_argument', message: `"${options.slug}" is not a valid permission slug.` }); + } + if (result.__typename === 'EnvironmentNotFound') { + exitWithError({ code: 'not_found', message: 'The target environment was not found.' }); + } + if (result.__typename !== 'PermissionCreated' || !('permission' in result)) { + exitWithError({ code: 'unexpected_result', message: `Could not create permission "${options.slug}".` }); + } + + const permission = shapePermission((result as { permission: PermissionNode }).permission); + if (isJsonMode()) { + outputJson({ permission }); + return; } + outputSuccess(`Created permission ${chalk.bold(permission.slug)}`); } -export interface PermissionUpdateOptions { +export interface PermissionUpdateOptions extends PermissionEnvironmentOptions { name?: string; description?: string; + yes?: boolean; + json?: boolean; } -export async function runPermissionUpdate( - slug: string, - options: PermissionUpdateOptions, - apiKey: string, - baseUrl?: string, -): Promise { - const client = createWorkOSClient(apiKey, baseUrl); +export async function runPermissionUpdate(slug: string, options: PermissionUpdateOptions = {}): Promise { + if (options.name === undefined && options.description === undefined) { + exitWithError({ code: 'missing_argument', message: 'Nothing to update. Pass --name and/or --description.' }); + } + + // require-flag: a privilege-surface change; non-interactive callers must pass --yes. + await requireConfirmationFlag(options, { action: `update permission ${slug}` }); + + const token = await requireCommandToken(); + const op = getOperation('updatePermission'); + + // Environment-scoped mutation: pre-validated resolved target as header. + const { environmentId } = await resolveEnvironmentTarget(token, { + flagValue: options.environmentId, + forMutation: op.kind === 'mutation', + }); + // The mutation is ID-keyed AND requires the full name/description (an + // omitted description would be cleared) — resolve and merge first. + const existing = await requirePermissionBySlug(token, environmentId, slug); + refuseSystemPermission(existing, slug, 'modified'); + const description = options.description ?? existing.description ?? undefined; + + let data: { + updatePermission: + | { __typename: 'PermissionUpdated'; permission: PermissionNode } + | { __typename: 'PermissionNotFound'; permissionId: string } + | { __typename: string }; + }; try { - const permission = await client.sdk.authorization.updatePermission(slug, { - ...(options.name !== undefined && { name: options.name }), - ...(options.description !== undefined && { description: options.description }), + data = await dashboardGraphqlRequest(resolveExecutableDocument(op), { + token, + variables: { + input: { + permissionId: existing.id, + name: options.name ?? existing.name, + ...(description !== undefined ? { description } : {}), + }, + }, + environmentId, }); - outputSuccess('Updated permission', permission); } catch (error) { - handleApiError(error); + reportDashboardError(error); + } + + const result = data.updatePermission; + if (result.__typename === 'PermissionNotFound') { + exitWithError({ code: 'not_found', message: `Permission "${slug}" was not found in this environment.` }); } + if (result.__typename !== 'PermissionUpdated' || !('permission' in result)) { + exitWithError({ code: 'unexpected_result', message: `Could not update permission "${slug}".` }); + } + + const permission = shapePermission((result as { permission: PermissionNode }).permission); + if (isJsonMode()) { + outputJson({ permission }); + return; + } + outputSuccess(`Updated permission ${chalk.bold(permission.slug)}`); } -export async function runPermissionDelete(slug: string, apiKey: string, baseUrl?: string): Promise { - const client = createWorkOSClient(apiKey, baseUrl); +export interface PermissionDeleteOptions extends PermissionEnvironmentOptions { + yes?: boolean; + json?: boolean; +} + +export async function runPermissionDelete(slug: string, options: PermissionDeleteOptions = {}): Promise { + // Destructive per the manifest: removes the permission from every role using it. + await confirmDestructive(options, { + action: `delete permission ${slug} — this permanently removes it from every role that uses it`, + }); + + const token = await requireCommandToken(); + const op = getOperation('deletePermission'); + + // Environment-scoped mutation: pre-validated resolved target as header. + const { environmentId } = await resolveEnvironmentTarget(token, { + flagValue: options.environmentId, + forMutation: op.kind === 'mutation', + }); + const existing = await requirePermissionBySlug(token, environmentId, slug); + refuseSystemPermission(existing, slug, 'deleted'); + + let data: { + deletePermission: + | { __typename: 'PermissionDeleted'; permissionId: string } + | { __typename: 'PermissionNotFound'; permissionId: string } + | { __typename: 'EnvironmentNotFound'; environmentId: string } + | { __typename: string }; + }; try { - await client.sdk.authorization.deletePermission(slug); - outputSuccess('Deleted permission', { slug }); + data = await dashboardGraphqlRequest(resolveExecutableDocument(op), { + token, + variables: { input: { permissionId: existing.id } }, + environmentId, + }); } catch (error) { - handleApiError(error); + reportDashboardError(error); + } + + const result = data.deletePermission; + if (result.__typename === 'PermissionNotFound' || result.__typename === 'EnvironmentNotFound') { + exitWithError({ code: 'not_found', message: `Permission "${slug}" was not found in this environment.` }); + } + if (result.__typename !== 'PermissionDeleted') { + exitWithError({ code: 'unexpected_result', message: `Could not delete permission "${slug}".` }); + } + + if (isJsonMode()) { + outputJson({ deleted: slug }); + return; } + outputSuccess(`Deleted permission ${chalk.bold(slug)}`); } diff --git a/src/commands/role.spec.ts b/src/commands/role.spec.ts index 8b0fec15..835066ca 100644 --- a/src/commands/role.spec.ts +++ b/src/commands/role.spec.ts @@ -1,31 +1,53 @@ import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'; -// Mock the unified client -const mockSdk = { - authorization: { - listEnvironmentRoles: vi.fn(), - listOrganizationRoles: vi.fn(), - getEnvironmentRole: vi.fn(), - getOrganizationRole: vi.fn(), - createEnvironmentRole: vi.fn(), - createOrganizationRole: vi.fn(), - updateEnvironmentRole: vi.fn(), - updateOrganizationRole: vi.fn(), - deleteOrganizationRole: vi.fn(), - setEnvironmentRolePermissions: vi.fn(), - setOrganizationRolePermissions: vi.fn(), - addEnvironmentRolePermission: vi.fn(), - addOrganizationRolePermission: vi.fn(), - removeOrganizationRolePermission: vi.fn(), - }, -}; +const mockRequireCommandToken = vi.fn(); +const mockGraphqlRequest = vi.fn(); +const mockConfirm = vi.fn(); +const mockIsCancel = vi.fn(() => false); +const mockGetActiveEnvironment = vi.fn(); +const mockGetConfig = vi.fn(); + +vi.mock('../lib/command-auth.js', async (importActual) => { + const actual = await importActual(); + return { + ...actual, + requireCommandToken: () => mockRequireCommandToken(), + }; +}); -vi.mock('../lib/workos-client.js', () => ({ - createWorkOSClient: () => ({ sdk: mockSdk }), +vi.mock('../lib/dashboard-graphql.js', async (importActual) => { + const actual = await importActual(); + return { + ...actual, + dashboardGraphqlRequest: (...args: unknown[]) => mockGraphqlRequest(...args), + }; +}); + +vi.mock('../utils/clack.js', () => ({ + default: { + confirm: (...args: unknown[]) => mockConfirm(...args), + isCancel: (...args: unknown[]) => mockIsCancel(...args), + select: vi.fn(), + }, })); -const { setOutputMode } = await import('../utils/output.js'); +// The environment-target resolver runs REAL against the mocked wire + config +// store, so mutation tests can assert the pre-validation fetch +// (teamProjectsV2) happens before the operation request. +vi.mock('../lib/config-store.js', async (importActual) => { + const actual = await importActual(); + return { + ...actual, + getActiveEnvironment: () => mockGetActiveEnvironment(), + getConfig: () => mockGetConfig(), + setProfileEnvironmentId: vi.fn(), + }; +}); +const { setOutputMode } = await import('../utils/output.js'); +const { resetInteractionModeForTests, setInteractionMode } = await import('../utils/interaction-mode.js'); +const { DashboardGraphqlError } = await import('../lib/dashboard-graphql.js'); +const { CliExit } = await import('../utils/cli-exit.js'); const { runRoleList, runRoleGet, @@ -37,294 +59,706 @@ const { runRoleRemovePermission, } = await import('./role.js'); -const mockEnvRole = { - id: 'role_123', - slug: 'admin', +const TEAM_ENVIRONMENTS_PAYLOAD = { + currentTeam: { + projectsV2: [{ environments: [{ id: 'env_profile', name: 'Sandbox', sandbox: true, clientId: null }] }], + }, +}; + +// List-operation shape: the Role fragment PLUS the list documents' explicit +// `permissions { id slug }` selection. +const ENV_ROLE = { + id: 'role_env', name: 'Admin', - description: 'Administrator role', - type: 'EnvironmentRole', - permissions: ['read-users', 'write-users'], - createdAt: '2024-01-01T00:00:00Z', - updatedAt: '2024-01-01T00:00:00Z', + slug: 'admin', + description: 'Administrator', + state: 'Active', + type: 'Environment', + permissions: [{ id: 'perm_1', slug: 'users:read' }], + createdAt: '2026-01-01T00:00:00.000Z', + updatedAt: '2026-02-01T00:00:00.000Z', + // Internal fields the curated shape must drop: + resourceTypeId: 'rt_1', + defaultForOrganizationsCount: 0, }; -const mockOrgRole = { - id: 'role_456', - slug: 'org-admin', +const ORG_ROLE = { + ...ENV_ROLE, + id: 'role_org', name: 'Org Admin', - description: 'Organization admin role', - type: 'OrganizationRole', - permissions: ['manage-members'], - createdAt: '2024-01-01T00:00:00Z', - updatedAt: '2024-01-01T00:00:00Z', + slug: 'org-admin', + description: 'Organization admin', + type: 'Organization', + permissions: [{ id: 'perm_2', slug: 'billing:manage' }], }; -describe('role commands', () => { +// Mutation-response shape: the bare Role fragment — it selects NO permissions, +// which is exactly why the handlers overlay the known slugs. +function mutationRole(overrides: Record = {}) { + const { permissions: _permissions, ...fragment } = ENV_ROLE; + return { ...fragment, ...overrides }; +} + +const ENV_LIST = { rolesForEnvironment: { roles: [ENV_ROLE] } }; +const ORG_LIST = { rolesForOrganization: { roles: [ORG_ROLE, ENV_ROLE] } }; +const CREATED = { createRole: { __typename: 'RoleCreated', role: mutationRole() } }; +const UPDATED = { updateRole: { __typename: 'RoleUpdated', role: mutationRole({ description: 'New desc' }) } }; +const DELETED = { deleteRole: { __typename: 'RoleDeleted', roleConfig: { id: 'rc_1' } } }; + +interface RouteMap { + envList?: unknown; + orgList?: unknown; + createRole?: unknown; + updateRole?: unknown; + deleteRole?: unknown; +} + +/** + * Route the wire mock by document: the environment resolver's pre-validation + * fetch (`teamProjectsV2`) gets the team's environments; mutations and list + * lookups get their configured payloads. + */ +function respondWith(routes: RouteMap): void { + mockGraphqlRequest.mockImplementation(async (doc: unknown) => { + const text = String(doc); + if (text.includes('teamProjectsV2')) return TEAM_ENVIRONMENTS_PAYLOAD; + if (text.includes('mutation createRole')) return routes.createRole; + if (text.includes('mutation updateRole')) return routes.updateRole; + if (text.includes('mutation deleteRole')) return routes.deleteRole; + if (text.includes('rolesForOrganization')) return routes.orgList; + if (text.includes('rolesForEnvironment')) return routes.envList; + throw new Error(`Unrouted document in test: ${text.slice(0, 80)}`); + }); +} + +function requestedDocs(): string[] { + return mockGraphqlRequest.mock.calls.map((call) => String(call[0])); +} + +function lastCallOptions(): Record { + return mockGraphqlRequest.mock.calls.at(-1)![1] as Record; +} + +async function expectExit(promise: Promise, code: number): Promise { + try { + await promise; + } catch (err) { + if (err instanceof CliExit) { + expect(err.exitCode).toBe(code); + return err; + } + throw err; + } + throw new Error(`Expected CliExit(${code}) but promise resolved`); +} + +/** The authoritative curated role JSON shape (documented contract). */ +const ROLE_SHAPE_KEYS = ['id', 'slug', 'name', 'description', 'type', 'permissions', 'createdAt', 'updatedAt']; + +/** Every subcommand's happy-path invocation, for the shared contract matrices. */ +const SUBCOMMANDS: Array<{ name: string; routes: RouteMap; run: () => Promise }> = [ + { name: 'list', routes: { envList: ENV_LIST }, run: () => runRoleList({}) }, + { name: 'get', routes: { envList: ENV_LIST }, run: () => runRoleGet('admin') }, + { + name: 'create', + routes: { createRole: CREATED }, + run: () => runRoleCreate({ slug: 'admin', name: 'Admin', yes: true }), + }, + { + name: 'update', + routes: { envList: ENV_LIST, updateRole: UPDATED }, + run: () => runRoleUpdate('admin', { description: 'New desc', yes: true }), + }, + { + name: 'delete', + routes: { orgList: ORG_LIST, deleteRole: DELETED }, + run: () => runRoleDelete('org-admin', { org: 'org_1', yes: true }), + }, + { + name: 'set-permissions', + routes: { envList: ENV_LIST, updateRole: UPDATED }, + run: () => runRoleSetPermissions('admin', ['a:read'], { yes: true }), + }, + { + name: 'add-permission', + routes: { envList: ENV_LIST, updateRole: UPDATED }, + run: () => runRoleAddPermission('admin', 'b:write', { yes: true }), + }, + { + name: 'remove-permission', + routes: { orgList: ORG_LIST, updateRole: UPDATED }, + run: () => runRoleRemovePermission('org-admin', 'billing:manage', { org: 'org_1', yes: true }), + }, +]; + +/** The require-flag-gated subcommands (privilege changes). */ +const GATED: Array<{ name: string; routes: RouteMap; run: (yes: boolean) => Promise }> = [ + { + name: 'create', + routes: { createRole: CREATED }, + run: (yes) => runRoleCreate({ slug: 'admin', name: 'Admin', yes }), + }, + { + name: 'update', + routes: { envList: ENV_LIST, updateRole: UPDATED }, + run: (yes) => runRoleUpdate('admin', { description: 'New desc', yes }), + }, + { + name: 'set-permissions', + routes: { envList: ENV_LIST, updateRole: UPDATED }, + run: (yes) => runRoleSetPermissions('admin', ['a:read'], { yes }), + }, + { + name: 'add-permission', + routes: { envList: ENV_LIST, updateRole: UPDATED }, + run: (yes) => runRoleAddPermission('admin', 'b:write', { yes }), + }, + { + name: 'remove-permission', + routes: { orgList: ORG_LIST, updateRole: UPDATED }, + run: (yes) => runRoleRemovePermission('org-admin', 'billing:manage', { org: 'org_1', yes }), + }, +]; + +describe('role command', () => { let consoleOutput: string[]; beforeEach(() => { vi.clearAllMocks(); + resetInteractionModeForTests(); + setOutputMode('human'); + mockRequireCommandToken.mockResolvedValue('tok_123'); + mockConfirm.mockReset(); + mockIsCancel.mockReset(); + mockIsCancel.mockReturnValue(false); + mockGetActiveEnvironment.mockReturnValue({ apiKey: 'sk_ignored', environmentId: 'env_profile' }); + mockGetConfig.mockReturnValue({ + activeEnvironment: 'default', + environments: { default: { apiKey: 'sk_ignored', environmentId: 'env_profile' } }, + }); consoleOutput = []; vi.spyOn(console, 'log').mockImplementation((...args: unknown[]) => { consoleOutput.push(args.map(String).join(' ')); }); + vi.spyOn(console, 'error').mockImplementation(() => {}); }); afterEach(() => { vi.restoreAllMocks(); + resetInteractionModeForTests(); + setOutputMode('human'); }); - describe('runRoleList', () => { - it('lists environment roles in table format', async () => { - mockSdk.authorization.listEnvironmentRoles.mockResolvedValue({ - data: [mockEnvRole], + describe('list', () => { + it('lists environment roles with the environment as variable and header', async () => { + respondWith({ envList: ENV_LIST }); + await runRoleList({}); + expect(mockGraphqlRequest).toHaveBeenCalledTimes(1); + expect(mockGraphqlRequest).toHaveBeenCalledWith(expect.stringContaining('rolesForEnvironment'), { + token: 'tok_123', + variables: { id: 'env_profile' }, + environmentId: 'env_profile', }); - await runRoleList(undefined, 'sk_test'); - expect(mockSdk.authorization.listEnvironmentRoles).toHaveBeenCalled(); - expect(mockSdk.authorization.listOrganizationRoles).not.toHaveBeenCalled(); - expect(consoleOutput.some((l) => l.includes('admin'))).toBe(true); - expect(consoleOutput.some((l) => l.includes('Admin'))).toBe(true); + const out = consoleOutput.join('\n'); + expect(out).toContain('admin'); + expect(out).toContain('Environment'); + }); + + it('lists organization roles via the org list operation with --org', async () => { + respondWith({ orgList: ORG_LIST }); + await runRoleList({ org: 'org_1' }); + expect(mockGraphqlRequest).toHaveBeenCalledWith(expect.stringContaining('rolesForOrganization'), { + token: 'tok_123', + variables: { organizationId: 'org_1', environmentId: 'env_profile' }, + environmentId: 'env_profile', + }); + expect(consoleOutput.join('\n')).toContain('org-admin'); }); - it('lists organization roles when orgId provided', async () => { - mockSdk.authorization.listOrganizationRoles.mockResolvedValue({ - data: [mockOrgRole], + it('--json emits the curated shape with permission slugs', async () => { + setOutputMode('json'); + respondWith({ envList: ENV_LIST }); + await runRoleList({}); + const raw = consoleOutput[0]; + expect(raw).not.toMatch(/graphql|userland|resource_type|resourceTypeId/i); + const out = JSON.parse(raw); + expect(Object.keys(out)).toEqual(['roles']); + expect(Object.keys(out.roles[0])).toEqual(ROLE_SHAPE_KEYS); + expect(out.roles[0]).toMatchObject({ + slug: 'admin', + type: 'Environment', + permissions: ['users:read'], }); - await runRoleList('org_abc', 'sk_test'); - expect(mockSdk.authorization.listOrganizationRoles).toHaveBeenCalledWith('org_abc'); - expect(mockSdk.authorization.listEnvironmentRoles).not.toHaveBeenCalled(); - expect(consoleOutput.some((l) => l.includes('org-admin'))).toBe(true); }); it('handles empty results', async () => { - mockSdk.authorization.listEnvironmentRoles.mockResolvedValue({ data: [] }); - await runRoleList(undefined, 'sk_test'); - expect(consoleOutput.some((l) => l.includes('No roles found'))).toBe(true); + respondWith({ envList: { rolesForEnvironment: { roles: [] } } }); + await runRoleList({}); + expect(consoleOutput.some((line) => line.includes('No roles found'))).toBe(true); }); + }); - it('displays type and permissions count in table', async () => { - mockSdk.authorization.listEnvironmentRoles.mockResolvedValue({ - data: [mockEnvRole], - }); - await runRoleList(undefined, 'sk_test'); - expect(consoleOutput.some((l) => l.includes('EnvironmentRole'))).toBe(true); - expect(consoleOutput.some((l) => l.includes('2'))).toBe(true); + describe('get', () => { + it('client-filters the list by slug and renders fields', async () => { + respondWith({ envList: ENV_LIST }); + await runRoleGet('admin'); + const out = consoleOutput.join('\n'); + expect(out).toContain('admin'); + expect(out).toContain('users:read'); }); - }); - describe('runRoleGet', () => { - it('gets environment role by slug', async () => { - mockSdk.authorization.getEnvironmentRole.mockResolvedValue(mockEnvRole); - await runRoleGet('admin', undefined, 'sk_test'); - expect(mockSdk.authorization.getEnvironmentRole).toHaveBeenCalledWith('admin'); - expect(consoleOutput.some((l) => l.includes('role_123'))).toBe(true); + it('resolves through the org list with --org', async () => { + respondWith({ orgList: ORG_LIST }); + await runRoleGet('org-admin', { org: 'org_1' }); + expect(requestedDocs()[0]).toContain('rolesForOrganization'); + expect(consoleOutput.join('\n')).toContain('org-admin'); + }); + + it('--json emits { role } in the curated shape', async () => { + setOutputMode('json'); + respondWith({ envList: ENV_LIST }); + await runRoleGet('admin'); + const out = JSON.parse(consoleOutput[0]); + expect(Object.keys(out)).toEqual(['role']); + expect(Object.keys(out.role)).toEqual(ROLE_SHAPE_KEYS); }); - it('gets organization role by slug and orgId', async () => { - mockSdk.authorization.getOrganizationRole.mockResolvedValue(mockOrgRole); - await runRoleGet('org-admin', 'org_abc', 'sk_test'); - expect(mockSdk.authorization.getOrganizationRole).toHaveBeenCalledWith('org_abc', 'org-admin'); - expect(consoleOutput.some((l) => l.includes('role_456'))).toBe(true); + it('errors not_found when the slug does not match', async () => { + respondWith({ envList: ENV_LIST }); + const err = await expectExit(runRoleGet('missing'), 1); + expect(err.context?.errorCode).toBe('not_found'); }); }); - describe('runRoleCreate', () => { - it('creates environment role', async () => { - mockSdk.authorization.createEnvironmentRole.mockResolvedValue(mockEnvRole); - await runRoleCreate({ slug: 'admin', name: 'Admin' }, undefined, 'sk_test'); - expect(mockSdk.authorization.createEnvironmentRole).toHaveBeenCalledWith({ - slug: 'admin', - name: 'Admin', + describe('create', () => { + it('pre-validates the environment, then sends the create input (org scope via --org)', async () => { + setInteractionMode({ mode: 'ci', source: 'ci_env' }); + respondWith({ createRole: CREATED }); + await runRoleCreate({ slug: 'org-admin', name: 'Org Admin', description: 'd', org: 'org_1', yes: true }); + expect(requestedDocs()[0]).toContain('teamProjectsV2'); + expect(requestedDocs()[1]).toContain('mutation createRole'); + expect(mockGraphqlRequest.mock.calls[1][1]).toEqual({ + token: 'tok_123', + variables: { + input: { slug: 'org-admin', name: 'Org Admin', description: 'd', organizationId: 'org_1' }, + }, + environmentId: 'env_profile', }); }); - it('creates organization role when orgId provided', async () => { - mockSdk.authorization.createOrganizationRole.mockResolvedValue(mockOrgRole); - await runRoleCreate({ slug: 'org-admin', name: 'Org Admin' }, 'org_abc', 'sk_test'); - expect(mockSdk.authorization.createOrganizationRole).toHaveBeenCalledWith('org_abc', { - slug: 'org-admin', - name: 'Org Admin', + it('omits description and organizationId when not passed', async () => { + setInteractionMode({ mode: 'ci', source: 'ci_env' }); + respondWith({ createRole: CREATED }); + await runRoleCreate({ slug: 'admin', name: 'Admin', yes: true }); + expect(mockGraphqlRequest.mock.calls[1][1]).toEqual({ + token: 'tok_123', + variables: { input: { slug: 'admin', name: 'Admin' } }, + environmentId: 'env_profile', }); }); - it('includes description when provided', async () => { - mockSdk.authorization.createEnvironmentRole.mockResolvedValue(mockEnvRole); - await runRoleCreate({ slug: 'admin', name: 'Admin', description: 'Desc' }, undefined, 'sk_test'); - expect(mockSdk.authorization.createEnvironmentRole).toHaveBeenCalledWith({ - slug: 'admin', - name: 'Admin', - description: 'Desc', + it('prints a success message in human mode', async () => { + respondWith({ createRole: CREATED }); + await runRoleCreate({ slug: 'admin', name: 'Admin', yes: true }); + expect(consoleOutput.join('\n')).toContain('Created role'); + }); + + it('errors already_exists on a duplicate slug', async () => { + respondWith({ createRole: { createRole: { __typename: 'RoleAlreadyExists', slug: 'admin' } } }); + const err = await expectExit(runRoleCreate({ slug: 'admin', name: 'Admin', yes: true }), 1); + expect(err.context?.errorCode).toBe('already_exists'); + }); + + it('errors not_found when the environment is rejected', async () => { + respondWith({ createRole: { createRole: { __typename: 'EnvironmentNotFound', environmentId: 'env_profile' } } }); + const err = await expectExit(runRoleCreate({ slug: 'admin', name: 'Admin', yes: true }), 1); + expect(err.context?.errorCode).toBe('not_found'); + }); + + it('--json emits the created { role }', async () => { + setOutputMode('json'); + respondWith({ createRole: CREATED }); + await runRoleCreate({ slug: 'admin', name: 'Admin', yes: true }); + const out = JSON.parse(consoleOutput[0]); + expect(Object.keys(out.role)).toEqual(ROLE_SHAPE_KEYS); + // The create grammar cannot seed permissions; the fresh role has none. + expect(out.role.permissions).toEqual([]); + }); + }); + + describe('update (read-merge-write)', () => { + it('requires --name or --description', async () => { + const err = await expectExit(runRoleUpdate('admin', {}), 1); + expect(err.context?.errorCode).toBe('missing_argument'); + expect(mockGraphqlRequest).not.toHaveBeenCalled(); + }); + + it('merges the current name/description into the ID-keyed input (update requires name)', async () => { + setInteractionMode({ mode: 'ci', source: 'ci_env' }); + respondWith({ envList: ENV_LIST, updateRole: UPDATED }); + await runRoleUpdate('admin', { description: 'New desc', yes: true }); + const docs = requestedDocs(); + expect(docs[0]).toContain('teamProjectsV2'); + expect(docs[1]).toContain('rolesForEnvironment'); + expect(docs[2]).toContain('mutation updateRole'); + // The current name rides along: the backing input REQUIRES name, and an + // omitted description would be cleared server-side. + expect(mockGraphqlRequest.mock.calls[2][1]).toEqual({ + token: 'tok_123', + variables: { input: { roleId: 'role_env', name: 'Admin', description: 'New desc' } }, + environmentId: 'env_profile', }); }); - it('outputs created message', async () => { - mockSdk.authorization.createEnvironmentRole.mockResolvedValue(mockEnvRole); - await runRoleCreate({ slug: 'admin', name: 'Admin' }, undefined, 'sk_test'); - expect(consoleOutput.some((l) => l.includes('Created role'))).toBe(true); + it('prints a success message in human mode', async () => { + respondWith({ envList: ENV_LIST, updateRole: UPDATED }); + await runRoleUpdate('admin', { name: 'Renamed', yes: true }); + expect(consoleOutput.join('\n')).toContain('Updated role'); + }); + + it('refuses to update an inherited environment role through --org', async () => { + setInteractionMode({ mode: 'ci', source: 'ci_env' }); + respondWith({ orgList: { rolesForOrganization: { roles: [ENV_ROLE] } } }); + const err = await expectExit(runRoleUpdate('admin', { name: 'X', org: 'org_1', yes: true }), 1); + expect(err.context?.errorCode).toBe('invalid_argument'); + expect(requestedDocs().some((doc) => doc.includes('mutation updateRole'))).toBe(false); + }); + + it('errors not_found when the slug does not resolve', async () => { + setInteractionMode({ mode: 'ci', source: 'ci_env' }); + respondWith({ envList: { rolesForEnvironment: { roles: [] } } }); + const err = await expectExit(runRoleUpdate('missing', { name: 'X', yes: true }), 1); + expect(err.context?.errorCode).toBe('not_found'); + }); + + it('errors not_found on the RoleNotFound mutation variant', async () => { + respondWith({ envList: ENV_LIST, updateRole: { updateRole: { __typename: 'RoleNotFound', roleId: 'role_env' } } }); + const err = await expectExit(runRoleUpdate('admin', { name: 'X', yes: true }), 1); + expect(err.context?.errorCode).toBe('not_found'); + }); + + it('--json emits the updated { role } with permissions overlaid (mutation response has none)', async () => { + setOutputMode('json'); + respondWith({ envList: ENV_LIST, updateRole: UPDATED }); + await runRoleUpdate('admin', { description: 'New desc', yes: true }); + const out = JSON.parse(consoleOutput[0]); + expect(Object.keys(out.role)).toEqual(ROLE_SHAPE_KEYS); + expect(out.role).toMatchObject({ slug: 'admin', description: 'New desc' }); + // The update mutation's response selects no permissions — the shape + // carries the pre-mutation list, which this input cannot change. + expect(out.role.permissions).toEqual(['users:read']); }); }); - describe('runRoleUpdate', () => { - it('updates environment role', async () => { - mockSdk.authorization.updateEnvironmentRole.mockResolvedValue(mockEnvRole); - await runRoleUpdate('admin', { name: 'Updated Admin' }, undefined, 'sk_test'); - expect(mockSdk.authorization.updateEnvironmentRole).toHaveBeenCalledWith('admin', { name: 'Updated Admin' }); + describe('delete (destructive, org-scoped)', () => { + it('refuses in agent mode without --yes (exit 1, confirmation_required)', async () => { + setInteractionMode({ mode: 'agent', source: 'agent_env' }); + const err = await expectExit(runRoleDelete('org-admin', { org: 'org_1', yes: false }), 1); + expect(err.context?.errorCode).toBe('confirmation_required'); + expect(mockGraphqlRequest).not.toHaveBeenCalled(); + }); + + it('refuses in CI mode without --yes', async () => { + setInteractionMode({ mode: 'ci', source: 'ci_env' }); + const err = await expectExit(runRoleDelete('org-admin', { org: 'org_1', yes: false }), 1); + expect(err.context?.errorCode).toBe('confirmation_required'); + expect(mockGraphqlRequest).not.toHaveBeenCalled(); }); - it('updates organization role when orgId provided', async () => { - mockSdk.authorization.updateOrganizationRole.mockResolvedValue(mockOrgRole); - await runRoleUpdate('org-admin', { name: 'Updated' }, 'org_abc', 'sk_test'); - expect(mockSdk.authorization.updateOrganizationRole).toHaveBeenCalledWith('org_abc', 'org-admin', { - name: 'Updated', + it('refuses in JSON mode without --yes (keeps stdout machine-readable)', async () => { + setInteractionMode({ mode: 'human', source: 'default' }); + setOutputMode('json'); + const err = await expectExit(runRoleDelete('org-admin', { org: 'org_1', yes: false }), 1); + expect(err.context?.errorCode).toBe('confirmation_required'); + expect(mockGraphqlRequest).not.toHaveBeenCalled(); + }); + + it('resolves the slug in the org scope, then deletes by role ID', async () => { + setInteractionMode({ mode: 'ci', source: 'ci_env' }); + respondWith({ orgList: ORG_LIST, deleteRole: DELETED }); + await runRoleDelete('org-admin', { org: 'org_1', yes: true }); + const docs = requestedDocs(); + expect(docs[0]).toContain('teamProjectsV2'); + expect(docs[1]).toContain('rolesForOrganization'); + expect(docs[2]).toContain('mutation deleteRole'); + expect(mockGraphqlRequest.mock.calls[2][1]).toEqual({ + token: 'tok_123', + variables: { input: { roleId: 'role_org' } }, + environmentId: 'env_profile', }); }); - it('sends only provided fields', async () => { - mockSdk.authorization.updateEnvironmentRole.mockResolvedValue(mockEnvRole); - await runRoleUpdate('admin', { description: 'New desc' }, undefined, 'sk_test'); - expect(mockSdk.authorization.updateEnvironmentRole).toHaveBeenCalledWith('admin', { description: 'New desc' }); + it('prints a success message in human mode', async () => { + setInteractionMode({ mode: 'human', source: 'default' }); + mockConfirm.mockResolvedValue(true); + respondWith({ orgList: ORG_LIST, deleteRole: DELETED }); + await runRoleDelete('org-admin', { org: 'org_1', yes: false }); + expect(consoleOutput.join('\n')).toContain('Deleted role'); + }); + + it('refuses to delete an inherited environment role through --org', async () => { + setInteractionMode({ mode: 'ci', source: 'ci_env' }); + respondWith({ orgList: { rolesForOrganization: { roles: [ENV_ROLE] } } }); + const err = await expectExit(runRoleDelete('admin', { org: 'org_1', yes: true }), 1); + expect(err.context?.errorCode).toBe('invalid_argument'); + expect(requestedDocs().some((doc) => doc.includes('mutation deleteRole'))).toBe(false); + }); + + it('errors not_found when the slug does not resolve in the organization', async () => { + setInteractionMode({ mode: 'ci', source: 'ci_env' }); + respondWith({ orgList: { rolesForOrganization: { roles: [] } } }); + const err = await expectExit(runRoleDelete('missing', { org: 'org_1', yes: true }), 1); + expect(err.context?.errorCode).toBe('not_found'); }); - }); - describe('runRoleDelete', () => { - it('deletes organization role', async () => { - mockSdk.authorization.deleteOrganizationRole.mockResolvedValue(undefined); - await runRoleDelete('org-admin', 'org_abc', 'sk_test'); - expect(mockSdk.authorization.deleteOrganizationRole).toHaveBeenCalledWith('org_abc', 'org-admin'); - expect(consoleOutput.some((l) => l.includes('Deleted'))).toBe(true); + it('errors not_found on the RoleNotFound mutation variant', async () => { + setInteractionMode({ mode: 'ci', source: 'ci_env' }); + respondWith({ orgList: ORG_LIST, deleteRole: { deleteRole: { __typename: 'RoleNotFound', roleId: 'role_org' } } }); + const err = await expectExit(runRoleDelete('org-admin', { org: 'org_1', yes: true }), 1); + expect(err.context?.errorCode).toBe('not_found'); }); - it('outputs confirmation with slug and org ID', async () => { - mockSdk.authorization.deleteOrganizationRole.mockResolvedValue(undefined); - await runRoleDelete('org-admin', 'org_abc', 'sk_test'); - expect(consoleOutput.some((l) => l.includes('org-admin'))).toBe(true); + it('prompts interactively and proceeds when confirmed', async () => { + setInteractionMode({ mode: 'human', source: 'default' }); + mockConfirm.mockResolvedValue(true); + respondWith({ orgList: ORG_LIST, deleteRole: DELETED }); + await runRoleDelete('org-admin', { org: 'org_1', yes: false }); + expect(mockConfirm).toHaveBeenCalledOnce(); + expect(mockGraphqlRequest).toHaveBeenCalled(); + }); + + it('cancels (exit 2) when the interactive prompt is declined', async () => { + setInteractionMode({ mode: 'human', source: 'default' }); + mockConfirm.mockResolvedValue(false); + await expectExit(runRoleDelete('org-admin', { org: 'org_1', yes: false }), 2); + expect(mockGraphqlRequest).not.toHaveBeenCalled(); + }); + + it('--json emits { deleted }', async () => { + setInteractionMode({ mode: 'agent', source: 'agent_env' }); + setOutputMode('json'); + respondWith({ orgList: ORG_LIST, deleteRole: DELETED }); + await runRoleDelete('org-admin', { org: 'org_1', yes: true }); + expect(JSON.parse(consoleOutput[0])).toEqual({ deleted: 'org-admin' }); }); }); - describe('runRoleSetPermissions', () => { - it('sets environment role permissions', async () => { - mockSdk.authorization.setEnvironmentRolePermissions.mockResolvedValue(mockEnvRole); - await runRoleSetPermissions('admin', ['read', 'write'], undefined, 'sk_test'); - expect(mockSdk.authorization.setEnvironmentRolePermissions).toHaveBeenCalledWith('admin', { - permissions: ['read', 'write'], + describe('set-permissions (rides the update op)', () => { + it('replaces the full slug list, carrying name/description', async () => { + setInteractionMode({ mode: 'ci', source: 'ci_env' }); + respondWith({ envList: ENV_LIST, updateRole: UPDATED }); + await runRoleSetPermissions('admin', ['a:read', 'b:write'], { yes: true }); + expect(mockGraphqlRequest.mock.calls[2][1]).toEqual({ + token: 'tok_123', + variables: { + input: { roleId: 'role_env', name: 'Admin', description: 'Administrator', permissions: ['a:read', 'b:write'] }, + }, + environmentId: 'env_profile', }); }); - it('sets organization role permissions', async () => { - mockSdk.authorization.setOrganizationRolePermissions.mockResolvedValue(mockOrgRole); - await runRoleSetPermissions('org-admin', ['manage'], 'org_abc', 'sk_test'); - expect(mockSdk.authorization.setOrganizationRolePermissions).toHaveBeenCalledWith('org_abc', 'org-admin', { - permissions: ['manage'], - }); + it('prints a success message in human mode', async () => { + respondWith({ envList: ENV_LIST, updateRole: UPDATED }); + await runRoleSetPermissions('admin', ['a:read', 'b:write'], { yes: true }); + expect(consoleOutput.join('\n')).toContain('Set 2 permissions on role'); + }); + + it('errors not_found on the RoleNotFound mutation variant', async () => { + respondWith({ envList: ENV_LIST, updateRole: { updateRole: { __typename: 'RoleNotFound', roleId: 'role_env' } } }); + const err = await expectExit(runRoleSetPermissions('admin', ['a:read'], { yes: true }), 1); + expect(err.context?.errorCode).toBe('not_found'); }); - it('outputs success message', async () => { - mockSdk.authorization.setEnvironmentRolePermissions.mockResolvedValue(mockEnvRole); - await runRoleSetPermissions('admin', ['read'], undefined, 'sk_test'); - expect(consoleOutput.some((l) => l.includes('Set permissions on role'))).toBe(true); + it('refuses to touch an inherited environment role through --org', async () => { + respondWith({ orgList: { rolesForOrganization: { roles: [ENV_ROLE] } } }); + const err = await expectExit(runRoleSetPermissions('admin', ['a:read'], { org: 'org_1', yes: true }), 1); + expect(err.context?.errorCode).toBe('invalid_argument'); + }); + + it('--json emits { role } with the new permission list overlaid', async () => { + setOutputMode('json'); + respondWith({ envList: ENV_LIST, updateRole: UPDATED }); + await runRoleSetPermissions('admin', ['a:read', 'b:write'], { yes: true }); + const out = JSON.parse(consoleOutput[0]); + expect(Object.keys(out.role)).toEqual(ROLE_SHAPE_KEYS); + expect(out.role.permissions).toEqual(['a:read', 'b:write']); }); }); - describe('runRoleAddPermission', () => { - it('adds permission to environment role', async () => { - mockSdk.authorization.addEnvironmentRolePermission.mockResolvedValue(mockEnvRole); - await runRoleAddPermission('admin', 'read-users', undefined, 'sk_test'); - expect(mockSdk.authorization.addEnvironmentRolePermission).toHaveBeenCalledWith('admin', { - permissionSlug: 'read-users', + describe('add-permission (rides the update op)', () => { + it('merges into the current list (deduped)', async () => { + setInteractionMode({ mode: 'ci', source: 'ci_env' }); + respondWith({ envList: ENV_LIST, updateRole: UPDATED }); + await runRoleAddPermission('admin', 'billing:manage', { yes: true }); + expect(mockGraphqlRequest.mock.calls[2][1]).toEqual({ + token: 'tok_123', + variables: { + input: { + roleId: 'role_env', + name: 'Admin', + description: 'Administrator', + permissions: ['users:read', 'billing:manage'], + }, + }, + environmentId: 'env_profile', }); }); - it('adds permission to organization role', async () => { - mockSdk.authorization.addOrganizationRolePermission.mockResolvedValue(mockOrgRole); - await runRoleAddPermission('org-admin', 'manage', 'org_abc', 'sk_test'); - expect(mockSdk.authorization.addOrganizationRolePermission).toHaveBeenCalledWith('org_abc', 'org-admin', { - permissionSlug: 'manage', - }); + it('is idempotent for an already-assigned slug', async () => { + respondWith({ envList: ENV_LIST, updateRole: UPDATED }); + await runRoleAddPermission('admin', 'users:read', { yes: true }); + expect( + (mockGraphqlRequest.mock.calls[2][1] as { variables: { input: { permissions: string[] } } }).variables.input + .permissions, + ).toEqual(['users:read']); + }); + + it('prints a success message in human mode', async () => { + respondWith({ envList: ENV_LIST, updateRole: UPDATED }); + await runRoleAddPermission('admin', 'billing:manage', { yes: true }); + expect(consoleOutput.join('\n')).toContain('Added permission'); + }); + + it('errors not_found when the role slug does not resolve', async () => { + respondWith({ envList: { rolesForEnvironment: { roles: [] } } }); + const err = await expectExit(runRoleAddPermission('missing', 'a:read', { yes: true }), 1); + expect(err.context?.errorCode).toBe('not_found'); + }); + + it('errors not_found on the RoleNotFound mutation variant', async () => { + respondWith({ envList: ENV_LIST, updateRole: { updateRole: { __typename: 'RoleNotFound', roleId: 'role_env' } } }); + const err = await expectExit(runRoleAddPermission('admin', 'billing:manage', { yes: true }), 1); + expect(err.context?.errorCode).toBe('not_found'); + }); + + it('--json emits { role } with the merged permission list overlaid', async () => { + setOutputMode('json'); + respondWith({ envList: ENV_LIST, updateRole: UPDATED }); + await runRoleAddPermission('admin', 'billing:manage', { yes: true }); + const out = JSON.parse(consoleOutput[0]); + expect(out.role.permissions).toEqual(['users:read', 'billing:manage']); }); }); - describe('runRoleRemovePermission', () => { - it('removes permission from organization role', async () => { - mockSdk.authorization.removeOrganizationRolePermission.mockResolvedValue(undefined); - await runRoleRemovePermission('org-admin', 'manage', 'org_abc', 'sk_test'); - expect(mockSdk.authorization.removeOrganizationRolePermission).toHaveBeenCalledWith('org_abc', 'org-admin', { - permissionSlug: 'manage', + describe('remove-permission (rides the update op, org scope required)', () => { + it('filters the current list', async () => { + setInteractionMode({ mode: 'ci', source: 'ci_env' }); + respondWith({ orgList: ORG_LIST, updateRole: UPDATED }); + await runRoleRemovePermission('org-admin', 'billing:manage', { org: 'org_1', yes: true }); + expect(mockGraphqlRequest.mock.calls[2][1]).toEqual({ + token: 'tok_123', + variables: { + input: { roleId: 'role_org', name: 'Org Admin', description: 'Organization admin', permissions: [] }, + }, + environmentId: 'env_profile', }); }); - it('outputs confirmation', async () => { - mockSdk.authorization.removeOrganizationRolePermission.mockResolvedValue(undefined); - await runRoleRemovePermission('org-admin', 'manage', 'org_abc', 'sk_test'); - expect(consoleOutput.some((l) => l.includes('Removed permission from role'))).toBe(true); + it('prints a success message in human mode', async () => { + respondWith({ orgList: ORG_LIST, updateRole: UPDATED }); + await runRoleRemovePermission('org-admin', 'billing:manage', { org: 'org_1', yes: true }); + expect(consoleOutput.join('\n')).toContain('Removed permission'); }); - }); - describe('JSON output mode', () => { - beforeEach(() => { + it('errors not_found when the role lacks the permission', async () => { + setInteractionMode({ mode: 'ci', source: 'ci_env' }); + respondWith({ orgList: ORG_LIST }); + const err = await expectExit(runRoleRemovePermission('org-admin', 'nope:none', { org: 'org_1', yes: true }), 1); + expect(err.context?.errorCode).toBe('not_found'); + expect(requestedDocs().some((doc) => doc.includes('mutation updateRole'))).toBe(false); + }); + + it('errors not_found on the RoleNotFound mutation variant', async () => { + respondWith({ orgList: ORG_LIST, updateRole: { updateRole: { __typename: 'RoleNotFound', roleId: 'role_org' } } }); + const err = await expectExit(runRoleRemovePermission('org-admin', 'billing:manage', { org: 'org_1', yes: true }), 1); + expect(err.context?.errorCode).toBe('not_found'); + }); + + it('refuses to touch an inherited environment role', async () => { + respondWith({ orgList: { rolesForOrganization: { roles: [ENV_ROLE] } } }); + const err = await expectExit(runRoleRemovePermission('admin', 'users:read', { org: 'org_1', yes: true }), 1); + expect(err.context?.errorCode).toBe('invalid_argument'); + }); + + it('--json emits { role } with the filtered permission list overlaid', async () => { setOutputMode('json'); + respondWith({ orgList: ORG_LIST, updateRole: UPDATED }); + await runRoleRemovePermission('org-admin', 'billing:manage', { org: 'org_1', yes: true }); + const out = JSON.parse(consoleOutput[0]); + expect(out.role.permissions).toEqual([]); }); + }); - afterEach(() => { - setOutputMode('human'); + describe('require-flag matrix (privilege changes)', () => { + it.each(GATED)('$name refuses in agent mode without --yes', async ({ run }) => { + setInteractionMode({ mode: 'agent', source: 'agent_env' }); + const err = await expectExit(run(false), 1); + expect(err.context?.errorCode).toBe('confirmation_required'); + expect(mockGraphqlRequest).not.toHaveBeenCalled(); }); - it('runRoleCreate outputs JSON success', async () => { - mockSdk.authorization.createEnvironmentRole.mockResolvedValue(mockEnvRole); - await runRoleCreate({ slug: 'admin', name: 'Admin' }, undefined, 'sk_test'); - const output = JSON.parse(consoleOutput[0]); - expect(output.status).toBe('ok'); - expect(output.message).toBe('Created role'); - expect(output.data.id).toBe('role_123'); + it.each(GATED)('$name refuses in CI mode without --yes', async ({ run }) => { + setInteractionMode({ mode: 'ci', source: 'ci_env' }); + const err = await expectExit(run(false), 1); + expect(err.context?.errorCode).toBe('confirmation_required'); + expect(mockGraphqlRequest).not.toHaveBeenCalled(); }); - it('runRoleGet outputs raw JSON for env role', async () => { - mockSdk.authorization.getEnvironmentRole.mockResolvedValue(mockEnvRole); - await runRoleGet('admin', undefined, 'sk_test'); - const output = JSON.parse(consoleOutput[0]); - expect(output.id).toBe('role_123'); - expect(output.slug).toBe('admin'); - expect(output).not.toHaveProperty('status'); + it.each(GATED)('$name refuses in JSON mode without --yes', async ({ run }) => { + setInteractionMode({ mode: 'human', source: 'default' }); + setOutputMode('json'); + const err = await expectExit(run(false), 1); + expect(err.context?.errorCode).toBe('confirmation_required'); + expect(mockGraphqlRequest).not.toHaveBeenCalled(); }); - it('runRoleGet outputs raw JSON for org role', async () => { - mockSdk.authorization.getOrganizationRole.mockResolvedValue(mockOrgRole); - await runRoleGet('org-admin', 'org_abc', 'sk_test'); - const output = JSON.parse(consoleOutput[0]); - expect(output.id).toBe('role_456'); - expect(output.type).toBe('OrganizationRole'); + it.each(GATED)('$name proceeds without --yes for an interactive human (no prompt)', async ({ run, routes }) => { + setInteractionMode({ mode: 'human', source: 'default' }); + respondWith(routes); + await run(false); + expect(mockConfirm).not.toHaveBeenCalled(); + expect(mockGraphqlRequest).toHaveBeenCalled(); }); - it('runRoleList outputs JSON with data array', async () => { - mockSdk.authorization.listEnvironmentRoles.mockResolvedValue({ - data: [mockEnvRole], - }); - await runRoleList(undefined, 'sk_test'); - const output = JSON.parse(consoleOutput[0]); - expect(output.data).toHaveLength(1); - expect(output.data[0].slug).toBe('admin'); + it.each(GATED)('$name proceeds in CI with --yes', async ({ run, routes }) => { + setInteractionMode({ mode: 'ci', source: 'ci_env' }); + respondWith(routes); + await run(true); + expect(mockGraphqlRequest).toHaveBeenCalled(); }); + }); - it('runRoleList outputs empty data array for no results', async () => { - mockSdk.authorization.listEnvironmentRoles.mockResolvedValue({ data: [] }); - await runRoleList(undefined, 'sk_test'); - const output = JSON.parse(consoleOutput[0]); - expect(output.data).toEqual([]); + describe('shared failure modes (every subcommand)', () => { + it.each(SUBCOMMANDS)('$name exits auth-required (code 4) when not logged in', async ({ run }) => { + mockRequireCommandToken.mockImplementation(() => { + throw new CliExit(4, { reason: 'auth_required', errorCode: 'auth_required' }); + }); + await expectExit(run(), 4); + expect(mockGraphqlRequest).not.toHaveBeenCalled(); }); - it('runRoleDelete outputs JSON success', async () => { - mockSdk.authorization.deleteOrganizationRole.mockResolvedValue(undefined); - await runRoleDelete('org-admin', 'org_abc', 'sk_test'); - const output = JSON.parse(consoleOutput[0]); - expect(output.status).toBe('ok'); - expect(output.data.slug).toBe('org-admin'); + it.each(SUBCOMMANDS)('$name sends the environment header on its operation request', async ({ run, routes }) => { + respondWith(routes); + await run(); + expect(mockGraphqlRequest).toHaveBeenCalled(); + expect(lastCallOptions()).toMatchObject({ token: 'tok_123', environmentId: 'env_profile' }); }); - it('runRoleSetPermissions outputs JSON success', async () => { - mockSdk.authorization.setEnvironmentRolePermissions.mockResolvedValue(mockEnvRole); - await runRoleSetPermissions('admin', ['read'], undefined, 'sk_test'); - const output = JSON.parse(consoleOutput[0]); - expect(output.status).toBe('ok'); - expect(output.message).toBe('Set permissions on role'); + it.each(SUBCOMMANDS)('$name surfaces a 403 without naming GraphQL', async ({ run }) => { + const consoleErrors: string[] = []; + vi.spyOn(console, 'error').mockImplementation((...args: unknown[]) => { + consoleErrors.push(args.map(String).join(' ')); + }); + // The resolver's own pre-validation fetch stays healthy; the OPERATION + // request is what the capability gate rejects. + mockGraphqlRequest.mockImplementation(async (doc: unknown) => { + if (String(doc).includes('teamProjectsV2')) return TEAM_ENVIRONMENTS_PAYLOAD; + throw new DashboardGraphqlError('The dashboard GraphQL API rejected this session (HTTP 403).', 'forbidden', 403); + }); + await expectExit(run(), 1); + const err = consoleErrors.join('\n'); + expect(err).toMatch(/account-plane capability/i); + expect(err).not.toMatch(/graphql/i); }); }); }); diff --git a/src/commands/role.ts b/src/commands/role.ts index 7206c7d4..0da57fb9 100644 --- a/src/commands/role.ts +++ b/src/commands/role.ts @@ -1,186 +1,560 @@ -import { createWorkOSClient } from '../lib/workos-client.js'; +/** + * `workos role` — RBAC role management on the dashboard account plane. + * + * Migrated from the API-key REST SDK (graphql-resource-migration Phase 5): the + * subcommand surface (list/get/create/update/delete/set-permissions/ + * add-permission/remove-permission) is unchanged, but every operation now runs + * catalog-backed dashboard operations with the user's OAuth bearer. Output + * shapes are new curated shapes (approved breaking change); the authoritative + * examples live in `role.spec.ts`. + * + * Backend divergences from REST (all loud, none faked): + * - The backing mutations are ID-keyed while the frozen grammar is slug-keyed, + * so every mutation first resolves the slug via the scope's list operation + * (one extra read — the membership-delete precedent). + * - The update mutation REQUIRES a name and CLEARS the description when it is + * omitted, so updates read-merge-write: the current name/description ride + * along whenever the flags are omitted. + * - `--org` listing includes environment roles the organization inherits. + * Mutating one of those through the org scope would change it + * environment-wide, so org-scoped mutations require the matched role to be + * organization-scoped and error otherwise. + * - The permission trio (set-permissions/add-permission/remove-permission) + * rides the update mutation's full permission list via read-merge-write. + * + * Safety posture per the manifest: `delete` is destructive → + * `confirmDestructive` (prompt, or --yes); create/update/set-permissions/ + * add-permission/remove-permission are privilege changes → `require-flag` + * (non-interactive callers must pass --yes). + */ + +import chalk from 'chalk'; +import { requireCommandToken } from '../lib/command-auth.js'; +import { dashboardGraphqlRequest } from '../lib/dashboard-graphql.js'; +import { resolveEnvironmentTarget } from '../lib/environment-target.js'; +import { getOperation, resolveExecutableDocument, reportDashboardError } from '../catalog/operation.js'; +import { confirmDestructive, requireConfirmationFlag } from '../catalog/confirm.js'; +import { isJsonMode, outputJson, outputSuccess, exitWithError } from '../utils/output.js'; import { formatTable } from '../utils/table.js'; -import { outputSuccess, outputJson, isJsonMode } from '../utils/output.js'; -import { createApiErrorHandler } from '../lib/api-error-handler.js'; -const handleApiError = createApiErrorHandler('Role'); +interface RoleNode { + id: string; + name: string; + slug: string; + description?: string | null; + state?: string | null; + type?: string | null; + permissions?: Array<{ id: string; slug: string }> | null; + createdAt?: string | null; + updatedAt?: string | null; +} -export async function runRoleList(orgId: string | undefined, apiKey: string, baseUrl?: string): Promise { - const client = createWorkOSClient(apiKey, baseUrl); +/** + * The curated role shape — the `--json` contract for every subcommand. + * camelCase, stable keys, no internal fields; see role.spec.ts for the + * authoritative example. `permissions` is the role's permission slugs. + */ +function shapeRole(role: RoleNode) { + return { + id: role.id, + slug: role.slug, + name: role.name, + description: role.description ?? null, + type: role.type ?? null, + permissions: (role.permissions ?? []).map((permission) => permission.slug), + createdAt: role.createdAt ?? null, + updatedAt: role.updatedAt ?? null, + }; +} - try { - const result = orgId - ? await client.sdk.authorization.listOrganizationRoles(orgId) - : await client.sdk.authorization.listEnvironmentRoles(); +type ShapedRole = ReturnType; - if (isJsonMode()) { - outputJson({ data: result.data }); - return; - } - - if (result.data.length === 0) { - console.log('No roles found.'); - return; +/** + * Fetch the roles visible in the requested scope: the environment's roles, or + * (with `orgId`) the roles assignable within an organization. The org listing + * includes environment roles the organization inherits — org-scoped mutations + * must therefore check `type` before acting (see {@link requireOrgScopedRole}). + */ +async function fetchRoles(token: string, environmentId: string, orgId: string | undefined): Promise { + if (orgId) { + const op = getOperation('rolesForOrganization'); + let data: { rolesForOrganization: { roles: RoleNode[] } | null }; + try { + data = await dashboardGraphqlRequest(resolveExecutableDocument(op), { + token, + variables: { organizationId: orgId, environmentId }, + environmentId, + }); + } catch (error) { + reportDashboardError(error); } + return data.rolesForOrganization?.roles ?? []; + } - const rows = result.data.map((role) => [ - role.slug, - role.name, - role.type, - String(role.permissions.length), - new Date(role.createdAt).toLocaleDateString(), - ]); - - console.log( - formatTable( - [{ header: 'Slug' }, { header: 'Name' }, { header: 'Type' }, { header: 'Permissions' }, { header: 'Created' }], - rows, - ), - ); + const op = getOperation('roles'); + let data: { rolesForEnvironment: { roles: RoleNode[] } | null }; + try { + data = await dashboardGraphqlRequest(resolveExecutableDocument(op), { + token, + variables: { id: environmentId }, + environmentId, + }); } catch (error) { - handleApiError(error); + reportDashboardError(error); } + return data.rolesForEnvironment?.roles ?? []; } -export async function runRoleGet( - slug: string, +/** Resolve a slug within the scope's role list, or exit not_found. */ +async function requireRoleBySlug( + token: string, + environmentId: string, orgId: string | undefined, - apiKey: string, - baseUrl?: string, -): Promise { - const client = createWorkOSClient(apiKey, baseUrl); + slug: string, +): Promise { + const roles = await fetchRoles(token, environmentId, orgId); + const role = roles.find((candidate) => candidate.slug === slug); + if (!role) { + exitWithError({ + code: 'not_found', + message: orgId + ? `Role "${slug}" was not found in organization "${orgId}".` + : `Role "${slug}" was not found in this environment.`, + }); + } + return role; +} - try { - const role = orgId - ? await client.sdk.authorization.getOrganizationRole(orgId, slug) - : await client.sdk.authorization.getEnvironmentRole(slug); - outputJson(role); - } catch (error) { - handleApiError(error); +/** + * Guard for org-scoped mutations: the org listing dedupes by slug with the + * organization's own role winning, so a match can still be an INHERITED + * environment role — mutating that through the org scope would change it + * environment-wide. Refuse loudly instead. + */ +function requireOrgScopedRole(role: RoleNode, slug: string, orgId: string): void { + if (role.type !== 'Organization') { + exitWithError({ + code: 'invalid_argument', + message: + `Role "${slug}" in organization "${orgId}" is an environment role. ` + + 'Modifying it here would change it for every organization — rerun without --org to update the environment role.', + }); + } +} + +function renderRoleTable(roles: ShapedRole[]): void { + const rows = roles.map((role) => [ + role.slug, + role.name, + role.type ?? chalk.dim('-'), + String(role.permissions.length), + role.createdAt ?? chalk.dim('-'), + ]); + console.log( + formatTable( + [{ header: 'Slug' }, { header: 'Name' }, { header: 'Type' }, { header: 'Permissions' }, { header: 'Created' }], + rows, + ), + ); +} + +function renderRoleFields(role: ShapedRole): void { + const fields: Array<[string, unknown]> = [ + ['Slug', role.slug], + ['Name', role.name], + ['Type', role.type], + ['Description', role.description], + ['Permissions', role.permissions.length > 0 ? role.permissions.join(', ') : null], + ['Created', role.createdAt], + ]; + for (const [label, value] of fields) { + if (value === null || value === undefined || value === '') continue; + console.log(`${chalk.bold(label)}: ${String(value)}`); + } + console.log(chalk.dim('Run with --json for the full record.')); +} + +export interface RoleScopeOptions { + /** `--environment-id` override; defaults from the active profile. */ + environmentId?: string; + /** `--org`: act on the organization scope instead of the environment. */ + org?: string; +} + +export async function runRoleList(options: RoleScopeOptions = {}): Promise { + const token = await requireCommandToken(); + + // Environment-scoped read: both list ops take the resolved ID as a variable + // and it rides as the environment header. + const { environmentId } = await resolveEnvironmentTarget(token, { + flagValue: options.environmentId, + forMutation: false, + }); + + const roles = (await fetchRoles(token, environmentId, options.org)).map(shapeRole); + + if (isJsonMode()) { + outputJson({ roles }); + return; } + if (roles.length === 0) { + console.log('No roles found.'); + return; + } + renderRoleTable(roles); } -export interface RoleCreateOptions { +export async function runRoleGet(slug: string, options: RoleScopeOptions = {}): Promise { + const token = await requireCommandToken(); + + const { environmentId } = await resolveEnvironmentTarget(token, { + flagValue: options.environmentId, + forMutation: false, + }); + + const role = shapeRole(await requireRoleBySlug(token, environmentId, options.org, slug)); + + if (isJsonMode()) { + outputJson({ role }); + return; + } + renderRoleFields(role); +} + +export interface RoleCreateOptions extends RoleScopeOptions { slug: string; name: string; description?: string; + yes?: boolean; + json?: boolean; } -export async function runRoleCreate( - options: RoleCreateOptions, - orgId: string | undefined, - apiKey: string, - baseUrl?: string, -): Promise { - const client = createWorkOSClient(apiKey, baseUrl); +export async function runRoleCreate(options: RoleCreateOptions): Promise { + // require-flag: expands the privilege surface; non-interactive callers must pass --yes. + await requireConfirmationFlag(options, { action: `create role ${options.slug}` }); + + const token = await requireCommandToken(); + const op = getOperation('createRole'); - const opts = { - slug: options.slug, - name: options.name, - ...(options.description && { description: options.description }), + // Environment-scoped mutation: pre-validated resolved target as header (the + // create input carries no environment field — the header IS the scope). + const { environmentId } = await resolveEnvironmentTarget(token, { + flagValue: options.environmentId, + forMutation: op.kind === 'mutation', + }); + + let data: { + createRole: + | { __typename: 'RoleCreated'; role: RoleNode } + | { __typename: 'RoleAlreadyExists'; slug: string } + | { __typename: 'EnvironmentNotFound'; environmentId: string } + | { __typename: string }; }; + try { + data = await dashboardGraphqlRequest(resolveExecutableDocument(op), { + token, + variables: { + input: { + slug: options.slug, + name: options.name, + ...(options.description !== undefined ? { description: options.description } : {}), + ...(options.org ? { organizationId: options.org } : {}), + }, + }, + environmentId, + }); + } catch (error) { + reportDashboardError(error); + } + + const result = data.createRole; + if (result.__typename === 'RoleAlreadyExists') { + exitWithError({ code: 'already_exists', message: `A role with slug "${options.slug}" already exists.` }); + } + if (result.__typename === 'EnvironmentNotFound') { + exitWithError({ code: 'not_found', message: 'The target environment was not found.' }); + } + if (result.__typename !== 'RoleCreated' || !('role' in result)) { + exitWithError({ code: 'unexpected_result', message: `Could not create role "${options.slug}".` }); + } + + const role = shapeRole((result as { role: RoleNode }).role); + if (isJsonMode()) { + outputJson({ role }); + return; + } + outputSuccess(`Created role ${chalk.bold(role.slug)}`); +} + +/** + * Shared updateRole call: the input REQUIRES `name` and CLEARS `description` + * when omitted, so callers pass the full merged trio (and optionally the full + * permission-slug list — omitting `permissions` leaves them unchanged). + * + * The mutation's response role carries NO permission selection, so the shaped + * result overlays `effectivePermissions` — the slugs known to hold after this + * mutation (the list that was sent, or the pre-mutation list when none was). + */ +async function executeRoleUpdate( + token: string, + environmentId: string, + input: { roleId: string; name: string; description?: string; permissions?: string[] }, + slug: string, + effectivePermissions: string[], +): Promise { + const op = getOperation('updateRole'); + let data: { + updateRole: + | { __typename: 'RoleUpdated'; role: RoleNode } + | { __typename: 'RoleNotFound'; roleId: string } + | { __typename: string }; + }; try { - const role = orgId - ? await client.sdk.authorization.createOrganizationRole(orgId, opts) - : await client.sdk.authorization.createEnvironmentRole(opts); - outputSuccess('Created role', role); + data = await dashboardGraphqlRequest(resolveExecutableDocument(op), { + token, + variables: { input }, + environmentId, + }); } catch (error) { - handleApiError(error); + reportDashboardError(error); + } + + const result = data.updateRole; + if (result.__typename === 'RoleNotFound') { + exitWithError({ code: 'not_found', message: `Role "${slug}" was not found in this environment.` }); + } + if (result.__typename !== 'RoleUpdated' || !('role' in result)) { + exitWithError({ code: 'unexpected_result', message: `Could not update role "${slug}".` }); } + return { ...shapeRole((result as { role: RoleNode }).role), permissions: effectivePermissions }; } -export interface RoleUpdateOptions { +/** The role's current permission slugs (from the scope's list operation). */ +function permissionSlugsOf(role: RoleNode): string[] { + return (role.permissions ?? []).map((permission) => permission.slug); +} + +/** Merge current name/description into an update input (see executeRoleUpdate). */ +function mergedUpdateInput( + role: RoleNode, + overrides: { name?: string; description?: string }, +): { roleId: string; name: string; description?: string } { + const description = overrides.description ?? role.description ?? undefined; + return { + roleId: role.id, + name: overrides.name ?? role.name, + ...(description !== undefined ? { description } : {}), + }; +} + +export interface RoleUpdateOptions extends RoleScopeOptions { name?: string; description?: string; + yes?: boolean; + json?: boolean; } -export async function runRoleUpdate( - slug: string, - options: RoleUpdateOptions, - orgId: string | undefined, - apiKey: string, - baseUrl?: string, -): Promise { - const client = createWorkOSClient(apiKey, baseUrl); +export async function runRoleUpdate(slug: string, options: RoleUpdateOptions = {}): Promise { + if (options.name === undefined && options.description === undefined) { + exitWithError({ code: 'missing_argument', message: 'Nothing to update. Pass --name and/or --description.' }); + } - const opts = { - ...(options.name !== undefined && { name: options.name }), - ...(options.description !== undefined && { description: options.description }), - }; + // require-flag: a privilege change; non-interactive callers must pass --yes. + await requireConfirmationFlag(options, { action: `update role ${slug}` }); - try { - const role = orgId - ? await client.sdk.authorization.updateOrganizationRole(orgId, slug, opts) - : await client.sdk.authorization.updateEnvironmentRole(slug, opts); - outputSuccess('Updated role', role); - } catch (error) { - handleApiError(error); + const token = await requireCommandToken(); + + // Environment-scoped mutation: pre-validated resolved target as header. + const { environmentId } = await resolveEnvironmentTarget(token, { + flagValue: options.environmentId, + forMutation: true, + }); + + // The mutation is ID-keyed; resolve the frozen slug grammar via the scope's + // list first (and refuse to touch an inherited environment role via --org). + const role = await requireRoleBySlug(token, environmentId, options.org, slug); + if (options.org) { + requireOrgScopedRole(role, slug, options.org); } + + const updated = await executeRoleUpdate( + token, + environmentId, + mergedUpdateInput(role, { name: options.name, description: options.description }), + slug, + permissionSlugsOf(role), // permissions unchanged by this input + ); + + if (isJsonMode()) { + outputJson({ role: updated }); + return; + } + outputSuccess(`Updated role ${chalk.bold(updated.slug)}`); +} + +export interface RoleDeleteOptions extends RoleScopeOptions { + /** Required by the command grammar: only organization roles can be deleted. */ + org: string; + yes?: boolean; + json?: boolean; } -export async function runRoleDelete(slug: string, orgId: string, apiKey: string, baseUrl?: string): Promise { - const client = createWorkOSClient(apiKey, baseUrl); +export async function runRoleDelete(slug: string, options: RoleDeleteOptions): Promise { + // Destructive per the manifest: permanently deletes the role; members lose it. + await confirmDestructive(options, { + action: `delete role ${slug} — this permanently removes the role and unassigns its members`, + }); + + const token = await requireCommandToken(); + const op = getOperation('deleteRole'); + + // Environment-scoped mutation: pre-validated resolved target as header. + const { environmentId } = await resolveEnvironmentTarget(token, { + flagValue: options.environmentId, + forMutation: op.kind === 'mutation', + }); + + const role = await requireRoleBySlug(token, environmentId, options.org, slug); + requireOrgScopedRole(role, slug, options.org); + let data: { + deleteRole: + | { __typename: 'RoleDeleted' } + | { __typename: 'RoleNotFound'; roleId: string } + | { __typename: 'EnvironmentNotFound'; environmentId: string } + | { __typename: string }; + }; try { - await client.sdk.authorization.deleteOrganizationRole(orgId, slug); - outputSuccess('Deleted role', { slug, organizationId: orgId }); + data = await dashboardGraphqlRequest(resolveExecutableDocument(op), { + token, + variables: { input: { roleId: role.id } }, + environmentId, + }); } catch (error) { - handleApiError(error); + reportDashboardError(error); + } + + const result = data.deleteRole; + if (result.__typename === 'RoleNotFound' || result.__typename === 'EnvironmentNotFound') { + exitWithError({ code: 'not_found', message: `Role "${slug}" was not found in organization "${options.org}".` }); + } + if (result.__typename !== 'RoleDeleted') { + exitWithError({ code: 'unexpected_result', message: `Could not delete role "${slug}".` }); } + + if (isJsonMode()) { + outputJson({ deleted: slug }); + return; + } + outputSuccess(`Deleted role ${chalk.bold(slug)}`); +} + +export interface RolePermissionsOptions extends RoleScopeOptions { + yes?: boolean; + json?: boolean; } export async function runRoleSetPermissions( slug: string, permissions: string[], - orgId: string | undefined, - apiKey: string, - baseUrl?: string, + options: RolePermissionsOptions = {}, ): Promise { - const client = createWorkOSClient(apiKey, baseUrl); + // require-flag: a privilege change; non-interactive callers must pass --yes. + await requireConfirmationFlag(options, { action: `replace the permissions on role ${slug}` }); - try { - const role = orgId - ? await client.sdk.authorization.setOrganizationRolePermissions(orgId, slug, { permissions }) - : await client.sdk.authorization.setEnvironmentRolePermissions(slug, { permissions }); - outputSuccess('Set permissions on role', role); - } catch (error) { - handleApiError(error); + const token = await requireCommandToken(); + + const { environmentId } = await resolveEnvironmentTarget(token, { + flagValue: options.environmentId, + forMutation: true, + }); + + const role = await requireRoleBySlug(token, environmentId, options.org, slug); + if (options.org) { + requireOrgScopedRole(role, slug, options.org); + } + + const updated = await executeRoleUpdate(token, environmentId, { ...mergedUpdateInput(role, {}), permissions }, slug, permissions); + + if (isJsonMode()) { + outputJson({ role: updated }); + return; } + outputSuccess(`Set ${permissions.length} permission${permissions.length === 1 ? '' : 's'} on role ${chalk.bold(slug)}`); } export async function runRoleAddPermission( slug: string, permissionSlug: string, - orgId: string | undefined, - apiKey: string, - baseUrl?: string, + options: RolePermissionsOptions = {}, ): Promise { - const client = createWorkOSClient(apiKey, baseUrl); + // require-flag: a privilege change; non-interactive callers must pass --yes. + await requireConfirmationFlag(options, { action: `add permission ${permissionSlug} to role ${slug}` }); - try { - const role = orgId - ? await client.sdk.authorization.addOrganizationRolePermission(orgId, slug, { permissionSlug }) - : await client.sdk.authorization.addEnvironmentRolePermission(slug, { permissionSlug }); - outputSuccess('Added permission to role', role); - } catch (error) { - handleApiError(error); + const token = await requireCommandToken(); + + const { environmentId } = await resolveEnvironmentTarget(token, { + flagValue: options.environmentId, + forMutation: true, + }); + + const role = await requireRoleBySlug(token, environmentId, options.org, slug); + if (options.org) { + requireOrgScopedRole(role, slug, options.org); } + + // The backing mutation carries the FULL permission list — merge (deduped) + // rather than sending just the addition. + const current = permissionSlugsOf(role); + const permissions = [...new Set([...current, permissionSlug])]; + + const updated = await executeRoleUpdate(token, environmentId, { ...mergedUpdateInput(role, {}), permissions }, slug, permissions); + + if (isJsonMode()) { + outputJson({ role: updated }); + return; + } + outputSuccess(`Added permission ${chalk.bold(permissionSlug)} to role ${chalk.bold(slug)}`); +} + +export interface RoleRemovePermissionOptions extends RolePermissionsOptions { + /** Required by the command grammar: org-scoped removal only. */ + org: string; } export async function runRoleRemovePermission( slug: string, permissionSlug: string, - orgId: string, - apiKey: string, - baseUrl?: string, + options: RoleRemovePermissionOptions, ): Promise { - const client = createWorkOSClient(apiKey, baseUrl); + // require-flag: a privilege change; non-interactive callers must pass --yes. + await requireConfirmationFlag(options, { action: `remove permission ${permissionSlug} from role ${slug}` }); - try { - await client.sdk.authorization.removeOrganizationRolePermission(orgId, slug, { permissionSlug }); - outputSuccess('Removed permission from role', { slug, permissionSlug, organizationId: orgId }); - } catch (error) { - handleApiError(error); + const token = await requireCommandToken(); + + const { environmentId } = await resolveEnvironmentTarget(token, { + flagValue: options.environmentId, + forMutation: true, + }); + + const role = await requireRoleBySlug(token, environmentId, options.org, slug); + requireOrgScopedRole(role, slug, options.org); + + const current = permissionSlugsOf(role); + if (!current.includes(permissionSlug)) { + exitWithError({ + code: 'not_found', + message: `Role "${slug}" does not have permission "${permissionSlug}".`, + }); + } + const permissions = current.filter((candidate) => candidate !== permissionSlug); + + const updated = await executeRoleUpdate(token, environmentId, { ...mergedUpdateInput(role, {}), permissions }, slug, permissions); + + if (isJsonMode()) { + outputJson({ role: updated }); + return; } + outputSuccess(`Removed permission ${chalk.bold(permissionSlug)} from role ${chalk.bold(slug)}`); } diff --git a/src/utils/help-json.ts b/src/utils/help-json.ts index 686e452e..0af5ec15 100644 --- a/src/utils/help-json.ts +++ b/src/utils/help-json.ts @@ -858,24 +858,24 @@ const commands: CommandSchema[] = [ // --- Resource Management Commands --- { name: 'role', - description: 'Manage WorkOS roles (environment and organization-scoped)', + description: 'Manage roles in the active environment (environment and organization-scoped)', options: [ insecureStorageOpt, - apiKeyOpt, { name: 'org', type: 'string', - description: 'Organization ID (for org-scoped roles)', + description: 'Organization ID (for organization roles)', required: false, hidden: false, }, ], commands: [ - { name: 'list', description: 'List roles', options: [] }, + { name: 'list', description: 'List roles', options: [environmentIdOpt] }, { name: 'get', description: 'Get a role by slug', positionals: [{ name: 'slug', type: 'string', description: 'Role slug', required: true }], + options: [environmentIdOpt], }, { name: 'create', @@ -884,6 +884,8 @@ const commands: CommandSchema[] = [ { name: 'slug', type: 'string', description: 'Role slug', required: true, hidden: false }, { name: 'name', type: 'string', description: 'Role name', required: true, hidden: false }, { name: 'description', type: 'string', description: 'Role description', required: false, hidden: false }, + requireFlagYesOpt, + environmentIdOpt, ], }, { @@ -893,12 +895,15 @@ const commands: CommandSchema[] = [ options: [ { name: 'name', type: 'string', description: 'New name', required: false, hidden: false }, { name: 'description', type: 'string', description: 'New description', required: false, hidden: false }, + requireFlagYesOpt, + environmentIdOpt, ], }, { name: 'delete', description: 'Delete an org-scoped role (requires --org)', positionals: [{ name: 'slug', type: 'string', description: 'Role slug', required: true }], + options: [confirmYesOpt, environmentIdOpt], }, { name: 'set-permissions', @@ -912,6 +917,8 @@ const commands: CommandSchema[] = [ required: true, hidden: false, }, + requireFlagYesOpt, + environmentIdOpt, ], }, { @@ -921,6 +928,7 @@ const commands: CommandSchema[] = [ { name: 'slug', type: 'string', description: 'Role slug', required: true }, { name: 'permissionSlug', type: 'string', description: 'Permission slug', required: true }, ], + options: [requireFlagYesOpt, environmentIdOpt], }, { name: 'remove-permission', @@ -929,19 +937,21 @@ const commands: CommandSchema[] = [ { name: 'slug', type: 'string', description: 'Role slug', required: true }, { name: 'permissionSlug', type: 'string', description: 'Permission slug', required: true }, ], + options: [requireFlagYesOpt, environmentIdOpt], }, ], }, { name: 'permission', - description: 'Manage WorkOS permissions', - options: [insecureStorageOpt, apiKeyOpt], + description: 'Manage permissions in the active environment', + options: [insecureStorageOpt], commands: [ - { name: 'list', description: 'List permissions', options: [...paginationOpts] }, + { name: 'list', description: 'List permissions', options: [environmentIdOpt] }, { name: 'get', description: 'Get a permission', positionals: [{ name: 'slug', type: 'string', description: 'Permission slug', required: true }], + options: [environmentIdOpt], }, { name: 'create', @@ -956,6 +966,8 @@ const commands: CommandSchema[] = [ required: false, hidden: false, }, + requireFlagYesOpt, + environmentIdOpt, ], }, { @@ -965,12 +977,15 @@ const commands: CommandSchema[] = [ options: [ { name: 'name', type: 'string', description: 'New name', required: false, hidden: false }, { name: 'description', type: 'string', description: 'New description', required: false, hidden: false }, + requireFlagYesOpt, + environmentIdOpt, ], }, { name: 'delete', description: 'Delete a permission', positionals: [{ name: 'slug', type: 'string', description: 'Permission slug', required: true }], + options: [confirmYesOpt, environmentIdOpt], }, ], }, @@ -1361,40 +1376,45 @@ const commands: CommandSchema[] = [ }, { name: 'feature-flag', - description: 'Manage feature flags', - options: [insecureStorageOpt, apiKeyOpt], + description: 'Manage feature flags in the active environment', + options: [insecureStorageOpt], commands: [ - { name: 'list', description: 'List feature flags', options: [...paginationOpts] }, + { name: 'list', description: 'List feature flags', options: [...paginationOpts, environmentIdOpt] }, { name: 'get', description: 'Get a feature flag', positionals: [{ name: 'slug', type: 'string', description: 'Feature flag slug', required: true }], + options: [environmentIdOpt], }, { name: 'enable', description: 'Enable a feature flag', positionals: [{ name: 'slug', type: 'string', description: 'Feature flag slug', required: true }], + options: [environmentIdOpt], }, { name: 'disable', description: 'Disable a feature flag', positionals: [{ name: 'slug', type: 'string', description: 'Feature flag slug', required: true }], + options: [environmentIdOpt], }, { name: 'add-target', - description: 'Add a target to a feature flag', + description: 'Add a target (user or organization) to a feature flag', positionals: [ { name: 'slug', type: 'string', description: 'Feature flag slug', required: true }, - { name: 'targetId', type: 'string', description: 'Target ID', required: true }, + { name: 'targetId', type: 'string', description: 'Target ID (user_* or org_*)', required: true }, ], + options: [environmentIdOpt], }, { name: 'remove-target', - description: 'Remove a target from a feature flag', + description: 'Remove a target (user or organization) from a feature flag', positionals: [ { name: 'slug', type: 'string', description: 'Feature flag slug', required: true }, - { name: 'targetId', type: 'string', description: 'Target ID', required: true }, + { name: 'targetId', type: 'string', description: 'Target ID (user_* or org_*)', required: true }, ], + options: [environmentIdOpt], }, ], }, From 35c10ff9d1ba9a9b20880c5548b892a10fe2e559 Mon Sep 17 00:00:00 2001 From: Nick Nisi Date: Wed, 22 Jul 2026 20:02:03 -0500 Subject: [PATCH 14/22] feat!: migrate event + org-domain to the dashboard account plane; directory stays REST (stop-rule) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- src/bin.ts | 84 ++++--- src/catalog/curation.ts | 16 ++ src/catalog/manifest.ts | 66 +++++ src/commands/event.spec.ts | 271 +++++++++++++-------- src/commands/event.ts | 142 ++++++++--- src/commands/org-domain.spec.ts | 415 ++++++++++++++++++++++++++------ src/commands/org-domain.ts | 295 ++++++++++++++++++++--- src/utils/help-json.ts | 35 ++- 8 files changed, 1041 insertions(+), 283 deletions(-) diff --git a/src/bin.ts b/src/bin.ts index 49db09af..4615c711 100644 --- a/src/bin.ts +++ b/src/bin.ts @@ -2119,7 +2119,7 @@ async function runCli(): Promise { return yargs.demandCommand(1, 'Please specify a directory subcommand').strict(); }) .command('event', 'Query WorkOS events', (yargs) => { - yargs.options({ ...insecureStorageOption, 'api-key': { type: 'string' as const, describe: 'WorkOS API key' } }); + yargs.options(insecureStorageOption); registerSubcommand( yargs, 'list', @@ -2127,29 +2127,23 @@ async function runCli(): Promise { (y) => y.options({ events: { type: 'string', demandOption: true, describe: 'Comma-separated event types' }, - after: { type: 'string' }, - org: { type: 'string' }, - 'range-start': { type: 'string' }, - 'range-end': { type: 'string' }, - limit: { type: 'number' }, + after: { type: 'string', describe: 'Cursor for results after a specific item' }, + 'range-start': { type: 'string', describe: 'Range start (ISO date)' }, + 'range-end': { type: 'string', describe: 'Range end (ISO date)' }, + limit: { type: 'number', describe: 'Limit number of results' }, + 'environment-id': { type: 'string', describe: 'Environment ID (defaults to the active environment)' }, }), async (argv) => { await applyInsecureStorage(argv.insecureStorage); - - const { resolveApiKey, resolveApiBaseUrl } = await import('./lib/api-key.js'); const { runEventList } = await import('./commands/event.js'); - await runEventList( - { - events: argv.events.split(','), - after: argv.after, - organizationId: argv.org, - rangeStart: argv.rangeStart, - rangeEnd: argv.rangeEnd, - limit: argv.limit, - }, - resolveApiKey({ apiKey: argv.apiKey }), - resolveApiBaseUrl(), - ); + await runEventList({ + events: argv.events.split(','), + after: argv.after, + rangeStart: argv.rangeStart, + rangeEnd: argv.rangeEnd, + limit: argv.limit, + environmentId: argv.environmentId as string | undefined, + }); }, ); return yargs.demandCommand(1, 'Please specify an event subcommand').strict(); @@ -2782,60 +2776,70 @@ async function runCli(): Promise { return yargs.demandCommand(1, 'Please specify an api-key subcommand').strict(); }) .command('org-domain', 'Manage organization domains', (yargs) => { - yargs.options({ ...insecureStorageOption, 'api-key': { type: 'string' as const, describe: 'WorkOS API key' } }); + yargs.options(insecureStorageOption); registerSubcommand( yargs, 'get ', 'Get a domain', - (y) => y.positional('id', { type: 'string', demandOption: true }), + (y) => + y + .positional('id', { type: 'string', demandOption: true, describe: 'Domain ID' }) + .option('environment-id', { type: 'string', describe: 'Environment ID (defaults to the active environment)' }), async (argv) => { await applyInsecureStorage(argv.insecureStorage); - - const { resolveApiKey, resolveApiBaseUrl } = await import('./lib/api-key.js'); const { runOrgDomainGet } = await import('./commands/org-domain.js'); - await runOrgDomainGet(argv.id, resolveApiKey({ apiKey: argv.apiKey }), resolveApiBaseUrl()); + await runOrgDomainGet(argv.id, { environmentId: argv.environmentId as string | undefined }); }, ); registerSubcommand( yargs, 'create ', - 'Create a domain', + 'Add a domain to an organization (added as verified)', (y) => y - .positional('domain', { type: 'string', demandOption: true }) - .option('org', { type: 'string', demandOption: true }), + .positional('domain', { type: 'string', demandOption: true, describe: 'Domain name' }) + .option('org', { type: 'string', demandOption: true, describe: 'Organization ID (org_*)' }) + .option('environment-id', { type: 'string', describe: 'Environment ID (defaults to the active environment)' }), async (argv) => { await applyInsecureStorage(argv.insecureStorage); - - const { resolveApiKey, resolveApiBaseUrl } = await import('./lib/api-key.js'); const { runOrgDomainCreate } = await import('./commands/org-domain.js'); - await runOrgDomainCreate(argv.domain, argv.org, resolveApiKey({ apiKey: argv.apiKey }), resolveApiBaseUrl()); + await runOrgDomainCreate(argv.domain, { + org: argv.org, + environmentId: argv.environmentId as string | undefined, + }); }, ); registerSubcommand( yargs, 'verify ', - 'Verify a domain', - (y) => y.positional('id', { type: 'string', demandOption: true }), + 'Restart verification for a domain (issues a fresh verification token)', + (y) => + y + .positional('id', { type: 'string', demandOption: true, describe: 'Domain ID' }) + .option('environment-id', { type: 'string', describe: 'Environment ID (defaults to the active environment)' }), async (argv) => { await applyInsecureStorage(argv.insecureStorage); - - const { resolveApiKey, resolveApiBaseUrl } = await import('./lib/api-key.js'); const { runOrgDomainVerify } = await import('./commands/org-domain.js'); - await runOrgDomainVerify(argv.id, resolveApiKey({ apiKey: argv.apiKey }), resolveApiBaseUrl()); + await runOrgDomainVerify(argv.id, { environmentId: argv.environmentId as string | undefined }); }, ); registerSubcommand( yargs, 'delete ', 'Delete a domain', - (y) => y.positional('id', { type: 'string', demandOption: true }), + (y) => + y + .positional('id', { type: 'string', demandOption: true, describe: 'Domain ID' }) + .option('yes', { alias: 'y', type: 'boolean', default: false, describe: 'Skip the confirmation prompt' }) + .option('environment-id', { type: 'string', describe: 'Environment ID (defaults to the active environment)' }), async (argv) => { await applyInsecureStorage(argv.insecureStorage); - - const { resolveApiKey, resolveApiBaseUrl } = await import('./lib/api-key.js'); const { runOrgDomainDelete } = await import('./commands/org-domain.js'); - await runOrgDomainDelete(argv.id, resolveApiKey({ apiKey: argv.apiKey }), resolveApiBaseUrl()); + await runOrgDomainDelete(argv.id, { + yes: argv.yes, + json: argv.json as boolean | undefined, + environmentId: argv.environmentId as string | undefined, + }); }, ); return yargs.demandCommand(1, 'Please specify an org-domain subcommand').strict(); diff --git a/src/catalog/curation.ts b/src/catalog/curation.ts index bebeadec..abcc3a9b 100644 --- a/src/catalog/curation.ts +++ b/src/catalog/curation.ts @@ -157,6 +157,22 @@ export const OVERRIDES: Record = { describe: "Update a feature flag's state and targeting in the current environment", }, + // --- org-infrastructure cluster (resource migration Phase 6) --- + // Op names/descriptions are clean upstream, but every manifest-curated op + // still needs an override so the manifest's clean `command` noun is the + // single source of truth. `org-domain get` rides the `organizations` op + // (owned by `organization list` above) with no entry of its own — see the + // manifest comment. The `directory` command stays REST this phase (stop-rule: + // no environment-wide directory listing exists), so no directory ops are + // curated here. + environmentEvents: { command: 'event list', describe: 'List recent events in the current environment' }, + addDomains: { command: 'org-domain create', describe: 'Add a verified domain to an organization' }, + restartOrganizationDomainVerification: { + command: 'org-domain verify', + describe: 'Restart verification for an organization domain', + }, + deleteOrganizationDomain: { command: 'org-domain delete', describe: 'Remove a domain from an organization' }, + // --- Phase 4: AuthKit app config --- // These op names/descriptions are already clean (no leak), but each still needs // an override so resolveCommandMeta returns the manifest's clean noun (the leak diff --git a/src/catalog/manifest.ts b/src/catalog/manifest.ts index 90d6bc86..1e65b999 100644 --- a/src/catalog/manifest.ts +++ b/src/catalog/manifest.ts @@ -670,6 +670,72 @@ const MANIFEST: CommandJustification[] = [ // flags from CI/scripts is the intended automation. ciPolicy: 'allow', }, + + // --- Resource migration: org-infrastructure cluster (event / org-domain) --- + // graphql-resource-migration Phase 6. Same recipe as the earlier clusters: + // unchanged subcommand grammar, dashboard-plane backend, new curated shapes. + // + // Mapping notes: + // - `directory` STAYS on the REST plane this phase: the delta's stop-rule + // triggered. The catalog's only directory listings are org-scoped + // (`directoriesForOrganization`) or per-directory (`directorySummary`), and + // the upstream schema has no environment-wide directories query at all, so + // migrating would force `--org` onto `directory list` — a frozen-grammar + // violation. Recorded as the upstream catalog ask. + // - `org-domain get` has NO backing single-domain query (the delta's + // `teamDashboardOrgDomains` candidate returns the domains of the team's OWN + // WorkOS organization — wrong plane): it is a client-side filter over one + // page of the `organizations` op (capped, loud miss wording), so it + // deliberately has no manifest entry of its own (invitation-get precedent). + // - `org-domain verify` maps to the restart-DNS-verification op, which is what + // REST verify did (initiate verification); the instant manual-verify op is + // deliberately NOT surfaced (grammar frozen). + // - Domain setters follow the `organization create/update` precedent (those + // already write domains): ciPolicy `allow`. The delete is `destructive` + // (confirmDestructive; ciPolicy stays `allow` because the destructive gate + // already covers non-interactive runs). + { + command: 'event list', + mapsTo: 'environmentEvents', + audiences: ['human', 'agent', 'ci'], + useCase: 'Audit recent activity in the active environment (debugging, sync monitoring, scripting)', + load: 'cheap', + mutation: false, + destructive: false, + ciPolicy: 'allow', + }, + { + command: 'org-domain create', + mapsTo: 'addDomains', + audiences: ['human', 'agent', 'ci'], + useCase: 'Add a verified domain to an organization from setup scripts or CI', + load: 'cheap', + mutation: true, + destructive: false, + ciPolicy: 'allow', + }, + { + command: 'org-domain verify', + mapsTo: 'restartOrganizationDomainVerification', + audiences: ['human', 'agent', 'ci'], + useCase: 'Restart DNS verification for an organization domain (issues a fresh verification token)', + load: 'cheap', + mutation: true, + destructive: false, + ciPolicy: 'allow', + }, + { + command: 'org-domain delete', + mapsTo: 'deleteOrganizationDomain', + audiences: ['human', 'agent'], + useCase: 'Remove a domain from an organization (cleanup, offboarding)', + load: 'cheap', + mutation: true, + // destructive: removes the domain; domain-based sign-in and capture stop + // working for it. + destructive: true, + ciPolicy: 'allow', + }, ]; /** Returns the curated command allowlist. */ diff --git a/src/commands/event.spec.ts b/src/commands/event.spec.ts index 4a32843b..e2d44327 100644 --- a/src/commands/event.spec.ts +++ b/src/commands/event.spec.ts @@ -1,142 +1,225 @@ import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'; -const mockSdk = { - events: { - listEvents: vi.fn(), - }, -}; +const mockRequireCommandToken = vi.fn(); +const mockGraphqlRequest = vi.fn(); +const mockGetActiveEnvironment = vi.fn(); +const mockGetConfig = vi.fn(); + +vi.mock('../lib/command-auth.js', async (importActual) => { + const actual = await importActual(); + return { + ...actual, + requireCommandToken: () => mockRequireCommandToken(), + }; +}); + +vi.mock('../lib/dashboard-graphql.js', async (importActual) => { + const actual = await importActual(); + return { + ...actual, + dashboardGraphqlRequest: (...args: unknown[]) => mockGraphqlRequest(...args), + }; +}); -vi.mock('../lib/workos-client.js', () => ({ - createWorkOSClient: () => ({ sdk: mockSdk }), -})); +// The environment-target resolver runs REAL against the mocked wire + config +// store (session.spec.ts recipe). +vi.mock('../lib/config-store.js', async (importActual) => { + const actual = await importActual(); + return { + ...actual, + getActiveEnvironment: () => mockGetActiveEnvironment(), + getConfig: () => mockGetConfig(), + setProfileEnvironmentId: vi.fn(), + }; +}); const { setOutputMode } = await import('../utils/output.js'); +const { resetInteractionModeForTests } = await import('../utils/interaction-mode.js'); +const { DashboardGraphqlError } = await import('../lib/dashboard-graphql.js'); +const { CliExit } = await import('../utils/cli-exit.js'); const { runEventList } = await import('./event.js'); -describe('event commands', () => { +const TEAM_ENVIRONMENTS_PAYLOAD = { + currentTeam: { + projectsV2: [{ environments: [{ id: 'env_profile', name: 'Sandbox', sandbox: true, clientId: null }] }], + }, +}; + +/** Route the wire mock by document (session.spec.ts recipe). */ +function respondWith(payload: unknown): void { + mockGraphqlRequest.mockImplementation(async (doc: unknown) => { + if (String(doc).includes('teamProjectsV2')) return TEAM_ENVIRONMENTS_PAYLOAD; + return payload; + }); +} + +async function expectExit(promise: Promise, code: number): Promise { + try { + await promise; + } catch (err) { + if (err instanceof CliExit) { + expect(err.exitCode).toBe(code); + return err; + } + throw err; + } + throw new Error(`Expected CliExit(${code}) but promise resolved`); +} + +/** The authoritative curated event JSON shape (documented contract). */ +const EVENT_SHAPE_KEYS = ['id', 'event', 'data', 'createdAt', 'updatedAt']; + +const EVENT_NODE = { + id: 'event_1', + name: 'dsync.user.created', + data: { directory_id: 'dir_1' }, + context: { actor: 'internal' }, + createdAt: '2026-01-01T00:00:00.000Z', + updatedAt: '2026-01-01T00:00:00.000Z', + metadata: {}, +}; + +function eventsPayload( + data: unknown[], + listMetadata: { before: string | null; after: string | null } = { before: null, after: null }, +) { + return { environment: { events: { data, listMetadata } } }; +} + +describe('event command', () => { let consoleOutput: string[]; beforeEach(() => { vi.clearAllMocks(); + resetInteractionModeForTests(); + setOutputMode('human'); + mockRequireCommandToken.mockResolvedValue('tok_123'); + mockGetActiveEnvironment.mockReturnValue({ apiKey: 'sk_ignored', environmentId: 'env_profile' }); + mockGetConfig.mockReturnValue({ + activeEnvironment: 'default', + environments: { default: { apiKey: 'sk_ignored', environmentId: 'env_profile' } }, + }); consoleOutput = []; vi.spyOn(console, 'log').mockImplementation((...args: unknown[]) => { consoleOutput.push(args.map(String).join(' ')); }); + vi.spyOn(console, 'error').mockImplementation(() => {}); }); afterEach(() => { vi.restoreAllMocks(); + resetInteractionModeForTests(); + setOutputMode('human'); }); - describe('runEventList', () => { - it('lists events in table format', async () => { - mockSdk.events.listEvents.mockResolvedValue({ - data: [ - { id: 'evt_01', event: 'dsync.user.created', createdAt: '2025-01-15T10:00:00Z' }, - { id: 'evt_02', event: 'connection.activated', createdAt: '2025-01-15T11:00:00Z' }, - ], - listMetadata: { before: null, after: null }, + describe('list', () => { + it('lists events with the environment threaded as variable AND header (read: no pre-validation fetch)', async () => { + respondWith(eventsPayload([EVENT_NODE])); + await runEventList({ events: ['dsync.user.created'] }); + expect(mockGraphqlRequest).toHaveBeenCalledTimes(1); + expect(mockGraphqlRequest).toHaveBeenCalledWith(expect.stringContaining('environmentEvents'), { + token: 'tok_123', + variables: { environmentId: 'env_profile', names: ['dsync.user.created'] }, + environmentId: 'env_profile', }); - - await runEventList({ events: ['dsync.user.created', 'connection.activated'] }, 'sk_test'); - - expect(mockSdk.events.listEvents).toHaveBeenCalledWith( - expect.objectContaining({ events: ['dsync.user.created', 'connection.activated'] }), - ); - expect(consoleOutput.some((l) => l.includes('evt_01'))).toBe(true); - expect(consoleOutput.some((l) => l.includes('dsync.user.created'))).toBe(true); + const out = consoleOutput.join('\n'); + expect(out).toContain('event_1'); + expect(out).toContain('dsync.user.created'); }); - it('passes optional filters', async () => { - mockSdk.events.listEvents.mockResolvedValue({ - data: [], - listMetadata: { before: null, after: null }, + it('maps range and pagination flags to catalog variables (single-page default)', async () => { + respondWith(eventsPayload([])); + await runEventList({ + events: ['user.created', 'user.updated'], + after: 'cursor_a', + rangeStart: '2026-01-01', + rangeEnd: '2026-02-01', + limit: 5, }); - - await runEventList( - { - events: ['dsync.user.created'], + expect(mockGraphqlRequest).toHaveBeenCalledTimes(1); + expect(mockGraphqlRequest).toHaveBeenCalledWith(expect.stringContaining('environmentEvents'), { + token: 'tok_123', + variables: { + environmentId: 'env_profile', + names: ['user.created', 'user.updated'], after: 'cursor_a', - organizationId: 'org_123', - rangeStart: '2025-01-01', - rangeEnd: '2025-02-01', - limit: 10, + rangeStart: '2026-01-01', + rangeEnd: '2026-02-01', + limit: 5, }, - 'sk_test', - ); - - expect(mockSdk.events.listEvents).toHaveBeenCalledWith( - expect.objectContaining({ - events: ['dsync.user.created'], - after: 'cursor_a', - organizationId: 'org_123', - rangeStart: '2025-01-01', - rangeEnd: '2025-02-01', - limit: 10, - }), - ); - }); - - it('handles empty results', async () => { - mockSdk.events.listEvents.mockResolvedValue({ - data: [], - listMetadata: { before: null, after: null }, + environmentId: 'env_profile', }); - - await runEventList({ events: ['dsync.user.created'] }, 'sk_test'); - expect(consoleOutput.some((l) => l.includes('No events found'))).toBe(true); }); - it('shows pagination cursors', async () => { - mockSdk.events.listEvents.mockResolvedValue({ - data: [{ id: 'evt_01', event: 'dsync.user.created', createdAt: '2025-01-15T10:00:00Z' }], - listMetadata: { before: 'cursor_b', after: 'cursor_a' }, + it('honors an --environment-id override', async () => { + respondWith(eventsPayload([])); + await runEventList({ events: ['user.created'], environmentId: 'env_flag' }); + expect(mockGraphqlRequest).toHaveBeenCalledWith(expect.stringContaining('environmentEvents'), { + token: 'tok_123', + variables: { environmentId: 'env_flag', names: ['user.created'] }, + environmentId: 'env_flag', }); - - await runEventList({ events: ['dsync.user.created'] }, 'sk_test'); - expect(consoleOutput.some((l) => l.includes('cursor_b'))).toBe(true); - expect(consoleOutput.some((l) => l.includes('cursor_a'))).toBe(true); }); - it('handles API errors', async () => { - mockSdk.events.listEvents.mockRejectedValue(new Error('Bad request')); + it('errors environment_not_found when the environment does not resolve', async () => { + respondWith({ environment: null }); + const err = await expectExit(runEventList({ events: ['user.created'] }), 1); + expect(err.context?.errorCode).toBe('environment_not_found'); + }); - await expect(runEventList({ events: ['dsync.user.created'] }, 'sk_test')).rejects.toThrow(); + it('handles empty results', async () => { + respondWith(eventsPayload([])); + await runEventList({ events: ['user.created'] }); + expect(consoleOutput.some((l) => l.includes('No events found'))).toBe(true); }); - }); - describe('JSON output mode', () => { - beforeEach(() => { + it('--json emits the documented curated shape (drops internal context/metadata)', async () => { setOutputMode('json'); + respondWith(eventsPayload([EVENT_NODE], { before: null, after: 'cursor_a' })); + await runEventList({ events: ['dsync.user.created'] }); + const raw = consoleOutput[0]; + expect(raw).not.toMatch(/graphql|userland|list_metadata/i); + const out = JSON.parse(raw); + expect(Object.keys(out)).toEqual(['events', 'pagination']); + expect(Object.keys(out.events[0])).toEqual(EVENT_SHAPE_KEYS); + expect(out.events[0]).toEqual({ + id: 'event_1', + event: 'dsync.user.created', + data: { directory_id: 'dir_1' }, + createdAt: '2026-01-01T00:00:00.000Z', + updatedAt: '2026-01-01T00:00:00.000Z', + }); + expect(out.pagination).toEqual({ before: null, after: 'cursor_a' }); }); - afterEach(() => { - setOutputMode('human'); + it('renders pagination cursors in human mode', async () => { + respondWith(eventsPayload([EVENT_NODE], { before: 'cursor_b', after: 'cursor_a' })); + await runEventList({ events: ['dsync.user.created'] }); + expect(consoleOutput.some((l) => l.includes('Before: cursor_b') && l.includes('After: cursor_a'))).toBe(true); }); + }); - it('outputs JSON with data and listMetadata', async () => { - mockSdk.events.listEvents.mockResolvedValue({ - data: [{ id: 'evt_01', event: 'dsync.user.created', createdAt: '2025-01-15T10:00:00Z' }], - listMetadata: { before: null, after: 'cursor_a' }, + describe('shared failure modes', () => { + it('exits auth-required (code 4) when not logged in', async () => { + mockRequireCommandToken.mockImplementation(() => { + throw new CliExit(4, { reason: 'auth_required', errorCode: 'auth_required' }); }); - - await runEventList({ events: ['dsync.user.created'] }, 'sk_test'); - const output = JSON.parse(consoleOutput[0]); - expect(output.data).toHaveLength(1); - expect(output.data[0].id).toBe('evt_01'); - expect(output.listMetadata.after).toBe('cursor_a'); + await expectExit(runEventList({ events: ['user.created'] }), 4); + expect(mockGraphqlRequest).not.toHaveBeenCalled(); }); - it('outputs empty data array for no results', async () => { - mockSdk.events.listEvents.mockResolvedValue({ - data: [], - listMetadata: { before: null, after: null }, + it('surfaces the gated-capability case on a 403 without naming GraphQL', async () => { + const consoleErrors: string[] = []; + vi.spyOn(console, 'error').mockImplementation((...args: unknown[]) => { + consoleErrors.push(args.map(String).join(' ')); }); - - await runEventList({ events: ['dsync.user.created'] }, 'sk_test'); - const output = JSON.parse(consoleOutput[0]); - expect(output.data).toEqual([]); - expect(output.listMetadata).toBeDefined(); + mockGraphqlRequest.mockRejectedValue( + new DashboardGraphqlError('The dashboard GraphQL API rejected this session (HTTP 403).', 'forbidden', 403), + ); + await expectExit(runEventList({ events: ['user.created'] }), 1); + const err = consoleErrors.join('\n'); + expect(err).toMatch(/account-plane capability/i); + expect(err).not.toMatch(/graphql/i); }); }); }); diff --git a/src/commands/event.ts b/src/commands/event.ts index b54c45e6..572805de 100644 --- a/src/commands/event.ts +++ b/src/commands/event.ts @@ -1,57 +1,129 @@ +/** + * `workos event` — environment event log on the dashboard account plane. + * + * Migrated from the API-key REST SDK (graphql-resource-migration Phase 6): the + * subcommand surface (`list`) is unchanged, but the listing now runs the + * catalog-backed environment-events operation with the user's OAuth bearer. + * Output shapes are new curated shapes (approved breaking change); the + * authoritative examples live in `event.spec.ts`. + * + * Backend divergences from REST (all loud, none faked): + * - `--org` was DROPPED: the backing operation's vendored document declares no + * organization variable. (The upstream schema accepts one, so a future + * snapshot re-vendor can restore the flag.) + * - `--events` stays required (frozen grammar) even though the backing filter + * is optional server-side. + * - The listing keeps the single-page default; `--after`/`--limit` map onto the + * operation's cursor pagination exactly. + */ + import chalk from 'chalk'; -import type { EventName } from '@workos-inc/node'; -import { createWorkOSClient } from '../lib/workos-client.js'; +import { requireCommandToken } from '../lib/command-auth.js'; +import { dashboardGraphqlRequest } from '../lib/dashboard-graphql.js'; +import { resolveEnvironmentTarget } from '../lib/environment-target.js'; +import { getOperation, resolveExecutableDocument, reportDashboardError } from '../catalog/operation.js'; +import { isJsonMode, outputJson, exitWithError } from '../utils/output.js'; import { formatTable } from '../utils/table.js'; -import { outputJson, isJsonMode } from '../utils/output.js'; -import { createApiErrorHandler } from '../lib/api-error-handler.js'; -const handleApiError = createApiErrorHandler('Event'); +interface EventNode { + id: string; + name?: string | null; + data?: unknown; + createdAt?: string | null; + updatedAt?: string | null; +} + +/** + * The curated event shape — the `--json` contract. camelCase, stable keys, no + * internal fields (the wire rows also carry request context/metadata, which + * REST never exposed and the curated shape drops); see event.spec.ts for the + * authoritative example. + */ +function shapeEvent(event: EventNode) { + return { + id: event.id, + event: event.name ?? null, + data: event.data ?? null, + createdAt: event.createdAt ?? null, + updatedAt: event.updatedAt ?? null, + }; +} export interface EventListOptions { + /** `--environment-id` override; defaults from the active profile. */ + environmentId?: string; + /** `--events` comma-separated event types (required by the frozen grammar). */ events: string[]; after?: string; - organizationId?: string; rangeStart?: string; rangeEnd?: string; limit?: number; } -export async function runEventList(options: EventListOptions, apiKey: string, baseUrl?: string): Promise { - const client = createWorkOSClient(apiKey, baseUrl); +export async function runEventList(options: EventListOptions): Promise { + const token = await requireCommandToken(); + const op = getOperation('environmentEvents'); + + // Environment-scoped read: resolved target as variable + header. + const { environmentId } = await resolveEnvironmentTarget(token, { + flagValue: options.environmentId, + forMutation: op.kind === 'mutation', + }); + let data: { + environment: { + events: { + data: EventNode[]; + listMetadata: { before: string | null; after: string | null }; + } | null; + } | null; + }; try { - const result = await client.sdk.events.listEvents({ - events: options.events as EventName[], - ...(options.after && { after: options.after }), - ...(options.organizationId && { organizationId: options.organizationId }), - ...(options.rangeStart && { rangeStart: options.rangeStart }), - ...(options.rangeEnd && { rangeEnd: options.rangeEnd }), - ...(options.limit && { limit: options.limit }), + data = await dashboardGraphqlRequest(resolveExecutableDocument(op), { + token, + variables: { + environmentId, + names: options.events, + ...(options.after ? { after: options.after } : {}), + ...(options.rangeStart ? { rangeStart: options.rangeStart } : {}), + ...(options.rangeEnd ? { rangeEnd: options.rangeEnd } : {}), + ...(options.limit !== undefined ? { limit: options.limit } : {}), + }, + environmentId, }); + } catch (error) { + reportDashboardError(error); + } + + if (!data.environment) { + exitWithError({ code: 'environment_not_found', message: `Environment "${environmentId}" was not found.` }); + } - if (isJsonMode()) { - outputJson({ data: result.data, listMetadata: result.listMetadata }); - return; - } + const events = (data.environment.events?.data ?? []).map(shapeEvent); + const pagination = { + before: data.environment.events?.listMetadata?.before ?? null, + after: data.environment.events?.listMetadata?.after ?? null, + }; - if (result.data.length === 0) { - console.log('No events found.'); - return; - } + if (isJsonMode()) { + outputJson({ events, pagination }); + return; + } - const rows = result.data.map((event) => [event.id, event.event, event.createdAt]); + if (events.length === 0) { + console.log('No events found.'); + return; + } - console.log(formatTable([{ header: 'ID' }, { header: 'Event Type' }, { header: 'Created At' }], rows)); + const rows = events.map((event) => [event.id, event.event ?? chalk.dim('-'), event.createdAt ?? chalk.dim('-')]); + console.log(formatTable([{ header: 'ID' }, { header: 'Event Type' }, { header: 'Created At' }], rows)); - const { before, after } = result.listMetadata; - if (before && after) { - console.log(chalk.dim(`Before: ${before} After: ${after}`)); - } else if (before) { - console.log(chalk.dim(`Before: ${before}`)); - } else if (after) { - console.log(chalk.dim(`After: ${after}`)); - } - } catch (error) { - handleApiError(error); + const { before, after } = pagination; + if (before && after) { + console.log(chalk.dim(`Before: ${before} After: ${after}`)); + } else if (before) { + console.log(chalk.dim(`Before: ${before}`)); + } else if (after) { + console.log(chalk.dim(`After: ${after}`)); } } diff --git a/src/commands/org-domain.spec.ts b/src/commands/org-domain.spec.ts index f65375f2..7d294763 100644 --- a/src/commands/org-domain.spec.ts +++ b/src/commands/org-domain.spec.ts @@ -1,128 +1,389 @@ import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'; -const mockSdk = { - organizationDomains: { - get: vi.fn(), - create: vi.fn(), - verify: vi.fn(), - delete: vi.fn(), - }, -}; +const mockRequireCommandToken = vi.fn(); +const mockGraphqlRequest = vi.fn(); +const mockConfirm = vi.fn(); +const mockIsCancel = vi.fn(() => false); +const mockGetActiveEnvironment = vi.fn(); +const mockGetConfig = vi.fn(); + +vi.mock('../lib/command-auth.js', async (importActual) => { + const actual = await importActual(); + return { + ...actual, + requireCommandToken: () => mockRequireCommandToken(), + }; +}); -vi.mock('../lib/workos-client.js', () => ({ - createWorkOSClient: () => ({ sdk: mockSdk }), +vi.mock('../lib/dashboard-graphql.js', async (importActual) => { + const actual = await importActual(); + return { + ...actual, + dashboardGraphqlRequest: (...args: unknown[]) => mockGraphqlRequest(...args), + }; +}); + +vi.mock('../utils/clack.js', () => ({ + default: { + confirm: (...args: unknown[]) => mockConfirm(...args), + isCancel: (...args: unknown[]) => mockIsCancel(...args), + select: vi.fn(), + }, })); +// The environment-target resolver runs REAL against the mocked wire + config +// store, so mutation tests can assert the pre-validation fetch +// (teamProjectsV2) happens before the operation request. +vi.mock('../lib/config-store.js', async (importActual) => { + const actual = await importActual(); + return { + ...actual, + getActiveEnvironment: () => mockGetActiveEnvironment(), + getConfig: () => mockGetConfig(), + setProfileEnvironmentId: vi.fn(), + }; +}); + const { setOutputMode } = await import('../utils/output.js'); +const { resetInteractionModeForTests, setInteractionMode } = await import('../utils/interaction-mode.js'); +const { DashboardGraphqlError } = await import('../lib/dashboard-graphql.js'); +const { CliExit } = await import('../utils/cli-exit.js'); +const { runOrgDomainGet, runOrgDomainCreate, runOrgDomainVerify, runOrgDomainDelete, ORG_DOMAIN_GET_SCAN_LIMIT } = + await import('./org-domain.js'); + +const TEAM_ENVIRONMENTS_PAYLOAD = { + currentTeam: { + projectsV2: [{ environments: [{ id: 'env_profile', name: 'Sandbox', sandbox: true, clientId: null }] }], + }, +}; + +/** Route the wire mock by document (session.spec.ts recipe). */ +function respondWith(payload: unknown): void { + mockGraphqlRequest.mockImplementation(async (doc: unknown) => { + if (String(doc).includes('teamProjectsV2')) return TEAM_ENVIRONMENTS_PAYLOAD; + return payload; + }); +} -const { runOrgDomainGet, runOrgDomainCreate, runOrgDomainVerify, runOrgDomainDelete } = await import('./org-domain.js'); +async function expectExit(promise: Promise, code: number): Promise { + try { + await promise; + } catch (err) { + if (err instanceof CliExit) { + expect(err.exitCode).toBe(code); + return err; + } + throw err; + } + throw new Error(`Expected CliExit(${code}) but promise resolved`); +} -const mockDomain = { - id: 'org_domain_123', +/** The authoritative curated org-domain JSON shape (documented contract). */ +const ORG_DOMAIN_SHAPE_KEYS = [ + 'id', + 'domain', + 'state', + 'organizationId', + 'subdomain', + 'verificationStrategy', + 'verificationContent', + 'domainCaptureEnabled', +]; + +const DOMAIN_NODE = { + id: 'org_domain_1', domain: 'example.com', - organizationId: 'org_456', - state: 'verified', - verificationStrategy: 'dns', - createdAt: '2024-01-01T00:00:00Z', - updatedAt: '2024-01-01T00:00:00Z', + state: 'Verified', + subdomain: null, + verificationContent: 'workos-verify=abc123', + verificationStrategy: 'Dns', + domainCaptureEnabled: false, + domainCaptureEnabledBy: null, }; -describe('org-domain commands', () => { +describe('org-domain command', () => { let consoleOutput: string[]; beforeEach(() => { vi.clearAllMocks(); + resetInteractionModeForTests(); + setOutputMode('human'); + mockRequireCommandToken.mockResolvedValue('tok_123'); + mockConfirm.mockReset(); + mockIsCancel.mockReset(); + mockIsCancel.mockReturnValue(false); + mockGetActiveEnvironment.mockReturnValue({ apiKey: 'sk_ignored', environmentId: 'env_profile' }); + mockGetConfig.mockReturnValue({ + activeEnvironment: 'default', + environments: { default: { apiKey: 'sk_ignored', environmentId: 'env_profile' } }, + }); consoleOutput = []; vi.spyOn(console, 'log').mockImplementation((...args: unknown[]) => { consoleOutput.push(args.map(String).join(' ')); }); + vi.spyOn(console, 'error').mockImplementation(() => {}); }); afterEach(() => { vi.restoreAllMocks(); + resetInteractionModeForTests(); + setOutputMode('human'); }); - describe('runOrgDomainGet', () => { - it('fetches domain by ID', async () => { - mockSdk.organizationDomains.get.mockResolvedValue(mockDomain); - await runOrgDomainGet('org_domain_123', 'sk_test'); - expect(mockSdk.organizationDomains.get).toHaveBeenCalledWith('org_domain_123'); - expect(consoleOutput.some((l) => l.includes('org_domain_123'))).toBe(true); + describe('get (capped client-side scan)', () => { + it('finds a domain in the organizations page with the environment header (read: no pre-validation fetch)', async () => { + respondWith({ + organizations: { + data: [ + { id: 'org_other', domains: [{ ...DOMAIN_NODE, id: 'org_domain_other', domain: 'other.com' }] }, + { id: 'org_1', domains: [DOMAIN_NODE] }, + ], + listMetadata: { before: null, after: null }, + }, + }); + await runOrgDomainGet('org_domain_1', {}); + expect(mockGraphqlRequest).toHaveBeenCalledTimes(1); + expect(mockGraphqlRequest).toHaveBeenCalledWith(expect.stringContaining('organizations'), { + token: 'tok_123', + variables: { environmentId: 'env_profile', limit: ORG_DOMAIN_GET_SCAN_LIMIT }, + environmentId: 'env_profile', + }); + const out = consoleOutput.join('\n'); + expect(out).toContain('org_domain_1'); + expect(out).toContain('example.com'); + expect(out).toContain('org_1'); }); - }); - describe('runOrgDomainCreate', () => { - it('creates domain with correct params', async () => { - mockSdk.organizationDomains.create.mockResolvedValue(mockDomain); - await runOrgDomainCreate('example.com', 'org_456', 'sk_test'); - expect(mockSdk.organizationDomains.create).toHaveBeenCalledWith({ - domain: 'example.com', - organizationId: 'org_456', + it('misses loudly with the scan-cap wording', async () => { + const consoleErrors: string[] = []; + vi.spyOn(console, 'error').mockImplementation((...args: unknown[]) => { + consoleErrors.push(args.map(String).join(' ')); + }); + respondWith({ organizations: { data: [{ id: 'org_1', domains: [] }], listMetadata: { before: null, after: null } } }); + const err = await expectExit(runOrgDomainGet('org_domain_missing', {}), 1); + expect(err.context?.errorCode).toBe('not_found'); + expect(consoleErrors.join('\n')).toContain(`first ${ORG_DOMAIN_GET_SCAN_LIMIT} organizations`); + }); + + it('honors an --environment-id override', async () => { + respondWith({ organizations: { data: [{ id: 'org_1', domains: [DOMAIN_NODE] }], listMetadata: { before: null, after: null } } }); + await runOrgDomainGet('org_domain_1', { environmentId: 'env_flag' }); + expect(mockGraphqlRequest).toHaveBeenCalledWith(expect.stringContaining('organizations'), { + token: 'tok_123', + variables: { environmentId: 'env_flag', limit: ORG_DOMAIN_GET_SCAN_LIMIT }, + environmentId: 'env_flag', }); }); - it('outputs success message', async () => { - mockSdk.organizationDomains.create.mockResolvedValue(mockDomain); - await runOrgDomainCreate('example.com', 'org_456', 'sk_test'); - expect(consoleOutput.some((l) => l.includes('Created organization domain'))).toBe(true); + it('--json emits the documented curated shape', async () => { + setOutputMode('json'); + respondWith({ organizations: { data: [{ id: 'org_1', domains: [DOMAIN_NODE] }], listMetadata: { before: null, after: null } } }); + await runOrgDomainGet('org_domain_1', {}); + const raw = consoleOutput[0]; + expect(raw).not.toMatch(/graphql|userland/i); + const out = JSON.parse(raw); + expect(Object.keys(out)).toEqual(['domain']); + expect(Object.keys(out.domain)).toEqual(ORG_DOMAIN_SHAPE_KEYS); + expect(out.domain).toEqual({ + id: 'org_domain_1', + domain: 'example.com', + state: 'Verified', + organizationId: 'org_1', + subdomain: null, + verificationStrategy: 'Dns', + verificationContent: 'workos-verify=abc123', + domainCaptureEnabled: false, + }); }); }); - describe('runOrgDomainVerify', () => { - it('verifies domain by ID', async () => { - mockSdk.organizationDomains.verify.mockResolvedValue(mockDomain); - await runOrgDomainVerify('org_domain_123', 'sk_test'); - expect(mockSdk.organizationDomains.verify).toHaveBeenCalledWith('org_domain_123'); + describe('create', () => { + const added = { + addDomains: { + __typename: 'DomainsAdded', + domains: [{ id: 'org_domain_1', state: 'Verified', domain: 'example.com', verificationStrategy: 'Manual' }], + }, + }; + + it('passes a one-element domain list, pre-validating the environment first (mutation ordering)', async () => { + respondWith(added); + await runOrgDomainCreate('example.com', { org: 'org_1' }); + // Mutation: the resolver fetches the team's environments BEFORE the op. + expect(String(mockGraphqlRequest.mock.calls[0][0])).toContain('teamProjectsV2'); + expect(String(mockGraphqlRequest.mock.calls[1][0])).toContain('addDomains'); + expect(mockGraphqlRequest.mock.calls[1][1]).toEqual({ + token: 'tok_123', + variables: { input: { organizationId: 'org_1', domains: ['example.com'] } }, + environmentId: 'env_profile', + }); + const out = consoleOutput.join('\n'); + expect(out).toContain('example.com'); + // Pin the added-as-verified divergence copy: the printed state is the + // loudness mechanism for the REST→dashboard create divergence. + expect(out).toContain('state: Verified'); }); - it('outputs success message', async () => { - mockSdk.organizationDomains.verify.mockResolvedValue(mockDomain); - await runOrgDomainVerify('org_domain_123', 'sk_test'); - expect(consoleOutput.some((l) => l.includes('Verified organization domain'))).toBe(true); + it('errors domain_in_use when another organization holds the domain', async () => { + respondWith({ + addDomains: { + __typename: 'OrganizationDomainAlreadyInUse', + domain: 'example.com', + organization: { name: 'FooCorp' }, + }, + }); + const err = await expectExit(runOrgDomainCreate('example.com', { org: 'org_1' }), 1); + expect(err.context?.errorCode).toBe('domain_in_use'); + }); + + it('errors consumer_domain_forbidden for consumer email domains', async () => { + respondWith({ addDomains: { __typename: 'ConsumerDomainForbidden', domain: 'gmail.com' } }); + const err = await expectExit(runOrgDomainCreate('gmail.com', { org: 'org_1' }), 1); + expect(err.context?.errorCode).toBe('consumer_domain_forbidden'); + }); + + it('errors domain_pending when a non-verified copy already exists on the organization', async () => { + respondWith({ + addDomains: { + __typename: 'ExistingNonVerifiedDomain', + nonVerifiedDomain: { state: 'Pending', domain: 'example.com' }, + }, + }); + const err = await expectExit(runOrgDomainCreate('example.com', { org: 'org_1' }), 1); + expect(err.context?.errorCode).toBe('domain_pending'); + }); + + it('errors unexpected_result on an unknown variant', async () => { + respondWith({ addDomains: { __typename: 'SomethingElse' } }); + const err = await expectExit(runOrgDomainCreate('example.com', { org: 'org_1' }), 1); + expect(err.context?.errorCode).toBe('unexpected_result'); + }); + + it('--json emits { domain } with the organization threaded in', async () => { + setOutputMode('json'); + respondWith(added); + await runOrgDomainCreate('example.com', { org: 'org_1' }); + const out = JSON.parse(consoleOutput[0]); + expect(Object.keys(out)).toEqual(['domain']); + expect(Object.keys(out.domain)).toEqual(ORG_DOMAIN_SHAPE_KEYS); + expect(out.domain).toMatchObject({ + id: 'org_domain_1', + domain: 'example.com', + state: 'Verified', + organizationId: 'org_1', + verificationStrategy: 'Manual', + }); }); }); - describe('runOrgDomainDelete', () => { - it('deletes domain by ID', async () => { - mockSdk.organizationDomains.delete.mockResolvedValue(undefined); - await runOrgDomainDelete('org_domain_123', 'sk_test'); - expect(mockSdk.organizationDomains.delete).toHaveBeenCalledWith('org_domain_123'); + describe('verify (restart verification)', () => { + const restarted = { + restartOrganizationDomainVerification: { ...DOMAIN_NODE, state: 'Pending' }, + }; + + it('restarts verification by ID, pre-validating the environment first', async () => { + respondWith(restarted); + await runOrgDomainVerify('org_domain_1', {}); + expect(String(mockGraphqlRequest.mock.calls[0][0])).toContain('teamProjectsV2'); + expect(String(mockGraphqlRequest.mock.calls[1][0])).toContain('restartOrganizationDomainVerification'); + expect(mockGraphqlRequest.mock.calls[1][1]).toEqual({ + token: 'tok_123', + variables: { id: 'org_domain_1' }, + environmentId: 'env_profile', + }); + const out = consoleOutput.join('\n'); + expect(out).toContain('example.com'); + expect(out).toContain('workos-verify=abc123'); }); - it('outputs deletion confirmation', async () => { - mockSdk.organizationDomains.delete.mockResolvedValue(undefined); - await runOrgDomainDelete('org_domain_123', 'sk_test'); - expect(consoleOutput.some((l) => l.includes('Deleted'))).toBe(true); - expect(consoleOutput.some((l) => l.includes('org_domain_123'))).toBe(true); + it('--json emits { domain } (no organization on the mutation payload)', async () => { + setOutputMode('json'); + respondWith(restarted); + await runOrgDomainVerify('org_domain_1', {}); + const out = JSON.parse(consoleOutput[0]); + expect(Object.keys(out)).toEqual(['domain']); + expect(Object.keys(out.domain)).toEqual(ORG_DOMAIN_SHAPE_KEYS); + expect(out.domain).toMatchObject({ id: 'org_domain_1', state: 'Pending', organizationId: null }); }); }); - describe('JSON output mode', () => { - beforeEach(() => setOutputMode('json')); - afterEach(() => setOutputMode('human')); + describe('delete (destructive)', () => { + const deleted = { deleteOrganizationDomain: { id: 'org_domain_1' } }; - it('runOrgDomainGet outputs raw JSON', async () => { - mockSdk.organizationDomains.get.mockResolvedValue(mockDomain); - await runOrgDomainGet('org_domain_123', 'sk_test'); - const output = JSON.parse(consoleOutput[0]); - expect(output.id).toBe('org_domain_123'); - expect(output.domain).toBe('example.com'); + it('refuses in agent mode without --yes (exit 1, confirmation_required)', async () => { + setInteractionMode({ mode: 'agent', source: 'agent_env' }); + const err = await expectExit(runOrgDomainDelete('org_domain_1', { yes: false }), 1); + expect(err.context?.errorCode).toBe('confirmation_required'); + expect(mockGraphqlRequest).not.toHaveBeenCalled(); }); - it('runOrgDomainCreate outputs JSON success', async () => { - mockSdk.organizationDomains.create.mockResolvedValue(mockDomain); - await runOrgDomainCreate('example.com', 'org_456', 'sk_test'); - const output = JSON.parse(consoleOutput[0]); - expect(output.status).toBe('ok'); - expect(output.data.id).toBe('org_domain_123'); + it('refuses in CI mode without --yes', async () => { + setInteractionMode({ mode: 'ci', source: 'ci_env' }); + const err = await expectExit(runOrgDomainDelete('org_domain_1', {}), 1); + expect(err.context?.errorCode).toBe('confirmation_required'); + expect(mockGraphqlRequest).not.toHaveBeenCalled(); }); - it('runOrgDomainDelete outputs JSON success', async () => { - mockSdk.organizationDomains.delete.mockResolvedValue(undefined); - await runOrgDomainDelete('org_domain_123', 'sk_test'); - const output = JSON.parse(consoleOutput[0]); - expect(output.status).toBe('ok'); - expect(output.data.id).toBe('org_domain_123'); + it('proceeds non-interactively with --yes, pre-validating the environment first', async () => { + setInteractionMode({ mode: 'ci', source: 'ci_env' }); + respondWith(deleted); + await runOrgDomainDelete('org_domain_1', { yes: true }); + expect(String(mockGraphqlRequest.mock.calls[0][0])).toContain('teamProjectsV2'); + expect(String(mockGraphqlRequest.mock.calls[1][0])).toContain('deleteOrganizationDomain'); + expect(mockGraphqlRequest.mock.calls[1][1]).toEqual({ + token: 'tok_123', + variables: { id: 'org_domain_1' }, + environmentId: 'env_profile', + }); + }); + + it('prompts interactively and proceeds when confirmed', async () => { + setInteractionMode({ mode: 'human', source: 'default' }); + mockConfirm.mockResolvedValue(true); + respondWith(deleted); + await runOrgDomainDelete('org_domain_1', { yes: false }); + expect(mockConfirm).toHaveBeenCalledOnce(); + expect(mockGraphqlRequest).toHaveBeenCalled(); + }); + + it('cancels (exit 2) when the interactive prompt is declined', async () => { + setInteractionMode({ mode: 'human', source: 'default' }); + mockConfirm.mockResolvedValue(false); + await expectExit(runOrgDomainDelete('org_domain_1', { yes: false }), 2); + expect(mockGraphqlRequest).not.toHaveBeenCalled(); + }); + + it('--json emits { deleted }', async () => { + setInteractionMode({ mode: 'agent', source: 'agent_env' }); + setOutputMode('json'); + respondWith(deleted); + await runOrgDomainDelete('org_domain_1', { yes: true }); + expect(JSON.parse(consoleOutput[0])).toEqual({ deleted: 'org_domain_1' }); + }); + }); + + describe('shared failure modes', () => { + it('exits auth-required (code 4) when not logged in', async () => { + mockRequireCommandToken.mockImplementation(() => { + throw new CliExit(4, { reason: 'auth_required', errorCode: 'auth_required' }); + }); + await expectExit(runOrgDomainGet('org_domain_1', {}), 4); + expect(mockGraphqlRequest).not.toHaveBeenCalled(); + }); + + it('surfaces the gated-capability case on a 403 without naming GraphQL', async () => { + const consoleErrors: string[] = []; + vi.spyOn(console, 'error').mockImplementation((...args: unknown[]) => { + consoleErrors.push(args.map(String).join(' ')); + }); + mockGraphqlRequest.mockRejectedValue( + new DashboardGraphqlError('The dashboard GraphQL API rejected this session (HTTP 403).', 'forbidden', 403), + ); + // Use the read path: mutation paths hit the environment pre-validation + // fetch first, whose failure the resolver reports in its own copy. + await expectExit(runOrgDomainGet('org_domain_1', {}), 1); + const err = consoleErrors.join('\n'); + expect(err).toMatch(/account-plane capability/i); + expect(err).not.toMatch(/graphql/i); }); }); }); diff --git a/src/commands/org-domain.ts b/src/commands/org-domain.ts index c43769f3..e11d2821 100644 --- a/src/commands/org-domain.ts +++ b/src/commands/org-domain.ts @@ -1,54 +1,291 @@ -import { createWorkOSClient } from '../lib/workos-client.js'; -import { outputSuccess, outputJson } from '../utils/output.js'; -import { createApiErrorHandler } from '../lib/api-error-handler.js'; +/** + * `workos org-domain` — organization-domain lifecycle on the dashboard account + * plane. + * + * Migrated from the API-key REST SDK (graphql-resource-migration Phase 6): the + * subcommand surface (get/create/verify/delete) is unchanged, but every + * operation now runs catalog-backed dashboard operations with the user's OAuth + * bearer. Output shapes are new curated shapes (approved breaking change); the + * authoritative examples live in `org-domain.spec.ts`. + * + * Backend divergences from REST (all loud, none faked): + * - `get ` has no backing single-domain query: it filters the first + * {@link ORG_DOMAIN_GET_SCAN_LIMIT} organizations' domains client-side + * (capped, loud miss wording — the invitation-get precedent). + * - `create` adds the domain **already verified** (the dashboard's manual-add + * flow); REST created it pending and started DNS verification. + * - `verify ` restarts DNS verification with a fresh token — the same + * initiate-verification semantics REST had. It does not instantly verify. + * + * Safety posture per the manifest: `delete` is destructive → + * `confirmDestructive` (prompt, or --yes). The consequence copy is hand-written + * (the operation carries no catalog confirmation phrase). + */ -const handleApiError = createApiErrorHandler('OrganizationDomain'); +import chalk from 'chalk'; +import { requireCommandToken } from '../lib/command-auth.js'; +import { dashboardGraphqlRequest } from '../lib/dashboard-graphql.js'; +import { resolveEnvironmentTarget } from '../lib/environment-target.js'; +import { getOperation, resolveExecutableDocument, reportDashboardError } from '../catalog/operation.js'; +import { confirmDestructive } from '../catalog/confirm.js'; +import { isJsonMode, outputJson, outputSuccess, exitWithError } from '../utils/output.js'; -export async function runOrgDomainGet(id: string, apiKey: string, baseUrl?: string): Promise { - const client = createWorkOSClient(apiKey, baseUrl); +/** + * `get ` scans one page of organizations (each carrying its domains) — a + * single bounded request, never an unbounded fan-out. + */ +export const ORG_DOMAIN_GET_SCAN_LIMIT = 100; +interface OrgDomainNode { + id?: string | null; + domain?: string | null; + state?: string | null; + subdomain?: string | null; + verificationStrategy?: string | null; + verificationContent?: string | null; + domainCaptureEnabled?: boolean | null; +} + +/** + * The curated organization-domain shape — the `--json` contract for every + * subcommand. camelCase, stable keys, no internal fields; fields the backing + * operation does not select come back `null`. See org-domain.spec.ts for the + * authoritative example. + */ +function shapeOrgDomain(domain: OrgDomainNode, organizationId: string | null) { + return { + id: domain.id ?? null, + domain: domain.domain ?? null, + state: domain.state ?? null, + organizationId, + subdomain: domain.subdomain ?? null, + verificationStrategy: domain.verificationStrategy ?? null, + verificationContent: domain.verificationContent ?? null, + domainCaptureEnabled: domain.domainCaptureEnabled ?? null, + }; +} + +type ShapedOrgDomain = ReturnType; + +function printOrgDomainFields(domain: ShapedOrgDomain): void { + const fields: Array<[string, unknown]> = [ + ['ID', domain.id], + ['Domain', domain.domain], + ['State', domain.state], + ['Org ID', domain.organizationId], + ['Verification', domain.verificationStrategy], + ]; + for (const [label, value] of fields) { + if (value === null || value === undefined || value === '') continue; + console.log(`${chalk.bold(label)}: ${String(value)}`); + } + console.log(chalk.dim('Run with --json for the full record.')); +} + +export interface OrgDomainGetOptions { + /** `--environment-id` override; defaults from the active profile. */ + environmentId?: string; +} + +export async function runOrgDomainGet(id: string, options: OrgDomainGetOptions = {}): Promise { + const token = await requireCommandToken(); + const op = getOperation('organizations'); + + // Environment-scoped read: resolved target as variable + header. + const { environmentId } = await resolveEnvironmentTarget(token, { + flagValue: options.environmentId, + forMutation: op.kind === 'mutation', + }); + + // There is no single-domain operation, so filter one page of organizations' + // domains client-side — capped, never an unbounded scan. + let data: { + organizations: { + data: Array<{ id: string; domains?: OrgDomainNode[] | null }>; + } | null; + }; try { - const result = await client.sdk.organizationDomains.get(id); - outputJson(result); + data = await dashboardGraphqlRequest(resolveExecutableDocument(op), { + token, + variables: { environmentId, limit: ORG_DOMAIN_GET_SCAN_LIMIT }, + environmentId, + }); } catch (error) { - handleApiError(error); + reportDashboardError(error); + } + + let match: ShapedOrgDomain | undefined; + for (const organization of data.organizations?.data ?? []) { + const found = (organization.domains ?? []).find((candidate) => candidate.id === id); + if (found) { + match = shapeOrgDomain(found, organization.id); + break; + } + } + + if (!match) { + exitWithError({ + code: 'not_found', + message: `Domain "${id}" was not found in the first ${ORG_DOMAIN_GET_SCAN_LIMIT} organizations in this environment. Use \`organization get \` to inspect a specific organization's domains.`, + }); + } + + if (isJsonMode()) { + outputJson({ domain: match }); + return; } + printOrgDomainFields(match); +} + +export interface OrgDomainCreateOptions { + /** `--environment-id` override; defaults from the active profile. */ + environmentId?: string; + /** Organization ID (org_*) the domain is added to. */ + org: string; } -export async function runOrgDomainCreate( - domain: string, - organizationId: string, - apiKey: string, - baseUrl?: string, -): Promise { - const client = createWorkOSClient(apiKey, baseUrl); +export async function runOrgDomainCreate(domain: string, options: OrgDomainCreateOptions): Promise { + const token = await requireCommandToken(); + const op = getOperation('addDomains'); + // Environment-scoped mutation: pre-validated resolved target as header. + const { environmentId } = await resolveEnvironmentTarget(token, { + flagValue: options.environmentId, + forMutation: op.kind === 'mutation', + }); + + let data: { + addDomains: + | { __typename: 'DomainsAdded'; domains: OrgDomainNode[] } + | { __typename: 'ConsumerDomainForbidden'; domain: string } + | { __typename: 'OrganizationDomainAlreadyInUse'; domain: string; organization: { name: string | null } } + | { __typename: 'ExistingNonVerifiedDomain'; nonVerifiedDomain: { state: string | null; domain: string } } + | { __typename: string }; + }; try { - const result = await client.sdk.organizationDomains.create({ domain, organizationId }); - outputSuccess('Created organization domain', result); + // The backing operation takes a domain list; the frozen single-domain + // grammar passes a one-element list. + data = await dashboardGraphqlRequest(resolveExecutableDocument(op), { + token, + variables: { input: { organizationId: options.org, domains: [domain] } }, + environmentId, + }); } catch (error) { - handleApiError(error); + reportDashboardError(error); + } + + const result = data.addDomains; + if (result.__typename === 'OrganizationDomainAlreadyInUse') { + const taken = result as { domain: string; organization: { name: string | null } }; + exitWithError({ + code: 'domain_in_use', + message: `Domain "${taken.domain}" is already in use by organization ${taken.organization?.name ?? 'another organization'}.`, + }); + } + if (result.__typename === 'ConsumerDomainForbidden') { + exitWithError({ + code: 'consumer_domain_forbidden', + message: `"${(result as { domain: string }).domain}" is a consumer email domain and cannot be added to an organization.`, + }); + } + if (result.__typename === 'ExistingNonVerifiedDomain') { + const existing = (result as { nonVerifiedDomain: { state: string | null; domain: string } }).nonVerifiedDomain; + exitWithError({ + code: 'domain_pending', + message: `Domain "${existing.domain}" already exists on this organization in a non-verified state (${existing.state ?? 'pending'}). Delete it with \`org-domain delete\` before re-adding it.`, + }); } + if (result.__typename !== 'DomainsAdded' || !('domains' in result)) { + exitWithError({ code: 'unexpected_result', message: `Could not add domain "${domain}".` }); + } + + const added = shapeOrgDomain((result as { domains: OrgDomainNode[] }).domains[0] ?? { domain }, options.org); + if (isJsonMode()) { + outputJson({ domain: added }); + return; + } + // Divergence from REST (loud): the dashboard adds the domain already + // verified; there is no DNS-verification step to complete. + outputSuccess(`Added domain ${chalk.bold(domain)} to organization ${chalk.bold(options.org)} (state: ${added.state ?? 'unknown'})`); +} + +export interface OrgDomainVerifyOptions { + /** `--environment-id` override; defaults from the active profile. */ + environmentId?: string; } -export async function runOrgDomainVerify(id: string, apiKey: string, baseUrl?: string): Promise { - const client = createWorkOSClient(apiKey, baseUrl); +export async function runOrgDomainVerify(id: string, options: OrgDomainVerifyOptions = {}): Promise { + const token = await requireCommandToken(); + const op = getOperation('restartOrganizationDomainVerification'); + // Environment-scoped mutation: pre-validated resolved target as header. + const { environmentId } = await resolveEnvironmentTarget(token, { + flagValue: options.environmentId, + forMutation: op.kind === 'mutation', + }); + + // No result union: a bad ID surfaces as a wire-level error via + // reportDashboardError. + let data: { restartOrganizationDomainVerification: OrgDomainNode }; try { - const result = await client.sdk.organizationDomains.verify(id); - outputSuccess('Verified organization domain', result); + data = await dashboardGraphqlRequest(resolveExecutableDocument(op), { + token, + variables: { id }, + environmentId, + }); } catch (error) { - handleApiError(error); + reportDashboardError(error); + } + + const domain = shapeOrgDomain(data.restartOrganizationDomainVerification ?? { id }, null); + if (isJsonMode()) { + outputJson({ domain }); + return; } + outputSuccess(`Restarted verification for domain ${chalk.bold(domain.domain ?? id)} (state: ${domain.state ?? 'unknown'})`); + if (domain.verificationContent) { + console.log(chalk.dim(`Verification record: ${domain.verificationContent}`)); + } +} + +export interface OrgDomainDeleteOptions { + /** `--environment-id` override; defaults from the active profile. */ + environmentId?: string; + yes?: boolean; + json?: boolean; } -export async function runOrgDomainDelete(id: string, apiKey: string, baseUrl?: string): Promise { - const client = createWorkOSClient(apiKey, baseUrl); +export async function runOrgDomainDelete(id: string, options: OrgDomainDeleteOptions = {}): Promise { + // Destructive per the manifest: the operation carries no catalog confirmation + // phrase, so the consequence copy is hand-written. + await confirmDestructive(options, { + action: `delete domain ${id} from its organization — domain-based sign-in and capture stop working for it`, + }); + + const token = await requireCommandToken(); + const op = getOperation('deleteOrganizationDomain'); + + // Environment-scoped mutation: pre-validated resolved target as header. + const { environmentId } = await resolveEnvironmentTarget(token, { + flagValue: options.environmentId, + forMutation: op.kind === 'mutation', + }); + // No result union: a bad ID surfaces as a wire-level error via + // reportDashboardError. try { - await client.sdk.organizationDomains.delete(id); - outputSuccess('Deleted organization domain', { id }); + await dashboardGraphqlRequest(resolveExecutableDocument(op), { + token, + variables: { id }, + environmentId, + }); } catch (error) { - handleApiError(error); + reportDashboardError(error); + } + + if (isJsonMode()) { + outputJson({ deleted: id }); + return; } + outputSuccess(`Deleted domain ${chalk.bold(id)}`); } diff --git a/src/utils/help-json.ts b/src/utils/help-json.ts index 0af5ec15..1eb3e936 100644 --- a/src/utils/help-json.ts +++ b/src/utils/help-json.ts @@ -1301,11 +1301,11 @@ const commands: CommandSchema[] = [ { name: 'event', description: 'Query WorkOS events', - options: [insecureStorageOpt, apiKeyOpt], + options: [insecureStorageOpt], commands: [ { name: 'list', - description: 'List events', + description: 'List events in the active environment', options: [ { name: 'events', @@ -1314,7 +1314,6 @@ const commands: CommandSchema[] = [ required: true, hidden: false, }, - { name: 'org', type: 'string', description: 'Filter by organization ID', required: false, hidden: false }, { name: 'range-start', type: 'string', @@ -1323,7 +1322,21 @@ const commands: CommandSchema[] = [ hidden: false, }, { name: 'range-end', type: 'string', description: 'Range end (ISO date)', required: false, hidden: false }, - ...paginationOpts, + { + name: 'after', + type: 'string', + description: 'Pagination cursor for results after a specific item', + required: false, + hidden: false, + }, + { + name: 'limit', + type: 'number', + description: 'Maximum number of results to return', + required: false, + hidden: false, + }, + environmentIdOpt, ], }, ], @@ -1662,28 +1675,34 @@ const commands: CommandSchema[] = [ { name: 'org-domain', description: 'Manage organization domains', - options: [insecureStorageOpt, apiKeyOpt], + options: [insecureStorageOpt], commands: [ { name: 'get', description: 'Get a domain', positionals: [{ name: 'id', type: 'string', description: 'Domain ID', required: true }], + options: [environmentIdOpt], }, { name: 'create', - description: 'Create a domain', + description: 'Add a domain to an organization (added as verified)', positionals: [{ name: 'domain', type: 'string', description: 'Domain name', required: true }], - options: [{ name: 'org', type: 'string', description: 'Organization ID', required: true, hidden: false }], + options: [ + { name: 'org', type: 'string', description: 'Organization ID (org_*)', required: true, hidden: false }, + environmentIdOpt, + ], }, { name: 'verify', - description: 'Verify a domain', + description: 'Restart verification for a domain (issues a fresh verification token)', positionals: [{ name: 'id', type: 'string', description: 'Domain ID', required: true }], + options: [environmentIdOpt], }, { name: 'delete', description: 'Delete a domain', positionals: [{ name: 'id', type: 'string', description: 'Domain ID', required: true }], + options: [confirmYesOpt, environmentIdOpt], }, ], }, From 873c7eae4adcacd38424c98e6c4e61031e7f4cc2 Mon Sep 17 00:00:00 2001 From: Nick Nisi Date: Wed, 22 Jul 2026 20:36:20 -0500 Subject: [PATCH 15/22] feat!: migrate webhook, portal, config to the dashboard account plane MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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). --- src/bin.ts | 118 ++++++---- src/catalog/curation.ts | 33 +++ src/catalog/manifest.ts | 89 +++++++ src/commands/config.spec.ts | 445 ++++++++++++++++++++++++++++++----- src/commands/config.ts | 304 +++++++++++++++++++++--- src/commands/portal.spec.ts | 241 +++++++++++++++---- src/commands/portal.ts | 158 +++++++++++-- src/commands/webhook.spec.ts | 369 +++++++++++++++++++++-------- src/commands/webhook.ts | 284 ++++++++++++++++------ src/utils/help-json.ts | 45 ++-- 10 files changed, 1667 insertions(+), 419 deletions(-) diff --git a/src/bin.ts b/src/bin.ts index 4615c711..eeb867e6 100644 --- a/src/bin.ts +++ b/src/bin.ts @@ -2390,71 +2390,84 @@ async function runCli(): Promise { return yargs.demandCommand(1, 'Please specify a feature-flag subcommand').strict(); }) .command('webhook', 'Manage webhooks', (yargs) => { - yargs.options({ ...insecureStorageOption, 'api-key': { type: 'string' as const, describe: 'WorkOS API key' } }); + yargs.options(insecureStorageOption); registerSubcommand( yargs, 'list', - 'List webhooks', - (y) => y, + 'List webhook endpoints', + (y) => + y.option('environment-id', { + type: 'string', + describe: 'Environment ID (defaults to the active environment)', + }), async (argv) => { await applyInsecureStorage(argv.insecureStorage); - - const { resolveApiKey, resolveApiBaseUrl } = await import('./lib/api-key.js'); const { runWebhookList } = await import('./commands/webhook.js'); - await runWebhookList(resolveApiKey({ apiKey: argv.apiKey }), resolveApiBaseUrl()); + await runWebhookList({ environmentId: argv.environmentId as string | undefined }); }, ); registerSubcommand( yargs, 'create', - 'Create a webhook', + 'Create a webhook endpoint (the signing secret is only visible in the WorkOS Dashboard)', (y) => y.options({ - url: { type: 'string', demandOption: true }, + url: { type: 'string', demandOption: true, describe: 'Webhook endpoint URL (HTTPS)' }, events: { type: 'string', demandOption: true, describe: 'Comma-separated event types' }, + 'environment-id': { type: 'string', describe: 'Environment ID (defaults to the active environment)' }, }), async (argv) => { await applyInsecureStorage(argv.insecureStorage); - - const { resolveApiKey, resolveApiBaseUrl } = await import('./lib/api-key.js'); const { runWebhookCreate } = await import('./commands/webhook.js'); - await runWebhookCreate( - argv.url, - argv.events.split(','), - resolveApiKey({ apiKey: argv.apiKey }), - resolveApiBaseUrl(), - ); + await runWebhookCreate({ + url: argv.url, + events: argv.events.split(','), + environmentId: argv.environmentId as string | undefined, + }); }, ); registerSubcommand( yargs, 'delete ', - 'Delete a webhook', - (y) => y.positional('id', { type: 'string', demandOption: true }), + 'Delete a webhook endpoint', + (y) => + y + .positional('id', { type: 'string', demandOption: true, describe: 'Webhook endpoint ID' }) + .option('yes', { alias: 'y', type: 'boolean', default: false, describe: 'Skip the confirmation prompt' }) + .option('environment-id', { + type: 'string', + describe: 'Environment ID (defaults to the active environment)', + }), async (argv) => { await applyInsecureStorage(argv.insecureStorage); - - const { resolveApiKey, resolveApiBaseUrl } = await import('./lib/api-key.js'); const { runWebhookDelete } = await import('./commands/webhook.js'); - await runWebhookDelete(argv.id, resolveApiKey({ apiKey: argv.apiKey }), resolveApiBaseUrl()); + await runWebhookDelete(argv.id, { + yes: argv.yes, + json: argv.json as boolean | undefined, + environmentId: argv.environmentId as string | undefined, + }); }, ); return yargs.demandCommand(1, 'Please specify a webhook subcommand').strict(); }) - .command('config', 'Manage WorkOS configuration (redirect URIs, CORS, homepage)', (yargs) => { - yargs.options({ ...insecureStorageOption, 'api-key': { type: 'string' as const, describe: 'WorkOS API key' } }); + .command('config', 'Manage AuthKit app configuration (redirect URIs, CORS, homepage)', (yargs) => { + yargs.options(insecureStorageOption); yargs.command('redirect', 'Manage redirect URIs', (yargs) => { registerSubcommand( yargs, 'add ', - 'Add a redirect URI', - (y) => y.positional('uri', { type: 'string', demandOption: true }), + 'Add a redirect URI (merges over the current list; a concurrent edit elsewhere may be overwritten)', + (y) => + y + .positional('uri', { type: 'string', demandOption: true, describe: 'Redirect URI' }) + .option('environment-id', { + type: 'string', + describe: 'Environment ID (defaults to the active environment)', + }), async (argv) => { await applyInsecureStorage(argv.insecureStorage); - - const { resolveApiKey, resolveApiBaseUrl } = await import('./lib/api-key.js'); const { runConfigRedirectAdd } = await import('./commands/config.js'); - await runConfigRedirectAdd(argv.uri, resolveApiKey({ apiKey: argv.apiKey }), resolveApiBaseUrl()); + await runConfigRedirectAdd(argv.uri, { environmentId: argv.environmentId as string | undefined }); }, ); return yargs.demandCommand(1).strict(); @@ -2463,14 +2476,18 @@ async function runCli(): Promise { registerSubcommand( yargs, 'add ', - 'Add a CORS origin', - (y) => y.positional('origin', { type: 'string', demandOption: true }), + 'Add a CORS origin (merges over the current list; a concurrent edit elsewhere may be overwritten)', + (y) => + y + .positional('origin', { type: 'string', demandOption: true, describe: 'CORS origin' }) + .option('environment-id', { + type: 'string', + describe: 'Environment ID (defaults to the active environment)', + }), async (argv) => { await applyInsecureStorage(argv.insecureStorage); - - const { resolveApiKey, resolveApiBaseUrl } = await import('./lib/api-key.js'); const { runConfigCorsAdd } = await import('./commands/config.js'); - await runConfigCorsAdd(argv.origin, resolveApiKey({ apiKey: argv.apiKey }), resolveApiBaseUrl()); + await runConfigCorsAdd(argv.origin, { environmentId: argv.environmentId as string | undefined }); }, ); return yargs.demandCommand(1).strict(); @@ -2479,14 +2496,18 @@ async function runCli(): Promise { registerSubcommand( yargs, 'set ', - 'Set the homepage URL', - (y) => y.positional('url', { type: 'string', demandOption: true }), + "Set the app homepage URL on the environment's AuthKit application", + (y) => + y + .positional('url', { type: 'string', demandOption: true, describe: 'Homepage URL' }) + .option('environment-id', { + type: 'string', + describe: 'Environment ID (defaults to the active environment)', + }), async (argv) => { await applyInsecureStorage(argv.insecureStorage); - - const { resolveApiKey, resolveApiBaseUrl } = await import('./lib/api-key.js'); const { runConfigHomepageUrlSet } = await import('./commands/config.js'); - await runConfigHomepageUrlSet(argv.url, resolveApiKey({ apiKey: argv.apiKey }), resolveApiBaseUrl()); + await runConfigHomepageUrlSet(argv.url, { environmentId: argv.environmentId as string | undefined }); }, ); return yargs.demandCommand(1).strict(); @@ -2494,32 +2515,29 @@ async function runCli(): Promise { return yargs.demandCommand(1, 'Please specify a config subcommand').strict(); }) .command('portal', 'Manage Admin Portal', (yargs) => { - yargs.options({ ...insecureStorageOption, 'api-key': { type: 'string' as const, describe: 'WorkOS API key' } }); + yargs.options(insecureStorageOption); registerSubcommand( yargs, 'generate-link', - 'Generate an Admin Portal link', + 'Generate an Admin Portal setup link (expires prior links of the same intent; --return-url/--success-url and the audit_logs intent are not supported on this plane)', (y) => y.options({ intent: { type: 'string', demandOption: true, - describe: 'Portal intent (sso, dsync, audit_logs, log_streams)', + describe: 'Portal intent (sso, dsync, log_streams, domain_verification, certificate_renewal)', }, org: { type: 'string', demandOption: true, describe: 'Organization ID' }, - 'return-url': { type: 'string' }, - 'success-url': { type: 'string' }, + 'environment-id': { type: 'string', describe: 'Environment ID (defaults to the active environment)' }, }), async (argv) => { await applyInsecureStorage(argv.insecureStorage); - - const { resolveApiKey, resolveApiBaseUrl } = await import('./lib/api-key.js'); const { runPortalGenerateLink } = await import('./commands/portal.js'); - await runPortalGenerateLink( - { intent: argv.intent, organization: argv.org, returnUrl: argv.returnUrl, successUrl: argv.successUrl }, - resolveApiKey({ apiKey: argv.apiKey }), - resolveApiBaseUrl(), - ); + await runPortalGenerateLink({ + intent: argv.intent, + organization: argv.org, + environmentId: argv.environmentId as string | undefined, + }); }, ); return yargs.demandCommand(1, 'Please specify a portal subcommand').strict(); diff --git a/src/catalog/curation.ts b/src/catalog/curation.ts index abcc3a9b..6c762e5e 100644 --- a/src/catalog/curation.ts +++ b/src/catalog/curation.ts @@ -173,6 +173,39 @@ export const OVERRIDES: Record = { }, deleteOrganizationDomain: { command: 'org-domain delete', describe: 'Remove a domain from an organization' }, + // --- app-config cluster (resource migration Phase 7) --- + // Op names/descriptions are clean upstream, but every manifest-curated op + // still needs an override so the manifest's clean `command` noun is the + // single source of truth. `config redirect add` / `config cors add` ride the + // redirectUris/setRedirectUris/corsConfig/updateCorsConfig ops that the + // `authkit` nouns above already own (OVERRIDES is op-keyed; one op ↔ one + // command noun) — see the manifest comment. `config homepage-url set` is + // backed by TWO ops sharing the noun: the default-application resolution read + // and the update mutation, whose `Userland*`-named input/union types the leak + // test cannot see (it inspects op names/descriptions only) — the command + // handlers must never echo those type names. + webhookEndpoints: { command: 'webhook list', describe: 'List webhook endpoints in the current environment' }, + createWebhookEndpoint: { + command: 'webhook create', + describe: 'Create a webhook endpoint subscribed to the given events', + }, + deleteWebhookEndpoint: { + command: 'webhook delete', + describe: 'Delete a webhook endpoint so it stops receiving events', + }, + generatePortalSetupLink: { + command: 'portal generate-link', + describe: 'Generate an Admin Portal setup link for an organization', + }, + defaultAuthkitApplication: { + command: 'config homepage-url set', + describe: "Set the app homepage URL on the environment's AuthKit application", + }, + updateAuthkitApplication: { + command: 'config homepage-url set', + describe: "Set the app homepage URL on the environment's AuthKit application", + }, + // --- Phase 4: AuthKit app config --- // These op names/descriptions are already clean (no leak), but each still needs // an override so resolveCommandMeta returns the manifest's clean noun (the leak diff --git a/src/catalog/manifest.ts b/src/catalog/manifest.ts index 1e65b999..52dc599c 100644 --- a/src/catalog/manifest.ts +++ b/src/catalog/manifest.ts @@ -736,6 +736,95 @@ const MANIFEST: CommandJustification[] = [ destructive: true, ciPolicy: 'allow', }, + + // --- Resource migration: app-config cluster (webhook / portal / config) --- + // graphql-resource-migration Phase 7. Same recipe as the earlier clusters: + // unchanged subcommand grammar, dashboard-plane backend, new curated shapes. + // + // Mapping notes: + // - `config redirect add` and `config cors add` have NO entries of their own: + // the backing ops (`redirectUris`/`setRedirectUris`, `corsConfig`/ + // `updateCorsConfig`) are already owned by the `authkit redirect-uris *` / + // `authkit cors *` entries above, and OVERRIDES is op-keyed (one op ↔ one + // command noun — the invitation-get precedent). The config subcommands are + // read-merge-write conveniences over those same full-list setters: fetch + // current, no-op if present, else append and set. The concurrent-editor + // race window is accepted and noted in help text (the same trade-off + // `authkit redirect-uris set` already makes). + // - `config homepage-url set` is backed by TWO ops sharing the noun (the + // `membership list` precedent): `defaultAuthkitApplication` resolves the + // environment's default AuthKit application ID, then + // `updateAuthkitApplication` sets its homepage URL. This matches the REST + // endpoint's semantics, which dual-wrote the default application. + // - `portal generate-link` generates a setup link and expires prior links of + // the same intent server-side; not destructive (links are re-generable) and + // generating them from scripts/CI is the intended automation (the + // onboard-org workflow precedent). + // - `webhook delete` is `destructive` (confirmDestructive; the op carries no + // catalog confirmation phrase, so the consequence copy is hand-written; + // ciPolicy stays `allow` because the destructive gate already covers + // non-interactive runs). + { + command: 'webhook list', + mapsTo: 'webhookEndpoints', + audiences: ['human', 'agent', 'ci'], + useCase: 'List webhook endpoints in the active environment (verify setup, audits, scripting)', + load: 'cheap', + mutation: false, + destructive: false, + ciPolicy: 'allow', + }, + { + command: 'webhook create', + mapsTo: 'createWebhookEndpoint', + audiences: ['human', 'agent', 'ci'], + useCase: 'Register a webhook endpoint from setup scripts or CI', + load: 'cheap', + mutation: true, + destructive: false, + ciPolicy: 'allow', + }, + { + command: 'webhook delete', + mapsTo: 'deleteWebhookEndpoint', + audiences: ['human', 'agent'], + useCase: 'Delete a webhook endpoint (cleanup, decommissioned receivers)', + load: 'cheap', + mutation: true, + // destructive: the endpoint stops receiving events immediately. + destructive: true, + ciPolicy: 'allow', + }, + { + command: 'portal generate-link', + mapsTo: 'generatePortalSetupLink', + audiences: ['human', 'agent', 'ci'], + useCase: 'Generate an Admin Portal setup link for an organization during onboarding automation', + load: 'cheap', + mutation: true, + destructive: false, + ciPolicy: 'allow', + }, + { + command: 'config homepage-url set', + mapsTo: 'defaultAuthkitApplication', + audiences: ['human', 'agent', 'ci'], + useCase: "Resolve the environment's AuthKit application (the lookup step of homepage-url set)", + load: 'cheap', + mutation: false, + destructive: false, + ciPolicy: 'allow', + }, + { + command: 'config homepage-url set', + mapsTo: 'updateAuthkitApplication', + audiences: ['human', 'agent', 'ci'], + useCase: 'Set the app homepage URL when wiring AuthKit (setup scripts/CI)', + load: 'cheap', + mutation: true, + destructive: false, + ciPolicy: 'allow', + }, ]; /** Returns the curated command allowlist. */ diff --git a/src/commands/config.spec.ts b/src/commands/config.spec.ts index fa2360bc..dee6ff28 100644 --- a/src/commands/config.spec.ts +++ b/src/commands/config.spec.ts @@ -1,108 +1,423 @@ import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'; -const mockClient = { - sdk: {}, - redirectUris: { add: vi.fn() }, - corsOrigins: { add: vi.fn() }, - homepageUrl: { set: vi.fn() }, -}; +const mockRequireCommandToken = vi.fn(); +const mockGraphqlRequest = vi.fn(); +const mockGetActiveEnvironment = vi.fn(); +const mockGetConfig = vi.fn(); + +vi.mock('../lib/command-auth.js', async (importActual) => { + const actual = await importActual(); + return { + ...actual, + requireCommandToken: () => mockRequireCommandToken(), + }; +}); + +vi.mock('../lib/dashboard-graphql.js', async (importActual) => { + const actual = await importActual(); + return { + ...actual, + dashboardGraphqlRequest: (...args: unknown[]) => mockGraphqlRequest(...args), + }; +}); -vi.mock('../lib/workos-client.js', () => ({ - createWorkOSClient: () => mockClient, -})); +// The environment-target resolver runs REAL against the mocked wire + config +// store, so mutation tests can assert the pre-validation fetch +// (teamProjectsV2) happens before the operation requests. +vi.mock('../lib/config-store.js', async (importActual) => { + const actual = await importActual(); + return { + ...actual, + getActiveEnvironment: () => mockGetActiveEnvironment(), + getConfig: () => mockGetConfig(), + setProfileEnvironmentId: vi.fn(), + }; +}); const { setOutputMode } = await import('../utils/output.js'); +const { resetInteractionModeForTests } = await import('../utils/interaction-mode.js'); +const { DashboardGraphqlError } = await import('../lib/dashboard-graphql.js'); +const { CliExit } = await import('../utils/cli-exit.js'); +const { runConfigRedirectAdd, runConfigCorsAdd, runConfigHomepageUrlSet, REDIRECT_MERGE_SCAN_LIMIT } = await import( + './config.js' +); + +const TEAM_ENVIRONMENTS_PAYLOAD = { + currentTeam: { + projectsV2: [{ environments: [{ id: 'env_profile', name: 'Sandbox', sandbox: true, clientId: null }] }], + }, +}; + +/** + * Route the wire mock by document content. Ordered: mutation names are checked + * before the query names they contain (setRedirectUris vs redirectUris). + */ +function respondByDocument(routes: Array<[marker: string, payload: unknown]>): void { + mockGraphqlRequest.mockImplementation(async (doc: unknown) => { + const document = String(doc); + if (document.includes('teamProjectsV2')) return TEAM_ENVIRONMENTS_PAYLOAD; + for (const [marker, payload] of routes) { + if (document.includes(marker)) { + if (payload instanceof Error) throw payload; + return payload; + } + } + throw new Error(`Unrouted document in test: ${document.slice(0, 80)}`); + }); +} -const { runConfigRedirectAdd, runConfigCorsAdd, runConfigHomepageUrlSet } = await import('./config.js'); +async function expectExit(promise: Promise, code: number): Promise { + try { + await promise; + } catch (err) { + if (err instanceof CliExit) { + expect(err.exitCode).toBe(code); + return err; + } + throw err; + } + throw new Error(`Expected CliExit(${code}) but promise resolved`); +} -describe('config commands', () => { +const EXISTING_URIS = { + redirectUris: { + data: [ + { id: 'uri_1', uri: 'https://app.example.com/callback', isDefault: true }, + { id: 'uri_2', uri: 'https://staging.example.com/callback', isDefault: false }, + ], + listMetadata: { before: null, after: null }, + }, +}; + +const URIS_SET = { + setRedirectUris: { + __typename: 'RedirectUrisSet', + redirectUris: [ + { id: 'uri_1', uri: 'https://app.example.com/callback', isDefault: true }, + { id: 'uri_2', uri: 'https://staging.example.com/callback', isDefault: false }, + { id: 'uri_3', uri: 'http://localhost:3000/callback', isDefault: false }, + ], + }, +}; + +const EXISTING_ORIGINS = { webOrigins: { webOrigins: { origins: ['https://app.example.com'] } } }; +const ORIGINS_SET = { + setWebOrigins: { __typename: 'WebOriginsSet', origins: ['https://app.example.com', 'http://localhost:3000'] }, +}; + +const DEFAULT_APP = { defaultUserlandApplication: { id: 'app_123' } }; +const APP_UPDATED = { + updateUserlandApplication: { + __typename: 'UserlandApplicationUpdated', + userlandApplication: { id: 'app_123', appHomepageUrl: 'https://example.com' }, + }, +}; + +describe('config command', () => { let consoleOutput: string[]; beforeEach(() => { vi.clearAllMocks(); + resetInteractionModeForTests(); + setOutputMode('human'); + mockRequireCommandToken.mockResolvedValue('tok_123'); + mockGetActiveEnvironment.mockReturnValue({ apiKey: 'sk_ignored', environmentId: 'env_profile' }); + mockGetConfig.mockReturnValue({ + activeEnvironment: 'default', + environments: { default: { apiKey: 'sk_ignored', environmentId: 'env_profile' } }, + }); consoleOutput = []; vi.spyOn(console, 'log').mockImplementation((...args: unknown[]) => { consoleOutput.push(args.map(String).join(' ')); }); + vi.spyOn(console, 'error').mockImplementation(() => {}); }); afterEach(() => { vi.restoreAllMocks(); + resetInteractionModeForTests(); + setOutputMode('human'); }); - describe('runConfigRedirectAdd', () => { - it('adds redirect URI', async () => { - mockClient.redirectUris.add.mockResolvedValue({ success: true, alreadyExists: false }); - await runConfigRedirectAdd('http://localhost:3000/callback', 'sk_test'); - expect(mockClient.redirectUris.add).toHaveBeenCalledWith('http://localhost:3000/callback'); - expect(consoleOutput.some((l) => l.includes('Added redirect URI'))).toBe(true); + describe('redirect add (read-merge-write)', () => { + it('appends the URI to the current list, preserving ids and defaults (pre-validation → read → write)', async () => { + respondByDocument([ + ['setRedirectUris', URIS_SET], + ['redirectUris', EXISTING_URIS], + ]); + await runConfigRedirectAdd('http://localhost:3000/callback', {}); + // Mutation: teamProjectsV2 pre-validation, then the merge read, then the set. + expect(String(mockGraphqlRequest.mock.calls[0][0])).toContain('teamProjectsV2'); + expect(String(mockGraphqlRequest.mock.calls[1][0])).toContain('redirectUris'); + expect(mockGraphqlRequest.mock.calls[1][1]).toEqual({ + token: 'tok_123', + variables: { environmentId: 'env_profile', limit: REDIRECT_MERGE_SCAN_LIMIT }, + environmentId: 'env_profile', + }); + expect(String(mockGraphqlRequest.mock.calls[2][0])).toContain('setRedirectUris'); + expect(mockGraphqlRequest.mock.calls[2][1]).toEqual({ + token: 'tok_123', + variables: { + input: { + environmentId: 'env_profile', + redirectUris: [ + { id: 'uri_1', uri: 'https://app.example.com/callback', isDefault: true }, + { id: 'uri_2', uri: 'https://staging.example.com/callback', isDefault: false }, + { uri: 'http://localhost:3000/callback' }, + ], + }, + }, + environmentId: 'env_profile', + }); + expect(consoleOutput.join('\n')).toContain('Added redirect URI'); }); - it('handles already exists gracefully', async () => { - mockClient.redirectUris.add.mockResolvedValue({ success: true, alreadyExists: true }); - await runConfigRedirectAdd('http://localhost:3000/callback', 'sk_test'); - expect(consoleOutput.some((l) => l.includes('already exists'))).toBe(true); + it('no-ops when the URI already exists (no write request)', async () => { + respondByDocument([ + ['setRedirectUris', URIS_SET], + ['redirectUris', EXISTING_URIS], + ]); + await runConfigRedirectAdd('https://app.example.com/callback', {}); + const documents = mockGraphqlRequest.mock.calls.map((call) => String(call[0])); + expect(documents.some((doc) => doc.includes('setRedirectUris'))).toBe(false); + expect(consoleOutput.join('\n')).toContain('already exists'); }); - }); - describe('runConfigCorsAdd', () => { - it('adds CORS origin', async () => { - mockClient.corsOrigins.add.mockResolvedValue({ success: true, alreadyExists: false }); - await runConfigCorsAdd('http://localhost:3000', 'sk_test'); - expect(mockClient.corsOrigins.add).toHaveBeenCalledWith('http://localhost:3000'); - expect(consoleOutput.some((l) => l.includes('Added CORS origin'))).toBe(true); + it('refuses loudly when the list is longer than one page (never truncates silently)', async () => { + respondByDocument([ + ['setRedirectUris', URIS_SET], + ['redirectUris', { redirectUris: { data: [], listMetadata: { before: null, after: 'cursor_next' } } }], + ]); + const err = await expectExit(runConfigRedirectAdd('http://localhost:3000/callback', {}), 1); + expect(err.context?.errorCode).toBe('too_many_uris'); + const documents = mockGraphqlRequest.mock.calls.map((call) => String(call[0])); + expect(documents.some((doc) => doc.includes('setRedirectUris'))).toBe(false); }); - it('handles already exists gracefully', async () => { - mockClient.corsOrigins.add.mockResolvedValue({ success: true, alreadyExists: true }); - await runConfigCorsAdd('http://localhost:3000', 'sk_test'); - expect(consoleOutput.some((l) => l.includes('already exists'))).toBe(true); + it('errors invalid_redirect_uri on a rejected URI', async () => { + respondByDocument([ + [ + 'setRedirectUris', + { + setRedirectUris: { + __typename: 'InvalidRedirectUriError', + message: 'Redirect URI must use HTTPS', + uri: 'ftp://bad', + }, + }, + ], + ['redirectUris', EXISTING_URIS], + ]); + const err = await expectExit(runConfigRedirectAdd('ftp://bad', {}), 1); + expect(err.context?.errorCode).toBe('invalid_redirect_uri'); + }); + + it('honors an --environment-id override', async () => { + respondByDocument([ + ['setRedirectUris', URIS_SET], + ['redirectUris', EXISTING_URIS], + ]); + await runConfigRedirectAdd('http://localhost:3000/callback', { environmentId: 'env_profile' }); + expect(mockGraphqlRequest.mock.calls[1][1]).toMatchObject({ environmentId: 'env_profile' }); + }); + + it('--json emits { uri, alreadyExists: false } on add', async () => { + setOutputMode('json'); + respondByDocument([ + ['setRedirectUris', URIS_SET], + ['redirectUris', EXISTING_URIS], + ]); + await runConfigRedirectAdd('http://localhost:3000/callback', {}); + const raw = consoleOutput[0]; + expect(raw).not.toMatch(/graphql|userland/i); + expect(JSON.parse(raw)).toEqual({ uri: 'http://localhost:3000/callback', alreadyExists: false }); + }); + + it('--json emits { uri, alreadyExists: true } on a no-op', async () => { + setOutputMode('json'); + respondByDocument([ + ['setRedirectUris', URIS_SET], + ['redirectUris', EXISTING_URIS], + ]); + await runConfigRedirectAdd('https://app.example.com/callback', {}); + expect(JSON.parse(consoleOutput[0])).toEqual({ uri: 'https://app.example.com/callback', alreadyExists: true }); }); }); - describe('runConfigHomepageUrlSet', () => { - it('sets homepage URL', async () => { - mockClient.homepageUrl.set.mockResolvedValue(undefined); - await runConfigHomepageUrlSet('http://localhost:3000', 'sk_test'); - expect(mockClient.homepageUrl.set).toHaveBeenCalledWith('http://localhost:3000'); - expect(consoleOutput.some((l) => l.includes('Set homepage URL'))).toBe(true); + describe('cors add (read-merge-write)', () => { + it('appends the origin to the current list (pre-validation → read → write)', async () => { + respondByDocument([ + ['updateCorsConfig', ORIGINS_SET], + ['corsConfig', EXISTING_ORIGINS], + ]); + await runConfigCorsAdd('http://localhost:3000', {}); + expect(String(mockGraphqlRequest.mock.calls[0][0])).toContain('teamProjectsV2'); + expect(String(mockGraphqlRequest.mock.calls[1][0])).toContain('corsConfig'); + expect(String(mockGraphqlRequest.mock.calls[2][0])).toContain('updateCorsConfig'); + expect(mockGraphqlRequest.mock.calls[2][1]).toEqual({ + token: 'tok_123', + variables: { environmentId: 'env_profile', origins: ['https://app.example.com', 'http://localhost:3000'] }, + environmentId: 'env_profile', + }); + expect(consoleOutput.join('\n')).toContain('Added CORS origin'); + }); + + it('no-ops when the origin already exists (no write request)', async () => { + respondByDocument([ + ['updateCorsConfig', ORIGINS_SET], + ['corsConfig', EXISTING_ORIGINS], + ]); + await runConfigCorsAdd('https://app.example.com', {}); + const documents = mockGraphqlRequest.mock.calls.map((call) => String(call[0])); + expect(documents.some((doc) => doc.includes('updateCorsConfig'))).toBe(false); + expect(consoleOutput.join('\n')).toContain('already exists'); + }); + + it('errors invalid_web_origin on a rejected origin', async () => { + respondByDocument([ + [ + 'updateCorsConfig', + { + setWebOrigins: { + __typename: 'MalformedWebOrigin', + message: 'Not a valid origin', + uri: 'nonsense', + }, + }, + ], + ['corsConfig', EXISTING_ORIGINS], + ]); + const err = await expectExit(runConfigCorsAdd('nonsense', {}), 1); + expect(err.context?.errorCode).toBe('invalid_web_origin'); + }); + + it('--json emits { origin, alreadyExists }', async () => { + setOutputMode('json'); + respondByDocument([ + ['updateCorsConfig', ORIGINS_SET], + ['corsConfig', EXISTING_ORIGINS], + ]); + await runConfigCorsAdd('http://localhost:3000', {}); + const raw = consoleOutput[0]; + expect(raw).not.toMatch(/graphql|userland/i); + expect(JSON.parse(raw)).toEqual({ origin: 'http://localhost:3000', alreadyExists: false }); }); }); - describe('JSON output mode', () => { - beforeEach(() => setOutputMode('json')); - afterEach(() => setOutputMode('human')); + describe('homepage-url set (two-step: resolve application, then update)', () => { + it('resolves the AuthKit application, then sets the homepage URL on it', async () => { + respondByDocument([ + ['updateAuthkitApplication', APP_UPDATED], + ['defaultAuthkitApplication', DEFAULT_APP], + ]); + await runConfigHomepageUrlSet('https://example.com', {}); + expect(String(mockGraphqlRequest.mock.calls[0][0])).toContain('teamProjectsV2'); + expect(String(mockGraphqlRequest.mock.calls[1][0])).toContain('defaultAuthkitApplication'); + expect(mockGraphqlRequest.mock.calls[1][1]).toEqual({ + token: 'tok_123', + variables: { environmentId: 'env_profile' }, + environmentId: 'env_profile', + }); + expect(String(mockGraphqlRequest.mock.calls[2][0])).toContain('updateAuthkitApplication'); + expect(mockGraphqlRequest.mock.calls[2][1]).toEqual({ + token: 'tok_123', + variables: { input: { applicationId: 'app_123', appHomepageUrl: 'https://example.com' } }, + environmentId: 'env_profile', + }); + expect(consoleOutput.join('\n')).toContain('Set homepage URL'); + }); + + it('errors not_found when the environment has no AuthKit application', async () => { + respondByDocument([ + ['updateAuthkitApplication', APP_UPDATED], + ['defaultAuthkitApplication', { defaultUserlandApplication: null }], + ]); + const err = await expectExit(runConfigHomepageUrlSet('https://example.com', {}), 1); + expect(err.context?.errorCode).toBe('not_found'); + const documents = mockGraphqlRequest.mock.calls.map((call) => String(call[0])); + expect(documents.some((doc) => doc.includes('updateAuthkitApplication'))).toBe(false); + }); + + it('errors not_found when the update reports the application missing', async () => { + respondByDocument([ + [ + 'updateAuthkitApplication', + { updateUserlandApplication: { __typename: 'UserlandApplicationNotFound', applicationId: 'app_123' } }, + ], + ['defaultAuthkitApplication', DEFAULT_APP], + ]); + const err = await expectExit(runConfigHomepageUrlSet('https://example.com', {}), 1); + expect(err.context?.errorCode).toBe('not_found'); + }); - it('runConfigRedirectAdd outputs JSON success', async () => { - mockClient.redirectUris.add.mockResolvedValue({ success: true, alreadyExists: false }); - await runConfigRedirectAdd('http://localhost:3000/callback', 'sk_test'); - const output = JSON.parse(consoleOutput[0]); - expect(output.status).toBe('ok'); - expect(output.data.uri).toBe('http://localhost:3000/callback'); + it('errors invalid_argument with the server message on validation failure', async () => { + const consoleErrors: string[] = []; + vi.spyOn(console, 'error').mockImplementation((...args: unknown[]) => { + consoleErrors.push(args.map(String).join(' ')); + }); + respondByDocument([ + [ + 'updateAuthkitApplication', + { + updateUserlandApplication: { + __typename: 'UserlandApplicationValidationFailed', + message: 'The app homepage URL must be a valid URL.', + }, + }, + ], + ['defaultAuthkitApplication', DEFAULT_APP], + ]); + const err = await expectExit(runConfigHomepageUrlSet('nonsense', {}), 1); + expect(err.context?.errorCode).toBe('invalid_argument'); + expect(consoleErrors.join('\n')).toContain('valid URL'); }); - it('runConfigRedirectAdd outputs JSON for already exists', async () => { - mockClient.redirectUris.add.mockResolvedValue({ success: true, alreadyExists: true }); - await runConfigRedirectAdd('http://localhost:3000/callback', 'sk_test'); - const output = JSON.parse(consoleOutput[0]); - expect(output.status).toBe('ok'); - expect(output.data.alreadyExists).toBe(true); + it('errors unexpected_result on an unknown variant', async () => { + respondByDocument([ + ['updateAuthkitApplication', { updateUserlandApplication: { __typename: 'SomethingElse' } }], + ['defaultAuthkitApplication', DEFAULT_APP], + ]); + const err = await expectExit(runConfigHomepageUrlSet('https://example.com', {}), 1); + expect(err.context?.errorCode).toBe('unexpected_result'); }); - it('runConfigCorsAdd outputs JSON success', async () => { - mockClient.corsOrigins.add.mockResolvedValue({ success: true, alreadyExists: false }); - await runConfigCorsAdd('http://localhost:3000', 'sk_test'); - const output = JSON.parse(consoleOutput[0]); - expect(output.status).toBe('ok'); - expect(output.data.origin).toBe('http://localhost:3000'); + it('--json emits { homepageUrl, applicationId } without internal naming', async () => { + setOutputMode('json'); + respondByDocument([ + ['updateAuthkitApplication', APP_UPDATED], + ['defaultAuthkitApplication', DEFAULT_APP], + ]); + await runConfigHomepageUrlSet('https://example.com', {}); + const raw = consoleOutput[0]; + expect(raw).not.toMatch(/graphql|userland/i); + expect(JSON.parse(raw)).toEqual({ homepageUrl: 'https://example.com', applicationId: 'app_123' }); + }); + }); + + describe('shared failure modes', () => { + it('exits auth-required (code 4) when not logged in', async () => { + mockRequireCommandToken.mockImplementation(() => { + throw new CliExit(4, { reason: 'auth_required', errorCode: 'auth_required' }); + }); + await expectExit(runConfigRedirectAdd('http://localhost:3000/callback', {}), 4); + expect(mockGraphqlRequest).not.toHaveBeenCalled(); }); - it('runConfigHomepageUrlSet outputs JSON success', async () => { - mockClient.homepageUrl.set.mockResolvedValue(undefined); - await runConfigHomepageUrlSet('http://localhost:3000', 'sk_test'); - const output = JSON.parse(consoleOutput[0]); - expect(output.status).toBe('ok'); - expect(output.data.url).toBe('http://localhost:3000'); + it('surfaces the gated-capability case on a 403 without naming GraphQL', async () => { + const consoleErrors: string[] = []; + vi.spyOn(console, 'error').mockImplementation((...args: unknown[]) => { + consoleErrors.push(args.map(String).join(' ')); + }); + respondByDocument([ + [ + 'redirectUris', + new DashboardGraphqlError('The dashboard GraphQL API rejected this session (HTTP 403).', 'forbidden', 403), + ], + ]); + await expectExit(runConfigRedirectAdd('http://localhost:3000/callback', {}), 1); + const err = consoleErrors.join('\n'); + expect(err).toMatch(/account-plane capability/i); + expect(err).not.toMatch(/graphql/i); }); }); }); diff --git a/src/commands/config.ts b/src/commands/config.ts index 6675b68d..d1fdc3d7 100644 --- a/src/commands/config.ts +++ b/src/commands/config.ts @@ -1,59 +1,293 @@ +/** + * `workos config` — additive AuthKit app-config conveniences on the dashboard + * account plane. + * + * Migrated from the API-key REST plane (graphql-resource-migration Phase 7): + * the subcommand surface (redirect add / cors add / homepage-url set) is + * unchanged, but every operation now runs catalog-backed dashboard operations + * with the user's OAuth bearer. Output shapes are new curated shapes (approved + * breaking change); the authoritative examples live in `config.spec.ts`. + * + * Backend divergences from REST (all loud, none faked): + * - The REST redirect/CORS endpoints were additive; the dashboard plane only + * has full-list setters. `redirect add` / `cors add` are therefore + * READ-MERGE-WRITE: fetch the current list, no-op if the value is already + * present, otherwise append and set the whole list. A concurrent editor's + * change between the read and the write is lost — an accepted race window, + * noted in help text (the same trade-off `authkit redirect-uris set` makes). + * - The redirect merge read is one bounded page: if more URIs exist than the + * scan cap, the command errors loudly rather than silently dropping the + * overflow from the rewritten list. + * - `homepage-url set` resolves the environment's AuthKit application, then + * updates its homepage URL — the same application the REST endpoint + * dual-wrote. + * + * `config` and `authkit` intentionally both survive with overlapping + * capability (consolidation is deferred by decision). + */ + import chalk from 'chalk'; -import { createWorkOSClient } from '../lib/workos-client.js'; -import { outputSuccess, isJsonMode } from '../utils/output.js'; -import { createApiErrorHandler } from '../lib/api-error-handler.js'; +import { requireCommandToken } from '../lib/command-auth.js'; +import { dashboardGraphqlRequest } from '../lib/dashboard-graphql.js'; +import { resolveEnvironmentTarget } from '../lib/environment-target.js'; +import { getOperation, resolveExecutableDocument, reportDashboardError } from '../catalog/operation.js'; +import { isJsonMode, outputJson, outputSuccess, exitWithError } from '../utils/output.js'; + +/** + * `redirect add` merges over one page of existing URIs — a single bounded + * request, never an unbounded fan-out. More than this and the merge would + * silently drop the overflow, so the command refuses instead. + */ +export const REDIRECT_MERGE_SCAN_LIMIT = 100; + +interface UriNode { + id?: string | null; + uri: string; + isDefault?: boolean | null; +} + +export interface ConfigRedirectAddOptions { + /** `--environment-id` override; defaults from the active profile. */ + environmentId?: string; +} -const handleApiError = createApiErrorHandler('Config'); +export async function runConfigRedirectAdd(uri: string, options: ConfigRedirectAddOptions = {}): Promise { + const token = await requireCommandToken(); + const readOp = getOperation('redirectUris'); + const writeOp = getOperation('setRedirectUris'); -export async function runConfigRedirectAdd(uri: string, apiKey: string, baseUrl?: string): Promise { - const client = createWorkOSClient(apiKey, baseUrl); + // The subcommand as a whole mutates: pre-validate the resolved target. + const { environmentId } = await resolveEnvironmentTarget(token, { + flagValue: options.environmentId, + forMutation: writeOp.kind === 'mutation', + }); + // READ: the current full list (the backing setter replaces the whole list). + let current: { + redirectUris: { + data: UriNode[]; + listMetadata?: { before?: string | null; after?: string | null } | null; + } | null; + }; try { - const result = await client.redirectUris.add(uri); - - if (result.alreadyExists) { - if (isJsonMode()) { - outputSuccess('Redirect URI already exists', { uri, alreadyExists: true }); - } else { - console.log(chalk.yellow('Redirect URI already exists (no change)')); - } - return; + current = await dashboardGraphqlRequest(resolveExecutableDocument(readOp), { + token, + variables: { environmentId, limit: REDIRECT_MERGE_SCAN_LIMIT }, + environmentId, + }); + } catch (error) { + reportDashboardError(error); + } + + const existing = current.redirectUris?.data ?? []; + // Refuse to merge over a truncated read: rewriting the list from one page + // would silently drop every URI beyond the cap. + if (current.redirectUris?.listMetadata?.after) { + exitWithError({ + code: 'too_many_uris', + message: `This environment has more than ${REDIRECT_MERGE_SCAN_LIMIT} redirect URIs; adding one here could drop the rest. Use \`authkit redirect-uris set\` with the full list instead.`, + }); + } + + if (existing.some((node) => node.uri === uri)) { + if (isJsonMode()) { + outputJson({ uri, alreadyExists: true }); + } else { + console.log(chalk.yellow('Redirect URI already exists (no change)')); } + return; + } - outputSuccess('Added redirect URI', { uri }); + // MERGE-WRITE: existing entries keep their id/isDefault; the new URI rides + // along without a default marker. + const merged = [ + ...existing.map((node) => ({ + ...(node.id != null ? { id: node.id } : {}), + uri: node.uri, + ...(node.isDefault != null ? { isDefault: node.isDefault } : {}), + })), + { uri }, + ]; + + let data: { + setRedirectUris: + | { __typename: 'RedirectUrisSet'; redirectUris: UriNode[] } + | { __typename: string; message?: string; uri?: string }; + }; + try { + data = await dashboardGraphqlRequest(resolveExecutableDocument(writeOp), { + token, + variables: { input: { environmentId, redirectUris: merged } }, + environmentId, + }); } catch (error) { - handleApiError(error); + reportDashboardError(error); + } + + const result = data.setRedirectUris; + if (result.__typename !== 'RedirectUrisSet' || !('redirectUris' in result)) { + const detail = + 'message' in result && result.message + ? `${result.message}${result.uri ? ` (${result.uri})` : ''}` + : 'Invalid redirect URI.'; + exitWithError({ code: 'invalid_redirect_uri', message: detail }); + } + + if (isJsonMode()) { + outputJson({ uri, alreadyExists: false }); + return; } + outputSuccess(`Added redirect URI ${chalk.bold(uri)}`); } -export async function runConfigCorsAdd(origin: string, apiKey: string, baseUrl?: string): Promise { - const client = createWorkOSClient(apiKey, baseUrl); +export interface ConfigCorsAddOptions { + /** `--environment-id` override; defaults from the active profile. */ + environmentId?: string; +} + +export async function runConfigCorsAdd(origin: string, options: ConfigCorsAddOptions = {}): Promise { + const token = await requireCommandToken(); + const readOp = getOperation('corsConfig'); + const writeOp = getOperation('updateCorsConfig'); + + // The subcommand as a whole mutates: pre-validate the resolved target. + const { environmentId } = await resolveEnvironmentTarget(token, { + flagValue: options.environmentId, + forMutation: writeOp.kind === 'mutation', + }); + // READ: the current full list (the backing setter replaces the whole list; + // origins are unpaginated). + let current: { webOrigins: { webOrigins: { origins: string[] } | null } | null }; try { - const result = await client.corsOrigins.add(origin); - - if (result.alreadyExists) { - if (isJsonMode()) { - outputSuccess('CORS origin already exists', { origin, alreadyExists: true }); - } else { - console.log(chalk.yellow('CORS origin already exists (no change)')); - } - return; + current = await dashboardGraphqlRequest(resolveExecutableDocument(readOp), { + token, + variables: { environmentId }, + environmentId, + }); + } catch (error) { + reportDashboardError(error); + } + + const existing = current.webOrigins?.webOrigins?.origins ?? []; + if (existing.includes(origin)) { + if (isJsonMode()) { + outputJson({ origin, alreadyExists: true }); + } else { + console.log(chalk.yellow('CORS origin already exists (no change)')); } + return; + } - outputSuccess('Added CORS origin', { origin }); + // MERGE-WRITE: append and set the whole list. + let data: { + setWebOrigins: + | { __typename: 'WebOriginsSet'; origins: string[] } + | { __typename: string; message?: string; uri?: string }; + }; + try { + data = await dashboardGraphqlRequest(resolveExecutableDocument(writeOp), { + token, + variables: { environmentId, origins: [...existing, origin] }, + environmentId, + }); } catch (error) { - handleApiError(error); + reportDashboardError(error); } + + const result = data.setWebOrigins; + if (result.__typename !== 'WebOriginsSet' || !('origins' in result)) { + const detail = + 'message' in result && result.message + ? `${result.message}${result.uri ? ` (${result.uri})` : ''}` + : 'Invalid web origin.'; + exitWithError({ code: 'invalid_web_origin', message: detail }); + } + + if (isJsonMode()) { + outputJson({ origin, alreadyExists: false }); + return; + } + outputSuccess(`Added CORS origin ${chalk.bold(origin)}`); +} + +export interface ConfigHomepageUrlSetOptions { + /** `--environment-id` override; defaults from the active profile. */ + environmentId?: string; } -export async function runConfigHomepageUrlSet(url: string, apiKey: string, baseUrl?: string): Promise { - const client = createWorkOSClient(apiKey, baseUrl); +export async function runConfigHomepageUrlSet(url: string, options: ConfigHomepageUrlSetOptions = {}): Promise { + const token = await requireCommandToken(); + const lookupOp = getOperation('defaultAuthkitApplication'); + const writeOp = getOperation('updateAuthkitApplication'); + + // The subcommand as a whole mutates: pre-validate the resolved target. + const { environmentId } = await resolveEnvironmentTarget(token, { + flagValue: options.environmentId, + forMutation: writeOp.kind === 'mutation', + }); + + // Step 1: resolve the environment's AuthKit application — the update is + // keyed by application ID, and this is the application the REST endpoint + // wrote to. + let lookup: { defaultUserlandApplication: { id: string } | null }; + try { + lookup = await dashboardGraphqlRequest(resolveExecutableDocument(lookupOp), { + token, + variables: { environmentId }, + environmentId, + }); + } catch (error) { + reportDashboardError(error); + } + const applicationId = lookup.defaultUserlandApplication?.id; + if (!applicationId) { + exitWithError({ + code: 'not_found', + message: 'No AuthKit application was found for this environment.', + }); + } + + // Step 2: set the homepage URL on it. The result is a discriminated union + // whose variant names are internal — matched on, never echoed. + let data: { + updateUserlandApplication: + | { __typename: 'UserlandApplicationUpdated'; userlandApplication: { id: string; appHomepageUrl?: string | null } } + | { __typename: 'UserlandApplicationNotFound'; applicationId: string } + | { __typename: 'UserlandApplicationValidationFailed'; message: string } + | { __typename: string }; + }; try { - await client.homepageUrl.set(url); - outputSuccess('Set homepage URL', { url }); + data = await dashboardGraphqlRequest(resolveExecutableDocument(writeOp), { + token, + variables: { input: { applicationId, appHomepageUrl: url } }, + environmentId, + }); } catch (error) { - handleApiError(error); + reportDashboardError(error); + } + + const result = data.updateUserlandApplication; + if (result.__typename === 'UserlandApplicationNotFound') { + exitWithError({ + code: 'not_found', + message: 'The AuthKit application for this environment could not be found.', + }); + } + if (result.__typename === 'UserlandApplicationValidationFailed') { + exitWithError({ + code: 'invalid_argument', + message: (result as { message: string }).message || `Could not set the homepage URL to "${url}".`, + }); + } + if (result.__typename !== 'UserlandApplicationUpdated') { + exitWithError({ code: 'unexpected_result', message: `Could not set the homepage URL to "${url}".` }); + } + + if (isJsonMode()) { + outputJson({ homepageUrl: url, applicationId }); + return; } + outputSuccess(`Set homepage URL to ${chalk.bold(url)}`); } diff --git a/src/commands/portal.spec.ts b/src/commands/portal.spec.ts index 7ea1cf97..c68767f0 100644 --- a/src/commands/portal.spec.ts +++ b/src/commands/portal.spec.ts @@ -1,81 +1,230 @@ import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'; -const mockSdk = { - portal: { - generateLink: vi.fn(), +const mockRequireCommandToken = vi.fn(); +const mockGraphqlRequest = vi.fn(); +const mockGetActiveEnvironment = vi.fn(); +const mockGetConfig = vi.fn(); + +vi.mock('../lib/command-auth.js', async (importActual) => { + const actual = await importActual(); + return { + ...actual, + requireCommandToken: () => mockRequireCommandToken(), + }; +}); + +vi.mock('../lib/dashboard-graphql.js', async (importActual) => { + const actual = await importActual(); + return { + ...actual, + dashboardGraphqlRequest: (...args: unknown[]) => mockGraphqlRequest(...args), + }; +}); + +// The environment-target resolver runs REAL against the mocked wire + config +// store, so mutation tests can assert the pre-validation fetch +// (teamProjectsV2) happens before the operation request. +vi.mock('../lib/config-store.js', async (importActual) => { + const actual = await importActual(); + return { + ...actual, + getActiveEnvironment: () => mockGetActiveEnvironment(), + getConfig: () => mockGetConfig(), + setProfileEnvironmentId: vi.fn(), + }; +}); + +const { setOutputMode } = await import('../utils/output.js'); +const { resetInteractionModeForTests } = await import('../utils/interaction-mode.js'); +const { DashboardGraphqlError } = await import('../lib/dashboard-graphql.js'); +const { CliExit } = await import('../utils/cli-exit.js'); +const { runPortalGenerateLink } = await import('./portal.js'); + +const TEAM_ENVIRONMENTS_PAYLOAD = { + currentTeam: { + projectsV2: [{ environments: [{ id: 'env_profile', name: 'Sandbox', sandbox: true, clientId: null }] }], }, }; -vi.mock('../lib/workos-client.js', () => ({ - createWorkOSClient: () => ({ sdk: mockSdk }), -})); +/** Route the wire mock by document (session.spec.ts recipe). */ +function respondWith(payload: unknown): void { + mockGraphqlRequest.mockImplementation(async (doc: unknown) => { + if (String(doc).includes('teamProjectsV2')) return TEAM_ENVIRONMENTS_PAYLOAD; + return payload; + }); +} -const { setOutputMode } = await import('../utils/output.js'); +async function expectExit(promise: Promise, code: number): Promise { + try { + await promise; + } catch (err) { + if (err instanceof CliExit) { + expect(err.exitCode).toBe(code); + return err; + } + throw err; + } + throw new Error(`Expected CliExit(${code}) but promise resolved`); +} -const { runPortalGenerateLink } = await import('./portal.js'); +/** The authoritative curated portal-setup-link JSON shape (documented contract). */ +const PORTAL_LINK_SHAPE_KEYS = ['id', 'link', 'intents', 'state', 'expiresAt']; + +const GENERATED = { + generatePortalSetupLink: { + __typename: 'PortalSetupLinkGenerated', + portalSetupLink: { + id: 'portal_setup_link_1', + expiresAt: '2026-08-01T00:00:00Z', + intents: ['Sso'], + token: 'tok_setup', + url: 'https://setup.workos.com/abc', + state: 'Active', + }, + }, +}; -describe('portal commands', () => { +describe('portal command', () => { let consoleOutput: string[]; beforeEach(() => { vi.clearAllMocks(); + resetInteractionModeForTests(); + setOutputMode('human'); + mockRequireCommandToken.mockResolvedValue('tok_123'); + mockGetActiveEnvironment.mockReturnValue({ apiKey: 'sk_ignored', environmentId: 'env_profile' }); + mockGetConfig.mockReturnValue({ + activeEnvironment: 'default', + environments: { default: { apiKey: 'sk_ignored', environmentId: 'env_profile' } }, + }); consoleOutput = []; vi.spyOn(console, 'log').mockImplementation((...args: unknown[]) => { consoleOutput.push(args.map(String).join(' ')); }); + vi.spyOn(console, 'error').mockImplementation(() => {}); }); afterEach(() => { vi.restoreAllMocks(); + resetInteractionModeForTests(); + setOutputMode('human'); }); - describe('runPortalGenerateLink', () => { - it('generates portal link with correct params', async () => { - mockSdk.portal.generateLink.mockResolvedValue({ link: 'https://portal.workos.com/abc' }); - await runPortalGenerateLink({ intent: 'sso', organization: 'org_123' }, 'sk_test'); - expect(mockSdk.portal.generateLink).toHaveBeenCalledWith( - expect.objectContaining({ intent: 'sso', organization: 'org_123' }), - ); + describe('generate-link', () => { + it('maps the CLI intent onto the operation input, pre-validating the environment first (mutation ordering)', async () => { + respondWith(GENERATED); + await runPortalGenerateLink({ intent: 'sso', organization: 'org_123' }); + // Mutation: the resolver fetches the team's environments BEFORE the op. + expect(String(mockGraphqlRequest.mock.calls[0][0])).toContain('teamProjectsV2'); + expect(String(mockGraphqlRequest.mock.calls[1][0])).toContain('generatePortalSetupLink'); + expect(mockGraphqlRequest.mock.calls[1][1]).toEqual({ + token: 'tok_123', + // expireIntents scopes expiry to the same intent — omitted, the server + // would expire ALL of the organization's active setup links. + variables: { input: { organizationId: 'org_123', intents: ['Sso'], expireIntents: ['Sso'] } }, + environmentId: 'env_profile', + }); + }); + + it('outputs the link URL and its expiry in human mode', async () => { + respondWith(GENERATED); + await runPortalGenerateLink({ intent: 'sso', organization: 'org_123' }); + const out = consoleOutput.join('\n'); + expect(out).toContain('https://setup.workos.com/abc'); + expect(out).toContain('expires at 2026-08-01T00:00:00Z'); + }); + + it.each([ + ['dsync', 'Dsync'], + ['log_streams', 'LogStreams'], + ['domain_verification', 'DomainVerification'], + ['certificate_renewal', 'CertificateRenewal'], + ])('maps intent %s to %s', async (cliIntent, opIntent) => { + respondWith(GENERATED); + await runPortalGenerateLink({ intent: cliIntent, organization: 'org_123' }); + expect(mockGraphqlRequest.mock.calls[1][1]).toMatchObject({ + variables: { input: { organizationId: 'org_123', intents: [opIntent], expireIntents: [opIntent] } }, + }); }); - it('outputs link URL in human mode', async () => { - mockSdk.portal.generateLink.mockResolvedValue({ link: 'https://portal.workos.com/abc' }); - await runPortalGenerateLink({ intent: 'sso', organization: 'org_123' }, 'sk_test'); - expect(consoleOutput.some((l) => l.includes('https://portal.workos.com/abc'))).toBe(true); + it('rejects audit_logs loudly (no equivalent on this plane) without calling the wire', async () => { + const consoleErrors: string[] = []; + vi.spyOn(console, 'error').mockImplementation((...args: unknown[]) => { + consoleErrors.push(args.map(String).join(' ')); + }); + const err = await expectExit(runPortalGenerateLink({ intent: 'audit_logs', organization: 'org_123' }), 1); + expect(err.context?.errorCode).toBe('invalid_argument'); + expect(mockGraphqlRequest).not.toHaveBeenCalled(); + expect(consoleErrors.join('\n')).toContain('Supported intents'); }); - it('shows expiry note in human mode', async () => { - mockSdk.portal.generateLink.mockResolvedValue({ link: 'https://portal.workos.com/abc' }); - await runPortalGenerateLink({ intent: 'sso', organization: 'org_123' }, 'sk_test'); - expect(consoleOutput.some((l) => l.includes('expire'))).toBe(true); + it('rejects unknown intents with the supported set', async () => { + const err = await expectExit(runPortalGenerateLink({ intent: 'nonsense', organization: 'org_123' }), 1); + expect(err.context?.errorCode).toBe('invalid_argument'); + expect(mockGraphqlRequest).not.toHaveBeenCalled(); }); - it('passes optional returnUrl and successUrl', async () => { - mockSdk.portal.generateLink.mockResolvedValue({ link: 'https://portal.workos.com/abc' }); - await runPortalGenerateLink( - { - intent: 'dsync', - organization: 'org_123', - returnUrl: 'https://app.com/return', - successUrl: 'https://app.com/success', - }, - 'sk_test', - ); - expect(mockSdk.portal.generateLink).toHaveBeenCalledWith( - expect.objectContaining({ returnUrl: 'https://app.com/return', successUrl: 'https://app.com/success' }), - ); + it('honors an --environment-id override', async () => { + respondWith(GENERATED); + await runPortalGenerateLink({ intent: 'sso', organization: 'org_123', environmentId: 'env_profile' }); + expect(mockGraphqlRequest.mock.calls[1][1]).toMatchObject({ environmentId: 'env_profile' }); + }); + + it('errors not_found when the organization is unknown', async () => { + respondWith({ + generatePortalSetupLink: { __typename: 'OrganizationNotFound', organizationId: 'org_missing' }, + }); + const err = await expectExit(runPortalGenerateLink({ intent: 'sso', organization: 'org_missing' }), 1); + expect(err.context?.errorCode).toBe('not_found'); + }); + + it('errors unexpected_result on an unknown variant', async () => { + respondWith({ generatePortalSetupLink: { __typename: 'SomethingElse' } }); + const err = await expectExit(runPortalGenerateLink({ intent: 'sso', organization: 'org_123' }), 1); + expect(err.context?.errorCode).toBe('unexpected_result'); + }); + + it('--json emits the documented curated shape with intents in CLI grammar', async () => { + setOutputMode('json'); + respondWith(GENERATED); + await runPortalGenerateLink({ intent: 'sso', organization: 'org_123' }); + const raw = consoleOutput[0]; + expect(raw).not.toMatch(/graphql|userland/i); + const out = JSON.parse(raw); + expect(Object.keys(out)).toEqual(['portalSetupLink']); + expect(Object.keys(out.portalSetupLink)).toEqual(PORTAL_LINK_SHAPE_KEYS); + expect(out.portalSetupLink).toEqual({ + id: 'portal_setup_link_1', + link: 'https://setup.workos.com/abc', + intents: ['sso'], + state: 'Active', + expiresAt: '2026-08-01T00:00:00Z', + }); }); }); - describe('JSON output mode', () => { - beforeEach(() => setOutputMode('json')); - afterEach(() => setOutputMode('human')); + describe('shared failure modes', () => { + it('exits auth-required (code 4) when not logged in', async () => { + mockRequireCommandToken.mockImplementation(() => { + throw new CliExit(4, { reason: 'auth_required', errorCode: 'auth_required' }); + }); + await expectExit(runPortalGenerateLink({ intent: 'sso', organization: 'org_123' }), 4); + expect(mockGraphqlRequest).not.toHaveBeenCalled(); + }); - it('outputs full response object', async () => { - mockSdk.portal.generateLink.mockResolvedValue({ link: 'https://portal.workos.com/abc' }); - await runPortalGenerateLink({ intent: 'sso', organization: 'org_123' }, 'sk_test'); - const output = JSON.parse(consoleOutput[0]); - expect(output.link).toBe('https://portal.workos.com/abc'); + it('surfaces the gated-capability case on a 403 without naming GraphQL', async () => { + const consoleErrors: string[] = []; + vi.spyOn(console, 'error').mockImplementation((...args: unknown[]) => { + consoleErrors.push(args.map(String).join(' ')); + }); + mockGraphqlRequest.mockImplementation(async (doc: unknown) => { + if (String(doc).includes('teamProjectsV2')) return TEAM_ENVIRONMENTS_PAYLOAD; + throw new DashboardGraphqlError('The dashboard GraphQL API rejected this session (HTTP 403).', 'forbidden', 403); + }); + await expectExit(runPortalGenerateLink({ intent: 'sso', organization: 'org_123' }), 1); + const err = consoleErrors.join('\n'); + expect(err).toMatch(/account-plane capability/i); + expect(err).not.toMatch(/graphql/i); }); }); }); diff --git a/src/commands/portal.ts b/src/commands/portal.ts index 15eaf43e..de529ceb 100644 --- a/src/commands/portal.ts +++ b/src/commands/portal.ts @@ -1,40 +1,148 @@ +/** + * `workos portal` — Admin Portal setup links on the dashboard account plane. + * + * Migrated from the API-key REST plane (graphql-resource-migration Phase 7): + * the subcommand surface (generate-link) is unchanged, but the link is now a + * catalog-backed dashboard operation with the user's OAuth bearer. Output + * shapes are new curated shapes (approved breaking change); the authoritative + * examples live in `portal.spec.ts`. + * + * Backend divergences from REST (all loud, none faked): + * - `--return-url` / `--success-url` have no equivalent on the backing + * operation → flags REMOVED (strict() rejects them at parse time). + * - `--intent audit_logs` has no equivalent intent on the dashboard plane → + * structured error naming the supported set; `domain_verification` and + * `certificate_renewal` are supported. + * - REST links expired after 5 minutes; setup links carry a server-side + * `expiresAt`, which the output prints. Generating a link expires prior + * links of the same intent. + */ + import chalk from 'chalk'; -import { createWorkOSClient } from '../lib/workos-client.js'; -import { outputJson, isJsonMode } from '../utils/output.js'; -import { createApiErrorHandler } from '../lib/api-error-handler.js'; +import { requireCommandToken } from '../lib/command-auth.js'; +import { dashboardGraphqlRequest } from '../lib/dashboard-graphql.js'; +import { resolveEnvironmentTarget } from '../lib/environment-target.js'; +import { getOperation, resolveExecutableDocument, reportDashboardError } from '../catalog/operation.js'; +import { isJsonMode, outputJson, exitWithError } from '../utils/output.js'; + +/** + * CLI intent grammar (frozen REST values) → backing enum names. `audit_logs` + * is deliberately absent: the dashboard plane has no such intent, and a value + * with no equivalent errors loudly rather than being faked. + */ +const INTENT_MAP: Record = { + sso: 'Sso', + dsync: 'Dsync', + log_streams: 'LogStreams', + domain_verification: 'DomainVerification', + certificate_renewal: 'CertificateRenewal', +}; + +const SUPPORTED_INTENTS = Object.keys(INTENT_MAP).join(', '); + +/** Reverse map for echoing intents back in the CLI's grammar. */ +const INTENT_REVERSE_MAP: Record = Object.fromEntries( + Object.entries(INTENT_MAP).map(([cli, op]) => [op, cli]), +); + +interface PortalSetupLinkNode { + id?: string | null; + url?: string | null; + intents?: string[] | null; + state?: string | null; + expiresAt?: string | null; +} -const handleApiError = createApiErrorHandler('Portal'); +/** + * The curated portal-setup-link shape — the `--json` contract. camelCase, + * stable keys; intents echoed in the CLI's own grammar. See portal.spec.ts for + * the authoritative example. + */ +function shapePortalSetupLink(link: PortalSetupLinkNode) { + return { + id: link.id ?? null, + link: link.url ?? null, + intents: (link.intents ?? []).map((intent) => INTENT_REVERSE_MAP[intent] ?? intent), + state: link.state ?? null, + expiresAt: link.expiresAt ?? null, + }; +} export interface PortalGenerateOptions { intent: string; organization: string; - returnUrl?: string; - successUrl?: string; + /** `--environment-id` override; defaults from the active profile. */ + environmentId?: string; } -export async function runPortalGenerateLink( - options: PortalGenerateOptions, - apiKey: string, - baseUrl?: string, -): Promise { - const client = createWorkOSClient(apiKey, baseUrl); +export async function runPortalGenerateLink(options: PortalGenerateOptions): Promise { + const mappedIntent = INTENT_MAP[options.intent]; + if (!mappedIntent) { + exitWithError({ + code: 'invalid_argument', + message: + options.intent === 'audit_logs' + ? `Intent "audit_logs" is not available for setup links. Supported intents: ${SUPPORTED_INTENTS}.` + : `Unknown intent "${options.intent}". Supported intents: ${SUPPORTED_INTENTS}.`, + }); + } + + const token = await requireCommandToken(); + const op = getOperation('generatePortalSetupLink'); + // Environment-scoped mutation: pre-validated resolved target as header (the + // organization lives in the environment). + const { environmentId } = await resolveEnvironmentTarget(token, { + flagValue: options.environmentId, + forMutation: op.kind === 'mutation', + }); + + let data: { + generatePortalSetupLink: + | { __typename: 'PortalSetupLinkGenerated'; portalSetupLink: PortalSetupLinkNode } + | { __typename: 'OrganizationNotFound'; organizationId: string } + | { __typename: string }; + }; try { - const result = await client.sdk.portal.generateLink({ - intent: options.intent as Parameters[0]['intent'], - organization: options.organization, - ...(options.returnUrl && { returnUrl: options.returnUrl }), - ...(options.successUrl && { successUrl: options.successUrl }), + // expireIntents is passed explicitly: omitted, the server expires ALL of + // the organization's active setup links; scoped to the requested intent it + // expires only prior links of the same intent (what the help text says). + data = await dashboardGraphqlRequest(resolveExecutableDocument(op), { + token, + variables: { + input: { + organizationId: options.organization, + intents: [mappedIntent], + expireIntents: [mappedIntent], + }, + }, + environmentId, }); + } catch (error) { + reportDashboardError(error); + } - if (isJsonMode()) { - outputJson(result); - return; - } + const result = data.generatePortalSetupLink; + if (result.__typename === 'OrganizationNotFound') { + exitWithError({ + code: 'not_found', + message: `Organization "${options.organization}" was not found in this environment.`, + }); + } + if (result.__typename !== 'PortalSetupLinkGenerated' || !('portalSetupLink' in result)) { + exitWithError({ code: 'unexpected_result', message: 'Could not generate an Admin Portal setup link.' }); + } - console.log(result.link); - console.log(chalk.dim('Note: Portal links expire after 5 minutes.')); - } catch (error) { - handleApiError(error); + const link = shapePortalSetupLink((result as { portalSetupLink: PortalSetupLinkNode }).portalSetupLink); + if (isJsonMode()) { + outputJson({ portalSetupLink: link }); + return; + } + + console.log(link.link ?? ''); + if (link.expiresAt) { + console.log(chalk.dim(`Note: This link expires at ${link.expiresAt}. Prior ${options.intent} links are expired.`)); + } else { + console.log(chalk.dim(`Prior ${options.intent} links are expired.`)); } } diff --git a/src/commands/webhook.spec.ts b/src/commands/webhook.spec.ts index 3bbd583c..046ad6cf 100644 --- a/src/commands/webhook.spec.ts +++ b/src/commands/webhook.spec.ts @@ -1,164 +1,333 @@ import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'; -const mockClient = { - sdk: {}, - webhooks: { - list: vi.fn(), - create: vi.fn(), - delete: vi.fn(), - }, -}; +const mockRequireCommandToken = vi.fn(); +const mockGraphqlRequest = vi.fn(); +const mockConfirm = vi.fn(); +const mockIsCancel = vi.fn(() => false); +const mockGetActiveEnvironment = vi.fn(); +const mockGetConfig = vi.fn(); + +vi.mock('../lib/command-auth.js', async (importActual) => { + const actual = await importActual(); + return { + ...actual, + requireCommandToken: () => mockRequireCommandToken(), + }; +}); + +vi.mock('../lib/dashboard-graphql.js', async (importActual) => { + const actual = await importActual(); + return { + ...actual, + dashboardGraphqlRequest: (...args: unknown[]) => mockGraphqlRequest(...args), + }; +}); -vi.mock('../lib/workos-client.js', () => ({ - createWorkOSClient: () => mockClient, +vi.mock('../utils/clack.js', () => ({ + default: { + confirm: (...args: unknown[]) => mockConfirm(...args), + isCancel: (...args: unknown[]) => mockIsCancel(...args), + select: vi.fn(), + }, })); -const { setOutputMode } = await import('../utils/output.js'); +// The environment-target resolver runs REAL against the mocked wire + config +// store, so mutation tests can assert the pre-validation fetch +// (teamProjectsV2) happens before the operation request. +vi.mock('../lib/config-store.js', async (importActual) => { + const actual = await importActual(); + return { + ...actual, + getActiveEnvironment: () => mockGetActiveEnvironment(), + getConfig: () => mockGetConfig(), + setProfileEnvironmentId: vi.fn(), + }; +}); +const { setOutputMode } = await import('../utils/output.js'); +const { resetInteractionModeForTests, setInteractionMode } = await import('../utils/interaction-mode.js'); +const { DashboardGraphqlError } = await import('../lib/dashboard-graphql.js'); +const { CliExit } = await import('../utils/cli-exit.js'); const { runWebhookList, runWebhookCreate, runWebhookDelete } = await import('./webhook.js'); -const mockWebhook = { +const TEAM_ENVIRONMENTS_PAYLOAD = { + currentTeam: { + projectsV2: [{ environments: [{ id: 'env_profile', name: 'Sandbox', sandbox: true, clientId: null }] }], + }, +}; + +/** Route the wire mock by document (session.spec.ts recipe). */ +function respondWith(payload: unknown): void { + mockGraphqlRequest.mockImplementation(async (doc: unknown) => { + if (String(doc).includes('teamProjectsV2')) return TEAM_ENVIRONMENTS_PAYLOAD; + return payload; + }); +} + +async function expectExit(promise: Promise, code: number): Promise { + try { + await promise; + } catch (err) { + if (err instanceof CliExit) { + expect(err.exitCode).toBe(code); + return err; + } + throw err; + } + throw new Error(`Expected CliExit(${code}) but promise resolved`); +} + +/** The authoritative curated webhook-endpoint JSON shape (documented contract). */ +const WEBHOOK_ENDPOINT_SHAPE_KEYS = ['id', 'url', 'events', 'state', 'createdAt']; + +const ENDPOINT_NODE = { id: 'we_123', - endpoint_url: 'https://example.com/hook', + endpointUrl: 'https://example.com/hook', events: ['dsync.user.created'], - created_at: '2024-01-01T00:00:00Z', - updated_at: '2024-01-01T00:00:00Z', + state: 'Active', + createdAt: '2024-01-01T00:00:00Z', }; -describe('webhook commands', () => { +describe('webhook command', () => { let consoleOutput: string[]; beforeEach(() => { vi.clearAllMocks(); + resetInteractionModeForTests(); + setOutputMode('human'); + mockRequireCommandToken.mockResolvedValue('tok_123'); + mockConfirm.mockReset(); + mockIsCancel.mockReset(); + mockIsCancel.mockReturnValue(false); + mockGetActiveEnvironment.mockReturnValue({ apiKey: 'sk_ignored', environmentId: 'env_profile' }); + mockGetConfig.mockReturnValue({ + activeEnvironment: 'default', + environments: { default: { apiKey: 'sk_ignored', environmentId: 'env_profile' } }, + }); consoleOutput = []; vi.spyOn(console, 'log').mockImplementation((...args: unknown[]) => { consoleOutput.push(args.map(String).join(' ')); }); + vi.spyOn(console, 'error').mockImplementation(() => {}); }); afterEach(() => { vi.restoreAllMocks(); + resetInteractionModeForTests(); + setOutputMode('human'); }); - describe('runWebhookList', () => { - it('lists endpoints in table', async () => { - mockClient.webhooks.list.mockResolvedValue({ - data: [mockWebhook], - list_metadata: { before: null, after: null }, + describe('list', () => { + it('lists endpoints with the environment variable + header (read: no pre-validation fetch)', async () => { + respondWith({ + webhookEndpoints: { data: [ENDPOINT_NODE], listMetadata: { before: null, after: null } }, }); - await runWebhookList('sk_test'); - expect(consoleOutput.some((l) => l.includes('we_123'))).toBe(true); - expect(consoleOutput.some((l) => l.includes('https://example.com/hook'))).toBe(true); + await runWebhookList({}); + expect(mockGraphqlRequest).toHaveBeenCalledTimes(1); + expect(mockGraphqlRequest).toHaveBeenCalledWith(expect.stringContaining('webhookEndpoints'), { + token: 'tok_123', + variables: { environmentId: 'env_profile' }, + environmentId: 'env_profile', + }); + const out = consoleOutput.join('\n'); + expect(out).toContain('we_123'); + expect(out).toContain('https://example.com/hook'); + expect(out).toContain('dsync.user.created'); }); it('handles empty results', async () => { - mockClient.webhooks.list.mockResolvedValue({ - data: [], - list_metadata: { before: null, after: null }, - }); - await runWebhookList('sk_test'); + respondWith({ webhookEndpoints: { data: [], listMetadata: { before: null, after: null } } }); + await runWebhookList({}); expect(consoleOutput.some((l) => l.includes('No webhook endpoints found'))).toBe(true); }); + it('honors an --environment-id override', async () => { + respondWith({ webhookEndpoints: { data: [], listMetadata: { before: null, after: null } } }); + await runWebhookList({ environmentId: 'env_flag' }); + expect(mockGraphqlRequest).toHaveBeenCalledWith(expect.stringContaining('webhookEndpoints'), { + token: 'tok_123', + variables: { environmentId: 'env_flag' }, + environmentId: 'env_flag', + }); + }); + it('truncates long event lists with a "+N more" suffix', async () => { - mockClient.webhooks.list.mockResolvedValue({ - data: [ - { - ...mockWebhook, - events: [ - 'user.created', - 'user.updated', - 'user.deleted', - 'session.created', - 'session.revoked', - 'organization.created', - 'organization.updated', - ], - }, - ], - list_metadata: { before: null, after: null }, + respondWith({ + webhookEndpoints: { + data: [ + { + ...ENDPOINT_NODE, + events: [ + 'user.created', + 'user.updated', + 'user.deleted', + 'session.created', + 'session.revoked', + 'organization.created', + 'organization.updated', + ], + }, + ], + listMetadata: { before: null, after: null }, + }, }); - await runWebhookList('sk_test'); + await runWebhookList({}); expect(consoleOutput.some((l) => /\+\d+ more/.test(l))).toBe(true); }); it('always shows at least one event when a single event name exceeds the budget', async () => { const longEvent = 'a.very.long.namespace.with.many.segments.that.exceeds.sixty.chars.event'; - mockClient.webhooks.list.mockResolvedValue({ - data: [{ ...mockWebhook, events: [longEvent, 'user.created'] }], - list_metadata: { before: null, after: null }, + respondWith({ + webhookEndpoints: { + data: [{ ...ENDPOINT_NODE, events: [longEvent, 'user.created'] }], + listMetadata: { before: null, after: null }, + }, }); - await runWebhookList('sk_test'); + await runWebhookList({}); expect(consoleOutput.some((l) => l.includes(longEvent))).toBe(true); expect(consoleOutput.some((l) => l.includes('(+1 more)'))).toBe(true); }); + + it('--json emits the documented curated shape', async () => { + setOutputMode('json'); + respondWith({ + webhookEndpoints: { data: [ENDPOINT_NODE], listMetadata: { before: null, after: 'cursor_a' } }, + }); + await runWebhookList({}); + const raw = consoleOutput[0]; + expect(raw).not.toMatch(/graphql|userland/i); + const out = JSON.parse(raw); + expect(Object.keys(out)).toEqual(['webhookEndpoints', 'pagination']); + expect(out.pagination).toEqual({ before: null, after: 'cursor_a' }); + expect(Object.keys(out.webhookEndpoints[0])).toEqual(WEBHOOK_ENDPOINT_SHAPE_KEYS); + expect(out.webhookEndpoints[0]).toEqual({ + id: 'we_123', + url: 'https://example.com/hook', + events: ['dsync.user.created'], + state: 'Active', + createdAt: '2024-01-01T00:00:00Z', + }); + }); }); - describe('runWebhookCreate', () => { - it('creates webhook with url and events', async () => { - mockClient.webhooks.create.mockResolvedValue(mockWebhook); - await runWebhookCreate('https://example.com/hook', ['dsync.user.created'], 'sk_test'); - expect(mockClient.webhooks.create).toHaveBeenCalledWith('https://example.com/hook', ['dsync.user.created']); + describe('create', () => { + const created = { createWebhookEndpoint: { id: 'we_123' } }; + + it('creates with url + events, pre-validating the environment first (mutation ordering)', async () => { + respondWith(created); + await runWebhookCreate({ url: 'https://example.com/hook', events: ['dsync.user.created'] }); + // Mutation: the resolver fetches the team's environments BEFORE the op. + expect(String(mockGraphqlRequest.mock.calls[0][0])).toContain('teamProjectsV2'); + expect(String(mockGraphqlRequest.mock.calls[1][0])).toContain('createWebhookEndpoint'); + expect(mockGraphqlRequest.mock.calls[1][1]).toEqual({ + token: 'tok_123', + variables: { + endpointUrl: 'https://example.com/hook', + environmentId: 'env_profile', + events: ['dsync.user.created'], + }, + environmentId: 'env_profile', + }); + expect(consoleOutput.some((l) => l.includes('we_123'))).toBe(true); }); - it('displays secret warning in human mode', async () => { - mockClient.webhooks.create.mockResolvedValue({ ...mockWebhook, secret: 'whsec_abc123' }); - await runWebhookCreate('https://example.com/hook', ['dsync.user.created'], 'sk_test'); - expect(consoleOutput.some((l) => l.includes('Created webhook endpoint'))).toBe(true); - expect(consoleOutput.some((l) => l.includes('whsec_abc123'))).toBe(true); - expect(consoleOutput.some((l) => l.includes('not be shown again'))).toBe(true); + it('says loudly that the signing secret is not returned (REST divergence)', async () => { + respondWith(created); + await runWebhookCreate({ url: 'https://example.com/hook', events: ['dsync.user.created'] }); + const out = consoleOutput.join('\n'); + expect(out).toContain('signing secret'); + expect(out).toContain('Dashboard'); }); - }); - describe('runWebhookDelete', () => { - it('deletes webhook by ID', async () => { - mockClient.webhooks.delete.mockResolvedValue(undefined); - await runWebhookDelete('we_123', 'sk_test'); - expect(mockClient.webhooks.delete).toHaveBeenCalledWith('we_123'); - expect(consoleOutput.some((l) => l.includes('Deleted'))).toBe(true); + it('--json emits { webhookEndpoint } echoing the inputs', async () => { + setOutputMode('json'); + respondWith(created); + await runWebhookCreate({ url: 'https://example.com/hook', events: ['a.b', 'c.d'] }); + const out = JSON.parse(consoleOutput[0]); + expect(out).toEqual({ + webhookEndpoint: { id: 'we_123', url: 'https://example.com/hook', events: ['a.b', 'c.d'] }, + }); }); }); - describe('JSON output mode', () => { - beforeEach(() => setOutputMode('json')); - afterEach(() => setOutputMode('human')); + describe('delete (destructive)', () => { + const deleted = { deleteWebhookEndpoint: 'we_123' }; - it('list normalizes list_metadata to listMetadata', async () => { - mockClient.webhooks.list.mockResolvedValue({ - data: [mockWebhook], - list_metadata: { before: null, after: 'cursor_a' }, - }); - await runWebhookList('sk_test'); - const output = JSON.parse(consoleOutput[0]); - expect(output.listMetadata).toBeDefined(); - expect(output.listMetadata.after).toBe('cursor_a'); - expect(output).not.toHaveProperty('list_metadata'); + it('refuses in agent mode without --yes (exit 1, confirmation_required)', async () => { + setInteractionMode({ mode: 'agent', source: 'agent_env' }); + const err = await expectExit(runWebhookDelete('we_123', { yes: false }), 1); + expect(err.context?.errorCode).toBe('confirmation_required'); + expect(mockGraphqlRequest).not.toHaveBeenCalled(); }); - it('list outputs empty data for no results', async () => { - mockClient.webhooks.list.mockResolvedValue({ - data: [], - list_metadata: { before: null, after: null }, + it('refuses in CI mode without --yes', async () => { + setInteractionMode({ mode: 'ci', source: 'ci_env' }); + const err = await expectExit(runWebhookDelete('we_123', {}), 1); + expect(err.context?.errorCode).toBe('confirmation_required'); + expect(mockGraphqlRequest).not.toHaveBeenCalled(); + }); + + it('proceeds non-interactively with --yes, pre-validating the environment first', async () => { + setInteractionMode({ mode: 'ci', source: 'ci_env' }); + respondWith(deleted); + await runWebhookDelete('we_123', { yes: true }); + expect(String(mockGraphqlRequest.mock.calls[0][0])).toContain('teamProjectsV2'); + expect(String(mockGraphqlRequest.mock.calls[1][0])).toContain('deleteWebhookEndpoint'); + expect(mockGraphqlRequest.mock.calls[1][1]).toEqual({ + token: 'tok_123', + variables: { id: 'we_123' }, + environmentId: 'env_profile', }); - await runWebhookList('sk_test'); - const output = JSON.parse(consoleOutput[0]); - expect(output.data).toEqual([]); }); - it('create includes secret in JSON output', async () => { - mockClient.webhooks.create.mockResolvedValue({ ...mockWebhook, secret: 'whsec_abc123' }); - await runWebhookCreate('https://example.com/hook', ['dsync.user.created'], 'sk_test'); - const output = JSON.parse(consoleOutput[0]); - expect(output.status).toBe('ok'); - expect(output.data.secret).toBe('whsec_abc123'); + it('prompts interactively and proceeds when confirmed', async () => { + setInteractionMode({ mode: 'human', source: 'default' }); + mockConfirm.mockResolvedValue(true); + respondWith(deleted); + await runWebhookDelete('we_123', { yes: false }); + expect(mockConfirm).toHaveBeenCalledOnce(); + expect(mockGraphqlRequest).toHaveBeenCalled(); + }); + + it('cancels (exit 2) when the interactive prompt is declined', async () => { + setInteractionMode({ mode: 'human', source: 'default' }); + mockConfirm.mockResolvedValue(false); + await expectExit(runWebhookDelete('we_123', { yes: false }), 2); + expect(mockGraphqlRequest).not.toHaveBeenCalled(); }); - it('delete outputs JSON success', async () => { - mockClient.webhooks.delete.mockResolvedValue(undefined); - await runWebhookDelete('we_123', 'sk_test'); - const output = JSON.parse(consoleOutput[0]); - expect(output.status).toBe('ok'); - expect(output.data.id).toBe('we_123'); + it('--json emits { deleted }', async () => { + setInteractionMode({ mode: 'agent', source: 'agent_env' }); + setOutputMode('json'); + respondWith(deleted); + await runWebhookDelete('we_123', { yes: true }); + expect(JSON.parse(consoleOutput[0])).toEqual({ deleted: 'we_123' }); + }); + }); + + describe('shared failure modes', () => { + it('exits auth-required (code 4) when not logged in', async () => { + mockRequireCommandToken.mockImplementation(() => { + throw new CliExit(4, { reason: 'auth_required', errorCode: 'auth_required' }); + }); + await expectExit(runWebhookList({}), 4); + expect(mockGraphqlRequest).not.toHaveBeenCalled(); + }); + + it('surfaces the gated-capability case on a 403 without naming GraphQL', async () => { + const consoleErrors: string[] = []; + vi.spyOn(console, 'error').mockImplementation((...args: unknown[]) => { + consoleErrors.push(args.map(String).join(' ')); + }); + mockGraphqlRequest.mockRejectedValue( + new DashboardGraphqlError('The dashboard GraphQL API rejected this session (HTTP 403).', 'forbidden', 403), + ); + // Use the read path: mutation paths hit the environment pre-validation + // fetch first, whose failure the resolver reports in its own copy. + await expectExit(runWebhookList({}), 1); + const err = consoleErrors.join('\n'); + expect(err).toMatch(/account-plane capability/i); + expect(err).not.toMatch(/graphql/i); }); }); }); diff --git a/src/commands/webhook.ts b/src/commands/webhook.ts index 501dc46a..4ed5579f 100644 --- a/src/commands/webhook.ts +++ b/src/commands/webhook.ts @@ -1,99 +1,231 @@ +/** + * `workos webhook` — webhook-endpoint lifecycle on the dashboard account plane. + * + * Migrated from the API-key REST plane (graphql-resource-migration Phase 7): + * the subcommand surface (list/create/delete) is unchanged, but every operation + * now runs catalog-backed dashboard operations with the user's OAuth bearer. + * Output shapes are new curated shapes (approved breaking change); the + * authoritative examples live in `webhook.spec.ts`. + * + * Backend divergences from REST (all loud, none faked): + * - `create` no longer returns the signing secret: the backing operation + * returns only the new endpoint's ID (REST returned the secret once, at + * create time). Human output says where to find it; help text says so too. + * + * Safety posture per the manifest: `delete` is destructive → + * `confirmDestructive` (prompt, or --yes). The consequence copy is hand-written + * (the operation carries no catalog confirmation phrase). + */ + import chalk from 'chalk'; -import { createWorkOSClient } from '../lib/workos-client.js'; +import { requireCommandToken } from '../lib/command-auth.js'; +import { dashboardGraphqlRequest } from '../lib/dashboard-graphql.js'; +import { resolveEnvironmentTarget } from '../lib/environment-target.js'; +import { getOperation, resolveExecutableDocument, reportDashboardError } from '../catalog/operation.js'; +import { confirmDestructive } from '../catalog/confirm.js'; +import { isJsonMode, outputJson, outputSuccess } from '../utils/output.js'; import { formatTable } from '../utils/table.js'; -import { outputJson, outputSuccess, isJsonMode } from '../utils/output.js'; -import { createApiErrorHandler } from '../lib/api-error-handler.js'; -const handleApiError = createApiErrorHandler('Webhook'); +interface WebhookEndpointNode { + id?: string | null; + endpointUrl?: string | null; + events?: string[] | null; + state?: string | null; + createdAt?: string | null; +} + +/** + * The curated webhook-endpoint shape — the `--json` contract. camelCase, + * stable keys, no internal fields. See webhook.spec.ts for the authoritative + * example. + */ +function shapeWebhookEndpoint(endpoint: WebhookEndpointNode) { + return { + id: endpoint.id ?? null, + url: endpoint.endpointUrl ?? null, + events: endpoint.events ?? [], + state: endpoint.state ?? null, + createdAt: endpoint.createdAt ?? null, + }; +} + +type ShapedWebhookEndpoint = ReturnType; + +/** + * Truncate an endpoint's event list to a table-cell budget, always keeping at + * least the first event so the cell isn't content-free. + */ +function formatEventsCell(events: string[]): string { + const maxEventsChars = 60; + if (events.length === 0) return chalk.dim('—'); + const joined = events.join(', '); + if (joined.length <= maxEventsChars) return joined; + const visible: string[] = [events[0]]; + let len = events[0].length; + for (let i = 1; i < events.length; i++) { + const next = len + 2 + events[i].length; + if (next > maxEventsChars) break; + visible.push(events[i]); + len = next; + } + const hidden = events.length - visible.length; + const suffix = hidden > 0 ? `, … (+${hidden} more)` : ''; + return `${visible.join(', ')}${suffix}`; +} + +export interface WebhookListOptions { + /** `--environment-id` override; defaults from the active profile. */ + environmentId?: string; +} + +export async function runWebhookList(options: WebhookListOptions = {}): Promise { + const token = await requireCommandToken(); + const op = getOperation('webhookEndpoints'); -export async function runWebhookList(apiKey: string, baseUrl?: string): Promise { - const client = createWorkOSClient(apiKey, baseUrl); + // Environment-scoped read: resolved target as variable + header. + const { environmentId } = await resolveEnvironmentTarget(token, { + flagValue: options.environmentId, + forMutation: op.kind === 'mutation', + }); + let data: { + webhookEndpoints: { + data: WebhookEndpointNode[]; + listMetadata?: { before?: string | null; after?: string | null } | null; + } | null; + }; try { - const result = await client.webhooks.list(); - - if (isJsonMode()) { - // Normalize snake_case list_metadata to camelCase for consistent CLI output - outputJson({ - data: result.data, - listMetadata: { - before: result.list_metadata.before, - after: result.list_metadata.after, - }, - }); - return; - } - - if (result.data.length === 0) { - console.log('No webhook endpoints found.'); - return; - } - - const maxEventsChars = 60; - const rows = result.data.map((ep) => { - const joined = ep.events.join(', '); - if (joined.length <= maxEventsChars) { - return [ep.id, ep.endpoint_url, joined, ep.created_at]; - } - // Always include the first event so the cell isn't content-free when a single event name exceeds the budget. - const visible: string[] = [ep.events[0]]; - let len = ep.events[0].length; - for (let i = 1; i < ep.events.length; i++) { - const next = len + 2 + ep.events[i].length; - if (next > maxEventsChars) break; - visible.push(ep.events[i]); - len = next; - } - const hidden = ep.events.length - visible.length; - const suffix = hidden > 0 ? `, … (+${hidden} more)` : ''; - return [ep.id, ep.endpoint_url, `${visible.join(', ')}${suffix}`, ep.created_at]; + data = await dashboardGraphqlRequest(resolveExecutableDocument(op), { + token, + variables: { environmentId }, + environmentId, }); + } catch (error) { + reportDashboardError(error); + } - console.log(formatTable([{ header: 'ID' }, { header: 'URL' }, { header: 'Events' }, { header: 'Created' }], rows)); + const endpoints = (data.webhookEndpoints?.data ?? []).map(shapeWebhookEndpoint); + const pagination = { + before: data.webhookEndpoints?.listMetadata?.before ?? null, + after: data.webhookEndpoints?.listMetadata?.after ?? null, + }; - const { before, after } = result.list_metadata; - if (before && after) { - console.log(chalk.dim(`Before: ${before} After: ${after}`)); - } else if (before) { - console.log(chalk.dim(`Before: ${before}`)); - } else if (after) { - console.log(chalk.dim(`After: ${after}`)); - } - } catch (error) { - handleApiError(error); + if (isJsonMode()) { + outputJson({ webhookEndpoints: endpoints, pagination }); + return; + } + + if (endpoints.length === 0) { + console.log('No webhook endpoints found.'); + return; } + + const rows = endpoints.map((endpoint: ShapedWebhookEndpoint) => [ + endpoint.id ?? '', + endpoint.url ?? '', + formatEventsCell(endpoint.events), + endpoint.state ?? '', + endpoint.createdAt ?? '', + ]); + console.log( + formatTable( + [{ header: 'ID' }, { header: 'URL' }, { header: 'Events' }, { header: 'State' }, { header: 'Created' }], + rows, + ), + ); + + const { before, after } = pagination; + if (before && after) { + console.log(chalk.dim(`Before: ${before} After: ${after}`)); + } else if (before) { + console.log(chalk.dim(`Before: ${before}`)); + } else if (after) { + console.log(chalk.dim(`After: ${after}`)); + } +} + +export interface WebhookCreateOptions { + /** `--environment-id` override; defaults from the active profile. */ + environmentId?: string; + url: string; + events: string[]; } -export async function runWebhookCreate(url: string, events: string[], apiKey: string, baseUrl?: string): Promise { - const client = createWorkOSClient(apiKey, baseUrl); +export async function runWebhookCreate(options: WebhookCreateOptions): Promise { + const token = await requireCommandToken(); + const op = getOperation('createWebhookEndpoint'); + + // Environment-scoped mutation: pre-validated resolved target as variable + + // header. + const { environmentId } = await resolveEnvironmentTarget(token, { + flagValue: options.environmentId, + forMutation: op.kind === 'mutation', + }); + // No result union: validation failures (e.g. a non-HTTPS endpoint URL) + // surface as wire-level errors via reportDashboardError. + let data: { createWebhookEndpoint: { id: string } | null }; try { - const endpoint = await client.webhooks.create(url, events); - - if (isJsonMode()) { - outputJson({ status: 'ok', message: 'Created webhook endpoint', data: endpoint }); - return; - } - - console.log(chalk.green('Created webhook endpoint')); - console.log(JSON.stringify(endpoint, null, 2)); - if (endpoint.secret) { - console.log(''); - console.log(chalk.yellow('Signing secret: ') + endpoint.secret); - console.log(chalk.yellow('Save this secret now — it will not be shown again.')); - } + data = await dashboardGraphqlRequest(resolveExecutableDocument(op), { + token, + variables: { endpointUrl: options.url, environmentId, events: options.events }, + environmentId, + }); } catch (error) { - handleApiError(error); + reportDashboardError(error); + } + + // The backing operation returns only the new endpoint's ID; echo the inputs + // so the output is self-describing. + const created = { id: data.createWebhookEndpoint?.id ?? null, url: options.url, events: options.events }; + if (isJsonMode()) { + outputJson({ webhookEndpoint: created }); + return; } + outputSuccess(`Created webhook endpoint ${chalk.bold(created.id ?? options.url)}`); + // Divergence from REST (loud): the signing secret is no longer returned at + // create time. + console.log(chalk.dim('The signing secret is not shown here — view it in the WorkOS Dashboard under Webhooks.')); +} + +export interface WebhookDeleteOptions { + /** `--environment-id` override; defaults from the active profile. */ + environmentId?: string; + yes?: boolean; + json?: boolean; } -export async function runWebhookDelete(id: string, apiKey: string, baseUrl?: string): Promise { - const client = createWorkOSClient(apiKey, baseUrl); +export async function runWebhookDelete(id: string, options: WebhookDeleteOptions = {}): Promise { + // Destructive per the manifest: the operation carries no catalog confirmation + // phrase, so the consequence copy is hand-written. + await confirmDestructive(options, { + action: `delete webhook endpoint ${id} — it stops receiving events immediately`, + }); + + const token = await requireCommandToken(); + const op = getOperation('deleteWebhookEndpoint'); + // Environment-scoped mutation: pre-validated resolved target as header. + const { environmentId } = await resolveEnvironmentTarget(token, { + flagValue: options.environmentId, + forMutation: op.kind === 'mutation', + }); + + // No result union: a bad ID surfaces as a wire-level error via + // reportDashboardError. try { - await client.webhooks.delete(id); - outputSuccess('Deleted webhook endpoint', { id }); + await dashboardGraphqlRequest(resolveExecutableDocument(op), { + token, + variables: { id }, + environmentId, + }); } catch (error) { - handleApiError(error); + reportDashboardError(error); + } + + if (isJsonMode()) { + outputJson({ deleted: id }); + return; } + outputSuccess(`Deleted webhook endpoint ${chalk.bold(id)}`); } diff --git a/src/utils/help-json.ts b/src/utils/help-json.ts index 1eb3e936..2d6eb4df 100644 --- a/src/utils/help-json.ts +++ b/src/utils/help-json.ts @@ -1434,28 +1434,30 @@ const commands: CommandSchema[] = [ { name: 'webhook', description: 'Manage webhooks', - options: [insecureStorageOpt, apiKeyOpt], + options: [insecureStorageOpt], commands: [ - { name: 'list', description: 'List webhooks' }, + { name: 'list', description: 'List webhook endpoints', options: [environmentIdOpt] }, { name: 'create', - description: 'Create a webhook', + description: 'Create a webhook endpoint (the signing secret is only visible in the WorkOS Dashboard)', options: [ - { name: 'url', type: 'string', description: 'Webhook endpoint URL', required: true, hidden: false }, + { name: 'url', type: 'string', description: 'Webhook endpoint URL (HTTPS)', required: true, hidden: false }, { name: 'events', type: 'string', description: 'Comma-separated event types', required: true, hidden: false }, + environmentIdOpt, ], }, { name: 'delete', - description: 'Delete a webhook', - positionals: [{ name: 'id', type: 'string', description: 'Webhook ID', required: true }], + description: 'Delete a webhook endpoint', + positionals: [{ name: 'id', type: 'string', description: 'Webhook endpoint ID', required: true }], + options: [confirmYesOpt, environmentIdOpt], }, ], }, { name: 'config', - description: 'Manage WorkOS configuration (redirect URIs, CORS, homepage)', - options: [insecureStorageOpt, apiKeyOpt], + description: 'Manage AuthKit app configuration (redirect URIs, CORS, homepage)', + options: [insecureStorageOpt], commands: [ { name: 'redirect', @@ -1463,8 +1465,10 @@ const commands: CommandSchema[] = [ commands: [ { name: 'add', - description: 'Add a redirect URI', + description: + 'Add a redirect URI (merges over the current list; a concurrent edit elsewhere may be overwritten)', positionals: [{ name: 'uri', type: 'string', description: 'Redirect URI', required: true }], + options: [environmentIdOpt], }, ], }, @@ -1474,8 +1478,10 @@ const commands: CommandSchema[] = [ commands: [ { name: 'add', - description: 'Add a CORS origin', + description: + 'Add a CORS origin (merges over the current list; a concurrent edit elsewhere may be overwritten)', positionals: [{ name: 'origin', type: 'string', description: 'CORS origin', required: true }], + options: [environmentIdOpt], }, ], }, @@ -1485,8 +1491,9 @@ const commands: CommandSchema[] = [ commands: [ { name: 'set', - description: 'Set the homepage URL', + description: "Set the app homepage URL on the environment's AuthKit application", positionals: [{ name: 'url', type: 'string', description: 'Homepage URL', required: true }], + options: [environmentIdOpt], }, ], }, @@ -1495,28 +1502,22 @@ const commands: CommandSchema[] = [ { name: 'portal', description: 'Manage Admin Portal', - options: [insecureStorageOpt, apiKeyOpt], + options: [insecureStorageOpt], commands: [ { name: 'generate-link', - description: 'Generate an Admin Portal link', + description: + 'Generate an Admin Portal setup link (expires prior links of the same intent; --return-url/--success-url and the audit_logs intent are not supported on this plane)', options: [ { name: 'intent', type: 'string', - description: 'Portal intent (sso, dsync, audit_logs, log_streams)', + description: 'Portal intent (sso, dsync, log_streams, domain_verification, certificate_renewal)', required: true, hidden: false, }, { name: 'org', type: 'string', description: 'Organization ID', required: true, hidden: false }, - { - name: 'return-url', - type: 'string', - description: 'Return URL after portal', - required: false, - hidden: false, - }, - { name: 'success-url', type: 'string', description: 'Success URL', required: false, hidden: false }, + environmentIdOpt, ], }, ], From 4b2f7a51e9bc56328675962b2479011937c75754 Mon Sep 17 00:00:00 2001 From: Nick Nisi Date: Wed, 22 Jul 2026 20:53:37 -0500 Subject: [PATCH 16/22] chore: remove dead REST-plane code and align docs with dashboard auth MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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). --- CLAUDE.md | 2 +- README.md | 82 ++++++++++++++++++++--------------- src/lib/workos-client.spec.ts | 54 +---------------------- src/lib/workos-client.ts | 52 +++------------------- 4 files changed, 55 insertions(+), 135 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index 92193875..9cb20404 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -12,7 +12,7 @@ WorkOS CLI for installing AuthKit integrations and managing WorkOS resources (or ## Non-TTY Behavior - **Output**: Auto-switches to JSON when piped or `--json` flag. `WORKOS_FORCE_TTY=1` overrides. -- **Auth**: Exits code 4 instead of opening browser. Requires prior `workos auth login` or `WORKOS_API_KEY` env var. +- **Auth**: Exits code 4 instead of opening browser. Resource commands (organization, user, role, permission, membership, invitation, session, event, feature-flag, org-domain, portal, webhook, config) use the dashboard session from a prior `workos auth login`; expired access tokens refresh silently while the stored refresh token is valid, so only a truly dead session exits 4. `WORKOS_API_KEY` applies only to `workos api` and the still-REST commands (`connection`, `directory`, `audit-log`, `api-key`, `vault`, plus the workflow/debug commands `seed`, `setup-org`, `onboard-user`, `verify-login`, `debug-sso`, `debug-sync`, `migrations`). - **Errors**: Structured JSON to stderr: `{ "error": { "code": "...", "message": "..." } }` - **Exit codes**: 0=success, 1=error, 2=cancelled, 4=auth required (follows `gh` CLI convention) - **Headless flags**: `--no-branch`, `--no-commit`, `--create-pr`, `--no-git-check`. CI mode (`WORKOS_MODE=ci`) auto-continues past a dirty tree without `--no-git-check`; agent mode requires the flag. diff --git a/README.md b/README.md index 76f2678a..77e9017e 100644 --- a/README.md +++ b/README.md @@ -96,7 +96,7 @@ Workflows: debug-sync Diagnose directory sync issues ``` -All management commands support `--json` for structured output (auto-enabled in non-TTY) and `--api-key` to override the active environment's key. +All management commands support `--json` for structured output (auto-enabled in non-TTY). Still-REST commands (`connection`, `directory`, `audit-log`, `api-key`, `vault`, `workos api`, and the workflow/debug commands) also accept `--api-key` to override the active environment's key; the other resource commands authenticate with your `workos auth login` session (see [Resource Management](#resource-management)). ### Unclaimed Environments @@ -255,7 +255,11 @@ API keys are stored in the system keychain via `@napi-rs/keyring`, with a JSON f ### Resource Management -All resource commands follow the same pattern: `workos [args] [--options]`. API keys resolve via: `--api-key` flag → `WORKOS_API_KEY` env var → active environment's stored key. +All resource commands follow the same pattern: `workos [args] [--options]`. + +Most resource commands — organization, user, role, permission, membership, invitation, session, event, feature-flag, org-domain, portal, webhook, config — authenticate with your WorkOS dashboard session from `workos auth login` and target the active environment (see `workos env`). Pass `--environment-id ` on any of them to target a different environment for a single invocation. Access tokens refresh automatically while the session is valid, so a logged-in machine keeps working headlessly; a dead session exits with code 4. + +The remaining commands (`connection`, `directory`, `audit-log`, `api-key`, `vault`) still use the REST plane, as does the raw escape hatch `workos api`. Those resolve an API key via: `--api-key` flag → `WORKOS_API_KEY` env var → active environment's stored key. #### organization @@ -264,68 +268,72 @@ workos organization create [domain:state ...] workos organization update [domain] [state] workos organization get workos organization list [--domain] [--limit] [--before] [--after] [--order] -workos organization delete +workos organization delete [--yes] ``` #### user ```bash workos user get -workos user list [--email] [--organization] [--limit] -workos user update [--first-name] [--last-name] [--email-verified] [--password] [--external-id] -workos user delete +workos user list [--email] [--limit] [--before] [--after] [--order] +workos user update [--first-name] [--last-name] [--email] [--locale] [--external-id] +workos user delete [--yes] ``` #### role +Role mutations change the privilege surface; non-interactive callers must pass `--yes`. + ```bash workos role list [--org ] workos role get [--org ] -workos role create --slug --name [--org ] -workos role update [--name] [--description] [--org ] -workos role delete --org -workos role set-permissions --permissions [--org ] -workos role add-permission [--org ] -workos role remove-permission --org +workos role create --slug --name [--description] [--org ] [--yes] +workos role update [--name] [--description] [--org ] [--yes] +workos role delete --org [--yes] +workos role set-permissions --permissions [--org ] [--yes] +workos role add-permission [--org ] [--yes] +workos role remove-permission --org [--yes] ``` #### permission ```bash -workos permission list [--limit] +workos permission list workos permission get -workos permission create --slug --name [--description] -workos permission update [--name] [--description] -workos permission delete +workos permission create --slug --name [--description] [--yes] +workos permission update [--name] [--description] [--yes] +workos permission delete [--yes] ``` #### membership +`--org` and `--user` are mutually exclusive on `list`; pagination flags apply to `--org` listings only. + ```bash -workos membership list [--org] [--user] [--limit] +workos membership list (--org | --user ) [--limit] [--before] [--after] [--order] workos membership get workos membership create --org --user [--role] -workos membership update [--role] -workos membership delete -workos membership deactivate +workos membership update [--role] [--yes] +workos membership delete [--yes] +workos membership deactivate [--yes] workos membership reactivate ``` #### invitation ```bash -workos invitation list [--org] [--email] [--limit] +workos invitation list [--org] [--email] [--limit] [--before] [--after] workos invitation get workos invitation send --email [--org] [--role] [--expires-in-days] -workos invitation revoke +workos invitation revoke [--yes] workos invitation resend ``` #### session ```bash -workos session list [--limit] -workos session revoke +workos session list [--limit] [--before] [--after] +workos session revoke [--yes] ``` #### connection @@ -349,7 +357,7 @@ workos directory list-groups --directory [--limit] #### event ```bash -workos event list --events [--org] [--range-start] [--range-end] [--limit] +workos event list --events [--range-start] [--range-end] [--after] [--limit] ``` #### audit-log @@ -366,7 +374,7 @@ workos audit-log get-retention #### feature-flag ```bash -workos feature-flag list [--limit] +workos feature-flag list [--limit] [--before] [--after] [--order] workos feature-flag get workos feature-flag enable workos feature-flag disable @@ -379,7 +387,7 @@ workos feature-flag remove-target ```bash workos webhook list workos webhook create --url --events -workos webhook delete +workos webhook delete [--yes] ``` #### config @@ -393,9 +401,11 @@ workos config homepage-url set #### portal ```bash -workos portal generate-link --intent --org [--return-url] [--success-url] +workos portal generate-link --intent --org ``` +Supported intents: `sso`, `dsync`, `log_streams`, `domain_verification`, `certificate_renewal`. Generating a link expires prior links of the same intent. + #### vault ```bash @@ -425,7 +435,7 @@ workos api-key delete workos org-domain get workos org-domain create --org workos org-domain verify -workos org-domain delete +workos org-domain delete [--yes] ``` ### Installer Options @@ -464,11 +474,11 @@ mkdir my-app && cd my-app && npx workos@latest install # With visual dashboard (experimental) npx workos@latest dashboard -# JSON output (explicit) -workos org list --json --api-key sk_test_xxx +# JSON output (explicit; requires a prior `workos auth login`) +workos org list --json # Pipe-friendly (auto-detects non-TTY) -workos org list --api-key sk_test_xxx | jq '.data[].name' +workos org list | jq '.data[].name' # Machine-readable command discovery workos --help --json | jq '.commands[].name' @@ -494,7 +504,7 @@ The CLI also auto-detects non-TTY environments (piped output, CI, coding agents) All commands produce structured JSON when piped or with `--json`: ```bash -workos org list --api-key sk_test_xxx | jq . +workos org list | jq . # → { "data": [...], "list_metadata": { "before": null, "after": "..." } } workos env list --json @@ -504,8 +514,8 @@ workos env list --json Errors go to stderr as structured JSON: ```bash -workos org list 2>&1 -# → { "error": { "code": "no_api_key", "message": "No API key configured..." } } +workos org list 2>&1 # not logged in → exit code 4 +# → { "error": { "code": "auth_required", "message": "Not logged in..." } } ``` ### Agent Mode @@ -557,7 +567,7 @@ workos install --api-key sk_test_xxx --client-id client_xxx --no-commit 2>/dev/n | Variable | Effect | | ------------------------ | ----------------------------------------------------------------- | -| `WORKOS_API_KEY` | API key for management commands (bypasses stored config) | +| `WORKOS_API_KEY` | API key for `workos api`, the still-REST commands (connection, directory, audit-log, api-key, vault), and workflow/debug commands. Resource commands use the `workos auth login` session instead | | `WORKOS_API_URL` | Override the API base URL for all CLI commands (e.g. a local API) | | `WORKOS_API_BASE_URL` | Accepted alias for `WORKOS_API_URL` (what `workos dev` sets) | | `WORKOS_MODE` | Interaction mode: `human`, `agent`, or `ci` | diff --git a/src/lib/workos-client.spec.ts b/src/lib/workos-client.spec.ts index 390ca7cc..610a30ea 100644 --- a/src/lib/workos-client.spec.ts +++ b/src/lib/workos-client.spec.ts @@ -46,67 +46,15 @@ describe('workos-client', () => { expect(client.sdk.baseURL).toBe('https://api.workos.com'); }); - it('exposes sdk, webhooks, redirectUris, corsOrigins, homepageUrl', () => { + it('exposes sdk, redirectUris, corsOrigins, homepageUrl', () => { const client = createWorkOSClient('sk_test_123'); expect(client.sdk).toBeDefined(); - expect(client.webhooks).toBeDefined(); expect(client.redirectUris).toBeDefined(); expect(client.corsOrigins).toBeDefined(); expect(client.homepageUrl).toBeDefined(); }); }); - describe('webhooks', () => { - it('list calls correct path', async () => { - const mockData = { data: [], list_metadata: { before: null, after: null } }; - mockRequest.mockResolvedValue(mockData); - - const client = createWorkOSClient('sk_test_123', 'https://api.workos.com'); - const result = await client.webhooks.list(); - - expect(mockRequest).toHaveBeenCalledWith( - expect.objectContaining({ - method: 'GET', - path: '/webhook_endpoints', - apiKey: 'sk_test_123', - baseUrl: 'https://api.workos.com', - }), - ); - expect(result).toBe(mockData); - }); - - it('create calls correct path with body', async () => { - const mockEndpoint = { id: 'we_123', url: 'https://example.com/hook', events: ['user.created'] }; - mockRequest.mockResolvedValue(mockEndpoint); - - const client = createWorkOSClient('sk_test_123', 'https://api.workos.com'); - const result = await client.webhooks.create('https://example.com/hook', ['user.created']); - - expect(mockRequest).toHaveBeenCalledWith( - expect.objectContaining({ - method: 'POST', - path: '/webhook_endpoints', - body: { endpoint_url: 'https://example.com/hook', events: ['user.created'] }, - }), - ); - expect(result).toBe(mockEndpoint); - }); - - it('delete calls correct path', async () => { - mockRequest.mockResolvedValue(null); - - const client = createWorkOSClient('sk_test_123', 'https://api.workos.com'); - await client.webhooks.delete('we_123'); - - expect(mockRequest).toHaveBeenCalledWith( - expect.objectContaining({ - method: 'DELETE', - path: '/webhook_endpoints/we_123', - }), - ); - }); - }); - describe('redirectUris', () => { it('add returns success on 201', async () => { mockRequest.mockResolvedValue({ id: 'ru_123' }); diff --git a/src/lib/workos-client.ts b/src/lib/workos-client.ts index 6ba7b222..38e427f6 100644 --- a/src/lib/workos-client.ts +++ b/src/lib/workos-client.ts @@ -1,24 +1,19 @@ /** - * Unified WorkOS client for CLI commands. + * Unified WorkOS client for the still-REST CLI commands. * * Wraps @workos-inc/node SDK for documented endpoints and extends with - * raw-fetch methods for undocumented/write-only endpoints (webhooks, redirect URIs, etc.). - * Commands import one client; they don't care whether a method is SDK-backed or raw fetch. + * raw-fetch methods for undocumented/write-only endpoints (redirect URIs, + * CORS origins, audit-log metadata). Commands import one client; they don't + * care whether a method is SDK-backed or raw fetch. + * + * Migrated resource commands (organization, user, ... — see CLAUDE.md) no + * longer use this client; they run on the dashboard session plane. */ import { WorkOS } from '@workos-inc/node'; import { workosRequest, type WorkOSListResponse } from './workos-api.js'; import { resolveApiKey, resolveApiBaseUrl } from './api-key.js'; -export interface WebhookEndpoint { - id: string; - endpoint_url: string; - events: string[]; - secret?: string; - created_at: string; - updated_at: string; -} - export interface AuditLogAction { action: string; } @@ -29,11 +24,6 @@ export interface AuditLogRetention { export interface WorkOSCLIClient { sdk: WorkOS; - webhooks: { - list(): Promise>; - create(endpointUrl: string, events: string[]): Promise; - delete(id: string): Promise; - }; redirectUris: { add(uri: string): Promise<{ success: boolean; alreadyExists: boolean }>; }; @@ -70,34 +60,6 @@ export function createWorkOSClient(apiKey?: string, baseUrl?: string): WorkOSCLI return { sdk, - webhooks: { - async list() { - return workosRequest>({ - method: 'GET', - path: '/webhook_endpoints', - apiKey: key, - baseUrl: base, - }); - }, - async create(endpointUrl: string, events: string[]) { - return workosRequest({ - method: 'POST', - path: '/webhook_endpoints', - apiKey: key, - baseUrl: base, - body: { endpoint_url: endpointUrl, events }, - }); - }, - async delete(id: string) { - await workosRequest({ - method: 'DELETE', - path: `/webhook_endpoints/${id}`, - apiKey: key, - baseUrl: base, - }); - }, - }, - redirectUris: { async add(uri: string) { try { From ff89236834889085b33212ca86a458975730cfcf Mon Sep 17 00:00:00 2001 From: Nick Nisi Date: Wed, 22 Jul 2026 22:37:01 -0500 Subject: [PATCH 17/22] test: add live smoke script for dashboard-plane resource commands 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. --- scripts/smoke-dashboard-plane.sh | 337 +++++++++++++++++++++++++++++++ 1 file changed, 337 insertions(+) create mode 100755 scripts/smoke-dashboard-plane.sh diff --git a/scripts/smoke-dashboard-plane.sh b/scripts/smoke-dashboard-plane.sh new file mode 100755 index 00000000..1437d2dd --- /dev/null +++ b/scripts/smoke-dashboard-plane.sh @@ -0,0 +1,337 @@ +#!/usr/bin/env bash +# Live smoke test for the dashboard-plane resource commands (organization, user, +# role, permission, membership, invitation, session, event, feature-flag, +# org-domain, portal, webhook, config) against the real WorkOS API. +# +# Prereq: `workos auth login` (device flow) with the environment you want to +# target set as the active env. Then: +# +# ./scripts/smoke-dashboard-plane.sh # read-only (safe) +# ./scripts/smoke-dashboard-plane.sh --mutate # + CRUD round-trips in a disposable org +# ./scripts/smoke-dashboard-plane.sh --config-writes # + config redirect/cors add (snapshot & restore) +# ./scripts/smoke-dashboard-plane.sh --keep # don't clean up created resources +# +# Env overrides: +# WORKOS_BIN command to invoke the CLI (default: node /dist/bin.js) +# SMOKE_EVENT_TYPES comma-separated event types for `event list` (default: user.created) +# +# Exit code: 0 if every executed test passed, 1 otherwise. +set -u + +REPO="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +MUTATE=0 CONFIG_WRITES=0 KEEP=0 +for a in "$@"; do + case "$a" in + --mutate) MUTATE=1 ;; + --config-writes) CONFIG_WRITES=1 ;; + --keep) KEEP=1 ;; + -h|--help) sed -n '2,18p' "${BASH_SOURCE[0]}"; exit 0 ;; + *) echo "unknown flag: $a (see --help)"; exit 2 ;; + esac +done + +if [ -n "${WORKOS_BIN:-}" ]; then + # shellcheck disable=SC2206 + BIN=($WORKOS_BIN) +else + [ -f "$REPO/dist/bin.js" ] || { echo "dist/bin.js not found — run: pnpm build"; exit 1; } + BIN=(node "$REPO/dist/bin.js") +fi + +# The migrated commands must run on the OAuth token alone; surface any REST +# leftovers by removing the API-key plane from the environment entirely. +if [ -n "${WORKOS_API_KEY:-}" ]; then + echo "note: WORKOS_API_KEY is set — unsetting it for this run (migrated commands must not need it)" + unset WORKOS_API_KEY +fi +unset WORKOS_FORCE_TTY 2>/dev/null || true + +WORK="$(mktemp -d "${TMPDIR:-/tmp}/workos-smoke.XXXXXX")" +c_g=$'\033[32m' c_r=$'\033[31m' c_y=$'\033[33m' c_d=$'\033[2m' c_0=$'\033[0m' +N=0 NPASS=0 NFAIL=0 NSKIP=0 +FAILURES=() +LAST="" + +say() { printf '\n%s\n' "$1"; } + +# t