provisioning: Linphone setup QR, "Set up a phone app"
ci / check (push) Successful in 53s
ci / images (push) Successful in 24s

GET /api/extensions/:id/softphone-qr (admin) and /api/me/softphone-qr
return {url, qr, expiresAt}: the link Linphone fetches and its QR as a
PNG data URL (qrcode), valid 24 h. Both are audited like revealing the
SIP password. JSON rather than a bare PNG, so the page can show the
link for desktop Linphone too.

The token is stateless per 07 §5: base64url(extensionId.expiry.hmac),
HMAC-SHA256 with APP_SECRET, checked timing-safe. /prov/linphone/
<token>.xml matches it before the other file names are lower-cased
(the token is case-sensitive); a bad or expired token is a 404 and a
counted failure. auth_info keys checked against liblinphone's config
reader; proxy keys stay (verify).

Web: a "Set up a phone app" section on My settings and in the admin
Credentials tab: Linphone with the QR, MicroSIP/Baresip by hand, and
the plain warning about mobile apps without push. e2e m10-t3: as a
user, the QR's link gives the XML and a baresip phone registers with
only what it holds.

Task: M10-T3
This commit is contained in:
iamdoubz committed 2026-10-19 14:16:28 -05:00
1 parent 6195aca2fb
commit c1f8b04e7e
17 files changed
+631 -4

No files matched your search

+17
View File
@@ -12,8 +12,10 @@ import {
type ListResponse,
type MeDto,
type VoicemailDto,
type SoftphoneQr,
type WebRtcCreds,
} from '@selfphoneng/shared';
import { softphoneQr } from '../provisioning/linphone.ts';
import { decrypt, encrypt, isTrivialPin } from '../crypto/secrets.ts';
import { setDnd, setForward } from '../services/astdb.ts';
import { audit } from '../services/audit.ts';
@@ -175,6 +177,21 @@ export async function meRoutes(app: FastifyInstance) {
return myState(req.user!.id);
});
/** "Set up a phone app" (07 §5): a Linphone setup QR for my own extension. */
app.get('/api/me/softphone-qr', async (req): Promise<SoftphoneQr> => {
const [ext] = await db.select().from(extensions).where(eq(extensions.userId, req.user!.id));
if (!ext) throw notFound("You don't have an extension yet. Ask your admin.");
await audit(db, {
actorUserId: req.user!.id,
actorIp: req.ip,
action: 'reveal_sip_credentials',
entityType: 'extension',
entityId: ext.id,
summary: `Made a phone app setup QR for extension ${ext.number}`,
});
return softphoneQr(app.config.APP_SECRET, app.config.PUBLIC_HOSTNAME, ext.id);
});
app.get('/api/me/webrtc', async (req): Promise<WebRtcCreds> => {
const [ext] = await db.select().from(extensions).where(eq(extensions.userId, req.user!.id));
if (!ext) throw notFound("You don't have an extension yet. Ask your admin.");