Repository navigation
feat(calls): prepare guest labels and live waiting-room controls #98
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
abc8688
7c19478
4dbdaf0
c15b944
9b8c960
5781db1
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,27 @@ | ||
| { | ||
| "sourceAccepted": false, | ||
| "published": false, | ||
| "apiVersion": "2026-09-22", | ||
| "apiSource": "0983e9263de1f0887b249faf9d85207d543989e3", | ||
| "sdkSource": "e6df859b2560c67a87f947f8c4cf13e2361cfc95", | ||
| "sdkSnippetExport": "b9454930a2a821cccde2881efeb4430a0a96e676", | ||
| "physicalPackageSha256": "34b858821df0405c215306997a3e3cfdbea1cf404db90c09422be4075018cda4", | ||
| "publishedDependencyUnchanged": true, | ||
| "operations": ["set_approval_required", "admit", "deny"], | ||
| "authority": "same held live media connection; backend checks call owner and current provider admin", | ||
| "replay": "never automatic; a matching acknowledgement is required", | ||
| "evidence": "POLYMORFA_GUEST_SDK_REQUIRED=1 native CLI calls-sockets fixture with candidate package", | ||
| "releaseGates": [ | ||
| "accepted API and SDK source", | ||
| "matching staging acceptance before nightly", | ||
| "matching production acceptance before stable", | ||
| "native paired-host waiting-room moderation acceptance", | ||
| "matching published SDK version before dependency pin", | ||
| "CLI merge and publication after the matching SDK dependency is pinned" | ||
| ], | ||
| "acceptance": { | ||
| "publishedDependency": "format, lint, typecheck, build, reference checks and full suite pass: 1175 tests, 1 candidate-only skip; old SDK refuses unsupported controls before connecting", | ||
| "candidatePackage": "typecheck, build and all 1176 tests pass with physical SDK archive, including native held-connection guest moderation; emitted browser module and production audit pass", | ||
| "dependencyPin": "published version unchanged until matching SDK publication and API environment acceptance; full candidate source compatibility is verified" | ||
| } | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -58,12 +58,26 @@ export function renderCallActivity(activity: CallActivity): string { | |
| `participant=${activity.participant.id}`, | ||
| `state=${activity.participant.state}`, | ||
| ) | ||
| if ((activity.participant as {guest?: boolean}).guest === true) | ||
| fields.push('guest=true') | ||
| if (typeof (activity.participant as {name?: string}).name === 'string') | ||
| fields.push( | ||
| `name=${JSON.stringify((activity.participant as {name?: string}).name)}`, | ||
| ) | ||
|
Comment on lines
+63
to
+66
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Escape C1 and bidirectional control characters in untrusted guest names. The README says rendered names are quoted so control characters cannot become terminal instructions. A hostile guest can inject terminal sequences or spoof the roster line an admin reads before an Add one shared helper and use it in all three places. Proposed fix
Also applies to: 78-78
|
||
| break | ||
| case 'participant_left': | ||
| fields.push(`participant=${activity.participantId}`) | ||
| if (activity.reason !== undefined) | ||
| fields.push(`reason=${activity.reason}`) | ||
| break | ||
| case 'waiting_room': | ||
| fields.push( | ||
| `approvalRequired=${activity.room.enabled}`, | ||
| `isAdmin=${activity.room.isAdmin}`, | ||
| `inWaitingRoom=${activity.room.inWaitingRoom}`, | ||
| `users=${JSON.stringify(activity.room.users)}`, | ||
| ) | ||
| break | ||
| case 'ended': | ||
| fields.push(`reason=${activity.reason}`, `duration=${activity.duration}s`) | ||
| break | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -30,3 +30,123 @@ export async function muteCallMedia(call: object): Promise<void> { | |
| ) | ||
| } | ||
| } | ||
|
|
||
| export interface WaitingRoomCommand { | ||
| readonly action: 'set_approval_required' | 'admit' | 'deny' | ||
| readonly enabled?: boolean | ||
| readonly participantId?: string | ||
| } | ||
| interface WaitingRoomControls { | ||
| setApprovalRequired(enabled: boolean): Promise<void> | ||
| admitParticipant(participantId: string): Promise<void> | ||
| denyParticipant(participantId: string): Promise<void> | ||
| } | ||
|
|
||
| export function assertWaitingRoomSupported( | ||
| candidate: object = Call.prototype, | ||
| ): asserts candidate is WaitingRoomControls { | ||
| const controls = candidate as Partial<WaitingRoomControls> | ||
| if ( | ||
| typeof controls.setApprovalRequired !== 'function' || | ||
| typeof controls.admitParticipant !== 'function' || | ||
| typeof controls.denyParticipant !== 'function' | ||
| ) { | ||
| throw new PolymorfaConfigurationError( | ||
| 'This CLI build needs a published Calls SDK with waiting-room support before --control-input can be used.', | ||
| 'control-input', | ||
| ) | ||
| } | ||
| } | ||
|
|
||
| /** One command uses only the Call's current media connection. Never retry it. */ | ||
| export async function moderateWaitingRoom( | ||
| call: object, | ||
| command: WaitingRoomCommand, | ||
| ): Promise<void> { | ||
| assertWaitingRoomSupported(call) | ||
| if (command.action === 'set_approval_required') | ||
| await call.setApprovalRequired(command.enabled!) | ||
| else if (command.action === 'admit') | ||
| await call.admitParticipant(command.participantId!) | ||
| else await call.denyParticipant(command.participantId!) | ||
| } | ||
|
|
||
| /** Read bounded UTF-8 JSON lines from a file or pipe, separate from PCM input. */ | ||
| export async function* waitingRoomCommands( | ||
| input: AsyncIterable<Uint8Array>, | ||
| ): AsyncGenerator<WaitingRoomCommand> { | ||
| const decoder = new TextDecoder('utf-8', {fatal: true}) | ||
| let pending = '' | ||
| function parse(line: string): WaitingRoomCommand { | ||
| let value: unknown | ||
| try { | ||
| value = JSON.parse(line) | ||
| } catch { | ||
| throw invalidControl() | ||
| } | ||
| if (!value || typeof value !== 'object' || Array.isArray(value)) | ||
| throw invalidControl() | ||
| const command = value as Record<string, unknown> | ||
| const keys = Object.keys(command).sort().join(',') | ||
| if ( | ||
| command.action === 'set_approval_required' && | ||
| keys === 'action,enabled' && | ||
| typeof command.enabled === 'boolean' | ||
| ) | ||
| return {action: command.action, enabled: command.enabled} | ||
| if ( | ||
| (command.action === 'admit' || command.action === 'deny') && | ||
| keys === 'action,participantId' && | ||
| typeof command.participantId === 'string' && | ||
| /^[1-9][0-9]{0,18}$/.test(command.participantId) | ||
| ) | ||
| return {action: command.action, participantId: command.participantId} | ||
| throw invalidControl() | ||
| } | ||
| for await (const chunk of input) { | ||
| // Process pieces so one large read never grows the pending line without bound. | ||
| for (let offset = 0; offset < chunk.length; offset += 1024) { | ||
| pending += decoder.decode(chunk.subarray(offset, offset + 1024), { | ||
| stream: true, | ||
| }) | ||
|
Comment on lines
+109
to
+111
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Report invalid UTF-8 control input as a
Wrap the decode calls and throw Proposed fix
|
||
| let newline: number | ||
| while ((newline = pending.indexOf('\n')) !== -1) { | ||
| const line = pending.slice(0, newline).trim() | ||
| if (newline > 4096) throw invalidControl() | ||
| pending = pending.slice(newline + 1) | ||
| if (line) yield parse(line) | ||
| } | ||
| if (pending.length > 4096) throw invalidControl() | ||
| } | ||
| } | ||
| pending += decoder.decode() | ||
| if (pending.trim()) yield parse(pending.trim()) | ||
| } | ||
| function invalidControl(): PolymorfaConfigurationError { | ||
| return new PolymorfaConfigurationError( | ||
| 'Expected a JSON line with action set_approval_required and enabled, or action admit/deny and a public participantId. Lines must be at most 4096 characters.', | ||
| 'control-input', | ||
| ) | ||
| } | ||
|
|
||
| export interface CallWaitingRoom { | ||
| readonly enabled: boolean | ||
| readonly isAdmin: boolean | ||
| readonly inWaitingRoom: boolean | ||
| readonly users: readonly { | ||
| readonly id: string | ||
| readonly state: string | ||
| readonly guest?: boolean | ||
| readonly name?: string | ||
| }[] | ||
| } | ||
| export function observeWaitingRoom( | ||
| call: object, | ||
| listener: (state: CallWaitingRoom) => void, | ||
| ): void { | ||
| ;( | ||
| call as { | ||
| on(event: 'waitingRoom', listener: (state: CallWaitingRoom) => void): void | ||
| } | ||
| ).on('waitingRoom', listener) | ||
| } | ||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Script executed:
Repository: polymorfa/cli
Length of output: 6805
Script executed:
Repository: polymorfa/cli
Length of output: 25782
Script executed:
Repository: polymorfa/cli
Length of output: 14189
Script executed:
Repository: polymorfa/cli
Length of output: 11867
Report control-stream errors against the control input.
A control read error can be reported as an audio error naming the PCM input if the bridge returns. If the bridge rejects first, it can instead be reported as a connection error. Control-file open errors also use the audio error helper.
Use a separate control error handler and state variable. Report control errors with the control path, the
control-inputfield, and a message that does not say “audio”, including when the bridge rejects. Use the same attribution for control-file open errors.