diff --git a/packages/anthropicadministrator/api.test.ts b/packages/anthropicadministrator/api.test.ts new file mode 100644 index 000000000..f9fdabeae --- /dev/null +++ b/packages/anthropicadministrator/api.test.ts @@ -0,0 +1,155 @@ +import { + ApiKeySchema, + InviteSchema, + OrganizationSchema, + UserSchema, + WorkspaceSchema, +} from './endpoints/types'; +import type { AnthropicAdministratorContext } from './index'; + +/** + * Live suite against api.anthropic.com. Requires an Admin API key + * (`sk-ant-admin…`) — a standard API key is rejected by these endpoints. + * Excluded from CI by path; enable with: + * + * ANTHROPIC_ADMIN_API_KEY=sk-ant-admin-… LIVE_TEST=1 pnpm test + * + * Read-only operations only: this suite never creates, updates, archives or + * deletes anything in a real organization. + */ +const ADMIN_KEY = process.env.ANTHROPIC_ADMIN_API_KEY; +const LIVE = process.env.LIVE_TEST === '1' || process.env.LIVE_TEST === 'true'; + +type Ops = Record< + string, + Record< + string, + ( + c: AnthropicAdministratorContext, + i: Record, + ) => Promise + > +>; + +let ops: Ops; + +function op(group: string, name: string) { + const fn = ops[group]?.[name]; + if (!fn) throw new Error(`missing endpoint ${group}.${name}`); + return fn; +} + +function ctx(key = ADMIN_KEY): AnthropicAdministratorContext { + return { + key, + options: {}, + db: {}, + } as unknown as AnthropicAdministratorContext; +} + +const suite = ADMIN_KEY && LIVE ? describe : describe.skip; + +suite('Anthropic Admin API (live)', () => { + beforeAll(async () => { + const mod = await import('./index'); + ops = mod.anthropicAdministratorEndpointsNested as unknown as Ops; + }); + + it('getOrganization returns the organization for the key', async () => { + const org = await op('organization', 'getOrganization')(ctx(), {}); + expect(() => OrganizationSchema.parse(org)).not.toThrow(); + }); + + it('listUsers returns members matching the documented shape', async () => { + const res = (await op('users', 'listUsers')(ctx(), { limit: 5 })) as { + data: unknown[]; + has_more: boolean; + }; + expect(typeof res.has_more).toBe('boolean'); + for (const user of res.data) { + expect(() => UserSchema.parse(user)).not.toThrow(); + } + }); + + it('listInvites returns invites matching the documented shape', async () => { + const res = (await op('invites', 'listInvites')(ctx(), { limit: 5 })) as { + data: unknown[]; + }; + for (const invite of res.data) { + expect(() => InviteSchema.parse(invite)).not.toThrow(); + } + }); + + it('listWorkspaces returns workspaces matching the documented shape', async () => { + const res = (await op('workspaces', 'listWorkspaces')(ctx(), { + limit: 5, + })) as { data: unknown[] }; + for (const workspace of res.data) { + expect(() => WorkspaceSchema.parse(workspace)).not.toThrow(); + } + }); + + it('listApiKeys returns API keys matching the documented shape', async () => { + const res = (await op('apiKeys', 'listApiKeys')(ctx(), { limit: 5 })) as { + data: unknown[]; + }; + for (const key of res.data) { + expect(() => ApiKeySchema.parse(key)).not.toThrow(); + } + }); + + it('honours cursor pagination on listUsers', async () => { + const page = (await op('users', 'listUsers')(ctx(), { limit: 1 })) as { + data: unknown[]; + last_id: string | null; + has_more: boolean; + }; + expect(page.data.length).toBeLessThanOrEqual(1); + + if (page.has_more && page.last_id) { + const next = (await op('users', 'listUsers')(ctx(), { + limit: 1, + after_id: page.last_id, + })) as { data: unknown[] }; + expect(next.data).not.toEqual(page.data); + } + }); + + it('rejects a non-admin key', async () => { + await expect( + op('users', 'listUsers')(ctx('sk-ant-not-an-admin-key'), {}), + ).rejects.toThrow(); + }); +}); + +/** + * Reachability check that needs no credentials: a bogus key must produce a 401 + * `authentication_error` from Anthropic. A 404 or a network error would mean + * the base URL, path or auth header name is wrong. Enable with LIVE_TEST=1. + */ +const reachability = LIVE ? describe : describe.skip; + +reachability('Anthropic Admin API reachability (no key required)', () => { + beforeAll(async () => { + const mod = await import('./index'); + ops = mod.anthropicAdministratorEndpointsNested as unknown as Ops; + }); + + it.each([ + ['organization', 'getOrganization', {}], + ['users', 'listUsers', { limit: 1 }], + ['workspaces', 'listWorkspaces', { limit: 1 }], + ['apiKeys', 'listApiKeys', { limit: 1 }], + ] as const)( + '%s.%s resolves to a real endpoint', + async (group, name, input) => { + const error = (await op(group, name)( + ctx('sk-ant-admin-not-a-real-key'), + input as Record, + ).catch((e: unknown) => e)) as { status?: number; errorType?: string }; + + expect(error.status).toBe(401); + expect(error.errorType).toBe('authentication_error'); + }, + ); +}); diff --git a/packages/anthropicadministrator/client.ts b/packages/anthropicadministrator/client.ts new file mode 100644 index 000000000..8765e65a7 --- /dev/null +++ b/packages/anthropicadministrator/client.ts @@ -0,0 +1,215 @@ +import type { ApiRequestOptions, OpenAPIConfig } from 'corsair/http'; +import { ApiError, request } from 'corsair/http'; + +export type AnthropicAdministratorMethod = 'GET' | 'POST' | 'DELETE'; + +/** + * Error thrown by every Admin API call. + * + * The transport status and rate-limit metadata are copied off the underlying + * `ApiError` so `error-handlers.ts` can match on them — a wrapper that only + * carried `message` would make the 429 policy unreachable, because corsair + * throws a 429 with the message "Too Many Requests" (no status in the text). + */ +export class AnthropicAdministratorAPIError extends Error { + public readonly status?: number; + public readonly statusText?: string; + /** Admin API error bodies are `{ type: "error", error: { type, message } }`. */ + public readonly body?: unknown; + public readonly retryAfter?: number; + /** HTTP method of the failed request, so retries can tell reads from writes. */ + public readonly method?: AnthropicAdministratorMethod; + /** Anthropic error type, e.g. `authentication_error`, `not_found_error`. */ + public readonly errorType?: string; + + constructor( + message: string, + options?: { cause?: Error; method?: AnthropicAdministratorMethod }, + ) { + super(message, options); + this.name = 'AnthropicAdministratorAPIError'; + this.method = options?.method; + + const cause = options?.cause; + if (cause instanceof ApiError) { + this.status = cause.status; + this.statusText = cause.statusText; + this.body = cause.body; + this.retryAfter = cause.retryAfter; + this.errorType = readErrorType(cause.body); + } + } +} + +/** Pulls `error.type` out of an Anthropic error envelope when present. */ +function readErrorType(body: unknown): string | undefined { + if (typeof body !== 'object' || body === null) return undefined; + const error = (body as { error?: unknown }).error; + if (typeof error !== 'object' || error === null) return undefined; + const type = (error as { type?: unknown }).type; + return typeof type === 'string' ? type : undefined; +} + +const ANTHROPIC_API_BASE = 'https://api.anthropic.com'; + +/** + * Version header required on every request to the Anthropic API. + * https://platform.claude.com/docs/en/api/versioning + */ +const ANTHROPIC_VERSION = '2023-06-01'; + +/** + * Which credential `apiKey` holds. Admin API keys authenticate with `x-api-key`; + * OAuth tokens carrying the `org:admin` scope use `authorization: Bearer`. + * https://platform.claude.com/docs/en/manage-claude/admin-api + */ +export type AnthropicAdministratorAuthType = 'api_key' | 'oauth_2'; + +export type AnthropicAdministratorRequestOptions = { + method?: AnthropicAdministratorMethod; + authType?: AnthropicAdministratorAuthType; + /** Request payloads differ per operation; validated by per-op zod schemas. */ + body?: Record; + /** + * Query values are heterogeneous across the Admin API (cursors, limits, + * repeated filters), so arrays are allowed for repeatable params. + */ + query?: Record; +}; + +/** + * Performs a request against the Anthropic Admin API. + * + * Auth: an Admin API key (`sk-ant-admin…`) in the `x-api-key` header, or an + * OAuth token with the `org:admin` scope in `authorization: Bearer`. Admin keys + * are provisioned by organization admins and are distinct from standard API + * keys. + */ +/** + * Upper bound on a request path. + * + * Every path this plugin builds is a short literal prefix (at most + * `/v1/organizations/workspaces`) plus one or two percent-encoded resource + * IDs, so this is far above anything legitimate. + * + * It also bounds the work done by the `{placeholder}` substitution in + * `corsair/http`, whose regex is polynomial in the number of unmatched `{` + * characters (CodeQL `js/polynomial-redos`). Capping the input length is the + * documented mitigation when the regex itself is not owned here. + */ +const MAX_ENDPOINT_LENGTH = 512; + +/** Total attempts for a retryable failure (1 initial + 2 retries). */ +const MAX_ATTEMPTS = 3; + +/** Upper bound on an honoured `Retry-After`, so a hostile header cannot stall a caller. */ +const MAX_RETRY_DELAY_MS = 30_000; + +const sleep = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms)); + +/** + * Whether a failed attempt may be replayed. + * + * A 429 is safe for any method: the request was rejected before being applied. + * A 5xx may have been applied server-side, and the Admin API documents no + * idempotency key, so only GET is replayed. + */ +function isRetryable( + status: number | undefined, + method: AnthropicAdministratorMethod, +): boolean { + if (status === 429) return true; + if (status !== undefined && status >= 500) return method === 'GET'; + return false; +} + +function retryDelayMs(error: ApiError, attempt: number): number { + const retryAfter = error.retryAfter; + if (typeof retryAfter === 'number' && retryAfter > 0) { + return Math.min(retryAfter, MAX_RETRY_DELAY_MS); + } + return Math.min(2 ** (attempt - 1) * 1000, MAX_RETRY_DELAY_MS); +} + +/** + * Performs a request against the Anthropic Admin API. + * + * Auth: an Admin API key (`sk-ant-admin…`) in the `x-api-key` header, or an + * OAuth token with the `org:admin` scope in `authorization: Bearer`. Admin keys + * are provisioned by organization admins and are distinct from standard API + * keys. + * + * Retries are performed here rather than delegated to the shared endpoint + * binder, so a request that succeeds on retry returns that result to the + * caller instead of surfacing the first failure. + */ +export async function makeAnthropicAdministratorRequest( + endpoint: string, + apiKey: string, + options: AnthropicAdministratorRequestOptions = {}, +): Promise { + const { method = 'GET', body, query, authType = 'api_key' } = options; + + if (endpoint.length > MAX_ENDPOINT_LENGTH) { + throw new AnthropicAdministratorAPIError( + `Request path exceeds ${MAX_ENDPOINT_LENGTH} characters`, + { method }, + ); + } + + const isWrite = method === 'POST'; + + const credential: Record = + authType === 'oauth_2' + ? { authorization: `Bearer ${apiKey}` } + : { 'x-api-key': apiKey }; + + const config: OpenAPIConfig = { + BASE: ANTHROPIC_API_BASE, + VERSION: ANTHROPIC_VERSION, + WITH_CREDENTIALS: false, + CREDENTIALS: 'omit', + TOKEN: undefined, + HEADERS: { + ...credential, + 'anthropic-version': ANTHROPIC_VERSION, + ...(isWrite ? { 'content-type': 'application/json' } : {}), + }, + }; + + const requestOptions: ApiRequestOptions = { + method, + url: endpoint, + body: isWrite ? body : undefined, + mediaType: isWrite ? 'application/json' : undefined, + query, + }; + + let lastError: unknown; + + for (let attempt = 1; attempt <= MAX_ATTEMPTS; attempt++) { + try { + return await request(config, requestOptions); + } catch (error) { + lastError = error; + + const status = error instanceof ApiError ? error.status : undefined; + const canRetry = + error instanceof ApiError && + attempt < MAX_ATTEMPTS && + isRetryable(status, method); + + if (!canRetry) break; + + await sleep(retryDelayMs(error as ApiError, attempt)); + } + } + + if (lastError instanceof Error) { + throw new AnthropicAdministratorAPIError(lastError.message, { + cause: lastError, + method, + }); + } + throw new AnthropicAdministratorAPIError('Unknown error', { method }); +} diff --git a/packages/anthropicadministrator/endpoints.test.ts b/packages/anthropicadministrator/endpoints.test.ts new file mode 100644 index 000000000..05bc254ad --- /dev/null +++ b/packages/anthropicadministrator/endpoints.test.ts @@ -0,0 +1,397 @@ +import { request } from 'corsair/http'; +import type { AnthropicAdministratorContext } from './index'; +import { anthropicadministrator } from './index'; + +jest.mock('corsair/core', () => ({ + ...jest.requireActual('corsair/core'), + logEventFromContext: jest.fn(async () => undefined), +})); + +jest.mock('corsair/http', () => { + const actual = jest.requireActual('corsair/http'); + return { ...actual, request: jest.fn() }; +}); + +const mockRequest = request as jest.Mock; + +const upserts: Array<[string, string, unknown]> = []; +const deletes: Array<[string, string]> = []; + +function entity(name: string) { + return { + upsertByEntityId: async (id: string, data: unknown) => { + upserts.push([name, id, data]); + }, + deleteByEntityId: async (id: string) => { + deletes.push([name, id]); + return true; + }, + }; +} + +const ctx = { + key: 'sk-ant-admin-test', + options: {}, + db: { + users: entity('users'), + invites: entity('invites'), + workspaces: entity('workspaces'), + workspaceMembers: entity('workspaceMembers'), + apiKeys: entity('apiKeys'), + }, + // Test-only partial context; endpoints read `key` and `db` only. +} as unknown as AnthropicAdministratorContext; + +function ops() { + const plugin = anthropicadministrator({ key: 'sk-ant-admin-test' }); + return plugin.endpoints as unknown as Record< + string, + Record< + string, + ( + c: AnthropicAdministratorContext, + i: Record, + ) => Promise + > + >; +} + +function call( + group: string, + name: string, + input: Record = {}, +) { + const fn = ops()[group]?.[name]; + if (!fn) throw new Error(`missing endpoint ${group}.${name}`); + return fn(ctx, input); +} + +function sent() { + const c = mockRequest.mock.calls.at(-1); + if (!c) throw new Error('request was never called'); + return { + config: c[0] as { BASE: string; HEADERS: Record }, + options: c[1] as { + method: string; + url: string; + body?: Record; + query?: Record; + }, + }; +} + +const USER = { + id: 'user_1', + added_at: '2026-01-01T00:00:00Z', + email: 'a@b.com', + name: 'A', + role: 'developer', + type: 'user', +}; +const WORKSPACE = { + id: 'wrkspc_1', + archived_at: null, + created_at: '2026-01-01T00:00:00Z', + name: 'W', + type: 'workspace', +}; +const MEMBER = { + type: 'workspace_member', + user_id: 'user_1', + workspace_id: 'wrkspc_1', + workspace_role: 'workspace_developer', +}; + +beforeEach(() => { + mockRequest.mockReset(); + mockRequest.mockResolvedValue({}); + upserts.length = 0; + deletes.length = 0; +}); + +describe('Admin API transport', () => { + it('sends x-api-key and anthropic-version against api.anthropic.com', async () => { + await call('organization', 'getOrganization'); + + expect(sent().config.BASE).toBe('https://api.anthropic.com'); + expect(sent().config.HEADERS).toMatchObject({ + 'x-api-key': 'sk-ant-admin-test', + 'anthropic-version': '2023-06-01', + }); + // Anthropic authenticates with x-api-key, not a bearer token. + expect(sent().config.HEADERS.Authorization).toBeUndefined(); + expect(sent().options.url).toBe('/v1/organizations/me'); + }); + + it('sends an oauth token as a bearer credential, not x-api-key', async () => { + const plugin = anthropicadministrator({ + key: 'oauth-access-token', + authType: 'oauth_2', + }); + const groups = plugin.endpoints as unknown as Record< + string, + Record< + string, + ( + c: AnthropicAdministratorContext, + i: Record, + ) => Promise + > + >; + const oauthCtx = { + key: 'oauth-access-token', + options: { authType: 'oauth_2' }, + db: {}, + } as unknown as AnthropicAdministratorContext; + + const fn = groups.organization?.getOrganization; + if (!fn) throw new Error('missing endpoint'); + await fn(oauthCtx, {}); + + expect(sent().config.HEADERS).toMatchObject({ + authorization: 'Bearer oauth-access-token', + 'anthropic-version': '2023-06-01', + }); + expect(sent().config.HEADERS['x-api-key']).toBeUndefined(); + }); + + it('never sends a body on GET or DELETE', async () => { + await call('users', 'listUsers', {}); + expect(sent().options.body).toBeUndefined(); + + await call('users', 'removeUser', { user_id: 'user_1' }); + expect(sent().options.method).toBe('DELETE'); + expect(sent().options.body).toBeUndefined(); + }); +}); + +describe('Admin API routes', () => { + it.each([ + ['organization', 'getOrganization', {}, 'GET', '/v1/organizations/me'], + ['users', 'listUsers', {}, 'GET', '/v1/organizations/users'], + [ + 'users', + 'getUser', + { user_id: 'u 1' }, + 'GET', + '/v1/organizations/users/u%201', + ], + [ + 'users', + 'updateUser', + { user_id: 'u1', role: 'developer' }, + 'POST', + '/v1/organizations/users/u1', + ], + [ + 'users', + 'removeUser', + { user_id: 'u1' }, + 'DELETE', + '/v1/organizations/users/u1', + ], + ['invites', 'listInvites', {}, 'GET', '/v1/organizations/invites'], + [ + 'invites', + 'createInvite', + { email: 'a@b.com', role: 'user' }, + 'POST', + '/v1/organizations/invites', + ], + [ + 'invites', + 'getInvite', + { invite_id: 'i1' }, + 'GET', + '/v1/organizations/invites/i1', + ], + [ + 'invites', + 'deleteInvite', + { invite_id: 'i1' }, + 'DELETE', + '/v1/organizations/invites/i1', + ], + ['workspaces', 'listWorkspaces', {}, 'GET', '/v1/organizations/workspaces'], + [ + 'workspaces', + 'createWorkspace', + { name: 'W' }, + 'POST', + '/v1/organizations/workspaces', + ], + [ + 'workspaces', + 'getWorkspace', + { workspace_id: 'w1' }, + 'GET', + '/v1/organizations/workspaces/w1', + ], + [ + 'workspaces', + 'updateWorkspace', + { workspace_id: 'w1', name: 'X' }, + 'POST', + '/v1/organizations/workspaces/w1', + ], + [ + 'workspaces', + 'archiveWorkspace', + { workspace_id: 'w1' }, + 'POST', + '/v1/organizations/workspaces/w1/archive', + ], + [ + 'workspaceMembers', + 'listWorkspaceMembers', + { workspace_id: 'w1' }, + 'GET', + '/v1/organizations/workspaces/w1/members', + ], + [ + 'workspaceMembers', + 'createWorkspaceMember', + { workspace_id: 'w1', user_id: 'u1', workspace_role: 'workspace_user' }, + 'POST', + '/v1/organizations/workspaces/w1/members', + ], + [ + 'workspaceMembers', + 'getWorkspaceMember', + { workspace_id: 'w1', user_id: 'u1' }, + 'GET', + '/v1/organizations/workspaces/w1/members/u1', + ], + [ + 'workspaceMembers', + 'updateWorkspaceMember', + { workspace_id: 'w1', user_id: 'u1', workspace_role: 'workspace_admin' }, + 'POST', + '/v1/organizations/workspaces/w1/members/u1', + ], + [ + 'workspaceMembers', + 'deleteWorkspaceMember', + { workspace_id: 'w1', user_id: 'u1' }, + 'DELETE', + '/v1/organizations/workspaces/w1/members/u1', + ], + ['apiKeys', 'listApiKeys', {}, 'GET', '/v1/organizations/api_keys'], + [ + 'apiKeys', + 'getApiKey', + { api_key_id: 'k1' }, + 'GET', + '/v1/organizations/api_keys/k1', + ], + [ + 'apiKeys', + 'updateApiKey', + { api_key_id: 'k1', name: 'n' }, + 'POST', + '/v1/organizations/api_keys/k1', + ], + ] as const)('%s.%s -> %s', async (group, name, input, method, url) => { + mockRequest.mockResolvedValueOnce({ data: [] }); + await call(group, name, input as Record); + + expect(sent().options.method).toBe(method); + expect(sent().options.url).toBe(url); + }); + + it('sends only the documented body fields, omitting undefined', async () => { + await call('workspaces', 'updateWorkspace', { + workspace_id: 'w1', + name: 'New name', + }); + + expect(sent().options.body).toEqual({ name: 'New name' }); + }); + + it('passes list filters through as query parameters', async () => { + mockRequest.mockResolvedValueOnce({ data: [] }); + await call('users', 'listUsers', { + limit: 50, + email: 'a@b.com', + roles: ['admin', 'developer'], + }); + + expect(sent().options.query).toMatchObject({ + limit: 50, + email: 'a@b.com', + roles: ['admin', 'developer'], + }); + }); +}); + +describe('Admin API cache mirroring', () => { + it('caches each item of a list page', async () => { + mockRequest.mockResolvedValueOnce({ + data: [USER], + first_id: 'user_1', + has_more: false, + last_id: 'user_1', + }); + + await call('users', 'listUsers', {}); + + expect(upserts).toEqual([['users', 'user_1', USER]]); + }); + + it('caches a single fetched entity', async () => { + mockRequest.mockResolvedValueOnce(WORKSPACE); + await call('workspaces', 'getWorkspace', { workspace_id: 'wrkspc_1' }); + + expect(upserts).toEqual([['workspaces', 'wrkspc_1', WORKSPACE]]); + }); + + it('keys workspace members by workspace and user', async () => { + mockRequest.mockResolvedValueOnce(MEMBER); + await call('workspaceMembers', 'getWorkspaceMember', { + workspace_id: 'wrkspc_1', + user_id: 'user_1', + }); + + expect(upserts).toEqual([['workspaceMembers', 'wrkspc_1:user_1', MEMBER]]); + }); + + it('evicts on delete', async () => { + mockRequest.mockResolvedValueOnce({ id: 'user_1', type: 'user_deleted' }); + await call('users', 'removeUser', { user_id: 'user_1' }); + expect(deletes).toEqual([['users', 'user_1']]); + + mockRequest.mockResolvedValueOnce({ + type: 'workspace_member_deleted', + user_id: 'user_1', + workspace_id: 'wrkspc_1', + }); + await call('workspaceMembers', 'deleteWorkspaceMember', { + workspace_id: 'wrkspc_1', + user_id: 'user_1', + }); + expect(deletes).toContainEqual(['workspaceMembers', 'wrkspc_1:user_1']); + }); + + it('refreshes rather than evicts an archived workspace', async () => { + mockRequest.mockResolvedValueOnce({ + ...WORKSPACE, + archived_at: '2026-02-01T00:00:00Z', + }); + await call('workspaces', 'archiveWorkspace', { workspace_id: 'wrkspc_1' }); + + expect(deletes).toEqual([]); + expect(upserts[0]?.[0]).toBe('workspaces'); + }); + + it('does not throw when no database is bound', async () => { + const bare = { + key: 'k', + options: {}, + } as unknown as AnthropicAdministratorContext; + mockRequest.mockResolvedValueOnce(WORKSPACE); + const fn = ops().workspaces?.getWorkspace; + if (!fn) throw new Error('missing endpoint'); + + await expect(fn(bare, { workspace_id: 'wrkspc_1' })).resolves.toBeDefined(); + }); +}); diff --git a/packages/anthropicadministrator/endpoints/api-keys.ts b/packages/anthropicadministrator/endpoints/api-keys.ts new file mode 100644 index 000000000..ff9169a10 --- /dev/null +++ b/packages/anthropicadministrator/endpoints/api-keys.ts @@ -0,0 +1,67 @@ +import type { AnthropicAdministratorEndpoints } from '../index'; +import { cacheEntity, cacheList, callAdminApi, compact } from './shared'; +import type { + ApiKey, + AnthropicAdministratorEndpointOutputs as Outputs, +} from './types'; + +const BASE = '/v1/organizations/api_keys'; + +/** GET /v1/organizations/api_keys */ +export const listApiKeys: AnthropicAdministratorEndpoints['listApiKeys'] = + async (ctx, input) => { + const response = await callAdminApi( + ctx, + 'apiKeys.listApiKeys', + BASE, + { + method: 'GET', + query: { + after_id: input.after_id, + before_id: input.before_id, + created_by_user_id: input.created_by_user_id, + limit: input.limit, + status: input.status, + workspace_id: input.workspace_id, + }, + }, + ); + + await cacheList(ctx, 'apiKeys', response.data, (k: ApiKey) => k.id); + return response; + }; + +/** GET /v1/organizations/api_keys/{api_key_id} */ +export const getApiKey: AnthropicAdministratorEndpoints['getApiKey'] = async ( + ctx, + input, +) => { + const response = await callAdminApi( + ctx, + 'apiKeys.getApiKey', + `${BASE}/${encodeURIComponent(input.api_key_id)}`, + { method: 'GET' }, + { api_key_id: input.api_key_id }, + ); + + await cacheEntity(ctx, 'apiKeys', response.id, response); + return response; +}; + +/** POST /v1/organizations/api_keys/{api_key_id} */ +export const updateApiKey: AnthropicAdministratorEndpoints['updateApiKey'] = + async (ctx, input) => { + const response = await callAdminApi( + ctx, + 'apiKeys.updateApiKey', + `${BASE}/${encodeURIComponent(input.api_key_id)}`, + { + method: 'POST', + body: compact({ name: input.name, status: input.status }), + }, + { api_key_id: input.api_key_id }, + ); + + await cacheEntity(ctx, 'apiKeys', response.id, response); + return response; + }; diff --git a/packages/anthropicadministrator/endpoints/index.ts b/packages/anthropicadministrator/endpoints/index.ts new file mode 100644 index 000000000..0ce37c80e --- /dev/null +++ b/packages/anthropicadministrator/endpoints/index.ts @@ -0,0 +1,48 @@ +import * as ApiKeys from './api-keys'; +import * as Invites from './invites'; +import * as Organization from './organization'; +import * as Users from './users'; +import * as WorkspaceMembers from './workspace-members'; +import * as Workspaces from './workspaces'; + +export const OrganizationEndpoints = { + getOrganization: Organization.getOrganization, +}; + +export const UsersEndpoints = { + listUsers: Users.listUsers, + getUser: Users.getUser, + updateUser: Users.updateUser, + removeUser: Users.removeUser, +}; + +export const InvitesEndpoints = { + listInvites: Invites.listInvites, + createInvite: Invites.createInvite, + getInvite: Invites.getInvite, + deleteInvite: Invites.deleteInvite, +}; + +export const WorkspacesEndpoints = { + listWorkspaces: Workspaces.listWorkspaces, + createWorkspace: Workspaces.createWorkspace, + getWorkspace: Workspaces.getWorkspace, + updateWorkspace: Workspaces.updateWorkspace, + archiveWorkspace: Workspaces.archiveWorkspace, +}; + +export const WorkspaceMembersEndpoints = { + listWorkspaceMembers: WorkspaceMembers.listWorkspaceMembers, + createWorkspaceMember: WorkspaceMembers.createWorkspaceMember, + getWorkspaceMember: WorkspaceMembers.getWorkspaceMember, + updateWorkspaceMember: WorkspaceMembers.updateWorkspaceMember, + deleteWorkspaceMember: WorkspaceMembers.deleteWorkspaceMember, +}; + +export const ApiKeysEndpoints = { + listApiKeys: ApiKeys.listApiKeys, + getApiKey: ApiKeys.getApiKey, + updateApiKey: ApiKeys.updateApiKey, +}; + +export * from './types'; diff --git a/packages/anthropicadministrator/endpoints/invites.ts b/packages/anthropicadministrator/endpoints/invites.ts new file mode 100644 index 000000000..ce8970aa4 --- /dev/null +++ b/packages/anthropicadministrator/endpoints/invites.ts @@ -0,0 +1,92 @@ +import type { AnthropicAdministratorEndpoints } from '../index'; +import { + cacheEntity, + cacheList, + callAdminApi, + compact, + evictEntity, +} from './shared'; +import type { + Invite, + AnthropicAdministratorEndpointOutputs as Outputs, +} from './types'; + +const BASE = '/v1/organizations/invites'; + +/** GET /v1/organizations/invites */ +export const listInvites: AnthropicAdministratorEndpoints['listInvites'] = + async (ctx, input) => { + const response = await callAdminApi( + ctx, + 'invites.listInvites', + BASE, + { + method: 'GET', + query: { + after_id: input.after_id, + before_id: input.before_id, + email: input.email, + limit: input.limit, + roles: input.roles, + statuses: input.statuses, + }, + }, + ); + + await cacheList(ctx, 'invites', response.data, (i: Invite) => i.id); + return response; + }; + +/** POST /v1/organizations/invites */ +export const createInvite: AnthropicAdministratorEndpoints['createInvite'] = + async (ctx, input) => { + const response = await callAdminApi( + ctx, + 'invites.createInvite', + BASE, + { + method: 'POST', + body: compact({ + email: input.email, + role: input.role, + rbac_group_ids: input.rbac_group_ids, + }), + }, + { role: input.role }, + ); + + await cacheEntity(ctx, 'invites', response.id, response); + return response; + }; + +/** GET /v1/organizations/invites/{invite_id} */ +export const getInvite: AnthropicAdministratorEndpoints['getInvite'] = async ( + ctx, + input, +) => { + const response = await callAdminApi( + ctx, + 'invites.getInvite', + `${BASE}/${encodeURIComponent(input.invite_id)}`, + { method: 'GET' }, + { invite_id: input.invite_id }, + ); + + await cacheEntity(ctx, 'invites', response.id, response); + return response; +}; + +/** DELETE /v1/organizations/invites/{invite_id} */ +export const deleteInvite: AnthropicAdministratorEndpoints['deleteInvite'] = + async (ctx, input) => { + const response = await callAdminApi( + ctx, + 'invites.deleteInvite', + `${BASE}/${encodeURIComponent(input.invite_id)}`, + { method: 'DELETE' }, + { invite_id: input.invite_id }, + ); + + await evictEntity(ctx, 'invites', input.invite_id); + return response; + }; diff --git a/packages/anthropicadministrator/endpoints/organization.ts b/packages/anthropicadministrator/endpoints/organization.ts new file mode 100644 index 000000000..f86d6ae89 --- /dev/null +++ b/packages/anthropicadministrator/endpoints/organization.ts @@ -0,0 +1,14 @@ +import type { AnthropicAdministratorEndpoints } from '../index'; +import { callAdminApi } from './shared'; +import type { AnthropicAdministratorEndpointOutputs as Outputs } from './types'; + +/** GET /v1/organizations/me */ +export const getOrganization: AnthropicAdministratorEndpoints['getOrganization'] = + async (ctx) => { + return callAdminApi( + ctx, + 'organization.getOrganization', + '/v1/organizations/me', + { method: 'GET' }, + ); + }; diff --git a/packages/anthropicadministrator/endpoints/shared.ts b/packages/anthropicadministrator/endpoints/shared.ts new file mode 100644 index 000000000..ac40eae2b --- /dev/null +++ b/packages/anthropicadministrator/endpoints/shared.ts @@ -0,0 +1,137 @@ +import { logEventFromContext } from 'corsair/core'; +import type { AnthropicAdministratorRequestOptions } from '../client'; +import { makeAnthropicAdministratorRequest } from '../client'; +import type { AnthropicAdministratorContext } from '../index'; + +/** Entities mirrored into the plugin's local cache. */ +export type CacheEntity = + | 'users' + | 'invites' + | 'workspaces' + | 'workspaceMembers' + | 'apiKeys'; + +type EntityClient = { + upsertByEntityId?: ( + entityId: string, + data: Record, + ) => Promise; + deleteByEntityId?: (entityId: string) => Promise; +}; + +function entityClient( + ctx: AnthropicAdministratorContext, + entity: CacheEntity, +): EntityClient | undefined { + // `ctx.db` exposes a per-entity client whose `upsert` argument is narrowed to + // that entity's schema. This helper is deliberately entity-agnostic, so the + // map is widened through `unknown`; the caller supplies an already-validated + // API response for the matching entity. + const db = ctx.db as unknown as + | Record + | undefined; + return db?.[entity]; +} + +/** Composite cache key for workspace members, which have no standalone ID. */ +export function workspaceMemberKey( + workspaceId: string, + userId: string, +): string { + return `${workspaceId}:${userId}`; +} + +export async function cacheEntity( + ctx: AnthropicAdministratorContext, + entity: CacheEntity, + entityId: string | undefined, + data: unknown, +): Promise { + if (!entityId || typeof data !== 'object' || data === null) return; + const client = entityClient(ctx, entity); + if (!client?.upsertByEntityId) return; + + try { + await client.upsertByEntityId(entityId, data as Record); + } catch (error) { + console.warn( + `[anthropicadministrator] failed to cache ${entity} ${entityId}:`, + error, + ); + } +} + +/** + * Mirrors a `{ data: [...] }` list page into the cache. Tolerates a missing or + * malformed `data` field so a caching concern can never fail the API call. + */ +export async function cacheList( + ctx: AnthropicAdministratorContext, + entity: CacheEntity, + items: readonly T[] | undefined, + idOf: (item: T) => string | undefined, +): Promise { + if (!Array.isArray(items)) return; + for (const item of items) { + await cacheEntity(ctx, entity, idOf(item), item); + } +} + +export async function evictEntity( + ctx: AnthropicAdministratorContext, + entity: CacheEntity, + entityId: string, +): Promise { + const client = entityClient(ctx, entity); + if (!client?.deleteByEntityId) return; + + try { + await client.deleteByEntityId(entityId); + } catch (error) { + console.warn( + `[anthropicadministrator] failed to evict ${entity} ${entityId}:`, + error, + ); + } +} + +/** + * Issues an Admin API request and records the operation. Logging happens after + * a successful response so a failed call is never recorded as `completed`. + */ +export async function callAdminApi( + ctx: AnthropicAdministratorContext, + operation: string, + path: string, + options: AnthropicAdministratorRequestOptions = {}, + logPayload: Record = {}, +): Promise { + const response = await makeAnthropicAdministratorRequest(path, ctx.key, { + ...options, + authType: ctx.options?.authType, + }); + + // The remote call already succeeded; a telemetry failure must not turn that + // into a thrown error for the caller. + try { + await logEventFromContext( + ctx, + `anthropicadministrator.${operation}`, + logPayload, + 'completed', + ); + } catch (error) { + console.warn(`[anthropicadministrator] failed to log ${operation}:`, error); + } + + return response; +} + +/** Drops undefined values so optional fields are never sent as `null` keys. */ +export function compact( + fields: Record, +): Record { + return Object.fromEntries( + Object.entries(fields).filter(([, value]) => value !== undefined), + ); +} diff --git a/packages/anthropicadministrator/endpoints/types.ts b/packages/anthropicadministrator/endpoints/types.ts new file mode 100644 index 000000000..fcb7d66c5 --- /dev/null +++ b/packages/anthropicadministrator/endpoints/types.ts @@ -0,0 +1,388 @@ +import { z } from 'zod'; + +/** + * Schemas mirror the Anthropic Admin API reference + * (platform.claude.com/docs/en/api/admin). Field names match the wire format + * (snake_case) so requests and cached rows are faithful to the API. + * + * Object schemas are `.loose()` because Anthropic adds fields over time and a + * response must not fail validation for being newer than this plugin. + */ + +// ── Shared ─────────────────────────────────────────────────────────────────── + +/** Organization roles accepted across users and invites. */ +export const OrganizationRoleSchema = z.enum([ + 'admin', + 'billing', + 'claude_code_user', + 'developer', + 'managed', + 'membership_admin', + 'owner', + 'primary_owner', + 'user', +]); + +/** Roles assignable when updating a user or creating an invite. */ +export const AssignableOrganizationRoleSchema = z.enum([ + 'billing', + 'claude_code_user', + 'developer', + 'user', + 'admin', +]); + +export const WorkspaceRoleSchema = z.enum([ + 'workspace_admin', + 'workspace_billing', + 'workspace_developer', + 'workspace_restricted_developer', + 'workspace_user', +]); + +/** Cursor pagination shared by every Admin API list endpoint. */ +const paginationFields = { + after_id: z + .string() + .optional() + .describe('Cursor: return the page immediately after this object ID'), + before_id: z + .string() + .optional() + .describe('Cursor: return the page immediately before this object ID'), + limit: z + .number() + .int() + .min(1) + .max(1000) + .optional() + .describe('Items per page (1-1000, default 20)'), +}; + +/** Envelope returned by every list endpoint. */ +function listResponse(item: T) { + return z + .object({ + data: z.array(item), + first_id: z.string().nullable(), + has_more: z.boolean(), + last_id: z.string().nullable(), + }) + .loose(); +} + +// ── Entities ───────────────────────────────────────────────────────────────── + +export const OrganizationSchema = z + .object({ + id: z.string(), + name: z.string(), + type: z.literal('organization'), + }) + .loose(); + +export const UserSchema = z + .object({ + id: z.string(), + added_at: z.string(), + email: z.string(), + name: z.string(), + role: OrganizationRoleSchema, + type: z.literal('user'), + }) + .loose(); + +export const InviteSchema = z + .object({ + id: z.string(), + accepted_at: z.string().nullable(), + email: z.string(), + expires_at: z.string(), + invited_at: z.string(), + rbac_group_ids: z.array(z.string()).optional(), + role: OrganizationRoleSchema, + status: z.enum(['accepted', 'deleted', 'expired', 'pending']), + type: z.literal('invite'), + }) + .loose(); + +export const WorkspaceSchema = z + .object({ + id: z.string(), + archived_at: z.string().nullable(), + compartment_id: z.string().optional(), + created_at: z.string(), + // Nested shape varies by organization plan; kept open. + data_residency: z.record(z.string(), z.unknown()).nullable().optional(), + display_color: z.string().optional(), + external_key_id: z.string().nullable().optional(), + name: z.string(), + tags: z.record(z.string(), z.string()).nullable().optional(), + type: z.literal('workspace'), + }) + .loose(); + +export const WorkspaceMemberSchema = z + .object({ + type: z.literal('workspace_member'), + user_id: z.string(), + workspace_id: z.string(), + workspace_role: WorkspaceRoleSchema, + }) + .loose(); + +/** `{ id, type }` reference used by APIKey.created_by / APIKey.principal. */ +const ActorRefSchema = z + .object({ id: z.string(), type: z.string() }) + .loose() + .nullable(); + +export const ApiKeySchema = z + .object({ + id: z.string(), + created_at: z.string(), + created_by: ActorRefSchema.optional(), + expires_at: z.string().nullable().optional(), + name: z.string(), + partial_key_hint: z.string().nullable().optional(), + principal: ActorRefSchema.optional(), + status: z.enum(['active', 'archived', 'expired', 'inactive']), + type: z.literal('api_key'), + workspace_id: z.string().nullable().optional(), + }) + .loose(); + +// ── organization ───────────────────────────────────────────────────────────── + +export const GetOrganizationInputSchema = z.object({}); + +// ── users ──────────────────────────────────────────────────────────────────── + +export const ListUsersInputSchema = z.object({ + ...paginationFields, + email: z.string().optional().describe('Filter by user email'), + roles: z + .array(OrganizationRoleSchema) + .optional() + .describe('Filter to users whose role matches any supplied value'), +}); + +export const GetUserInputSchema = z.object({ + user_id: z.string().min(1).max(256), +}); + +export const UpdateUserInputSchema = z.object({ + user_id: z.string().min(1).max(256), + role: AssignableOrganizationRoleSchema.describe('New organization role'), +}); + +export const RemoveUserInputSchema = z.object({ + user_id: z.string().min(1).max(256), +}); + +export const RemoveUserResponseSchema = z + .object({ id: z.string(), type: z.literal('user_deleted') }) + .loose(); + +// ── invites ────────────────────────────────────────────────────────────────── + +export const ListInvitesInputSchema = z.object({ + ...paginationFields, + email: z.string().optional(), + roles: z.array(OrganizationRoleSchema).optional(), + statuses: z.array(z.enum(['accepted', 'expired', 'pending'])).optional(), +}); + +export const CreateInviteInputSchema = z.object({ + email: z.string().min(1).describe('Email address to invite'), + role: AssignableOrganizationRoleSchema, + rbac_group_ids: z.array(z.string()).optional(), +}); + +export const GetInviteInputSchema = z.object({ + invite_id: z.string().min(1).max(256), +}); + +export const DeleteInviteInputSchema = z.object({ + invite_id: z.string().min(1).max(256), +}); + +export const DeleteInviteResponseSchema = z + .object({ id: z.string(), type: z.literal('invite_deleted') }) + .loose(); + +// ── workspaces ─────────────────────────────────────────────────────────────── + +export const ListWorkspacesInputSchema = z.object({ + ...paginationFields, + include_archived: z + .boolean() + .optional() + .describe('Include archived workspaces in the results'), +}); + +export const CreateWorkspaceInputSchema = z.object({ + name: z.string().min(1).describe('Workspace name'), + data_residency: z.record(z.string(), z.unknown()).nullable().optional(), + external_key_id: z.string().nullable().optional(), + tags: z.record(z.string(), z.string()).nullable().optional(), +}); + +export const GetWorkspaceInputSchema = z.object({ + workspace_id: z.string().min(1).max(256), +}); + +export const UpdateWorkspaceInputSchema = z.object({ + workspace_id: z.string().min(1).max(256), + name: z.string().optional(), + data_residency: z.record(z.string(), z.unknown()).nullable().optional(), + external_key_id: z.string().optional(), + tags: z.record(z.string(), z.string()).nullable().optional(), +}); + +export const ArchiveWorkspaceInputSchema = z.object({ + workspace_id: z.string().min(1).max(256), +}); + +// ── workspace members ──────────────────────────────────────────────────────── + +export const ListWorkspaceMembersInputSchema = z.object({ + workspace_id: z.string().min(1).max(256), + ...paginationFields, +}); + +export const CreateWorkspaceMemberInputSchema = z.object({ + workspace_id: z.string().min(1).max(256), + user_id: z.string().min(1).max(256), + workspace_role: z.enum([ + 'workspace_admin', + 'workspace_developer', + 'workspace_restricted_developer', + 'workspace_user', + ]), +}); + +export const GetWorkspaceMemberInputSchema = z.object({ + workspace_id: z.string().min(1).max(256), + user_id: z.string().min(1).max(256), +}); + +export const UpdateWorkspaceMemberInputSchema = z.object({ + workspace_id: z.string().min(1).max(256), + user_id: z.string().min(1).max(256), + workspace_role: WorkspaceRoleSchema, +}); + +export const DeleteWorkspaceMemberInputSchema = z.object({ + workspace_id: z.string().min(1).max(256), + user_id: z.string().min(1).max(256), +}); + +export const DeleteWorkspaceMemberResponseSchema = z + .object({ + type: z.literal('workspace_member_deleted'), + user_id: z.string(), + workspace_id: z.string(), + }) + .loose(); + +// ── api keys ───────────────────────────────────────────────────────────────── + +export const ListApiKeysInputSchema = z.object({ + ...paginationFields, + created_by_user_id: z.string().optional(), + status: z.enum(['active', 'archived', 'expired', 'inactive']).optional(), + workspace_id: z.string().optional(), +}); + +export const GetApiKeyInputSchema = z.object({ + api_key_id: z.string().min(1).max(256), +}); + +export const UpdateApiKeyInputSchema = z.object({ + api_key_id: z.string().min(1).max(256), + name: z.string().nullable().optional(), + status: z.enum(['active', 'archived', 'inactive']).nullable().optional(), +}); + +// ── list response schemas ──────────────────────────────────────────────────── + +export const ListUsersResponseSchema = listResponse(UserSchema); +export const ListInvitesResponseSchema = listResponse(InviteSchema); +export const ListWorkspacesResponseSchema = listResponse(WorkspaceSchema); +export const ListWorkspaceMembersResponseSchema = listResponse( + WorkspaceMemberSchema, +); +export const ListApiKeysResponseSchema = listResponse(ApiKeySchema); + +// ── input / output maps ────────────────────────────────────────────────────── + +export const AnthropicAdministratorEndpointInputSchemas = { + getOrganization: GetOrganizationInputSchema, + listUsers: ListUsersInputSchema, + getUser: GetUserInputSchema, + updateUser: UpdateUserInputSchema, + removeUser: RemoveUserInputSchema, + listInvites: ListInvitesInputSchema, + createInvite: CreateInviteInputSchema, + getInvite: GetInviteInputSchema, + deleteInvite: DeleteInviteInputSchema, + listWorkspaces: ListWorkspacesInputSchema, + createWorkspace: CreateWorkspaceInputSchema, + getWorkspace: GetWorkspaceInputSchema, + updateWorkspace: UpdateWorkspaceInputSchema, + archiveWorkspace: ArchiveWorkspaceInputSchema, + listWorkspaceMembers: ListWorkspaceMembersInputSchema, + createWorkspaceMember: CreateWorkspaceMemberInputSchema, + getWorkspaceMember: GetWorkspaceMemberInputSchema, + updateWorkspaceMember: UpdateWorkspaceMemberInputSchema, + deleteWorkspaceMember: DeleteWorkspaceMemberInputSchema, + listApiKeys: ListApiKeysInputSchema, + getApiKey: GetApiKeyInputSchema, + updateApiKey: UpdateApiKeyInputSchema, +} as const; + +export const AnthropicAdministratorEndpointOutputSchemas = { + getOrganization: OrganizationSchema, + listUsers: ListUsersResponseSchema, + getUser: UserSchema, + updateUser: UserSchema, + removeUser: RemoveUserResponseSchema, + listInvites: ListInvitesResponseSchema, + createInvite: InviteSchema, + getInvite: InviteSchema, + deleteInvite: DeleteInviteResponseSchema, + listWorkspaces: ListWorkspacesResponseSchema, + createWorkspace: WorkspaceSchema, + getWorkspace: WorkspaceSchema, + updateWorkspace: WorkspaceSchema, + archiveWorkspace: WorkspaceSchema, + listWorkspaceMembers: ListWorkspaceMembersResponseSchema, + createWorkspaceMember: WorkspaceMemberSchema, + getWorkspaceMember: WorkspaceMemberSchema, + updateWorkspaceMember: WorkspaceMemberSchema, + deleteWorkspaceMember: DeleteWorkspaceMemberResponseSchema, + listApiKeys: ListApiKeysResponseSchema, + getApiKey: ApiKeySchema, + updateApiKey: ApiKeySchema, +} as const; + +export type AnthropicAdministratorEndpointInputs = { + [K in keyof typeof AnthropicAdministratorEndpointInputSchemas]: z.infer< + (typeof AnthropicAdministratorEndpointInputSchemas)[K] + >; +}; + +export type AnthropicAdministratorEndpointOutputs = { + [K in keyof typeof AnthropicAdministratorEndpointOutputSchemas]: z.infer< + (typeof AnthropicAdministratorEndpointOutputSchemas)[K] + >; +}; + +export type Organization = z.infer; +export type User = z.infer; +export type Invite = z.infer; +export type Workspace = z.infer; +export type WorkspaceMember = z.infer; +export type ApiKey = z.infer; diff --git a/packages/anthropicadministrator/endpoints/users.ts b/packages/anthropicadministrator/endpoints/users.ts new file mode 100644 index 000000000..beda909ec --- /dev/null +++ b/packages/anthropicadministrator/endpoints/users.ts @@ -0,0 +1,85 @@ +import type { AnthropicAdministratorEndpoints } from '../index'; +import { cacheEntity, cacheList, callAdminApi, evictEntity } from './shared'; +import type { + AnthropicAdministratorEndpointOutputs as Outputs, + User, +} from './types'; + +const BASE = '/v1/organizations/users'; + +/** GET /v1/organizations/users */ +export const listUsers: AnthropicAdministratorEndpoints['listUsers'] = async ( + ctx, + input, +) => { + const response = await callAdminApi( + ctx, + 'users.listUsers', + BASE, + { + method: 'GET', + query: { + after_id: input.after_id, + before_id: input.before_id, + email: input.email, + limit: input.limit, + roles: input.roles, + }, + }, + { count: input.limit }, + ); + + await cacheList(ctx, 'users', response.data, (user: User) => user.id); + return response; +}; + +/** GET /v1/organizations/users/{user_id} */ +export const getUser: AnthropicAdministratorEndpoints['getUser'] = async ( + ctx, + input, +) => { + const response = await callAdminApi( + ctx, + 'users.getUser', + `${BASE}/${encodeURIComponent(input.user_id)}`, + { method: 'GET' }, + { user_id: input.user_id }, + ); + + await cacheEntity(ctx, 'users', response.id, response); + return response; +}; + +/** POST /v1/organizations/users/{user_id} */ +export const updateUser: AnthropicAdministratorEndpoints['updateUser'] = async ( + ctx, + input, +) => { + const response = await callAdminApi( + ctx, + 'users.updateUser', + `${BASE}/${encodeURIComponent(input.user_id)}`, + { method: 'POST', body: { role: input.role } }, + { user_id: input.user_id, role: input.role }, + ); + + await cacheEntity(ctx, 'users', response.id, response); + return response; +}; + +/** DELETE /v1/organizations/users/{user_id} */ +export const removeUser: AnthropicAdministratorEndpoints['removeUser'] = async ( + ctx, + input, +) => { + const response = await callAdminApi( + ctx, + 'users.removeUser', + `${BASE}/${encodeURIComponent(input.user_id)}`, + { method: 'DELETE' }, + { user_id: input.user_id }, + ); + + await evictEntity(ctx, 'users', input.user_id); + return response; +}; diff --git a/packages/anthropicadministrator/endpoints/workspace-members.ts b/packages/anthropicadministrator/endpoints/workspace-members.ts new file mode 100644 index 000000000..3344d257e --- /dev/null +++ b/packages/anthropicadministrator/endpoints/workspace-members.ts @@ -0,0 +1,133 @@ +import type { AnthropicAdministratorEndpoints } from '../index'; +import { + cacheEntity, + cacheList, + callAdminApi, + evictEntity, + workspaceMemberKey, +} from './shared'; +import type { + AnthropicAdministratorEndpointOutputs as Outputs, + WorkspaceMember, +} from './types'; + +const BASE = '/v1/organizations/workspaces'; + +function membersPath(workspaceId: string): string { + return `${BASE}/${encodeURIComponent(workspaceId)}/members`; +} + +/** GET /v1/organizations/workspaces/{workspace_id}/members */ +export const listWorkspaceMembers: AnthropicAdministratorEndpoints['listWorkspaceMembers'] = + async (ctx, input) => { + const response = await callAdminApi( + ctx, + 'workspaceMembers.listWorkspaceMembers', + membersPath(input.workspace_id), + { + method: 'GET', + query: { + after_id: input.after_id, + before_id: input.before_id, + limit: input.limit, + }, + }, + { workspace_id: input.workspace_id }, + ); + + await cacheList( + ctx, + 'workspaceMembers', + response.data, + (m: WorkspaceMember) => workspaceMemberKey(m.workspace_id, m.user_id), + ); + return response; + }; + +/** POST /v1/organizations/workspaces/{workspace_id}/members */ +export const createWorkspaceMember: AnthropicAdministratorEndpoints['createWorkspaceMember'] = + async (ctx, input) => { + const response = await callAdminApi( + ctx, + 'workspaceMembers.createWorkspaceMember', + membersPath(input.workspace_id), + { + method: 'POST', + body: { + user_id: input.user_id, + workspace_role: input.workspace_role, + }, + }, + { + workspace_id: input.workspace_id, + workspace_role: input.workspace_role, + }, + ); + + await cacheEntity( + ctx, + 'workspaceMembers', + workspaceMemberKey(response.workspace_id, response.user_id), + response, + ); + return response; + }; + +/** GET /v1/organizations/workspaces/{workspace_id}/members/{user_id} */ +export const getWorkspaceMember: AnthropicAdministratorEndpoints['getWorkspaceMember'] = + async (ctx, input) => { + const response = await callAdminApi( + ctx, + 'workspaceMembers.getWorkspaceMember', + `${membersPath(input.workspace_id)}/${encodeURIComponent(input.user_id)}`, + { method: 'GET' }, + { workspace_id: input.workspace_id, user_id: input.user_id }, + ); + + await cacheEntity( + ctx, + 'workspaceMembers', + workspaceMemberKey(response.workspace_id, response.user_id), + response, + ); + return response; + }; + +/** POST /v1/organizations/workspaces/{workspace_id}/members/{user_id} */ +export const updateWorkspaceMember: AnthropicAdministratorEndpoints['updateWorkspaceMember'] = + async (ctx, input) => { + const response = await callAdminApi( + ctx, + 'workspaceMembers.updateWorkspaceMember', + `${membersPath(input.workspace_id)}/${encodeURIComponent(input.user_id)}`, + { method: 'POST', body: { workspace_role: input.workspace_role } }, + { workspace_id: input.workspace_id, user_id: input.user_id }, + ); + + await cacheEntity( + ctx, + 'workspaceMembers', + workspaceMemberKey(response.workspace_id, response.user_id), + response, + ); + return response; + }; + +/** DELETE /v1/organizations/workspaces/{workspace_id}/members/{user_id} */ +export const deleteWorkspaceMember: AnthropicAdministratorEndpoints['deleteWorkspaceMember'] = + async (ctx, input) => { + const response = await callAdminApi( + ctx, + 'workspaceMembers.deleteWorkspaceMember', + `${membersPath(input.workspace_id)}/${encodeURIComponent(input.user_id)}`, + { method: 'DELETE' }, + { workspace_id: input.workspace_id, user_id: input.user_id }, + ); + + await evictEntity( + ctx, + 'workspaceMembers', + workspaceMemberKey(input.workspace_id, input.user_id), + ); + return response; + }; diff --git a/packages/anthropicadministrator/endpoints/workspaces.ts b/packages/anthropicadministrator/endpoints/workspaces.ts new file mode 100644 index 000000000..7b872380b --- /dev/null +++ b/packages/anthropicadministrator/endpoints/workspaces.ts @@ -0,0 +1,107 @@ +import type { AnthropicAdministratorEndpoints } from '../index'; +import { cacheEntity, cacheList, callAdminApi, compact } from './shared'; +import type { + AnthropicAdministratorEndpointOutputs as Outputs, + Workspace, +} from './types'; + +const BASE = '/v1/organizations/workspaces'; + +/** GET /v1/organizations/workspaces */ +export const listWorkspaces: AnthropicAdministratorEndpoints['listWorkspaces'] = + async (ctx, input) => { + const response = await callAdminApi( + ctx, + 'workspaces.listWorkspaces', + BASE, + { + method: 'GET', + query: { + after_id: input.after_id, + before_id: input.before_id, + include_archived: input.include_archived, + limit: input.limit, + }, + }, + ); + + await cacheList(ctx, 'workspaces', response.data, (w: Workspace) => w.id); + return response; + }; + +/** POST /v1/organizations/workspaces */ +export const createWorkspace: AnthropicAdministratorEndpoints['createWorkspace'] = + async (ctx, input) => { + const response = await callAdminApi( + ctx, + 'workspaces.createWorkspace', + BASE, + { + method: 'POST', + body: compact({ + name: input.name, + data_residency: input.data_residency, + external_key_id: input.external_key_id, + tags: input.tags, + }), + }, + { name: input.name }, + ); + + await cacheEntity(ctx, 'workspaces', response.id, response); + return response; + }; + +/** GET /v1/organizations/workspaces/{workspace_id} */ +export const getWorkspace: AnthropicAdministratorEndpoints['getWorkspace'] = + async (ctx, input) => { + const response = await callAdminApi( + ctx, + 'workspaces.getWorkspace', + `${BASE}/${encodeURIComponent(input.workspace_id)}`, + { method: 'GET' }, + { workspace_id: input.workspace_id }, + ); + + await cacheEntity(ctx, 'workspaces', response.id, response); + return response; + }; + +/** POST /v1/organizations/workspaces/{workspace_id} */ +export const updateWorkspace: AnthropicAdministratorEndpoints['updateWorkspace'] = + async (ctx, input) => { + const response = await callAdminApi( + ctx, + 'workspaces.updateWorkspace', + `${BASE}/${encodeURIComponent(input.workspace_id)}`, + { + method: 'POST', + body: compact({ + name: input.name, + data_residency: input.data_residency, + external_key_id: input.external_key_id, + tags: input.tags, + }), + }, + { workspace_id: input.workspace_id }, + ); + + await cacheEntity(ctx, 'workspaces', response.id, response); + return response; + }; + +/** POST /v1/organizations/workspaces/{workspace_id}/archive */ +export const archiveWorkspace: AnthropicAdministratorEndpoints['archiveWorkspace'] = + async (ctx, input) => { + const response = await callAdminApi( + ctx, + 'workspaces.archiveWorkspace', + `${BASE}/${encodeURIComponent(input.workspace_id)}/archive`, + { method: 'POST' }, + { workspace_id: input.workspace_id }, + ); + + // Archived workspaces remain readable, so refresh rather than evict. + await cacheEntity(ctx, 'workspaces', response.id, response); + return response; + }; diff --git a/packages/anthropicadministrator/error-handlers.ts b/packages/anthropicadministrator/error-handlers.ts new file mode 100644 index 000000000..abff01fd1 --- /dev/null +++ b/packages/anthropicadministrator/error-handlers.ts @@ -0,0 +1,67 @@ +import type { CorsairErrorHandler } from 'corsair/core'; +import { AnthropicAdministratorAPIError } from './client'; + +/** + * Handlers classify failures for logging and policy; they deliberately do not + * ask the shared endpoint binder to retry. + * + * Retries are performed inside `client.ts`, which returns the successful + * attempt's result. Delegating them here instead would route a retry through + * the binder, which awaits the recursive attempt without returning it and then + * rethrows the original error — so a request that succeeded on retry would + * still be reported to the caller as a rate-limit failure, leaving mutation + * outcomes ambiguous. + * + * Matching is on the HTTP status carried by `AnthropicAdministratorAPIError` + * rather than on message text: corsair throws a 429 with the message + * "Too Many Requests", which contains neither "429" nor "rate_limited". + */ +function asApiError(error: Error): AnthropicAdministratorAPIError | undefined { + return error instanceof AnthropicAdministratorAPIError ? error : undefined; +} + +export const errorHandlers = { + RATE_LIMIT_ERROR: { + // Already retried in the client (safe for any method — a 429 is rejected + // before being applied). By the time it surfaces here the budget is spent. + match: (error: Error) => asApiError(error)?.status === 429, + handler: async () => ({ maxRetries: 0 }), + }, + AUTH_ERROR: { + match: (error: Error) => { + const status = asApiError(error)?.status; + return status === 401 || status === 403; + }, + handler: async (error: Error) => { + const type = asApiError(error)?.errorType; + console.error( + `[ANTHROPICADMINISTRATOR] Authentication failed${type ? ` (${type})` : ''} — the Admin API requires an Admin API key (sk-ant-admin…), not a standard API key.`, + ); + return { maxRetries: 0 }; + }, + }, + NOT_FOUND_ERROR: { + match: (error: Error) => asApiError(error)?.status === 404, + handler: async () => ({ maxRetries: 0 }), + }, + INVALID_REQUEST_ERROR: { + match: (error: Error) => { + const status = asApiError(error)?.status; + return status === 400 || status === 422; + }, + handler: async () => ({ maxRetries: 0 }), + }, + SERVER_ERROR: { + // GET is retried in the client; mutations are never replayed because the + // Admin API documents no idempotency key. + match: (error: Error) => { + const status = asApiError(error)?.status; + return status !== undefined && status >= 500; + }, + handler: async () => ({ maxRetries: 0 }), + }, + DEFAULT: { + match: () => true, + handler: async () => ({ maxRetries: 0 }), + }, +} satisfies CorsairErrorHandler; diff --git a/packages/anthropicadministrator/errors.test.ts b/packages/anthropicadministrator/errors.test.ts new file mode 100644 index 000000000..ab9674120 --- /dev/null +++ b/packages/anthropicadministrator/errors.test.ts @@ -0,0 +1,225 @@ +import { ApiError, request } from 'corsair/http'; +import { + AnthropicAdministratorAPIError, + makeAnthropicAdministratorRequest, +} from './client'; +import { errorHandlers } from './error-handlers'; + +jest.mock('corsair/http', () => { + const actual = jest.requireActual('corsair/http'); + return { ...actual, request: jest.fn() }; +}); + +const mockRequest = request as jest.Mock; + +beforeEach(() => { + mockRequest.mockReset(); +}); + +/** Builds the error corsair/http actually throws for a given status. */ +function transportError(status: number, message: string, retryAfter?: number) { + return new ApiError( + { method: 'GET', url: '/v1/organizations/users' } as never, + { + url: '/v1/organizations/users', + ok: false, + status, + statusText: message, + body: { type: 'error', error: { type: 'rate_limit_error', message } }, + } as never, + message, + retryAfter === undefined ? undefined : { retryAfter }, + ); +} + +/** Wraps it the way client.ts does. */ +function wrapped( + status: number, + message: string, + method: 'GET' | 'POST' | 'DELETE' = 'GET', + retryAfter?: number, +) { + const cause = transportError(status, message, retryAfter); + return new AnthropicAdministratorAPIError(cause.message, { cause, method }); +} + +describe('client-side retry returns the successful attempt', () => { + const okBody = { id: 'org_1', name: 'Acme', type: 'organization' }; + + function rateLimited(retryAfterMs?: number) { + return transportError(429, 'Too Many Requests', retryAfterMs); + } + + it('returns the retry result instead of the first failure', async () => { + mockRequest + .mockRejectedValueOnce(rateLimited(1)) + .mockResolvedValueOnce(okBody); + + await expect( + makeAnthropicAdministratorRequest('/v1/organizations/me', 'k'), + ).resolves.toEqual(okBody); + expect(mockRequest).toHaveBeenCalledTimes(2); + }); + + it('retries a 429 on a mutation too — it was rejected before being applied', async () => { + mockRequest + .mockRejectedValueOnce(rateLimited(1)) + .mockResolvedValueOnce({ ok: true }); + + await expect( + makeAnthropicAdministratorRequest('/v1/organizations/invites', 'k', { + method: 'POST', + body: { email: 'a@b.com', role: 'user' }, + }), + ).resolves.toEqual({ ok: true }); + expect(mockRequest).toHaveBeenCalledTimes(2); + }); + + it('never replays a 5xx on a mutation', async () => { + mockRequest.mockRejectedValue(transportError(503, 'Service Unavailable')); + + await expect( + makeAnthropicAdministratorRequest('/v1/organizations/invites', 'k', { + method: 'POST', + }), + ).rejects.toThrow('Service Unavailable'); + expect(mockRequest).toHaveBeenCalledTimes(1); + }); + + it('retries a 5xx on GET', async () => { + mockRequest + .mockRejectedValueOnce(transportError(500, 'Internal Server Error')) + .mockResolvedValueOnce(okBody); + + await expect( + makeAnthropicAdministratorRequest('/v1/organizations/me', 'k'), + ).resolves.toEqual(okBody); + expect(mockRequest).toHaveBeenCalledTimes(2); + }); + + it('gives up after the attempt budget and surfaces the failure', async () => { + mockRequest.mockRejectedValue(rateLimited(1)); + + await expect( + makeAnthropicAdministratorRequest('/v1/organizations/me', 'k'), + ).rejects.toThrow('Too Many Requests'); + expect(mockRequest).toHaveBeenCalledTimes(3); + }); + + it('does not retry an auth failure', async () => { + mockRequest.mockRejectedValue(transportError(401, 'Unauthorized')); + + await expect( + makeAnthropicAdministratorRequest('/v1/organizations/me', 'k'), + ).rejects.toThrow('Unauthorized'); + expect(mockRequest).toHaveBeenCalledTimes(1); + }); +}); + +describe('request path bounds', () => { + it('rejects an over-long path before it reaches the transport', async () => { + const huge = `/v1/organizations/users/${'a'.repeat(600)}`; + + await expect(makeAnthropicAdministratorRequest(huge, 'k')).rejects.toThrow( + 'exceeds 512 characters', + ); + expect(mockRequest).not.toHaveBeenCalled(); + }); + + it('never forwards unmatched braces to the transport', async () => { + // Paths are built by interpolating percent-encoded IDs, so a brace can + // never survive into the request path. + mockRequest.mockResolvedValueOnce({}); + const { anthropicAdministratorEndpointsNested: ops } = await import( + './index' + ); + const groups = ops as unknown as Record< + string, + Record Promise> + >; + + const getUser = groups.users?.getUser; + if (!getUser) throw new Error('missing endpoint'); + await getUser( + { key: 'k', options: {}, db: {} }, + { user_id: '{a'.repeat(50) }, + ); + + const url = mockRequest.mock.calls[0]?.[1]?.url as string; + expect(url).not.toContain('{'); + expect(url).not.toContain('}'); + }); + + it('accepts a normal path', async () => { + mockRequest.mockResolvedValueOnce({}); + await expect( + makeAnthropicAdministratorRequest('/v1/organizations/me', 'k'), + ).resolves.toEqual({}); + }); +}); + +describe('error classification', () => { + it('classifies a 429 by status, not by message text', () => { + // corsair throws 429 with the literal message "Too Many Requests" — it + // contains neither "429" nor "rate_limited", so status must be preserved. + const error = wrapped(429, 'Too Many Requests', 'GET', 30_000); + + expect(error.message).toBe('Too Many Requests'); + expect(error.status).toBe(429); + expect(error.retryAfter).toBe(30_000); + expect(errorHandlers.RATE_LIMIT_ERROR.match(error)).toBe(true); + }); + + it('asks the binder for no retries — the client already retried', async () => { + // Delegating retries to the shared binder would discard a successful + // retry and rethrow the original failure. + // AUTH_ERROR logs guidance; keep the assertion output clean. + const consoleError = jest + .spyOn(console, 'error') + .mockImplementation(() => {}); + + // Handlers have mixed arity; drive them uniformly. + const handlers = [ + errorHandlers.RATE_LIMIT_ERROR, + errorHandlers.SERVER_ERROR, + errorHandlers.AUTH_ERROR, + errorHandlers.NOT_FOUND_ERROR, + errorHandlers.INVALID_REQUEST_ERROR, + errorHandlers.DEFAULT, + ] as unknown as Array<{ + handler: (error: Error) => Promise<{ maxRetries: number }>; + }>; + + for (const { handler } of handlers) { + expect( + (await handler(wrapped(500, 'Internal Server Error'))).maxRetries, + ).toBe(0); + } + + consoleError.mockRestore(); + }); + + it('matches auth, not-found, invalid-request and server errors by status', () => { + expect(errorHandlers.AUTH_ERROR.match(wrapped(401, 'Unauthorized'))).toBe( + true, + ); + expect(errorHandlers.AUTH_ERROR.match(wrapped(403, 'Forbidden'))).toBe( + true, + ); + expect(errorHandlers.NOT_FOUND_ERROR.match(wrapped(404, 'Not Found'))).toBe( + true, + ); + expect( + errorHandlers.INVALID_REQUEST_ERROR.match(wrapped(400, 'Bad Request')), + ).toBe(true); + expect( + errorHandlers.SERVER_ERROR.match(wrapped(503, 'Service Unavailable')), + ).toBe(true); + }); + + it('surfaces the Anthropic error type from the response body', () => { + expect(wrapped(429, 'Too Many Requests').errorType).toBe( + 'rate_limit_error', + ); + }); +}); diff --git a/packages/anthropicadministrator/index.ts b/packages/anthropicadministrator/index.ts new file mode 100644 index 000000000..6de4bbc36 --- /dev/null +++ b/packages/anthropicadministrator/index.ts @@ -0,0 +1,376 @@ +import type { + AuthTypes, + BindEndpoints, + CorsairEndpoint, + CorsairErrorHandler, + CorsairPlugin, + CorsairPluginContext, + KeyBuilderContext, + PickAuth, + PluginAuthConfig, + PluginPermissionsConfig, + RequiredPluginEndpointMeta, + RequiredPluginEndpointSchemas, +} from 'corsair/core'; +import { AuthMissingError } from 'corsair/core'; +import { + ApiKeysEndpoints, + InvitesEndpoints, + OrganizationEndpoints, + UsersEndpoints, + WorkspaceMembersEndpoints, + WorkspacesEndpoints, +} from './endpoints'; +import type { + AnthropicAdministratorEndpointInputs, + AnthropicAdministratorEndpointOutputs, +} from './endpoints/types'; +import { + AnthropicAdministratorEndpointInputSchemas, + AnthropicAdministratorEndpointOutputSchemas, +} from './endpoints/types'; +import { errorHandlers } from './error-handlers'; +import { AnthropicAdministratorSchema } from './schema'; + +export type AnthropicAdministratorPluginOptions = { + authType?: PickAuth<'api_key' | 'oauth_2'>; + key?: string; + hooks?: InternalAnthropicAdministratorPlugin['hooks']; + errorHandlers?: CorsairErrorHandler; + permissions?: PluginPermissionsConfig< + typeof anthropicAdministratorEndpointsNested + >; +}; + +export type AnthropicAdministratorContext = CorsairPluginContext< + typeof AnthropicAdministratorSchema, + AnthropicAdministratorPluginOptions +>; + +export type AnthropicAdministratorKeyBuilderContext = + KeyBuilderContext; + +export type AnthropicAdministratorBoundEndpoints = BindEndpoints< + typeof anthropicAdministratorEndpointsNested +>; + +type AnthropicAdministratorEndpoint< + K extends keyof AnthropicAdministratorEndpointOutputs, +> = CorsairEndpoint< + AnthropicAdministratorContext, + AnthropicAdministratorEndpointInputs[K], + AnthropicAdministratorEndpointOutputs[K] +>; + +export type AnthropicAdministratorEndpoints = { + [K in keyof AnthropicAdministratorEndpointOutputs]: AnthropicAdministratorEndpoint; +}; + +const anthropicAdministratorEndpointsNested = { + organization: OrganizationEndpoints, + users: UsersEndpoints, + invites: InvitesEndpoints, + workspaces: WorkspacesEndpoints, + workspaceMembers: WorkspaceMembersEndpoints, + apiKeys: ApiKeysEndpoints, +} as const; + +const anthropicAdministratorWebhooksNested = {} as const; + +export const anthropicAdministratorEndpointSchemas = { + 'organization.getOrganization': { + input: AnthropicAdministratorEndpointInputSchemas.getOrganization, + output: AnthropicAdministratorEndpointOutputSchemas.getOrganization, + }, + 'users.listUsers': { + input: AnthropicAdministratorEndpointInputSchemas.listUsers, + output: AnthropicAdministratorEndpointOutputSchemas.listUsers, + }, + 'users.getUser': { + input: AnthropicAdministratorEndpointInputSchemas.getUser, + output: AnthropicAdministratorEndpointOutputSchemas.getUser, + }, + 'users.updateUser': { + input: AnthropicAdministratorEndpointInputSchemas.updateUser, + output: AnthropicAdministratorEndpointOutputSchemas.updateUser, + }, + 'users.removeUser': { + input: AnthropicAdministratorEndpointInputSchemas.removeUser, + output: AnthropicAdministratorEndpointOutputSchemas.removeUser, + }, + 'invites.listInvites': { + input: AnthropicAdministratorEndpointInputSchemas.listInvites, + output: AnthropicAdministratorEndpointOutputSchemas.listInvites, + }, + 'invites.createInvite': { + input: AnthropicAdministratorEndpointInputSchemas.createInvite, + output: AnthropicAdministratorEndpointOutputSchemas.createInvite, + }, + 'invites.getInvite': { + input: AnthropicAdministratorEndpointInputSchemas.getInvite, + output: AnthropicAdministratorEndpointOutputSchemas.getInvite, + }, + 'invites.deleteInvite': { + input: AnthropicAdministratorEndpointInputSchemas.deleteInvite, + output: AnthropicAdministratorEndpointOutputSchemas.deleteInvite, + }, + 'workspaces.listWorkspaces': { + input: AnthropicAdministratorEndpointInputSchemas.listWorkspaces, + output: AnthropicAdministratorEndpointOutputSchemas.listWorkspaces, + }, + 'workspaces.createWorkspace': { + input: AnthropicAdministratorEndpointInputSchemas.createWorkspace, + output: AnthropicAdministratorEndpointOutputSchemas.createWorkspace, + }, + 'workspaces.getWorkspace': { + input: AnthropicAdministratorEndpointInputSchemas.getWorkspace, + output: AnthropicAdministratorEndpointOutputSchemas.getWorkspace, + }, + 'workspaces.updateWorkspace': { + input: AnthropicAdministratorEndpointInputSchemas.updateWorkspace, + output: AnthropicAdministratorEndpointOutputSchemas.updateWorkspace, + }, + 'workspaces.archiveWorkspace': { + input: AnthropicAdministratorEndpointInputSchemas.archiveWorkspace, + output: AnthropicAdministratorEndpointOutputSchemas.archiveWorkspace, + }, + 'workspaceMembers.listWorkspaceMembers': { + input: AnthropicAdministratorEndpointInputSchemas.listWorkspaceMembers, + output: AnthropicAdministratorEndpointOutputSchemas.listWorkspaceMembers, + }, + 'workspaceMembers.createWorkspaceMember': { + input: AnthropicAdministratorEndpointInputSchemas.createWorkspaceMember, + output: AnthropicAdministratorEndpointOutputSchemas.createWorkspaceMember, + }, + 'workspaceMembers.getWorkspaceMember': { + input: AnthropicAdministratorEndpointInputSchemas.getWorkspaceMember, + output: AnthropicAdministratorEndpointOutputSchemas.getWorkspaceMember, + }, + 'workspaceMembers.updateWorkspaceMember': { + input: AnthropicAdministratorEndpointInputSchemas.updateWorkspaceMember, + output: AnthropicAdministratorEndpointOutputSchemas.updateWorkspaceMember, + }, + 'workspaceMembers.deleteWorkspaceMember': { + input: AnthropicAdministratorEndpointInputSchemas.deleteWorkspaceMember, + output: AnthropicAdministratorEndpointOutputSchemas.deleteWorkspaceMember, + }, + 'apiKeys.listApiKeys': { + input: AnthropicAdministratorEndpointInputSchemas.listApiKeys, + output: AnthropicAdministratorEndpointOutputSchemas.listApiKeys, + }, + 'apiKeys.getApiKey': { + input: AnthropicAdministratorEndpointInputSchemas.getApiKey, + output: AnthropicAdministratorEndpointOutputSchemas.getApiKey, + }, + 'apiKeys.updateApiKey': { + input: AnthropicAdministratorEndpointInputSchemas.updateApiKey, + output: AnthropicAdministratorEndpointOutputSchemas.updateApiKey, + }, +} satisfies RequiredPluginEndpointSchemas< + typeof anthropicAdministratorEndpointsNested +>; + +const defaultAuthType = 'api_key' as const; + +const anthropicAdministratorEndpointMeta = { + 'organization.getOrganization': { + riskLevel: 'read', + description: 'Get the organization associated with the Admin API key', + }, + 'users.listUsers': { + riskLevel: 'read', + description: + 'List organization members, optionally filtered by email or role', + }, + 'users.getUser': { + riskLevel: 'read', + description: 'Get a single organization member by user ID', + }, + 'users.updateUser': { + riskLevel: 'write', + description: "Change an organization member's role", + }, + 'users.removeUser': { + riskLevel: 'destructive', + irreversible: true, + description: 'Remove a member from the organization', + }, + 'invites.listInvites': { + riskLevel: 'read', + description: 'List organization invites', + }, + 'invites.createInvite': { + riskLevel: 'write', + description: 'Invite someone to the organization with a given role', + }, + 'invites.getInvite': { + riskLevel: 'read', + description: 'Get a single invite by ID', + }, + 'invites.deleteInvite': { + riskLevel: 'destructive', + irreversible: true, + description: 'Delete a pending organization invite', + }, + 'workspaces.listWorkspaces': { + riskLevel: 'read', + description: 'List workspaces, optionally including archived ones', + }, + 'workspaces.createWorkspace': { + riskLevel: 'write', + description: 'Create a workspace', + }, + 'workspaces.getWorkspace': { + riskLevel: 'read', + description: 'Get a single workspace by ID', + }, + 'workspaces.updateWorkspace': { + riskLevel: 'write', + description: 'Update a workspace name, tags or data residency', + }, + 'workspaces.archiveWorkspace': { + riskLevel: 'destructive', + irreversible: true, + description: 'Archive a workspace', + }, + 'workspaceMembers.listWorkspaceMembers': { + riskLevel: 'read', + description: 'List members of a workspace', + }, + 'workspaceMembers.createWorkspaceMember': { + riskLevel: 'write', + description: 'Add an organization member to a workspace with a role', + }, + 'workspaceMembers.getWorkspaceMember': { + riskLevel: 'read', + description: 'Get a single workspace member', + }, + 'workspaceMembers.updateWorkspaceMember': { + riskLevel: 'write', + description: "Change a workspace member's role", + }, + 'workspaceMembers.deleteWorkspaceMember': { + riskLevel: 'destructive', + irreversible: true, + description: 'Remove a member from a workspace', + }, + 'apiKeys.listApiKeys': { + riskLevel: 'read', + description: 'List organization API keys, optionally filtered by status', + }, + 'apiKeys.getApiKey': { + riskLevel: 'read', + description: 'Get a single API key by ID', + }, + 'apiKeys.updateApiKey': { + riskLevel: 'write', + description: 'Rename an API key or change its active status', + }, +} as const satisfies RequiredPluginEndpointMeta< + typeof anthropicAdministratorEndpointsNested +>; + +export const anthropicAdministratorAuthConfig = { + api_key: {}, + oauth_2: {}, +} as const satisfies PluginAuthConfig; + +export type BaseAnthropicAdministratorPlugin< + T extends AnthropicAdministratorPluginOptions, +> = CorsairPlugin< + 'anthropicadministrator', + typeof AnthropicAdministratorSchema, + typeof anthropicAdministratorEndpointsNested, + typeof anthropicAdministratorWebhooksNested, + T, + typeof defaultAuthType +>; + +export type InternalAnthropicAdministratorPlugin = + BaseAnthropicAdministratorPlugin; + +export type ExternalAnthropicAdministratorPlugin< + T extends AnthropicAdministratorPluginOptions, +> = BaseAnthropicAdministratorPlugin; + +/** + * Anthropic Admin API plugin — organization members, invites, workspaces, + * workspace members and API keys. + * + * Requires an Admin API key (`sk-ant-admin…`) or an OAuth token with the + * `org:admin` scope. A standard Anthropic API key is rejected by these + * endpoints. + */ +export function anthropicadministrator< + const T extends AnthropicAdministratorPluginOptions, +>( + incomingOptions: AnthropicAdministratorPluginOptions & + T = {} as AnthropicAdministratorPluginOptions & T, +): ExternalAnthropicAdministratorPlugin { + const options = { + ...incomingOptions, + authType: incomingOptions.authType ?? defaultAuthType, + }; + return { + id: 'anthropicadministrator', + authConfig: anthropicAdministratorAuthConfig, + schema: AnthropicAdministratorSchema, + options, + hooks: options.hooks, + webhookHooks: undefined, + endpoints: anthropicAdministratorEndpointsNested, + webhooks: anthropicAdministratorWebhooksNested, + endpointMeta: anthropicAdministratorEndpointMeta, + endpointSchemas: anthropicAdministratorEndpointSchemas, + pluginWebhookMatcher: undefined, + errorHandlers: { + ...errorHandlers, + ...options.errorHandlers, + }, + keyBuilder: async ( + ctx: AnthropicAdministratorKeyBuilderContext, + source, + ) => { + if (source === 'endpoint' && options.key) { + return options.key; + } + + if (source === 'endpoint' && ctx.authType === 'api_key') { + const res = await ctx.keys.get_api_key(); + if (!res) { + throw new AuthMissingError('anthropicadministrator', 'api_key'); + } + return res; + } + + if (source === 'endpoint' && ctx.authType === 'oauth_2') { + const res = await ctx.keys.get_access_token(); + if (!res) { + throw new AuthMissingError('anthropicadministrator', 'oauth_2'); + } + return res; + } + + // Never fall through with an empty credential: an empty key would be + // sent as a real `x-api-key` header. + throw new AuthMissingError('anthropicadministrator', 'api_key'); + }, + } satisfies InternalAnthropicAdministratorPlugin; +} + +export { + anthropicAdministratorEndpointsNested, + AnthropicAdministratorEndpointInputSchemas, + AnthropicAdministratorEndpointOutputSchemas, +}; + +export type { + AnthropicAdministratorEndpointInputs, + AnthropicAdministratorEndpointOutputs, + ApiKey, + Invite, + Organization, + User, + Workspace, + WorkspaceMember, +} from './endpoints/types'; diff --git a/packages/anthropicadministrator/jest.config.cjs b/packages/anthropicadministrator/jest.config.cjs new file mode 100644 index 000000000..8c6218f64 --- /dev/null +++ b/packages/anthropicadministrator/jest.config.cjs @@ -0,0 +1,55 @@ +module.exports = { + preset: 'ts-jest', + testEnvironment: 'node', + roots: [''], + testMatch: [ + '**/*.test.ts', + '**/tests/**/*.test.ts', + '**/plugins/**/*.test.ts', + '**/setup/**/*.test.ts', + ], + collectCoverageFrom: [ + '**/*.ts', + '!**/*.d.ts', + '!**/node_modules/**', + '!**/dist/**', + '!jest.config.ts', + '!tests/**', + ], + moduleFileExtensions: ['ts', 'tsx', 'js', 'jsx', 'json'], + transform: { + '^.+\\.yaml$': '/../corsair/jest-yaml-transform.cjs', + '^.+\\.ts$': [ + 'ts-jest', + { + useESM: true, + tsconfig: { + esModuleInterop: true, + allowSyntheticDefaultImports: true, + verbatimModuleSyntax: false, + module: 'ESNext', + moduleResolution: 'Bundler', + }, + }, + ], + '.*\\.js$': [ + 'ts-jest', + { + useESM: true, + tsconfig: { + esModuleInterop: true, + allowSyntheticDefaultImports: true, + }, + }, + ], + }, + moduleNameMapper: { + '^corsair/core$': '/../corsair/core.ts', + '^corsair/http$': '/../corsair/http.ts', + '^(\\.\\.?/.*)\\.js$': '$1', + }, + transformIgnorePatterns: ['node_modules/(?!.*uuid.*)'], + extensionsToTreatAsEsm: ['.ts'], + testTimeout: 30000, + verbose: true, +}; diff --git a/packages/anthropicadministrator/package.json b/packages/anthropicadministrator/package.json new file mode 100644 index 000000000..1796de47d --- /dev/null +++ b/packages/anthropicadministrator/package.json @@ -0,0 +1,45 @@ +{ + "name": "@corsair-dev/anthropicadministrator", + "version": "0.1.0", + "description": "Anthropic Admin API plugin for Corsair \u2014 organization members, invites, workspaces and API keys", + "type": "module", + "main": "./dist/index.js", + "module": "./dist/index.js", + "types": "./dist/index.d.ts", + "exports": { + ".": { + "dev-source": "./index.ts", + "types": "./dist/index.d.ts", + "default": "./dist/index.js" + } + }, + "scripts": { + "build": "rm -rf dist && tsc --build --force && tsup", + "typecheck": "tsc --noEmit", + "test": "jest" + }, + "peerDependencies": { + "corsair": ">=0.1.0", + "zod": "^4.1.13" + }, + "devDependencies": { + "@types/jest": "^29.5.14", + "corsair": "workspace:*", + "jest": "^29.7.0", + "ts-jest": "^29.4.9", + "tsup": "^8.0.1", + "typescript": "catalog:", + "zod": "^4.1.13" + }, + "keywords": [ + "corsair", + "anthropic", + "admin-api", + "plugin" + ], + "author": "", + "license": "Apache-2.0", + "files": [ + "dist" + ] +} diff --git a/packages/anthropicadministrator/plugin.test.ts b/packages/anthropicadministrator/plugin.test.ts new file mode 100644 index 000000000..fef561aac --- /dev/null +++ b/packages/anthropicadministrator/plugin.test.ts @@ -0,0 +1,163 @@ +import { + anthropicAdministratorEndpointSchemas, + anthropicadministrator, +} from './index'; +import { AnthropicAdministratorSchema } from './schema'; + +function endpointPaths(tree: Record, prefix = ''): string[] { + return Object.entries(tree).flatMap(([key, value]) => { + const path = prefix ? `${prefix}.${key}` : key; + if (typeof value === 'function') return [path]; + if (value && typeof value === 'object') { + // Test-only: walk the nested endpoint groups as a plain tree. + return endpointPaths(value as Record, path); + } + return []; + }); +} + +const EXPECTED_OPERATIONS = [ + 'apiKeys.getApiKey', + 'apiKeys.listApiKeys', + 'apiKeys.updateApiKey', + 'invites.createInvite', + 'invites.deleteInvite', + 'invites.getInvite', + 'invites.listInvites', + 'organization.getOrganization', + 'users.getUser', + 'users.listUsers', + 'users.removeUser', + 'users.updateUser', + 'workspaceMembers.createWorkspaceMember', + 'workspaceMembers.deleteWorkspaceMember', + 'workspaceMembers.getWorkspaceMember', + 'workspaceMembers.listWorkspaceMembers', + 'workspaceMembers.updateWorkspaceMember', + 'workspaces.archiveWorkspace', + 'workspaces.createWorkspace', + 'workspaces.getWorkspace', + 'workspaces.listWorkspaces', + 'workspaces.updateWorkspace', +]; + +/** `keyBuilder` is optional on the shared plugin type; assert it is wired. */ +function keyBuilderOf(plugin: { keyBuilder?: unknown }) { + const keyBuilder = plugin.keyBuilder; + if (typeof keyBuilder !== 'function') { + throw new Error('keyBuilder is not registered'); + } + return keyBuilder as (ctx: unknown, source: string) => Promise; +} + +describe('anthropicadministrator plugin shape', () => { + const plugin = anthropicadministrator(); + + it('exposes exactly the 22 Admin API operations', () => { + expect( + endpointPaths(plugin.endpoints as Record).sort(), + ).toEqual(EXPECTED_OPERATIONS); + }); + + it('keeps endpoints, schemas and metadata in lockstep', () => { + const paths = endpointPaths( + plugin.endpoints as Record, + ).sort(); + expect(Object.keys(anthropicAdministratorEndpointSchemas).sort()).toEqual( + paths, + ); + expect(Object.keys(plugin.endpointMeta ?? {}).sort()).toEqual(paths); + }); + + it('registers no webhooks', () => { + // The Anthropic Admin API publishes no webhooks; the generator's example + // webhook (which accepted any signature) was removed. + expect(plugin.webhooks).toEqual({}); + expect(plugin.pluginWebhookMatcher).toBeUndefined(); + }); + + it('gives every operation a risk level and description', () => { + const meta = plugin.endpointMeta as unknown as Record< + string, + { riskLevel: string; description?: string; irreversible?: boolean } + >; + for (const entry of Object.values(meta)) { + expect(['read', 'write', 'destructive']).toContain(entry.riskLevel); + expect((entry.description ?? '').length).toBeGreaterThan(0); + } + }); + + it('marks every irreversible operation destructive', () => { + const meta = plugin.endpointMeta as unknown as Record< + string, + { riskLevel: string; irreversible?: boolean } + >; + const irreversible = Object.entries(meta) + .filter(([, e]) => e.irreversible) + .map(([name]) => name) + .sort(); + + expect(irreversible).toEqual([ + 'invites.deleteInvite', + 'users.removeUser', + 'workspaceMembers.deleteWorkspaceMember', + 'workspaces.archiveWorkspace', + ]); + for (const name of irreversible) { + expect(meta[name]?.riskLevel).toBe('destructive'); + } + }); + + it('registers the five cached entities', () => { + expect(Object.keys(AnthropicAdministratorSchema.entities).sort()).toEqual([ + 'apiKeys', + 'invites', + 'users', + 'workspaceMembers', + 'workspaces', + ]); + }); + + it('supports api key and oauth auth', () => { + expect(plugin.options?.authType).toBe('api_key'); + expect(plugin.authConfig).toEqual({ api_key: {}, oauth_2: {} }); + }); +}); + +describe('anthropicadministrator key resolution', () => { + it('prefers a statically configured key', async () => { + const plugin = anthropicadministrator({ key: 'sk-ant-admin-static' }); + const ctx = { + authType: 'api_key', + keys: { + get_api_key: async () => { + throw new Error('key store should not be consulted'); + }, + }, + }; + + await expect(keyBuilderOf(plugin)(ctx, 'endpoint')).resolves.toBe( + 'sk-ant-admin-static', + ); + }); + + it('fails closed rather than sending an empty x-api-key', async () => { + const plugin = anthropicadministrator(); + const ctx = { + authType: 'api_key', + keys: { get_api_key: async () => undefined }, + }; + + await expect(keyBuilderOf(plugin)(ctx, 'endpoint')).rejects.toThrow(); + }); + + it('fails closed when an oauth token is missing', async () => { + const plugin = anthropicadministrator({ authType: 'oauth_2' }); + const ctx = { + authType: 'oauth_2', + keys: { get_access_token: async () => undefined }, + }; + + await expect(keyBuilderOf(plugin)(ctx, 'endpoint')).rejects.toThrow(); + }); +}); diff --git a/packages/anthropicadministrator/schema/database.ts b/packages/anthropicadministrator/schema/database.ts new file mode 100644 index 000000000..5ab95e4e6 --- /dev/null +++ b/packages/anthropicadministrator/schema/database.ts @@ -0,0 +1,21 @@ +import { + ApiKeySchema, + InviteSchema, + UserSchema, + WorkspaceMemberSchema, + WorkspaceSchema, +} from '../endpoints/types'; + +/** + * Cached entities are the Admin API response objects verbatim, so the schemas + * are reused directly from `endpoints/types.ts` rather than restated — a + * second copy could drift from the shape the API actually returns. + * + * Workspace members have no standalone ID and are keyed by + * `${workspace_id}:${user_id}` (see `endpoints/shared.ts`). + */ +export const AnthropicAdministratorUser = UserSchema; +export const AnthropicAdministratorInvite = InviteSchema; +export const AnthropicAdministratorWorkspace = WorkspaceSchema; +export const AnthropicAdministratorWorkspaceMember = WorkspaceMemberSchema; +export const AnthropicAdministratorApiKey = ApiKeySchema; diff --git a/packages/anthropicadministrator/schema/index.ts b/packages/anthropicadministrator/schema/index.ts new file mode 100644 index 000000000..01a75b760 --- /dev/null +++ b/packages/anthropicadministrator/schema/index.ts @@ -0,0 +1,18 @@ +import { + AnthropicAdministratorApiKey, + AnthropicAdministratorInvite, + AnthropicAdministratorUser, + AnthropicAdministratorWorkspace, + AnthropicAdministratorWorkspaceMember, +} from './database'; + +export const AnthropicAdministratorSchema = { + version: '1.0.0', + entities: { + users: AnthropicAdministratorUser, + invites: AnthropicAdministratorInvite, + workspaces: AnthropicAdministratorWorkspace, + workspaceMembers: AnthropicAdministratorWorkspaceMember, + apiKeys: AnthropicAdministratorApiKey, + }, +} as const; diff --git a/packages/anthropicadministrator/tsconfig.json b/packages/anthropicadministrator/tsconfig.json new file mode 100644 index 000000000..15e507a13 --- /dev/null +++ b/packages/anthropicadministrator/tsconfig.json @@ -0,0 +1,20 @@ +{ + "extends": "../../tsconfig.base.json", + "compilerOptions": { + "lib": ["esnext"], + "types": ["node", "jest"], + "module": "ESNext", + "moduleResolution": "Bundler", + "outDir": "./dist", + "rootDir": "./", + "composite": true, + "incremental": true, + "emitDeclarationOnly": true, + "declaration": true, + "declarationMap": true, + "skipLibCheck": true + }, + "include": ["./**/*"], + "exclude": ["dist", "node_modules"], + "references": [] +} diff --git a/packages/anthropicadministrator/tsup.config.ts b/packages/anthropicadministrator/tsup.config.ts new file mode 100644 index 000000000..3ec221e23 --- /dev/null +++ b/packages/anthropicadministrator/tsup.config.ts @@ -0,0 +1,15 @@ +import { defineConfig } from 'tsup'; + +export default defineConfig({ + clean: false, + dts: false, + format: ['esm'], + target: 'esnext', + platform: 'node', + bundle: true, + splitting: true, + minify: true, + outDir: 'dist', + external: ['corsair', 'zod'], + entry: ['index.ts'], +}); diff --git a/packages/corsair/core/constants.ts b/packages/corsair/core/constants.ts index f14788a43..1c9a19349 100644 --- a/packages/corsair/core/constants.ts +++ b/packages/corsair/core/constants.ts @@ -36,6 +36,7 @@ export const BaseProviders = [ 'ambientweather', 'amcards', 'amplitude', + 'anthropicadministrator', 'apaleo', 'api2pdf', 'apibible', @@ -192,6 +193,7 @@ export const ProviderDisplayNames = { ambientweather: 'Ambient Weather', amcards: 'AMcards', amplitude: 'Amplitude', + anthropicadministrator: 'Anthropic Administrator', apaleo: 'Apaleo', api2pdf: 'API2PDF', apibible: 'API.Bible', @@ -355,6 +357,7 @@ export type AllProviders = | 'ambientweather' | 'amcards' | 'amplitude' + | 'anthropicadministrator' | 'apaleo' | 'api2pdf' | 'apibible'