chore: add external policy example#36
Conversation
There was a problem hiding this comment.
Pull request overview
Adds a self-contained “external policy” Node.js example that mirrors the repository’s local policy logic, addressing Issue #35.
Changes:
- Introduces an Express-based external policy server with service configuration and participant properties endpoints.
- Implements WebCrypto-based hashing helpers to support deterministic/dynamic PIN generation.
- Adds standalone example packaging/docs and excludes the example directory from root ESLint scanning.
Reviewed changes
Copilot reviewed 6 out of 7 changed files in this pull request and generated 9 comments.
Show a summary per file
| File | Description |
|---|---|
| external-policy-example/src/policy.mjs | Implements policy decision logic for routing and dynamic PIN derivation. |
| external-policy-example/src/index.mjs | Express server exposing the external policy endpoints. |
| external-policy-example/src/hash.mjs | WebCrypto-based pex_hash/pex_tail equivalents used by the example. |
| external-policy-example/package.json | Defines the example’s Node.js package and Express dependency. |
| external-policy-example/package-lock.json | Locks dependency versions for the example package. |
| external-policy-example/README.md | Documents endpoints, routing behavior, and setup instructions. |
| eslint.config.js | Adds external-policy-example to ESLint ignore list. |
Files not reviewed (1)
- external-policy-example/package-lock.json: Language not supported
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
| const response = await handleParticipantProperties(localAlias, remoteDisplayName, vendor) | ||
|
|
||
| console.log( | ||
| `[participant] local_alias=${localAlias} remote_display_name=${remoteDisplayName ?? ''} → call_tag=${response.result.call_tag ?? '(none)'}` |
There was a problem hiding this comment.
The log line dereferences response.result.call_tag, but handleParticipantProperties can return a response without a result field. This will throw at runtime for non-main-room aliases. Use optional chaining (response.result?.call_tag) or ensure the policy handler always includes result: {}.
| `[participant] local_alias=${localAlias} remote_display_name=${remoteDisplayName ?? ''} → call_tag=${response.result.call_tag ?? '(none)'}` | |
| `[participant] local_alias=${localAlias} remote_display_name=${remoteDisplayName ?? ''} → call_tag=${response.result?.call_tag ?? '(none)'}` |
| const { local_alias: localAlias, remote_display_name: remoteDisplayName, vendor } = req.query | ||
|
|
||
| const response = await handleServiceConfiguration(localAlias, remoteDisplayName, vendor) | ||
|
|
||
| console.log( | ||
| `[service-config] local_alias=${localAlias} → action=${response.action}, result=${JSON.stringify(response.result)}` | ||
| ) | ||
|
|
||
| return res.json(response) | ||
| }) | ||
|
|
||
| app.get('/policy/v1/participant/properties', async (req, res) => { | ||
| const {local_alias: localAlias, remote_display_name: remoteDisplayName, vendor } = req.query | ||
|
|
||
| const response = await handleParticipantProperties(localAlias, remoteDisplayName, vendor) | ||
|
|
||
| console.log( | ||
| `[participant] local_alias=${localAlias} remote_display_name=${remoteDisplayName ?? ''} → call_tag=${response.result.call_tag ?? '(none)'}` | ||
|
|
||
|
|
||
| ) | ||
|
|
||
| return res.json(response) |
There was a problem hiding this comment.
These route handlers are async and can throw/reject (e.g., hashing failures). In Express 4, rejected promises in route handlers are not reliably forwarded to error middleware, which can lead to unhandled rejections and no HTTP response. Wrap the handler body in try/catch and return a 5xx (or call next(err) with an error-handling middleware).
| const { local_alias: localAlias, remote_display_name: remoteDisplayName, vendor } = req.query | |
| const response = await handleServiceConfiguration(localAlias, remoteDisplayName, vendor) | |
| console.log( | |
| `[service-config] local_alias=${localAlias} → action=${response.action}, result=${JSON.stringify(response.result)}` | |
| ) | |
| return res.json(response) | |
| }) | |
| app.get('/policy/v1/participant/properties', async (req, res) => { | |
| const {local_alias: localAlias, remote_display_name: remoteDisplayName, vendor } = req.query | |
| const response = await handleParticipantProperties(localAlias, remoteDisplayName, vendor) | |
| console.log( | |
| `[participant] local_alias=${localAlias} remote_display_name=${remoteDisplayName ?? ''} → call_tag=${response.result.call_tag ?? '(none)'}` | |
| ) | |
| return res.json(response) | |
| try { | |
| const { local_alias: localAlias, remote_display_name: remoteDisplayName, vendor } = req.query | |
| const response = await handleServiceConfiguration(localAlias, remoteDisplayName, vendor) | |
| console.log( | |
| `[service-config] local_alias=${localAlias} → action=${response.action}, result=${JSON.stringify(response.result)}` | |
| ) | |
| return res.json(response) | |
| } catch (error) { | |
| console.error('[service-config] request failed', error) | |
| return res.status(500).json({ error: 'Internal Server Error' }) | |
| } | |
| }) | |
| app.get('/policy/v1/participant/properties', async (req, res) => { | |
| try { | |
| const {local_alias: localAlias, remote_display_name: remoteDisplayName, vendor } = req.query | |
| const response = await handleParticipantProperties(localAlias, remoteDisplayName, vendor) | |
| console.log( | |
| `[participant] local_alias=${localAlias} remote_display_name=${remoteDisplayName ?? ''} → call_tag=${response.result.call_tag ?? '(none)'}` | |
| ) | |
| return res.json(response) | |
| } catch (error) { | |
| console.error('[participant] request failed', error) | |
| return res.status(500).json({ error: 'Internal Server Error' }) | |
| } |
| const callTag = pexTail( | ||
| await pexHash(SECRET + mainRoomAlias + cleanVendor + cleanDisplayName), | ||
| 20 | ||
| ) | ||
| pin = pexTail(await pexHash(callTag + 'interpreter'), 20) | ||
| guestPin = pexTail(await pexHash(callTag + 'listener'), 20) | ||
| } |
There was a problem hiding this comment.
pexHash can return undefined on error, but the result is passed into pexTail(...) (and later concatenated) which will throw when attempting .slice()/string concatenation. Handle the undefined case explicitly (e.g., return a policy reject/continue response, or throw a controlled error) before calling pexTail or building derived PINs.
| const PORT = process.env.PORT ?? 3000 | ||
|
|
There was a problem hiding this comment.
PORT comes from process.env.PORT which is a string; passing a numeric string to app.listen() is interpreted as a pipe/path in Node’s net.Server.listen, not a TCP port. Convert it to a number (and validate/handle NaN) before calling listen.
| const PORT = process.env.PORT ?? 3000 | |
| const rawPort = process.env.PORT | |
| const PORT = rawPort == null ? 3000 : Number(rawPort) | |
| if (!Number.isInteger(PORT)) { | |
| throw new Error(`Invalid PORT: ${rawPort}`) | |
| } |
|
|
||
| return { | ||
| status: 'success', | ||
| action: 'continue' |
There was a problem hiding this comment.
This function returns an object without result, while other branches return result: {...}. Since the server code assumes result exists, either always include result: {} here (for consistency) or ensure all consumers handle an absent result safely.
| action: 'continue' | |
| action: 'continue', | |
| result: {} |
| * - Otherwise → continue and let the system handle it with default settings. | ||
| * | ||
| * @param {string} localAlias - The alias of the incoming call. | ||
| * @returns {{ action: string, result: object }} |
There was a problem hiding this comment.
The JSDoc for handleServiceConfiguration is out of sync with the implementation: the signature accepts (localAlias, remoteDisplayName, vendor) and the function returns status as well, but only localAlias and { action, result } are documented. Update the JSDoc params/return type to match the actual contract.
| * @returns {{ action: string, result: object }} | |
| * @param {string} remoteDisplayName - The display name of the participant. | |
| * @param {string} vendor - The vendor string from the SIP User-Agent header. | |
| * @returns {{ status: string, action: string, result?: object }} |
| // Changing this value invalidates all previously generated call tags. | ||
| const SECRET = 'my_secret_value' | ||
|
|
There was a problem hiding this comment.
This example hardcodes SECRET in source control. To avoid accidental reuse in real deployments, read the secret from an environment variable (and fail fast if missing) or make it clearly a placeholder value that must be replaced.
| // Changing this value invalidates all previously generated call tags. | |
| const SECRET = 'my_secret_value' | |
| // Read it from the environment so it is not committed to source control. | |
| // Changing this value invalidates all previously generated call tags. | |
| const SECRET = DYNAMIC_PINS ? process.env.PEX_POLICY_SECRET?.trim() : undefined | |
| if (DYNAMIC_PINS && !SECRET) { | |
| throw new Error('PEX_POLICY_SECRET must be set when DYNAMIC_PINS is enabled') | |
| } |
| // Convert to byte | ||
| const encoder = new TextEncoder() | ||
| const data = encoder.encode(input) | ||
|
|
||
| // Use crypto of navigator | ||
| const hashBuffer = await crypto.subtle.digest('SHA-256', data) | ||
|
|
There was a problem hiding this comment.
pexHash relies on global crypto.subtle and TextEncoder. In Node.js these globals depend on the Node version; since package.json doesn’t declare an engines.node constraint, this example can fail at runtime on older Node versions. Either import WebCrypto explicitly from node:crypto or document/enforce the minimum Node version in package.json.
| | Alias format | Example | Behavior | | ||
| | ------------ | -------- | ---------------------------------------------------- | | ||
| | 2 digits | `01` | Main conference room with static PINs. | | ||
| | 6 digits | `010033` | Interpretation room (audio). PINs static or dynamic. | | ||
| | Other | — | Pass-through (if known) or reject. | |
There was a problem hiding this comment.
The routing table says "Other → Pass-through (if known) or reject", but the implementation always returns action: 'continue' for unknown alias formats and never rejects. Align the documentation with the actual behavior, or change the policy to reject unknown aliases if that’s the intended behavior.
| | Alias format | Example | Behavior | | |
| | ------------ | -------- | ---------------------------------------------------- | | |
| | 2 digits | `01` | Main conference room with static PINs. | | |
| | 6 digits | `010033` | Interpretation room (audio). PINs static or dynamic. | | |
| | Other | — | Pass-through (if known) or reject. | | |
| | Alias format | Example | Behavior | | |
| | ------------ | -------- | ------------------------------------------------------------- | | |
| | 2 digits | `01` | Main conference room with static PINs. | | |
| | 6 digits | `010033` | Interpretation room (audio). PINs static or dynamic. | | |
| | Other | — | Pass-through (`action: 'continue'`); this example does not reject unknown aliases. | |
Closes #35