From ac860c49e393283978f695fb4ea2ee1e5506179a Mon Sep 17 00:00:00 2001 From: Or Dvir Date: Tue, 14 Jul 2026 19:34:49 +0300 Subject: [PATCH 1/3] fix: re-scope workspace tokens server-side, drop workosOrganizationId (AIT-182) - rescopeWorkspaceToken(workspaceId) calls POST /auth/rescope; the CLI no longer performs WorkOS org-scoped refreshes or sees WorkOS org ids - Workspace resolution by ws_ publicId or name only (org-slug path removed); workspaces wire type drops workosOrganizationId - Tests updated; leak-guard assertions keep the field banned from output --- src/__tests__/channels-move.test.ts | 2 +- src/__tests__/customers.test.ts | 16 +++-- src/__tests__/workspace-list.spec.ts | 8 ++- src/__tests__/workspace-use.spec.ts | 22 +++--- src/__tests__/workspace.test.ts | 48 +++++-------- src/api/__tests__/agent-refresh.test.ts | 2 +- .../__tests__/client-refresh-failure.test.ts | 2 +- src/api/client.ts | 59 ++++++++++++++-- src/auth/__tests__/agentmd-login.test.ts | 8 +-- src/auth/__tests__/bootstrap.test.ts | 3 +- src/auth/__tests__/login-device-url.test.ts | 5 +- src/auth/__tests__/login.test.ts | 10 +-- src/auth/login.ts | 25 +++---- src/commands/_helpers.ts | 4 +- src/commands/customers.ts | 4 +- src/commands/workspace.ts | 70 ++++++------------- src/types/workspace.ts | 1 - 17 files changed, 145 insertions(+), 144 deletions(-) diff --git a/src/__tests__/channels-move.test.ts b/src/__tests__/channels-move.test.ts index 80382a7..5f79a51 100644 --- a/src/__tests__/channels-move.test.ts +++ b/src/__tests__/channels-move.test.ts @@ -21,7 +21,7 @@ const wa = { // A customer workspace (kind='customer') is a valid cross-kind move target — // resolveWorkspace() is called with no kind restriction, so it matches by name // across both team and customer workspaces. -const customer = { id: 'ws_CUST0001', name: 'Acme Cafe', role: 'admin', kind: 'customer', workosOrganizationId: 'org_x' }; +const customer = { id: 'ws_CUST0001', name: 'Acme Cafe', role: 'admin', kind: 'customer' }; describe('channels move — cross-kind (team → customer)', () => { beforeEach(() => vi.mocked(apiClient).mockReset()); diff --git a/src/__tests__/customers.test.ts b/src/__tests__/customers.test.ts index 4bf4738..0b11bd6 100644 --- a/src/__tests__/customers.test.ts +++ b/src/__tests__/customers.test.ts @@ -16,6 +16,7 @@ process.env.HOOKMYAPP_CONFIG_DIR = CONFIG_DIR; vi.mock('../api/client.js', () => ({ apiClient: vi.fn(), forceTokenRefresh: vi.fn().mockResolvedValue(undefined), + rescopeWorkspaceToken: vi.fn().mockResolvedValue(undefined), setWorkspaceContext: vi.fn(), })); @@ -26,22 +27,23 @@ vi.mock('../auth/store.js', () => ({ const mockConsoleLog = vi.spyOn(console, 'log').mockImplementation(() => {}); -import { apiClient, forceTokenRefresh } from '../api/client.js'; +import { apiClient, rescopeWorkspaceToken } from '../api/client.js'; const mockedApi = vi.mocked(apiClient); -const mockedRefresh = vi.mocked(forceTokenRefresh); +const mockedRescope = vi.mocked(rescopeWorkspaceToken); const CONFIG_PATH = path.join(TMP_HOME, '.hookmyapp', 'config.json'); +// AIT-182 — the workspaces wire no longer carries workosOrganizationId. const fakeWorkspaces = [ - { id: 'ws_TEAM0001', name: 'HR', workosOrganizationId: 'org_01A', organizationPublicId: 'org_pub_A', role: 'admin', createdAt: '2026-01-01', kind: 'team' }, - { id: 'ws_CUST0001', name: 'Acme', workosOrganizationId: 'org_01A', organizationPublicId: 'org_pub_A', role: 'admin', createdAt: '2026-02-01', kind: 'customer' }, + { id: 'ws_TEAM0001', name: 'HR', organizationPublicId: 'org_pub_A', role: 'admin', createdAt: '2026-01-01', kind: 'team' }, + { id: 'ws_CUST0001', name: 'Acme', organizationPublicId: 'org_pub_A', role: 'admin', createdAt: '2026-02-01', kind: 'customer' }, ]; beforeEach(async () => { vi.resetModules(); mockedApi.mockReset(); - mockedRefresh.mockReset(); - mockedRefresh.mockResolvedValue(undefined); + mockedRescope.mockReset(); + mockedRescope.mockResolvedValue(undefined); mockConsoleLog.mockClear(); }); @@ -95,7 +97,7 @@ describe('customers use', () => { const cfg = JSON.parse(fs.readFileSync(CONFIG_PATH, 'utf-8')); expect(cfg.activeWorkspaceId).toBe('ws_CUST0001'); - expect(mockedRefresh).toHaveBeenCalledWith('org_01A'); + expect(mockedRescope).toHaveBeenCalledWith('ws_CUST0001'); }); it('refuses to switch into a team workspace', async () => { diff --git a/src/__tests__/workspace-list.spec.ts b/src/__tests__/workspace-list.spec.ts index 3fbe111..684f7ef 100644 --- a/src/__tests__/workspace-list.spec.ts +++ b/src/__tests__/workspace-list.spec.ts @@ -14,6 +14,7 @@ process.env.HOOKMYAPP_CONFIG_DIR = CONFIG_DIR; vi.mock('../api/client.js', () => ({ apiClient: vi.fn(), forceTokenRefresh: vi.fn(), + rescopeWorkspaceToken: vi.fn().mockResolvedValue(undefined), setWorkspaceContext: vi.fn(), })); @@ -30,9 +31,10 @@ const mockConsoleLog = vi.spyOn(console, 'log').mockImplementation(() => {}); const CONFIG_PATH = path.join(TMP_HOME, '.hookmyapp', 'config.json'); // Phase 117: every workspace id fixture is a ws_ publicId. +// AIT-182: the workspaces wire no longer carries workosOrganizationId. const fakeWorkspaces = [ - { id: 'ws_TEST0001', name: 'Acme', workosOrganizationId: 'org_01A', role: 'admin', createdAt: '2026-01-01', kind: 'team' }, - { id: 'ws_TEST0002', name: 'Globex', workosOrganizationId: 'org_01B', role: 'member', createdAt: '2026-02-01', kind: 'customer' }, + { id: 'ws_TEST0001', name: 'Acme', role: 'admin', createdAt: '2026-01-01', kind: 'team' }, + { id: 'ws_TEST0002', name: 'Globex', role: 'member', createdAt: '2026-02-01', kind: 'customer' }, ]; beforeEach(async () => { @@ -110,7 +112,7 @@ describe('workspace list (RBAC-UX-04)', () => { it('is team-only in JSON mode too, and an unknown kind never renders as team', async () => { mockedApi.mockResolvedValue([ ...fakeWorkspaces, - { id: 'ws_TEST0003', name: 'Mystery', workosOrganizationId: 'org_01C', role: 'admin', createdAt: '2026-03-01', kind: 'weird' }, + { id: 'ws_TEST0003', name: 'Mystery', role: 'admin', createdAt: '2026-03-01', kind: 'weird' }, ]); await runList(['--json'], false); diff --git a/src/__tests__/workspace-use.spec.ts b/src/__tests__/workspace-use.spec.ts index 0916570..f3bbdc9 100644 --- a/src/__tests__/workspace-use.spec.ts +++ b/src/__tests__/workspace-use.spec.ts @@ -18,6 +18,7 @@ process.env.HOOKMYAPP_CONFIG_DIR = CONFIG_DIR; vi.mock('../api/client.js', () => ({ apiClient: vi.fn(), forceTokenRefresh: vi.fn().mockResolvedValue(undefined), + rescopeWorkspaceToken: vi.fn().mockResolvedValue(undefined), setWorkspaceContext: vi.fn(), })); @@ -32,15 +33,16 @@ vi.mock('../auth/store.js', () => ({ const mockConsoleLog = vi.spyOn(console, 'log').mockImplementation(() => {}); -import { apiClient, forceTokenRefresh } from '../api/client.js'; +import { apiClient, rescopeWorkspaceToken } from '../api/client.js'; const mockedApi = vi.mocked(apiClient); -const mockedRefresh = vi.mocked(forceTokenRefresh); +const mockedRescope = vi.mocked(rescopeWorkspaceToken); const CONFIG_PATH = path.join(TMP_HOME, '.hookmyapp', 'config.json'); +// AIT-182 — the workspaces wire no longer carries workosOrganizationId. const fakeWorkspaces = [ - { id: 'ws_TEST0001', name: 'Acme', workosOrganizationId: 'org_01A', role: 'admin', createdAt: '2026-01-01', kind: 'team' }, - { id: 'ws_TEST0002', name: 'Globex', workosOrganizationId: 'org_01B', role: 'member', createdAt: '2026-02-01', kind: 'team' }, + { id: 'ws_TEST0001', name: 'Acme', role: 'admin', createdAt: '2026-01-01', kind: 'team' }, + { id: 'ws_TEST0002', name: 'Globex', role: 'member', createdAt: '2026-02-01', kind: 'team' }, ]; let originalIsTTY: boolean | undefined; @@ -48,8 +50,8 @@ let originalIsTTY: boolean | undefined; beforeEach(async () => { vi.resetModules(); mockedApi.mockReset(); - mockedRefresh.mockReset(); - mockedRefresh.mockResolvedValue(undefined); + mockedRescope.mockReset(); + mockedRescope.mockResolvedValue(undefined); mockConsoleLog.mockClear(); originalIsTTY = process.stdout.isTTY; }); @@ -88,10 +90,10 @@ describe('workspace use (RBAC-UX-01/02/03)', () => { expect(cfg.activeWorkspaceSlug).toBe('Acme'); }); - it('RBAC-UX-03: calls forceTokenRefresh(workosOrganizationId) after persist', async () => { + it('RBAC-UX-03: calls rescopeWorkspaceToken(ws publicId) after persist (AIT-182)', async () => { mockedApi.mockResolvedValue(fakeWorkspaces); await runWorkspaceUse(['Acme']); - expect(mockedRefresh).toHaveBeenCalledWith('org_01A'); + expect(mockedRescope).toHaveBeenCalledWith('ws_TEST0001'); }); it('RBAC-UX-02: no-arg TTY uses @inquirer/prompts select and switches', async () => { @@ -112,7 +114,7 @@ describe('workspace use (RBAC-UX-01/02/03)', () => { ); const cfg = JSON.parse(fs.readFileSync(CONFIG_PATH, 'utf-8')); expect(cfg.activeWorkspaceId).toBe('ws_TEST0002'); - expect(mockedRefresh).toHaveBeenCalledWith('org_01B'); + expect(mockedRescope).toHaveBeenCalledWith('ws_TEST0002'); }); it('RBAC-UX-02: no-arg non-TTY throws ValidationError with exitCode 2', async () => { @@ -128,7 +130,7 @@ describe('workspace use (RBAC-UX-01/02/03)', () => { it('refuses to switch into a customer workspace (customers use owns that)', async () => { mockedApi.mockResolvedValue([ ...fakeWorkspaces, - { id: 'ws_TEST0009', name: 'Client Co', workosOrganizationId: 'org_01A', role: 'admin', createdAt: '2026-03-01', kind: 'customer' }, + { id: 'ws_TEST0009', name: 'Client Co', role: 'admin', createdAt: '2026-03-01', kind: 'customer' }, ]); await expect(runWorkspaceUse(['Client Co'])).rejects.toThrow(/workspace "Client Co" not found/); diff --git a/src/__tests__/workspace.test.ts b/src/__tests__/workspace.test.ts index aa1f7a8..876ecf0 100644 --- a/src/__tests__/workspace.test.ts +++ b/src/__tests__/workspace.test.ts @@ -4,6 +4,7 @@ import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; vi.mock('../api/client.js', () => ({ apiClient: vi.fn(), forceTokenRefresh: vi.fn().mockResolvedValue(undefined), + rescopeWorkspaceToken: vi.fn().mockResolvedValue(undefined), setWorkspaceContext: vi.fn(), })); @@ -25,20 +26,21 @@ const mockExit = vi.spyOn(process, 'exit').mockImplementation((() => { const mockConsoleLog = vi.spyOn(console, 'log').mockImplementation(() => {}); -import { apiClient, forceTokenRefresh } from '../api/client.js'; +import { apiClient, rescopeWorkspaceToken } from '../api/client.js'; import { output } from '../output/format.js'; const mockedApiClient = vi.mocked(apiClient); const mockedOutput = vi.mocked(output); -const mockedForceTokenRefresh = vi.mocked(forceTokenRefresh); +const mockedRescopeWorkspaceToken = vi.mocked(rescopeWorkspaceToken); // Phase 117 — every id fixture below is a publicId (ws_/ch_/ssn_/mem_/inv_ prefix, 8-char // alphanumeric body). Raw UUIDs are rejected with a typed ValidationError at every // external flag/header/body surface; see workspace.ts resolveWorkspace + _helpers.ts. +// AIT-182 — the workspaces wire no longer carries workosOrganizationId. const fakeWorkspaces = [ - { id: 'ws_TEST0001', name: 'Alpha Workspace', workosOrganizationId: 'org_01ALPHA', role: 'owner', createdAt: '2026-01-01', kind: 'team' }, - { id: 'ws_TEST0002', name: 'Beta Workspace', workosOrganizationId: 'org_01BETAA', role: 'member', createdAt: '2026-02-01', kind: 'team' }, - { id: 'ws_TEST0003', name: 'Beta Workspace', workosOrganizationId: 'org_01BETAB', role: 'admin', createdAt: '2026-03-01', kind: 'team' }, + { id: 'ws_TEST0001', name: 'Alpha Workspace', role: 'owner', createdAt: '2026-01-01', kind: 'team' }, + { id: 'ws_TEST0002', name: 'Beta Workspace', role: 'member', createdAt: '2026-02-01', kind: 'team' }, + { id: 'ws_TEST0003', name: 'Beta Workspace', role: 'admin', createdAt: '2026-03-01', kind: 'team' }, ]; const fakeWorkspaceDetail = { @@ -48,8 +50,6 @@ const fakeWorkspaceDetail = { channelCount: 2, createdAt: '2026-01-01', updatedAt: '2026-01-15', - // Internal AuthKit plumbing on the detail wire — must never reach stdout. - workosOrganizationId: 'org_01ALPHA', }; describe('resolveWorkspace', () => { @@ -76,12 +76,6 @@ describe('resolveWorkspace', () => { expect(result).toEqual(fakeWorkspaces[0]); }); - it('resolves workspace by workosOrganizationId (slug)', async () => { - mockedApiClient.mockResolvedValue(fakeWorkspaces); - const result = await resolveWorkspace('org_01ALPHA'); - expect(result).toEqual(fakeWorkspaces[0]); - }); - it('throws CliError when workspace name is ambiguous', async () => { mockedApiClient.mockResolvedValue(fakeWorkspaces); const { CliError } = await import('../output/error.js'); @@ -225,14 +219,11 @@ describe('workspace commands', () => { }); describe('workspace new', () => { - // The create endpoint returns the raw Prisma row: `id` is the raw DB UUID, - // `publicId` is the ws_ handle. The CLI must persist/emit the publicId, never - // the raw UUID (which writeWorkspaceConfig rejects → the exit-2 break). + // The create endpoint returns the public DTO (AIT-147/AIT-182): `id` IS + // the ws_ publicId — no raw UUID, no workosOrganizationId on the wire. const created = { - id: 'a79634d0-7d8b-435c-8d49-9e5a524645ae', - publicId: 'ws_TEST0004', + id: 'ws_TEST0004', name: 'New WS', - workosOrganizationId: 'org_NEW0001', createdAt: '2026-04-01', updatedAt: '2026-04-01', }; @@ -262,7 +253,7 @@ describe('workspace commands', () => { registerWorkspaceCommand(program); await program.parseAsync(['workspace', 'new', 'New WS', '--json'], { from: 'user' }); - // publicId-as-id, name only — never the raw `id` UUID or workosOrganizationId. + // publicId-as-id, name only — never the raw `id` UUID. expect(mockedOutput).toHaveBeenCalledWith( { id: 'ws_TEST0004', name: 'New WS' }, { human: false }, @@ -410,12 +401,11 @@ describe('workspace commands', () => { }); it('outputs a clean public DTO under --json (no raw UUID)', async () => { - // PATCH returns the raw row (raw UUID `id` + workosOrganizationId). + // PATCH returns the public DTO (AIT-147): `id` is the ws_ publicId. mockedApiClient.mockResolvedValue({ - id: 'a79634d0-7d8b-435c-8d49-9e5a524645ae', - publicId: 'ws_TEST0001', + id: 'ws_TEST0001', name: 'Renamed WS', - workosOrganizationId: 'org_01ALPHA', + updatedAt: '2026-04-02', }); const fs = await import('node:fs'); @@ -476,8 +466,8 @@ describe('workspace commands', () => { } }); - it('re-scopes the token to the target org and emits a clean DTO under --json', async () => { - mockedForceTokenRefresh.mockClear(); + it('re-scopes the token via the server rescope endpoint and emits a clean DTO under --json', async () => { + mockedRescopeWorkspaceToken.mockClear(); mockedApiClient.mockResolvedValue(fakeWorkspaces); const fs = await import('node:fs'); @@ -495,9 +485,9 @@ describe('workspace commands', () => { registerWorkspaceCommand(program); await program.parseAsync(['workspace', 'use', 'Alpha Workspace', '--json'], { from: 'user' }); - // Token re-scoped to the resolved workspace's WorkOS org before the - // switch is persisted (the list DTO carries workosOrganizationId). - expect(mockedForceTokenRefresh).toHaveBeenCalledWith('org_01ALPHA'); + // AIT-182 — the server resolves workspace → org; the CLI only sends + // the ws_ publicId to POST /auth/rescope. + expect(mockedRescopeWorkspaceToken).toHaveBeenCalledWith('ws_TEST0001'); expect(mockedOutput).toHaveBeenCalledWith( { id: 'ws_TEST0001', name: 'Alpha Workspace' }, { human: false }, diff --git a/src/api/__tests__/agent-refresh.test.ts b/src/api/__tests__/agent-refresh.test.ts index 634c704..2e467a3 100644 --- a/src/api/__tests__/agent-refresh.test.ts +++ b/src/api/__tests__/agent-refresh.test.ts @@ -25,6 +25,6 @@ test('forceTokenRefresh is a no-op for an agent credential (no WorkOS call)', as const fetchMock = vi.fn(); vi.stubGlobal('fetch', fetchMock); const { forceTokenRefresh } = await import('../client.js'); - await expect(forceTokenRefresh('org_123')).resolves.toBeUndefined(); + await expect(forceTokenRefresh()).resolves.toBeUndefined(); expect(fetchMock).not.toHaveBeenCalled(); }); diff --git a/src/api/__tests__/client-refresh-failure.test.ts b/src/api/__tests__/client-refresh-failure.test.ts index 6ba1581..5c34c6d 100644 --- a/src/api/__tests__/client-refresh-failure.test.ts +++ b/src/api/__tests__/client-refresh-failure.test.ts @@ -90,7 +90,7 @@ describe('forceTokenRefresh — refresh endpoint failure', () => { stubRefreshResponse(400, { error: 'invalid_grant' }); const { forceTokenRefresh } = await import('../client.js'); - await expect(forceTokenRefresh('org_123')).rejects.toBeInstanceOf(AuthError); + await expect(forceTokenRefresh()).rejects.toBeInstanceOf(AuthError); expect(storedCredentials()).toBe(STORED); }); }); diff --git a/src/api/client.ts b/src/api/client.ts index c5a39fc..4e3ecee 100644 --- a/src/api/client.ts +++ b/src/api/client.ts @@ -43,16 +43,12 @@ function decodeJwtExp(token: string): number { async function refreshToken( refreshTokenValue: string, - organizationId?: string, ): Promise<{ accessToken: string; refreshToken: string; expiresAt: number }> { const params = new URLSearchParams({ grant_type: 'refresh_token', refresh_token: refreshTokenValue, client_id: getEffectiveWorkosClientId(), }); - if (organizationId) { - params.set('organization_id', organizationId); - } const res = await fetch('https://api.workos.com/user_management/authenticate', { method: 'POST', headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, @@ -84,24 +80,73 @@ async function refreshToken( }; } -export async function forceTokenRefresh(organizationId?: string): Promise { +export async function forceTokenRefresh(): Promise { const creds = await readCredentials(); if (!creds) { throw new AuthError('Not logged in. Run: hookmyapp login'); } // Agent (auth.md) credentials are org-scoped Bearer tokens with no refresh - // token; there is nothing to refresh and no org re-scope to perform. + // token; there is nothing to refresh. if (isAgentCredential(creds)) { return; } try { - const refreshed = await refreshToken(creds.refreshToken, organizationId); + const refreshed = await refreshToken(creds.refreshToken); await saveCredentials(refreshed); } catch { throw new AuthError('Session expired. Run: hookmyapp login'); } } +/** + * Re-scope the stored session to a workspace's organization via the backend + * (POST /auth/rescope). The server resolves workspace → org — the CLI never + * sees the internal WorkOS organization id (AIT-182). + */ +export async function rescopeWorkspaceToken(workspaceId: string): Promise { + const creds = await readCredentials(); + if (!creds) { + throw new AuthError('Not logged in. Run: hookmyapp login'); + } + // Agent (auth.md) credentials are org-scoped Bearer tokens with no refresh + // token; nothing to rescope. + if (isAgentCredential(creds)) { + return; + } + let res: Response; + try { + res = await fetch(`${getEffectiveApiUrl()}/auth/rescope`, { + method: 'POST', + headers: { 'Content-Type': 'application/json', ...buildVersionHeaders() }, + body: JSON.stringify({ refreshToken: creds.refreshToken, workspaceId }), + }); + } catch (err) { + if (isNetworkFailure(err)) { + throw new NetworkError( + `Could not connect to HookMyApp API (${new URL(getEffectiveApiUrl()).host}): ${describeFetchError(err)}. Check your internet connection or try again later.`, + ); + } + throw err; + } + if (!res.ok) { + throw await mapApiError(res); + } + const data = await res.json().catch(() => null); + // Shape guard — never persist a 200 with missing/empty tokens (same rule as + // refreshToken above; a corrupt credentials.json is worse than a hard error). + if ( + typeof data?.accessToken !== 'string' || data.accessToken === '' || + typeof data?.refreshToken !== 'string' || data.refreshToken === '' + ) { + throw new UnexpectedError('rescope response malformed', 'RESCOPE_FAILED'); + } + await saveCredentials({ + accessToken: data.accessToken, + refreshToken: data.refreshToken, + expiresAt: decodeJwtExp(data.accessToken), + }); +} + // Centralized HTTP-status → AppError subclass mapping. Every non-ok response // from apiClient funnels through here so that error shape/exit codes stay // consistent across commands. Keep this in sync with the error-hierarchy diff --git a/src/auth/__tests__/agentmd-login.test.ts b/src/auth/__tests__/agentmd-login.test.ts index 316b046..567dba5 100644 --- a/src/auth/__tests__/agentmd-login.test.ts +++ b/src/auth/__tests__/agentmd-login.test.ts @@ -151,7 +151,7 @@ function fetchByUrl(workspaces: unknown[]) { test('otp login with stale workspace + one live workspace → re-resolves to it', async () => { seedConfig('ws_stale123'); - vi.stubGlobal('fetch', fetchByUrl([{ id: 'ws_fresh456', name: 'Fresh', workosOrganizationId: 'org_1' }])); + vi.stubGlobal('fetch', fetchByUrl([{ id: 'ws_fresh456', name: 'Fresh' }])); const mod = await import('../login.js'); await mod.runAgentClaimLogin({ email: 'a@b.com', registrationId: '3333', otp: '654321', json: true }); @@ -163,8 +163,8 @@ test('otp login with stale workspace + one live workspace → re-resolves to it' test('otp login with stale workspace + several live workspaces → clears the selection', async () => { seedConfig('ws_stale123'); vi.stubGlobal('fetch', fetchByUrl([ - { id: 'ws_fresh456', name: 'Fresh', workosOrganizationId: 'org_1' }, - { id: 'ws_other789', name: 'Other', workosOrganizationId: 'org_2' }, + { id: 'ws_fresh456', name: 'Fresh' }, + { id: 'ws_other789', name: 'Other' }, ])); const mod = await import('../login.js'); @@ -175,7 +175,7 @@ test('otp login with stale workspace + several live workspaces → clears the se test('otp login with a still-valid workspace → selection untouched', async () => { seedConfig('ws_fresh456', 'Fresh'); - vi.stubGlobal('fetch', fetchByUrl([{ id: 'ws_fresh456', name: 'Fresh', workosOrganizationId: 'org_1' }])); + vi.stubGlobal('fetch', fetchByUrl([{ id: 'ws_fresh456', name: 'Fresh' }])); const mod = await import('../login.js'); await mod.runAgentClaimLogin({ email: 'a@b.com', registrationId: '3333', otp: '654321', json: true }); diff --git a/src/auth/__tests__/bootstrap.test.ts b/src/auth/__tests__/bootstrap.test.ts index 5505301..9c20d3a 100644 --- a/src/auth/__tests__/bootstrap.test.ts +++ b/src/auth/__tests__/bootstrap.test.ts @@ -59,7 +59,7 @@ function makeExchangeResponse(overrides: { accessToken: string; refreshToken: string; expiresAt: number; - workspace: { id: string; name: string; workosOrganizationId: string }; + workspace: { id: string; name: string }; user: { publicId: string; email: string }; } { return { @@ -72,7 +72,6 @@ function makeExchangeResponse(overrides: { workspace: { id: overrides.workspaceId ?? 'ws_NEWWS001', name: overrides.workspaceName ?? "Or's Workspace", - workosOrganizationId: 'org_new', }, user: { publicId: 'usr_NEW00001', diff --git a/src/auth/__tests__/login-device-url.test.ts b/src/auth/__tests__/login-device-url.test.ts index 411966d..d5f3709 100644 --- a/src/auth/__tests__/login-device-url.test.ts +++ b/src/auth/__tests__/login-device-url.test.ts @@ -15,11 +15,12 @@ vi.mock('../../observability/posthog.js', () => ({ })); const apiClientMock = vi.fn(async () => [ - { id: 'ws_TEST0001', name: 'acme-corp', role: 'admin', workosOrganizationId: 'org_1' }, + { id: 'ws_TEST0001', name: 'acme-corp', role: 'admin' }, ]); vi.mock('../../api/client.js', () => ({ apiClient: apiClientMock, forceTokenRefresh: vi.fn(), + rescopeWorkspaceToken: vi.fn(async () => undefined), setWorkspaceContext: vi.fn(), })); @@ -35,7 +36,7 @@ describe('login device-flow verification URL', () => { beforeEach(() => { vi.clearAllMocks(); apiClientMock.mockResolvedValue([ - { id: 'ws_TEST0001', name: 'acme-corp', role: 'admin', workosOrganizationId: 'org_1' }, + { id: 'ws_TEST0001', name: 'acme-corp', role: 'admin' }, ]); }); diff --git a/src/auth/__tests__/login.test.ts b/src/auth/__tests__/login.test.ts index 6fe1249..fdb6dec 100644 --- a/src/auth/__tests__/login.test.ts +++ b/src/auth/__tests__/login.test.ts @@ -15,9 +15,11 @@ vi.mock('@inquirer/prompts', () => ({ })); const apiClientMock = vi.fn(); +const rescopeWorkspaceTokenMock = vi.fn(); vi.mock('../../api/client.js', () => ({ apiClient: apiClientMock, forceTokenRefresh: vi.fn(), + rescopeWorkspaceToken: rescopeWorkspaceTokenMock, setWorkspaceContext: vi.fn(), })); @@ -81,7 +83,6 @@ describe('post-login wizard', () => { id: 'ws_TEST0001', name: 'acme-corp', role: 'admin', - workosOrganizationId: 'org_1', }, ]); const logSpy = vi.spyOn(console, 'log').mockImplementation(() => {}); @@ -107,19 +108,16 @@ describe('post-login wizard', () => { id: 'ws_TESTw001', name: 'acme-corp', role: 'admin', - workosOrganizationId: 'org_1', }, { id: 'ws_TESTw002', name: 'beta-workspace', role: 'member', - workosOrganizationId: 'org_2', }, ]); selectMock.mockResolvedValueOnce({ id: 'ws_TESTw001', name: 'acme-corp', - workosOrganizationId: 'org_1', }); // workspace picker only const logSpy = vi.spyOn(console, 'log').mockImplementation(() => {}); await runWizard(); @@ -156,7 +154,6 @@ describe('post-login wizard', () => { id: 'ws_TEST0001', name: 'acme-corp', role: 'admin', - workosOrganizationId: 'org_1', }, ]); const logSpy = vi.spyOn(console, 'log').mockImplementation(() => {}); @@ -176,7 +173,6 @@ describe('post-login wizard', () => { id: 'ws_TEST0001', name: 'acme', role: 'admin', - workosOrganizationId: 'org_1', }, ]) // sandbox sessions fetch — the session already exists (bound previously @@ -230,7 +226,6 @@ describe('post-login wizard', () => { id: 'ws_TEST0001', name: 'acme', role: 'admin', - workosOrganizationId: 'org_1', }, ]); const logSpy = vi.spyOn(console, 'log').mockImplementation(() => {}); @@ -248,7 +243,6 @@ describe('post-login wizard', () => { id: 'ws_TEST0001', name: 'acme-corp', role: 'admin', - workosOrganizationId: 'org_1', }, ]); const logSpy = vi.spyOn(console, 'log').mockImplementation(() => {}); diff --git a/src/auth/login.ts b/src/auth/login.ts index e193923..bb29f01 100644 --- a/src/auth/login.ts +++ b/src/auth/login.ts @@ -25,7 +25,6 @@ interface ExchangeBootstrapResponseDto { workspace: { id: string; // ws_<8> publicId name: string; - workosOrganizationId: string; }; user: { publicId: string; // usr_<8> @@ -38,7 +37,6 @@ interface Workspace { id: string; name: string; role?: string; - workosOrganizationId: string; slug?: string; } @@ -125,7 +123,7 @@ async function pollForTokens(opts: { * tried to listen, which the backend rejects. */ export async function runWizard(opts: WizardOpts = {}): Promise { - const { apiClient, forceTokenRefresh } = await import('../api/client.js'); + const { apiClient, rescopeWorkspaceToken } = await import('../api/client.js'); const { writeWorkspaceConfig, readWorkspaceConfig } = await import('../commands/workspace.js'); const { select } = await import('@inquirer/prompts'); @@ -143,7 +141,6 @@ export async function runWizard(opts: WizardOpts = {}): Promise { let activeWorkspaceId: string; let activeWorkspaceName: string; - let activeWorkspaceOrg: string; // Honor an already-active workspace (from prior `workspace use` or an // integration-test seed). This keeps the wizard idempotent across repeat @@ -156,7 +153,6 @@ export async function runWizard(opts: WizardOpts = {}): Promise { if (preselected) { activeWorkspaceId = preselected.id; activeWorkspaceName = preselected.name; - activeWorkspaceOrg = preselected.workosOrganizationId; console.log( `${c.success(icon.success)} Using workspace: ${c.dim(activeWorkspaceName)}`, ); @@ -164,7 +160,6 @@ export async function runWizard(opts: WizardOpts = {}): Promise { const only = workspaces[0]; activeWorkspaceId = only.id; activeWorkspaceName = only.name; - activeWorkspaceOrg = only.workosOrganizationId; writeWorkspaceConfig({ activeWorkspaceId, activeWorkspaceSlug: activeWorkspaceName, @@ -182,22 +177,20 @@ export async function runWizard(opts: WizardOpts = {}): Promise { })) as Workspace; activeWorkspaceId = chosen.id; activeWorkspaceName = chosen.name; - activeWorkspaceOrg = chosen.workosOrganizationId; writeWorkspaceConfig({ activeWorkspaceId, activeWorkspaceSlug: activeWorkspaceName, }); } - // Refresh JWT so it carries the picked org's context (role + org_id - // claims). Device-code grant issues a user-scoped token; without this - // step, workspace-admin endpoints 403 even for actual admins. - if (activeWorkspaceOrg) { - try { - await forceTokenRefresh(activeWorkspaceOrg); - } catch { - // non-fatal: next apiClient call will surface auth errors - } + // Re-scope JWT so it carries the picked org's context (role + org_id + // claims), via the backend (AIT-182). Device-code grant issues a + // user-scoped token; without this step, workspace-admin endpoints 403 + // even for actual admins. + try { + await rescopeWorkspaceToken(activeWorkspaceId); + } catch { + // non-fatal: next apiClient call will surface auth errors } // Step 2 — non-interactive next steps. diff --git a/src/commands/_helpers.ts b/src/commands/_helpers.ts index f9b9256..bbead9b 100644 --- a/src/commands/_helpers.ts +++ b/src/commands/_helpers.ts @@ -18,13 +18,12 @@ import type { Channel } from '../api/channel.js'; * authentication failure from CI scripts that check exit codes. */ async function listWorkspacesOrEmpty(): Promise< - Array<{ id: string; name: string; workosOrganizationId?: string }> + Array<{ id: string; name: string }> > { try { return (await apiClient('/workspaces')) as Array<{ id: string; name: string; - workosOrganizationId?: string; }>; } catch (err) { if (err instanceof AuthError || err instanceof NetworkError) { @@ -66,7 +65,6 @@ export async function getDefaultWorkspaceId(): Promise { (w) => w.name === flag || w.name.toLowerCase() === flag.toLowerCase() || - w.workosOrganizationId === flag || w.id === flag, ); if (!match) { diff --git a/src/commands/customers.ts b/src/commands/customers.ts index 95a1bf2..dafdca2 100644 --- a/src/commands/customers.ts +++ b/src/commands/customers.ts @@ -4,7 +4,7 @@ import { output } from '../output/format.js'; import { ValidationError } from '../output/error.js'; import { addExamples } from '../output/help.js'; import type { Workspace } from '../types/workspace.js'; -import { readWorkspaceConfig, switchActiveWorkspace, stripInternalWorkspaceFields } from './workspace.js'; +import { readWorkspaceConfig, switchActiveWorkspace } from './workspace.js'; interface OnboardingLinkRow { publicId: string; @@ -30,7 +30,7 @@ export function registerCustomersCommand(program: Command): void { const all = (await apiClient('/workspaces')) as Workspace[]; const customers = all.filter((w) => w.kind === 'customer'); if (opts.json || program.opts().json) { - console.log(JSON.stringify(customers.map(stripInternalWorkspaceFields), null, 2)); + console.log(JSON.stringify(customers, null, 2)); return; } const config = readWorkspaceConfig(); diff --git a/src/commands/workspace.ts b/src/commands/workspace.ts index 94f85ba..da8b9ac 100644 --- a/src/commands/workspace.ts +++ b/src/commands/workspace.ts @@ -1,5 +1,5 @@ import type { Command } from 'commander'; -import { apiClient, forceTokenRefresh } from '../api/client.js'; +import { apiClient, rescopeWorkspaceToken } from '../api/client.js'; import { output } from '../output/format.js'; import { ValidationError } from '../output/error.js'; import { addExamples } from '../output/help.js'; @@ -14,20 +14,6 @@ export interface WorkspaceConfig { activeWorkspaceSlug?: string; } -/** - * Drop `workosOrganizationId` from a workspace before it is displayed. Per the - * CLI-output-cleanup decision (spec 2026-05-27) it is a DROP-list field: the - * WorkOS org id is internal plumbing the CLI passes to WorkOS for token - * re-scoping (`workspace use`), not a customer-facing identifier. The ws_ - * publicId (`id`) and the HookMyApp-native `organizationPublicId` are the - * identifiers scripts should key on. Everything else the backend sends passes - * through untouched. - */ -export function stripInternalWorkspaceFields(w: Workspace): Omit { - const { workosOrganizationId: _drop, ...rest } = w; - return rest; -} - /** * The on-disk shape of `~/.hookmyapp/config.json`. This is shared with * `src/config/env-profiles.ts`, which owns the `env` field. Both modules @@ -96,11 +82,11 @@ export function writeWorkspaceConfig(config: WorkspaceConfig): void { safeWriteFileSync(getConfigFile(), JSON.stringify(merged, null, 2) + '\n'); } -export async function resolveWorkspace(nameOrId: string, kind?: 'team' | 'customer'): Promise<{ id: string; name: string; role: string; workosOrganizationId: string }> { - // Phase 117: raw UUID input is not an accepted shape. A `ws_` publicId, - // a workspace name, or a WorkOS organizationId (slug) are the three - // accepted identifier shapes — matching what the backend now surfaces - // over HTTP. If the caller passes a UUID, short-circuit with a typed +export async function resolveWorkspace(nameOrId: string, kind?: 'team' | 'customer'): Promise<{ id: string; name: string; role: string }> { + // Phase 117: raw UUID input is not an accepted shape. A `ws_` publicId or + // a workspace name are the accepted identifier shapes — matching what the + // backend surfaces over HTTP (AIT-182 removed the internal WorkOS org id + // from the wire). If the caller passes a UUID, short-circuit with a typed // error instead of silently accepting it (would 400 at the backend). if (isLikelyUuid(nameOrId)) { throw new ValidationError( @@ -119,9 +105,6 @@ export async function resolveWorkspace(nameOrId: string, kind?: 'team' | 'custom if (found) return found; throw new ValidationError(`${noun} "${nameOrId}" not found`); } - // WorkOS organization id shape (slug) — exact match, case-sensitive. - const orgMatch = workspaces.find((w: any) => w.workosOrganizationId === nameOrId); - if (orgMatch) return orgMatch; // Case-insensitive name match const matches = workspaces.filter((w: any) => w.name.toLowerCase() === nameOrId.toLowerCase()); if (matches.length === 1) return matches[0]; @@ -139,7 +122,8 @@ export async function resolveWorkspace(nameOrId: string, kind?: 'team' | 'custom /** * Resolve (or interactively pick) a workspace, re-scope the token to its - * WorkOS org, and persist it as the active workspace. Shared by + * organization via the backend (POST /auth/rescope, AIT-182), and persist it + * as the active workspace. Shared by * `workspace use` and `customers use`; `opts.kind` restricts the candidate * set (both resolve and picker) to that kind. */ @@ -165,7 +149,7 @@ export async function switchActiveWorkspace( choices: workspaces.map((w) => ({ name: `${w.name} (${w.role})`, value: w.id, - description: w.workosOrganizationId, + description: w.id, })), }); workspace = workspaces.find((w) => w.id === chosenId)!; @@ -173,7 +157,7 @@ export async function switchActiveWorkspace( // Re-scope the token to the target org BEFORE persisting the switch, so // a failed refresh never leaves config pointing at a workspace the token // isn't valid for (previously config was written first → poisoned state). - await forceTokenRefresh(workspace.workosOrganizationId); + await rescopeWorkspaceToken(workspace.id); writeWorkspaceConfig({ activeWorkspaceId: workspace.id, activeWorkspaceSlug: workspace.name, @@ -227,7 +211,7 @@ export function registerWorkspaceCommand(program: Command): void { const data = all.filter((w) => w.kind === 'team'); const config = readWorkspaceConfig(); if (opts.json) { - console.log(JSON.stringify(data.map(stripInternalWorkspaceFields), null, 2)); + console.log(JSON.stringify(data, null, 2)); return; } if (!program.opts().json) { @@ -241,7 +225,7 @@ export function registerWorkspaceCommand(program: Command): void { return; } // Default (non-human, no --json): still emit JSON array - console.log(JSON.stringify(data.map(stripInternalWorkspaceFields), null, 2)); + console.log(JSON.stringify(data, null, 2)); }); const wsNew = ws.command('new') @@ -252,24 +236,20 @@ export function registerWorkspaceCommand(program: Command): void { method: 'POST', body: JSON.stringify({ name }), }); - // Persist the ws_ publicId, never the raw DB UUID (`result.id`). The - // create endpoint returns the raw row whose `id` is the UUID; - // writeWorkspaceConfig rejects a non-publicId, which previously broke - // `workspace new` with exit 2. + // The create endpoint returns the public DTO where `id` IS the ws_ + // publicId (AIT-147) — safe to persist directly. writeWorkspaceConfig({ - activeWorkspaceId: result.publicId, + activeWorkspaceId: result.id, activeWorkspaceSlug: result.name, }); - // Refresh JWT so it's scoped to the newly-created workspace's WorkOS org; - // without this, subsequent commands (e.g. `workspace current`) hit the - // backend with a token still bound to the previous org and get 403. - if (result.workosOrganizationId) { - await forceTokenRefresh(result.workosOrganizationId); - } + // Re-scope the JWT to the newly-created workspace's org (server-side, + // AIT-182); without this, subsequent commands (e.g. `workspace current`) + // hit the backend with a token still bound to the previous org and get 403. + await rescopeWorkspaceToken(result.id); if (!program.opts().json) { console.log(`Created workspace "${result.name}" and switched to it`); } else { - output({ id: result.publicId, name: result.name }, { human: false }); + output({ id: result.id, name: result.name }, { human: false }); } }); @@ -284,10 +264,7 @@ export function registerWorkspaceCommand(program: Command): void { apiClient(`/workspaces/${workspaceId}`), ]); const listEntry = workspaces.find((w: any) => w.id === workspaceId); - // Drop workosOrganizationId before display — internal AuthKit plumbing, - // same rule as stripInternalWorkspaceFields on the list path. - const { workosOrganizationId: _drop, ...detailPublic } = detail; - const merged = { ...detailPublic, role: listEntry?.role }; + const merged = { ...detail, role: listEntry?.role }; if (!program.opts().json) { console.log(`Name: ${merged.name}`); console.log(`ID: ${merged.id}`); @@ -312,9 +289,8 @@ export function registerWorkspaceCommand(program: Command): void { if (!program.opts().json) { console.log(`Renamed workspace to "${result.name}"`); } else { - // Emit a clean public DTO; the PATCH endpoint returns the raw row - // (raw UUID `id` + workosOrganizationId) which must never reach stdout. - output({ id: result.publicId, name: result.name }, { human: false }); + // PATCH returns the public DTO (AIT-147): `id` is the ws_ publicId. + output({ id: result.id, name: result.name }, { human: false }); } }); diff --git a/src/types/workspace.ts b/src/types/workspace.ts index b18a7c1..0b491df 100644 --- a/src/types/workspace.ts +++ b/src/types/workspace.ts @@ -3,7 +3,6 @@ export type WorkspaceKind = 'team' | 'customer'; export interface Workspace { id: string; name: string; - workosOrganizationId: string; role: 'admin' | 'member'; createdAt: string; kind: WorkspaceKind; From 4d04502607ab0d85cc71aba1eef193adffbf35c6 Mon Sep 17 00:00:00 2001 From: Or Dvir Date: Wed, 15 Jul 2026 16:58:37 +0300 Subject: [PATCH 2/3] fix: scrub workosOrganizationId at CLI output boundary + rescope before persisting workspace switch (Refs AIT-182) - dropWorkosOrgId strips the internal id from ws list/current and customers list JSON output, covering rollout skew with older backends - ws new: rescope the token before writing config so a failed rescope can't leave config pointing at an unauthorized workspace --- src/__tests__/workspace-list.spec.ts | 7 ++++--- src/__tests__/workspace.test.ts | 3 +++ src/commands/customers.ts | 4 ++-- src/commands/workspace.ts | 14 +++++++------- src/types/workspace.ts | 12 ++++++++++++ 5 files changed, 28 insertions(+), 12 deletions(-) diff --git a/src/__tests__/workspace-list.spec.ts b/src/__tests__/workspace-list.spec.ts index 684f7ef..49aed27 100644 --- a/src/__tests__/workspace-list.spec.ts +++ b/src/__tests__/workspace-list.spec.ts @@ -31,10 +31,11 @@ const mockConsoleLog = vi.spyOn(console, 'log').mockImplementation(() => {}); const CONFIG_PATH = path.join(TMP_HOME, '.hookmyapp', 'config.json'); // Phase 117: every workspace id fixture is a ws_ publicId. -// AIT-182: the workspaces wire no longer carries workosOrganizationId. +// AIT-182: fixtures simulate an OLDER backend that still sends +// workosOrganizationId — the CLI must scrub it at the output boundary. const fakeWorkspaces = [ - { id: 'ws_TEST0001', name: 'Acme', role: 'admin', createdAt: '2026-01-01', kind: 'team' }, - { id: 'ws_TEST0002', name: 'Globex', role: 'member', createdAt: '2026-02-01', kind: 'customer' }, + { id: 'ws_TEST0001', name: 'Acme', role: 'admin', createdAt: '2026-01-01', kind: 'team', workosOrganizationId: 'org_01INTERNAL' }, + { id: 'ws_TEST0002', name: 'Globex', role: 'member', createdAt: '2026-02-01', kind: 'customer', workosOrganizationId: 'org_01INTERNAL' }, ]; beforeEach(async () => { diff --git a/src/__tests__/workspace.test.ts b/src/__tests__/workspace.test.ts index 876ecf0..2592abf 100644 --- a/src/__tests__/workspace.test.ts +++ b/src/__tests__/workspace.test.ts @@ -50,6 +50,9 @@ const fakeWorkspaceDetail = { channelCount: 2, createdAt: '2026-01-01', updatedAt: '2026-01-15', + // AIT-182 rollout skew: an older backend may still send this — the CLI + // must scrub it before output. + workosOrganizationId: 'org_01INTERNAL', }; describe('resolveWorkspace', () => { diff --git a/src/commands/customers.ts b/src/commands/customers.ts index dafdca2..4d87944 100644 --- a/src/commands/customers.ts +++ b/src/commands/customers.ts @@ -3,7 +3,7 @@ import { apiClient } from '../api/client.js'; import { output } from '../output/format.js'; import { ValidationError } from '../output/error.js'; import { addExamples } from '../output/help.js'; -import type { Workspace } from '../types/workspace.js'; +import { dropWorkosOrgId, type Workspace } from '../types/workspace.js'; import { readWorkspaceConfig, switchActiveWorkspace } from './workspace.js'; interface OnboardingLinkRow { @@ -30,7 +30,7 @@ export function registerCustomersCommand(program: Command): void { const all = (await apiClient('/workspaces')) as Workspace[]; const customers = all.filter((w) => w.kind === 'customer'); if (opts.json || program.opts().json) { - console.log(JSON.stringify(customers, null, 2)); + console.log(JSON.stringify(customers.map(dropWorkosOrgId), null, 2)); return; } const config = readWorkspaceConfig(); diff --git a/src/commands/workspace.ts b/src/commands/workspace.ts index da8b9ac..77bf7ba 100644 --- a/src/commands/workspace.ts +++ b/src/commands/workspace.ts @@ -3,7 +3,7 @@ import { apiClient, rescopeWorkspaceToken } from '../api/client.js'; import { output } from '../output/format.js'; import { ValidationError } from '../output/error.js'; import { addExamples } from '../output/help.js'; -import type { Workspace } from '../types/workspace.js'; +import { dropWorkosOrgId, type Workspace } from '../types/workspace.js'; import { isLikelyUuid, isValidPublicId } from '../lib/publicId.js'; import fs from 'node:fs'; import { getConfigFile, safeWriteFileSync } from '../storage/path.js'; @@ -208,7 +208,7 @@ export function registerWorkspaceCommand(program: Command): void { // Workspaces vs Customers split. Strict equality also enforces // the fail-safe: an unknown kind never renders as a team workspace. const all = (await apiClient('/workspaces')) as Workspace[]; - const data = all.filter((w) => w.kind === 'team'); + const data = all.filter((w) => w.kind === 'team').map(dropWorkosOrgId); const config = readWorkspaceConfig(); if (opts.json) { console.log(JSON.stringify(data, null, 2)); @@ -236,16 +236,16 @@ export function registerWorkspaceCommand(program: Command): void { method: 'POST', body: JSON.stringify({ name }), }); + // Re-scope the JWT to the newly-created workspace's org (server-side, + // AIT-182) BEFORE persisting the switch — a failed rescope must not + // leave config pointing at a workspace the token isn't valid for. + await rescopeWorkspaceToken(result.id); // The create endpoint returns the public DTO where `id` IS the ws_ // publicId (AIT-147) — safe to persist directly. writeWorkspaceConfig({ activeWorkspaceId: result.id, activeWorkspaceSlug: result.name, }); - // Re-scope the JWT to the newly-created workspace's org (server-side, - // AIT-182); without this, subsequent commands (e.g. `workspace current`) - // hit the backend with a token still bound to the previous org and get 403. - await rescopeWorkspaceToken(result.id); if (!program.opts().json) { console.log(`Created workspace "${result.name}" and switched to it`); } else { @@ -264,7 +264,7 @@ export function registerWorkspaceCommand(program: Command): void { apiClient(`/workspaces/${workspaceId}`), ]); const listEntry = workspaces.find((w: any) => w.id === workspaceId); - const merged = { ...detail, role: listEntry?.role }; + const merged = dropWorkosOrgId({ ...detail, role: listEntry?.role }); if (!program.opts().json) { console.log(`Name: ${merged.name}`); console.log(`ID: ${merged.id}`); diff --git a/src/types/workspace.ts b/src/types/workspace.ts index 0b491df..bdce580 100644 --- a/src/types/workspace.ts +++ b/src/types/workspace.ts @@ -7,3 +7,15 @@ export interface Workspace { createdAt: string; kind: WorkspaceKind; } + +/** + * AIT-182 rollout skew: an older backend may still include the internal + * workosOrganizationId on workspace rows. Drop it at the output boundary so + * the CLI never prints it regardless of backend version. + */ +export function dropWorkosOrgId(row: T): T { + const { workosOrganizationId: _drop, ...rest } = row as T & { + workosOrganizationId?: unknown; + }; + return rest as T; +} From d0f4365d7b008d865a3b334f61611ecae0c08be9 Mon Sep 17 00:00:00 2001 From: Or Dvir Date: Thu, 16 Jul 2026 11:01:46 +0300 Subject: [PATCH 3/3] fix: narrow dropWorkosOrgId return type to Omit Callers previously saw the field still present in the type even though it was stripped at runtime. Refs AIT-182. --- src/types/workspace.ts | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/src/types/workspace.ts b/src/types/workspace.ts index bdce580..4cbb86c 100644 --- a/src/types/workspace.ts +++ b/src/types/workspace.ts @@ -13,9 +13,11 @@ export interface Workspace { * workosOrganizationId on workspace rows. Drop it at the output boundary so * the CLI never prints it regardless of backend version. */ -export function dropWorkosOrgId(row: T): T { +export function dropWorkosOrgId( + row: T, +): Omit { const { workosOrganizationId: _drop, ...rest } = row as T & { workosOrganizationId?: unknown; }; - return rest as T; + return rest; }