diff --git a/README.md b/README.md index 556a114..4eb2a8e 100644 --- a/README.md +++ b/README.md @@ -67,6 +67,11 @@ on outbound records can be `mixed`. Recorded service/status is not independent channel or delivery proof. Recorded request/response/error fields can contain sensitive data; do not log them or assume that server text is sanitized. +FaceTime control-plane failures expose recognized server error codes as the +enumerable `ChertApiError.serverCode`. Unrecognized codes map to `unknown`; +when no code is provided, `serverCode` is undefined. The existing `code` remains +unchanged, and the default error text does not include server messages. + ## Languages and release status | Language | Available on `main` | Release / unified-channel work | Documentation | diff --git a/packages/control-plane/index.test.ts b/packages/control-plane/index.test.ts index e7e4e4d..6703de2 100644 --- a/packages/control-plane/index.test.ts +++ b/packages/control-plane/index.test.ts @@ -89,6 +89,37 @@ describe('control-plane wire contracts',()=>{ }); }); describe('safe failures',()=>{ + it('exposes server error codes without retaining server messages',async()=>{ + const responses=[ + Response.json({error:{code:'livekit_not_provisioned',message:'Use Shared or your own LiveKit.'}},{status:409}), + Response.json({error:{code:'new_server_code'}},{status:400}), + Response.json({error:{code:'billing_private token: secret',message:'secret'}},{status:500}), + Response.json({error:{message:'private message'}},{status:400}), + Response.json({error:{code:null}},{status:400}), + Response.json({error:{code:123}},{status:400}), + Response.json({error:{code:''}},{status:400}), + new Response('{invalid',{status:502}), + ]; + const client=new ChertControlPlane({fetch:(async()=>responses.shift()!) as unknown as typeof fetch}); + const first=await client.beginLogin({device_name:'Test'}).catch(e=>e); + expect(first).toMatchObject({status:409,code:'request_failed',serverCode:'livekit_not_provisioned'}); + expect(String(first)).not.toContain('Use Shared'); + expect(JSON.stringify(first)).toContain('livekit_not_provisioned'); + expect(JSON.stringify(first,Object.getOwnPropertyNames(first))).not.toContain('Use Shared'); + const second=await client.beginLogin({device_name:'Test'}).catch(e=>e); + expect(second).toMatchObject({status:400,serverCode:'unknown'}); + const third=await client.beginLogin({device_name:'Test'}).catch(e=>e); + expect(third).toMatchObject({status:500,code:'request_failed',serverCode:'unknown'}); + expect(JSON.stringify(third,Object.getOwnPropertyNames(third))).not.toContain('secret'); + const fourth=await client.beginLogin({device_name:'Test'}).catch(e=>e); + expect(fourth).toMatchObject({status:400,serverCode:undefined}); + for(let i=0;i<3;i++){ + const malformed=await client.beginLogin({device_name:'Test'}).catch(e=>e); + expect(malformed).toMatchObject({status:400,serverCode:'unknown'}); + } + const invalid=await client.beginLogin({device_name:'Test'}).catch(e=>e); + expect(invalid).toMatchObject({status:502,code:'invalid_response',serverCode:undefined}); + }); it('bounds response bytes and attaches a request deadline',async()=>{let signal:AbortSignal|undefined;const client=new ChertControlPlane({fetch:(async(_url: Parameters[0],init?: RequestInit)=>{signal=init?.signal??undefined;return new Response('x'.repeat(1_048_577));}) as typeof fetch});await expect(client.beginLogin({device_name:'Test'})).rejects.toMatchObject({code:'invalid_response'});expect(signal).toBeInstanceOf(AbortSignal);}); it('preserves permission failures without retry or leaking server data',async()=>{let calls=0;const client=new ChertControlPlane({token:'private',projectId:project,fetch:(async()=>{calls++;return Response.json({error:{code:'forbidden',message:'private token',details:{secret:'bad'}}},{status:403});}) as unknown as typeof fetch});try{await client.createCheckout({plan:'shared'},'stable-key');throw Error('expected');}catch(error){expect(error).toBeInstanceOf(ChertApiError);expect((error as ChertApiError).code).toBe('forbidden');expect(String(error)).not.toContain('private');expect(JSON.stringify(error)).not.toContain('secret');}expect(calls).toBe(1);}); it('terminal billing: purchase, change_plan and payment_method send keyed actions and accept only Stripe-hosted URLs',async()=>{ diff --git a/packages/control-plane/index.ts b/packages/control-plane/index.ts index 3fac495..2db7936 100644 --- a/packages/control-plane/index.ts +++ b/packages/control-plane/index.ts @@ -100,9 +100,15 @@ export interface Checkout {url:string} export interface CheckoutInput {plan:BillingPlan;coupon_code?:string} export interface BillingCancellation {status:'cancellation_requested';message:string} export interface CouponValidation {code:string;percent_off:number;mode:'live'|'test';currency:string;monthly_total?:number;due_now?:number;access_hours?:number;plans?:BillingPlan[]} -/** Server text, bodies, URLs and credentials are deliberately not included in errors. */ -export class ChertApiError extends Error {constructor(public readonly status:number,public readonly code:string){super(`Chert request failed (${status}, ${code}).`);this.name='ChertApiError';}} +export class ChertApiError extends Error { + constructor(public readonly status:number,public readonly code:string,public readonly serverCode?:string){ + super(`Chert request failed (${status}, ${code}).`);this.name='ChertApiError'; + } +} const safeCodes = new Set(['transcript_not_found','billing_plan_change_unavailable','living_capability_unsupported','living_surface_conflict','living_state_conflict','living_deployment_not_ready','living_input_not_allowed','living_session_not_found','media_capability_unsupported','media_session_conflict','call_not_active','call_ended','call_not_found','media_session_expired','media_transport_unavailable','unauthorized','forbidden','cli_scope_denied','billing_owner_required','billing_unavailable','billing_binding_mismatch','billing_checkout_exists','billing_coupon_unavailable','billing_dedicated_no_inventory','billing_dedicated_unqualified','billing_idempotency_conflict','billing_invalid','billing_no_subscription','billing_paid_period_required','billing_price_mismatch','billing_provider_mismatch','billing_provider_unavailable','billing_reservation_expired','billing_reservation_not_found','billing_reservation_pending','billing_sales_disabled','billing_subscription_conflict','billing_subscription_exists','billing_payment_failed','billing_plan_unchanged','billing_unconfigured','shared_no_capacity','project_required','invalid_request','cli_login_expired','cli_login_not_found','cli_login_forbidden','slow_down','cli_login_rate_limited','cli_login_busy','not_found','conflict','caller_screen_not_shared','media_content_unsupported','caller_screen_unsupported','media_limit_exceeded','video_composition_unsupported','composition_invalid','composition_revision_conflict','composition_base_unavailable']); +const serverCodes = new Set([...safeCodes, + 'livekit_not_provisioned', +]); export class ChertControlPlane { private readonly origin:string; private readonly token?:string;private readonly projectId?:string;private readonly fetcher:typeof globalThis.fetch; constructor(options:ClientOptions={}){ @@ -137,7 +143,7 @@ export class ChertControlPlane { throw new ChertApiError(response.status,response.status===404?'endpoint_unavailable':'invalid_response'); } if(!envelope||typeof envelope!=='object')throw new ChertApiError(response.status,'invalid_response'); - if(!response.ok){const error=(envelope as {error?:unknown}).error;const code=error&&typeof error==='object'?(error as {code?:unknown}).code:undefined;throw new ChertApiError(response.status,typeof code==='string'&&safeCodes.has(code)?code:'request_failed');} + if(!response.ok){const error=(envelope as {error?:unknown}).error;const fields=error&&typeof error==='object'?error as {code?:unknown}:undefined;const code=typeof fields?.code==='string'?fields.code:undefined;const serverCode=!fields||!Object.hasOwn(fields,'code')?undefined:code!==undefined&&serverCodes.has(code)?code:'unknown';throw new ChertApiError(response.status,code&&safeCodes.has(code)?code:'request_failed',serverCode);} if(!Object.hasOwn(envelope,'data'))throw new ChertApiError(response.status,'invalid_response'); return (envelope as {data:T}).data; }