Files
selfphoneng/apps/api/src/app.ts
T
iamdoubz 409724bc55 emergency: 911 dialplan, notifications, test button, settings, warnings
Dialplan: sp-emergency-dial (911, 9911) first in sp-from-ext;
sp-emergency has no class, channel-limit or DND check, dials every
enabled trunk in name order with the E911 caller ID, and raises
UserEvent SpEmergency. Static sp-emergency-alert (_X! so "0" matches)
for the ring-out.

api: SpEmergency -> emergency_events row, live emergency.call (admins
and notify users only), email via nodemailer (SMTP from settings) and
an Originate to every notify extension, each outcome recorded. Test
button runs the same with TEST in front, records and dials nothing.
GET/PATCH /api/settings (smtp password write-only, references
checked, read-only system keys), SMTP test, /api/system/warnings.
Fix: clearing a nullable setting stored SQL NULL (now JSON null).

UI: Settings page (Email, Emergency with the 06 §6 notice), user
Emergency tab, dashboard warnings banner, red 911 banner.

Verified: generator/emergency.test.ts; trunk-test.ts dials 911 from a
caller with no class to the fake provider (E911 caller ID in RPID,
SpEmergency on AMI, alert plays on baresip 101); scripts/e2e/m3-t4.mjs
(SMTP sink receives the test email, 101 rings, banner shows).
nodemailer pinned to 10.0.11 (newest release over two weeks old).

Task: M3-T4
2026-10-17 20:52:50 -05:00

105 lines
3.5 KiB
TypeScript

// SPDX-License-Identifier: AGPL-3.0-or-later
import { existsSync } from 'node:fs';
import { resolve } from 'node:path';
import Fastify from 'fastify';
import fastifyCookie from '@fastify/cookie';
import fastifyStatic from '@fastify/static';
import fastifyWebsocket from '@fastify/websocket';
import { sql } from 'drizzle-orm';
import type { Config } from './config.ts';
import type { Db } from './db/client.ts';
import type { Generator } from './generator/index.ts';
import type { AmiClient } from './ami/client.ts';
import { settingsRoutes } from './routes/settings.ts';
import { errorHandler, forbidden, notFound, sendError } from './errors.ts';
import { authRoutes } from './routes/auth.ts';
import { userRoutes } from './routes/users.ts';
import { systemRoutes } from './routes/system.ts';
import { extensionRoutes } from './routes/extensions.ts';
import { trunkRoutes } from './routes/trunks.ts';
import { didRoutes } from './routes/dids.ts';
import { outboundRouteRoutes } from './routes/outbound.ts';
import { liveRoutes } from './live/hub.ts';
declare module 'fastify' {
interface FastifyInstance {
db: Db;
config: Config;
/** Decorated by the caller (index.ts, tests) before ready(): it needs app.log. */
generator: Generator;
/** Decorated by the caller too (tests pass a fake). */
ami: Pick<AmiClient, 'action' | 'isConnected'>;
}
}
// Paths the SPA must never swallow (04 §1 step 5).
const NON_SPA = /^\/(api|prov|hooks)(\/|$)/;
const SAFE_METHODS = new Set(['GET', 'HEAD', 'OPTIONS']);
export function buildApp(config: Config, db: Db, publicDir = resolve('public')) {
const app = Fastify({
trustProxy: config.TRUST_PROXY ? '127.0.0.1' : false,
logger: {
level: config.LOG_LEVEL,
redact: [
'*.password',
'*.secret',
'*.token',
'*.pin',
'*.Secret',
'req.headers.authorization',
'req.headers.cookie',
],
},
});
app.decorate('db', db);
app.decorate('config', config);
app.decorateRequest('user', null);
app.decorateRequest('sessionId', null);
app.register(fastifyCookie);
app.register(fastifyWebsocket);
app.setErrorHandler(errorHandler);
// CSRF (04 §2.2): browsers can't add this header cross-site without CORS, and CORS is off.
// ponytail: required on every unsafe /api request, not only cookie-authenticated ones; the
// Bearer-token exemption arrives with API tokens in M11.
app.addHook('onRequest', async (req, reply) => {
if (
req.url.startsWith('/api/') &&
!SAFE_METHODS.has(req.method) &&
req.headers['x-requested-with'] !== 'selfphoneng'
)
return sendError(reply, forbidden('Missing X-Requested-With header', 'csrf'));
});
app.get('/healthz', async (_req, reply) => {
try {
await db.execute(sql`select 1`);
return { ok: true };
} catch {
return reply.code(503).send({ ok: false, reasons: ['db'] });
}
});
app.register(authRoutes);
app.register(userRoutes);
app.register(systemRoutes);
app.register(extensionRoutes);
app.register(trunkRoutes);
app.register(didRoutes);
app.register(outboundRouteRoutes);
app.register(settingsRoutes);
app.register(liveRoutes);
if (existsSync(publicDir)) {
app.register(fastifyStatic, { root: publicDir, wildcard: false });
}
app.setNotFoundHandler((req, reply) => {
if (existsSync(publicDir) && req.method === 'GET' && !NON_SPA.test(req.url))
return reply.sendFile('index.html');
return sendError(reply, notFound());
});
return app;
}