Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
30 changes: 30 additions & 0 deletions src/__tests__/customers.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -72,6 +72,9 @@ async function runCustomers(args: string[]): Promise<void> {

describe('customers list', () => {
it('lists only customer-kind workspaces (JSON mode)', async () => {
// AIT-263: customers list is scoped to the ACTIVE org, so an active
// workspace must be resolvable.
fs.writeFileSync(CONFIG_PATH, JSON.stringify({ activeWorkspaceId: 'ws_TEAM0001', activeWorkspaceSlug: 'HR' }));
mockedApi.mockResolvedValue(fakeWorkspaces);
await runCustomers(['list', '--json']);

Expand All @@ -80,7 +83,14 @@ describe('customers list', () => {
expect(parsed[0].kind).toBe('customer');
});

it('AIT-263: a stale active workspace throws the actionable ValidationError instead of an empty list', async () => {
fs.writeFileSync(CONFIG_PATH, JSON.stringify({ activeWorkspaceId: 'ws_GONE0001', activeWorkspaceSlug: 'gone' }));
mockedApi.mockResolvedValue(fakeWorkspaces);
await expect(runCustomers(['list', '--json'])).rejects.toThrow(/workspace use/);
});

it('human mode renders customer rows only', async () => {
fs.writeFileSync(CONFIG_PATH, JSON.stringify({ activeWorkspaceId: 'ws_TEAM0001', activeWorkspaceSlug: 'HR' }));
mockedApi.mockResolvedValue(fakeWorkspaces);
await runCustomers(['list']);

Expand Down Expand Up @@ -108,6 +118,7 @@ describe('customers use', () => {

describe('customers new', () => {
it('creates an empty customer via POST /organizations/:orgId/customers', async () => {
fs.writeFileSync(CONFIG_PATH, JSON.stringify({ activeWorkspaceId: 'ws_TEAM0001', activeWorkspaceSlug: 'HR' }));
mockedApi.mockImplementation(async (path: string) => {
if (path === '/workspaces') return fakeWorkspaces;
return { id: 'ws_NEWCUST1', name: 'Fresh Client', externalId: 'crm-9' };
Expand All @@ -122,6 +133,25 @@ describe('customers new', () => {
expect(logs).toContain('ws_NEWCUST1');
});

it('AIT-263: targets the ACTIVE org, never union row[0], for a multi-org user', async () => {
// row[0] is org_pub_OTHER; the active workspace ws_TEAM0001 is org_pub_A.
// The pre-fix code POSTed to row[0] (wrong org → 403); it must use org_pub_A.
const twoOrgUnion = [
{ id: 'ws_OTHER001', name: 'Other', organizationPublicId: 'org_pub_OTHER', role: 'admin', createdAt: '2026-01-01', kind: 'team' },
...fakeWorkspaces,
];
fs.writeFileSync(CONFIG_PATH, JSON.stringify({ activeWorkspaceId: 'ws_TEAM0001', activeWorkspaceSlug: 'HR' }));
mockedApi.mockImplementation(async (path: string) => {
if (path === '/workspaces') return twoOrgUnion;
return { id: 'ws_NEWCUST2', name: 'Bob', externalId: null };
});
await runCustomers(['new', 'Bob']);

const postCall = mockedApi.mock.calls.find((c) => c[1]?.method === 'POST');
expect(postCall?.[0]).toBe('/organizations/org_pub_A/customers');
expect(String(postCall?.[0])).not.toContain('org_pub_OTHER');
});

it('does NOT switch the active workspace to the new customer', async () => {
fs.writeFileSync(CONFIG_PATH, JSON.stringify({ activeWorkspaceId: 'ws_TEAM0001', activeWorkspaceSlug: 'HR' }));
mockedApi.mockImplementation(async (path: string) => {
Expand Down
15 changes: 11 additions & 4 deletions src/commands/__tests__/billing.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,13 +2,20 @@ import { describe, test, expect, vi, beforeEach, afterEach } from 'vitest';

vi.mock('../../api/client.js', () => ({
apiClient: vi.fn(),
setWorkspaceContext: vi.fn(),
}));

// getDefaultWorkspaceId is read off the user's local profile; stub to a fixed
// string so the test doesn't depend on profile state.
vi.mock('../_helpers.js', () => ({
getDefaultWorkspaceId: vi.fn(async () => 'ws_test'),
}));
// string so the test doesn't depend on profile state. resolveOrgPublicIdForWorkspace
// stays REAL (via importOriginal) so it exercises the actual active-workspace
// org derivation against the mocked apiClient union (AIT-263).
vi.mock('../_helpers.js', async (importOriginal) => {
const actual = await importOriginal<typeof import('../_helpers.js')>();
Comment thread
ord669 marked this conversation as resolved.
return {
...actual,
getDefaultWorkspaceId: vi.fn(async () => 'ws_test'),
};
});

// billingManage calls `open(url)` after resolving the Billing page URL;
// without this stub, it would try to launch the user's browser.
Expand Down
27 changes: 27 additions & 0 deletions src/commands/_helpers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -119,6 +119,33 @@ export async function getDefaultWorkspaceId(): Promise<string> {
);
}

/**
* Resolve the org publicId for an org-scoped CLI action (customers, billing)
* from the ACTIVE workspace's row in the /workspaces union.
*
* Never a bare `row[0]`: the union spans every org the user belongs to, so
* row[0] is nondeterministic for a user in 2+ orgs and 403s when it disagrees
* with the token's org (the backend `can()` boundary requires
* path-org == token-org). Pass the workspaceId from getDefaultWorkspaceId() —
* that's the workspace the CLI token is scoped to, so its org is the only one
* the backend will authorize. (AIT-263)
*/
export async function resolveOrgPublicIdForWorkspace(
workspaceId: string,
): Promise<string> {
const all = (await apiClient('/workspaces')) as Array<{
id: string;
organizationPublicId?: string;
}>;
const orgPublicId = all.find((w) => w.id === workspaceId)?.organizationPublicId;
if (!orgPublicId) {
throw new ValidationError(
'No organization found for your active workspace. Run: hookmyapp workspace use <name|id>',
);
}
return orgPublicId;
}

/**
* Resolve the channel a typed command should act on. Precedence (D6):
* 1. explicit --channel <phone|@handle|ch_id>
Expand Down
32 changes: 9 additions & 23 deletions src/commands/billing.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,39 +7,25 @@ import { ValidationError } from '../output/error.js';
import { addExamples } from '../output/help.js';
import { cliCommandPrefix } from '../output/cli-self.js';
import { getEffectiveAppUrl } from '../config/env-profiles.js';
import { getDefaultWorkspaceId } from './_helpers.js';
import { getDefaultWorkspaceId, resolveOrgPublicIdForWorkspace } from './_helpers.js';

// program is lazy-imported inside actions because commands/billing.ts is
// loaded by index.ts during program setup — a top-level import would form a
// cycle. Same pattern as other commands that need root-level opts.

/**
* Billing is org-scoped: the workspace-addressed /stripe/subscription and
* /stripe/checkout routes are retired (410 BILLING_ROUTE_MOVED), and
* /stripe/portal before them (410 BILLING_PORTAL_RETIRED). Resolve the org
* publicId from the /workspaces membership union — every row carries
* organizationPublicId (same mechanism as customers.ts) — preferring the row
* for the active workspace.
*/
async function resolveOrgPublicId(workspaceId: string): Promise<string> {
const all = (await apiClient('/workspaces')) as Array<{
id: string;
organizationPublicId?: string;
}>;
const orgPublicId = (all.find((w) => w.id === workspaceId) ?? all[0])?.organizationPublicId;
if (!orgPublicId) {
throw new ValidationError('No organization found for your account. Log in and try again.');
}
return orgPublicId;
}
// Billing is org-scoped (the workspace-addressed /stripe/subscription and
// /stripe/checkout routes are retired — 410 BILLING_ROUTE_MOVED). The org is
// resolved from the active workspace's row in the /workspaces union via the
// shared resolveOrgPublicIdForWorkspace helper (AIT-263 — one derivation for
// customers + billing, never a bare row[0]).

function orgBillingUrl(orgPublicId: string): string {
return `${getEffectiveAppUrl()}/org/${orgPublicId}/billing`;
}

export async function billingManage(opts: { json?: boolean } = {}): Promise<void> {
const workspaceId = await getDefaultWorkspaceId();
const url = orgBillingUrl(await resolveOrgPublicId(workspaceId));
const url = orgBillingUrl(await resolveOrgPublicIdForWorkspace(workspaceId));

// --json is a machine contract: emit the URL and take NO interactive side
// effect (no browser open, no human text) so agents/CI get clean JSON.
Expand All @@ -54,7 +40,7 @@ export async function billingManage(opts: { json?: boolean } = {}): Promise<void

export async function billingStatus(opts: { json?: boolean; human?: boolean } = {}): Promise<void> {
const workspaceId = await getDefaultWorkspaceId();
const orgPublicId = await resolveOrgPublicId(workspaceId);
const orgPublicId = await resolveOrgPublicIdForWorkspace(workspaceId);
const [sub, usage] = await Promise.all([
apiClient(`/organizations/${orgPublicId}/billing/subscription`),
apiClient('/webhook/usage', { workspaceId }),
Expand Down Expand Up @@ -113,7 +99,7 @@ export async function billingUpgrade(opts: { json?: boolean } = {}): Promise<voi
}

const workspaceId = await getDefaultWorkspaceId();
const orgPublicId = await resolveOrgPublicId(workspaceId);
const orgPublicId = await resolveOrgPublicIdForWorkspace(workspaceId);
const sub = await apiClient(`/organizations/${orgPublicId}/billing/subscription`);
// Phase A drops stripeSubscriptionId. Gate on plan.slug for paid-tier
// detection AND preserve the existing status check so cancelled or
Expand Down
44 changes: 32 additions & 12 deletions src/commands/customers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,10 @@ import { ValidationError } from '../output/error.js';
import { addExamples } from '../output/help.js';
import { dropWorkosOrgId, type Workspace } from '../types/workspace.js';
import { readWorkspaceConfig, switchActiveWorkspace } from './workspace.js';
import { getDefaultWorkspaceId, resolveOrgPublicIdForWorkspace } from './_helpers.js';

/** A /workspaces union row carries the org it belongs to; the base Workspace type does not. */
type WorkspaceRow = Workspace & { organizationPublicId?: string };

interface OnboardingLinkRow {
publicId: string;
Expand All @@ -17,8 +21,10 @@ interface OnboardingLinkRow {
* Customers surface. A customer IS a workspace (`kind='customer'`) —
* this group is a filtered view over the same `/workspaces` union plus the
* org onboarding-link endpoints, reusing the workspace active-context
* machinery. `customers new` is intentionally omitted: a customer is born
* when its owner connects via an onboarding link.
* machinery. Every org-scoped action derives its org from the ACTIVE
* workspace (via resolveOrgPublicIdForWorkspace), never a bare union row[0] —
* for a user in 2+ orgs, row[0] is nondeterministic and the wrong org 403s
* against the token's scope (AIT-263).
*/
export function registerCustomersCommand(program: Command): void {
const cust = program.command('customers').description('Manage customers (customer workspaces)');
Expand All @@ -27,8 +33,23 @@ export function registerCustomersCommand(program: Command): void {
.description('List customers')
.option('--json', 'Output machine-readable JSON')
.action(async (opts: { json?: boolean }) => {
const all = (await apiClient('/workspaces')) as Workspace[];
const customers = all.filter((w) => w.kind === 'customer');
// Scope to the ACTIVE org: the /workspaces union spans every org the
// user belongs to, so an unscoped filter would mix other orgs' customers
// into the list (AIT-263). Derive the active org from the active
// workspace's row and show only its customers.
const workspaceId = await getDefaultWorkspaceId();
const all = (await apiClient('/workspaces')) as WorkspaceRow[];
const activeOrg = all.find((w) => w.id === workspaceId)?.organizationPublicId;
Comment thread
ord669 marked this conversation as resolved.
if (!activeOrg) {
// A stale/absent active workspace must surface actionably — never as a
// silently empty list. Same contract as resolveOrgPublicIdForWorkspace.
throw new ValidationError(
'No organization found for your active workspace. Run: hookmyapp workspace use <name|id>',
);
}
const customers = all.filter(
(w) => w.kind === 'customer' && w.organizationPublicId === activeOrg,
);
Comment thread
coderabbitai[bot] marked this conversation as resolved.
if (opts.json || program.opts().json) {
console.log(JSON.stringify(customers.map(dropWorkosOrgId), null, 2));
return;
Expand All @@ -50,14 +71,13 @@ export function registerCustomersCommand(program: Command): void {
.option('--external-id <id>', 'Your own identifier for this customer (CRM/system id)')
.option('--json', 'Output machine-readable JSON')
.action(async (name: string, opts: { externalId?: string; json?: boolean }) => {
// The customers endpoint is org-scoped; resolve the org publicId from the
// membership union (every row carries organizationPublicId). Does NOT
// switch the active workspace — an empty customer has nothing to work in.
const all = (await apiClient('/workspaces')) as Array<Workspace & { organizationPublicId: string }>;
const orgPublicId = all[0]?.organizationPublicId;
if (!orgPublicId) {
throw new ValidationError('No organization found for your account. Log in and try again.');
}
// The customers endpoint is org-scoped. Resolve the org from the ACTIVE
// workspace (the org the CLI token is scoped to) — never a bare union
// row[0], which for a multi-org user is nondeterministic and 403s when
// it disagrees with the token's org (AIT-263). Does NOT switch the
// active workspace — an empty customer has nothing to work in.
const workspaceId = await getDefaultWorkspaceId();
const orgPublicId = await resolveOrgPublicIdForWorkspace(workspaceId);
const created = await apiClient(`/organizations/${orgPublicId}/customers`, {
method: 'POST',
body: JSON.stringify({ name, ...(opts.externalId ? { externalId: opts.externalId } : {}) }),
Expand Down
Loading