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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 |
Expand Down
31 changes: 31 additions & 0 deletions packages/control-plane/index.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<typeof fetch>[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()=>{
Expand Down
12 changes: 9 additions & 3 deletions packages/control-plane/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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={}){
Expand Down Expand Up @@ -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;
}
Expand Down
Loading