diff --git a/.env.example b/.env.example
index 5835676..2deb592 100644
--- a/.env.example
+++ b/.env.example
@@ -15,6 +15,7 @@ APP_UID=1000
APP_GID=1000
LOG_LEVEL=info
TRUST_PROXY=true # true when behind Caddy/nginx
+RATE_LIMIT_PER_MINUTE=600 # requests per minute per client IP, every route; 0 = off
RTP_PORT_START=10000 # call audio (UDP), both ends included: open this range on the
RTP_PORT_END=20000 # firewall and router. Two ports per call; 200 is plenty for a small office
# Local AI (admin guide: AI). Starting values for Settings → AI; that page wins once saved.
@@ -26,3 +27,6 @@ RTP_PORT_END=20000 # firewall and router. Two ports per call;
#AI_TTS_URL=http://127.0.0.1:8000/v1
#AI_TTS_MODEL=speaches-ai/Kokoro-82M-v1.0-ONNX
#AI_TTS_VOICE=af_heart
+# A private CA your identity provider or directory uses (admin guide: Sign-in). Put the PEM file
+# in data/tls/; the api reads it at start, so restart the api after changing it.
+#NODE_EXTRA_CA_CERTS=/var/lib/selfphoneng/tls/idp-ca.pem
diff --git a/AGENTS.md b/AGENTS.md
index 58d0544..210f7e2 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -28,6 +28,7 @@ write the software around it: database, config generator, API, web UI.
| `docs/spec/08-security-ops.md` | Security, backups, monitoring, legal requirements | Before any release, and when touching auth or networking |
| `docs/spec/09-roadmap.md` | Milestones, ordered tasks, acceptance criteria | Picking your next task |
| `docs/spec/11-contacts.md` | Contacts: the builtin phonebook or Contrack, lookup, the Contrack timeline | Working on contacts or caller names |
+| `docs/spec/12-sso.md` | Single sign-on: OpenID Connect (Keycloak and others) and LDAP/LDAPS | Working on sign-in, users or roles |
## Hard rules
diff --git a/apps/api/package.json b/apps/api/package.json
index f4aa6a9..fc387d0 100644
--- a/apps/api/package.json
+++ b/apps/api/package.json
@@ -12,13 +12,16 @@
},
"dependencies": {
"@fastify/cookie": "^11.1.3",
+ "@fastify/rate-limit": "^11.2.1",
"@fastify/static": "^10.1.6",
"@fastify/websocket": "^11.3.4",
"@node-rs/argon2": "^2.2.2",
"@selfphoneng/shared": "workspace:*",
"drizzle-orm": "^0.45.4",
"fastify": "^5.12.7",
+ "ldapts": "^9.2.0",
"nodemailer": "10.0.11",
+ "openid-client": "^6.8.8",
"pg": "^8.23.1",
"qrcode": "1.5.4",
"zod": "^4.6.7"
diff --git a/apps/api/src/app.ts b/apps/api/src/app.ts
index ee254f6..496e119 100644
--- a/apps/api/src/app.ts
+++ b/apps/api/src/app.ts
@@ -3,6 +3,7 @@ import { existsSync } from 'node:fs';
import { resolve } from 'node:path';
import Fastify from 'fastify';
import fastifyCookie from '@fastify/cookie';
+import fastifyRateLimit from '@fastify/rate-limit';
import fastifyStatic from '@fastify/static';
import fastifyWebsocket from '@fastify/websocket';
import { sql } from 'drizzle-orm';
@@ -11,9 +12,10 @@ 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 { HttpError, errorHandler, forbidden, notFound, sendError } from './errors.ts';
import { authRoutes } from './routes/auth.ts';
import { userRoutes } from './routes/users.ts';
+import { oidcRoutes } from './routes/oidc.ts';
import { systemRoutes } from './routes/system.ts';
import { extensionRoutes } from './routes/extensions.ts';
import { trunkRoutes } from './routes/trunks.ts';
@@ -91,6 +93,15 @@ export function buildApp(config: Config, db: Db, publicDir = resolve('public'))
app.decorateRequest('sessionId', null);
app.register(fastifyCookie);
app.register(fastifyWebsocket);
+ // Every route, per client IP (08 §2.4). Before the routes, so each one gets it; routes with
+ // their own limits (login, /prov) or a stricter one say so in `config.rateLimit`.
+ if (config.RATE_LIMIT_PER_MINUTE > 0)
+ app.register(fastifyRateLimit, {
+ max: config.RATE_LIMIT_PER_MINUTE,
+ timeWindow: 60_000,
+ errorResponseBuilder: (_req, ctx) =>
+ new HttpError(429, 'rate_limited', `Too many requests, try again in ${ctx.after}`),
+ });
app.setErrorHandler(errorHandler);
// CSRF (04 §2.2): browsers can't add this header cross-site without CORS, and CORS is off.
@@ -139,6 +150,7 @@ export function buildApp(config: Config, db: Db, publicDir = resolve('public'))
app.register(authRoutes);
app.register(userRoutes);
+ app.register(oidcRoutes);
app.register(systemRoutes);
app.register(extensionRoutes);
app.register(trunkRoutes);
diff --git a/apps/api/src/auth/guards.ts b/apps/api/src/auth/guards.ts
index 0cc18c1..925aa73 100644
--- a/apps/api/src/auth/guards.ts
+++ b/apps/api/src/auth/guards.ts
@@ -1,9 +1,10 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
-// preHandler guards (04 §2.4). Routes list them in `preHandler`.
+// preHandler guards (04 §2.4). Routes list them in `preHandler`; the rate limit presets go in
+// `config`.
import type { FastifyReply, FastifyRequest } from 'fastify';
import type { users } from '../db/schema.ts';
import { forbidden, unauthorized } from '../errors.ts';
-import { COOKIE, getSessionUser, setSessionCookie } from './sessions.ts';
+import { COOKIE, getSessionUser, mustChange, setSessionCookie } from './sessions.ts';
import { getTokenUser } from './tokens.ts';
export type CurrentUser = typeof users.$inferSelect;
@@ -39,7 +40,7 @@ export async function requireAuth(req: FastifyRequest, reply: FastifyReply): Pro
if (found.extended) setSessionCookie(reply, token!);
req.user = found.user;
req.sessionId = found.sessionId;
- if (found.user.mustChangePassword && !req.url.startsWith('/api/auth/'))
+ if (mustChange(found) && !req.url.startsWith('/api/auth/'))
throw forbidden('Change your password first', 'password_change_required');
}
@@ -47,3 +48,8 @@ export async function requireAdmin(req: FastifyRequest, reply: FastifyReply): Pr
await requireAuth(req, reply);
if (req.user!.role !== 'admin') throw forbidden('Admins only');
}
+
+/** Route `config` for what calls out or does real work (Test buttons, downloads): 10 a minute. */
+export const SLOW = { rateLimit: { max: 10, timeWindow: 60_000 } };
+/** Route `config` for routes with their own limit (/prov) or only Asterisk on this machine. */
+export const NO_LIMIT = { rateLimit: false as const };
diff --git a/apps/api/src/auth/login-limiter.ts b/apps/api/src/auth/login-limiter.ts
index 696c598..13b5c47 100644
--- a/apps/api/src/auth/login-limiter.ts
+++ b/apps/api/src/auth/login-limiter.ts
@@ -15,21 +15,23 @@ function recent(key: string, windowMs: number, now: number): number[] {
return list;
}
-export function isLoginBlocked(ip: string, email: string, now = Date.now()): boolean {
+/** `email` null: by IP only (an OpenID callback names no account, 12 §3.2). */
+export function isLoginBlocked(ip: string, email: string | null, now = Date.now()): boolean {
return (
recent(`ip:${ip}`, RULES.ip.windowMs, now).length >= RULES.ip.max ||
- recent(`email:${email.toLowerCase()}`, RULES.email.windowMs, now).length >= RULES.email.max
+ (email !== null &&
+ recent(`email:${email.toLowerCase()}`, RULES.email.windowMs, now).length >= RULES.email.max)
);
}
-export function recordLoginFailure(ip: string, email: string, now = Date.now()): void {
+export function recordLoginFailure(ip: string, email: string | null, now = Date.now()): void {
// Forget keys past every window, at most once a minute: emails tried once and never again
// would otherwise stay until restart (08 §6).
if (now - lastSweep > MIN) {
lastSweep = now;
for (const [key, list] of failures) if (now - list.at(-1)! >= LONGEST) failures.delete(key);
}
- for (const key of [`ip:${ip}`, `email:${email.toLowerCase()}`]) {
+ for (const key of [`ip:${ip}`, ...(email === null ? [] : [`email:${email.toLowerCase()}`])]) {
failures.set(key, [...(failures.get(key) ?? []), now]);
}
}
diff --git a/apps/api/src/auth/sessions.ts b/apps/api/src/auth/sessions.ts
index 5aa617a..be71fe0 100644
--- a/apps/api/src/auth/sessions.ts
+++ b/apps/api/src/auth/sessions.ts
@@ -4,6 +4,7 @@ import { createHash, randomBytes } from 'node:crypto';
import { and, eq, gt, ne } from 'drizzle-orm';
import type { FastifyReply } from 'fastify';
import type { Db } from '../db/client.ts';
+import { encrypt } from '../crypto/secrets.ts';
import { sessions, users } from '../db/schema.ts';
export const COOKIE = 'sp_session';
@@ -32,6 +33,9 @@ export async function createSession(
userId: string,
ip: string | null,
userAgent: string | null,
+ method: 'password' | 'ldap' | 'oidc' = 'password',
+ /** OpenID sessions (12 §3.5): kept encrypted for the leaver check and signing out there. */
+ idp?: { refreshToken: string | null; idToken: string },
): Promise {
const token = randomBytes(32).toString('base64url');
await db.insert(sessions).values({
@@ -40,6 +44,10 @@ export async function createSession(
expiresAt: new Date(Date.now() + LIFETIME),
ip,
userAgent: userAgent?.slice(0, 500) ?? null,
+ method,
+ idpRefreshEnc: idp?.refreshToken ? encrypt(idp.refreshToken) : null,
+ idpIdTokenEnc: idp ? encrypt(idp.idToken) : null,
+ idpCheckedAt: idp ? new Date() : null,
});
return token;
}
@@ -51,7 +59,7 @@ export async function createSession(
export async function getSessionUser(db: Db, token: string, slide = true) {
const id = sha256(token);
const [row] = await db
- .select({ expiresAt: sessions.expiresAt, user: users })
+ .select({ expiresAt: sessions.expiresAt, method: sessions.method, user: users })
.from(sessions)
.innerJoin(users, eq(users.id, sessions.userId))
.where(and(eq(sessions.id, id), gt(sessions.expiresAt, new Date()), eq(users.isActive, true)));
@@ -64,9 +72,13 @@ export async function getSessionUser(db: Db, token: string, slide = true) {
.where(eq(sessions.id, id));
extended = true;
}
- return { user: row.user, sessionId: id, extended };
+ return { user: row.user, sessionId: id, method: row.method, extended };
}
+/** A password to change only blocks sessions made with it (OpenID sessions skip it, 12). */
+export const mustChange = (f: { user: { mustChangePassword: boolean }; method: string }) =>
+ f.user.mustChangePassword && f.method === 'password';
+
export async function deleteSession(db: Db, token: string) {
await db.delete(sessions).where(eq(sessions.id, sha256(token)));
}
diff --git a/apps/api/src/config.ts b/apps/api/src/config.ts
index 59421f6..a41589f 100644
--- a/apps/api/src/config.ts
+++ b/apps/api/src/config.ts
@@ -35,6 +35,8 @@ const EnvSchema = z
API_PORT: z.coerce.number().int().min(1).max(65535).default(8080),
LOG_LEVEL: z.enum(['fatal', 'error', 'warn', 'info', 'debug', 'trace']).default('info'),
TRUST_PROXY: z.stringbool().default(true),
+ /** Requests per minute per client IP, every route (08 §2.4); stricter ones per route. 0: off. */
+ RATE_LIMIT_PER_MINUTE: z.coerce.number().int().min(0).default(600),
/** Call audio (RTP) ports, both ends included: the firewall opens this UDP range (01 §3.6). */
RTP_PORT_START: z.coerce.number().int().min(1024).max(65534).default(10000),
RTP_PORT_END: z.coerce.number().int().min(1025).max(65535).default(20000),
diff --git a/apps/api/src/db/migrations/0012_sso.sql b/apps/api/src/db/migrations/0012_sso.sql
new file mode 100644
index 0000000..29c0ad7
--- /dev/null
+++ b/apps/api/src/db/migrations/0012_sso.sql
@@ -0,0 +1,21 @@
+CREATE TYPE "public"."identity_kind" AS ENUM('oidc', 'ldap');--> statement-breakpoint
+CREATE TABLE "user_identities" (
+ "id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
+ "user_id" uuid NOT NULL,
+ "kind" "identity_kind" NOT NULL,
+ "subject" text NOT NULL,
+ "email" text,
+ "groups" text[] DEFAULT '{}' NOT NULL,
+ "created_at" timestamp with time zone DEFAULT now() NOT NULL,
+ "last_used_at" timestamp with time zone,
+ CONSTRAINT "user_identities_kind_subject_unique" UNIQUE("kind","subject"),
+ CONSTRAINT "user_identities_userId_kind_unique" UNIQUE("user_id","kind")
+);
+--> statement-breakpoint
+ALTER TABLE "users" ALTER COLUMN "password_hash" DROP NOT NULL;--> statement-breakpoint
+ALTER TABLE "sessions" ADD COLUMN "method" text DEFAULT 'password' NOT NULL;--> statement-breakpoint
+ALTER TABLE "sessions" ADD COLUMN "idp_refresh_enc" text;--> statement-breakpoint
+ALTER TABLE "sessions" ADD COLUMN "idp_id_token_enc" text;--> statement-breakpoint
+ALTER TABLE "sessions" ADD COLUMN "idp_checked_at" timestamp with time zone;--> statement-breakpoint
+ALTER TABLE "user_identities" ADD CONSTRAINT "user_identities_user_id_users_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."users"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
+ALTER TABLE "sessions" ADD CONSTRAINT "sessions_method_check" CHECK ("sessions"."method" in ('password', 'ldap', 'oidc'));
\ No newline at end of file
diff --git a/apps/api/src/db/migrations/meta/0012_snapshot.json b/apps/api/src/db/migrations/meta/0012_snapshot.json
new file mode 100644
index 0000000..95d5f3f
--- /dev/null
+++ b/apps/api/src/db/migrations/meta/0012_snapshot.json
@@ -0,0 +1,4487 @@
+{
+ "id": "92199db9-7d77-4ea4-8cc5-fe79a0d702e5",
+ "prevId": "e22c65f0-c8ee-42c8-aa4f-667a4831350d",
+ "version": "7",
+ "dialect": "postgresql",
+ "tables": {
+ "public.announcements": {
+ "name": "announcements",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "uuid",
+ "primaryKey": true,
+ "notNull": true,
+ "default": "gen_random_uuid()"
+ },
+ "name": {
+ "name": "name",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "sound_id": {
+ "name": "sound_id",
+ "type": "uuid",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "destination": {
+ "name": "destination",
+ "type": "jsonb",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ },
+ "updated_at": {
+ "name": "updated_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ }
+ },
+ "indexes": {},
+ "foreignKeys": {
+ "announcements_sound_id_sound_files_id_fk": {
+ "name": "announcements_sound_id_sound_files_id_fk",
+ "tableFrom": "announcements",
+ "tableTo": "sound_files",
+ "columnsFrom": [
+ "sound_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "restrict",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.api_tokens": {
+ "name": "api_tokens",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "uuid",
+ "primaryKey": true,
+ "notNull": true,
+ "default": "gen_random_uuid()"
+ },
+ "name": {
+ "name": "name",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "token_prefix": {
+ "name": "token_prefix",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "token_hash": {
+ "name": "token_hash",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "created_by": {
+ "name": "created_by",
+ "type": "uuid",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "last_used_at": {
+ "name": "last_used_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "revoked_at": {
+ "name": "revoked_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ },
+ "updated_at": {
+ "name": "updated_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ }
+ },
+ "indexes": {},
+ "foreignKeys": {
+ "api_tokens_created_by_users_id_fk": {
+ "name": "api_tokens_created_by_users_id_fk",
+ "tableFrom": "api_tokens",
+ "tableTo": "users",
+ "columnsFrom": [
+ "created_by"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "no action",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {
+ "api_tokens_tokenHash_unique": {
+ "name": "api_tokens_tokenHash_unique",
+ "nullsNotDistinct": false,
+ "columns": [
+ "token_hash"
+ ]
+ }
+ },
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.audit_log": {
+ "name": "audit_log",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "bigserial",
+ "primaryKey": true,
+ "notNull": true
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ },
+ "actor_user_id": {
+ "name": "actor_user_id",
+ "type": "uuid",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "actor_ip": {
+ "name": "actor_ip",
+ "type": "inet",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "action": {
+ "name": "action",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "entity_type": {
+ "name": "entity_type",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "entity_id": {
+ "name": "entity_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "summary": {
+ "name": "summary",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "diff": {
+ "name": "diff",
+ "type": "jsonb",
+ "primaryKey": false,
+ "notNull": false
+ }
+ },
+ "indexes": {},
+ "foreignKeys": {
+ "audit_log_actor_user_id_users_id_fk": {
+ "name": "audit_log_actor_user_id_users_id_fk",
+ "tableFrom": "audit_log",
+ "tableTo": "users",
+ "columnsFrom": [
+ "actor_user_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "set null",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.blocked_numbers": {
+ "name": "blocked_numbers",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "uuid",
+ "primaryKey": true,
+ "notNull": true,
+ "default": "gen_random_uuid()"
+ },
+ "number": {
+ "name": "number",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "reason": {
+ "name": "reason",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ },
+ "updated_at": {
+ "name": "updated_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ }
+ },
+ "indexes": {},
+ "foreignKeys": {},
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {
+ "blocked_numbers_number_unique": {
+ "name": "blocked_numbers_number_unique",
+ "nullsNotDistinct": false,
+ "columns": [
+ "number"
+ ]
+ }
+ },
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.call_extensions": {
+ "name": "call_extensions",
+ "schema": "",
+ "columns": {
+ "call_id": {
+ "name": "call_id",
+ "type": "uuid",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "extension_id": {
+ "name": "extension_id",
+ "type": "uuid",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "role": {
+ "name": "role",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ },
+ "updated_at": {
+ "name": "updated_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ }
+ },
+ "indexes": {},
+ "foreignKeys": {
+ "call_extensions_call_id_calls_id_fk": {
+ "name": "call_extensions_call_id_calls_id_fk",
+ "tableFrom": "call_extensions",
+ "tableTo": "calls",
+ "columnsFrom": [
+ "call_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ },
+ "call_extensions_extension_id_extensions_id_fk": {
+ "name": "call_extensions_extension_id_extensions_id_fk",
+ "tableFrom": "call_extensions",
+ "tableTo": "extensions",
+ "columnsFrom": [
+ "extension_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {
+ "call_extensions_call_id_extension_id_role_pk": {
+ "name": "call_extensions_call_id_extension_id_role_pk",
+ "columns": [
+ "call_id",
+ "extension_id",
+ "role"
+ ]
+ }
+ },
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {
+ "call_extensions_role_check": {
+ "name": "call_extensions_role_check",
+ "value": "\"call_extensions\".\"role\" in ('caller', 'rang', 'answered')"
+ }
+ },
+ "isRLSEnabled": false
+ },
+ "public.calls": {
+ "name": "calls",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "uuid",
+ "primaryKey": true,
+ "notNull": true,
+ "default": "gen_random_uuid()"
+ },
+ "linkedid": {
+ "name": "linkedid",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "direction": {
+ "name": "direction",
+ "type": "call_direction",
+ "typeSchema": "public",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "status": {
+ "name": "status",
+ "type": "call_status",
+ "typeSchema": "public",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "started_at": {
+ "name": "started_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "answered_at": {
+ "name": "answered_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "ended_at": {
+ "name": "ended_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "from_number": {
+ "name": "from_number",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "from_name": {
+ "name": "from_name",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "to_number": {
+ "name": "to_number",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "did_id": {
+ "name": "did_id",
+ "type": "uuid",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "answered_by_extension_id": {
+ "name": "answered_by_extension_id",
+ "type": "uuid",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "talk_seconds": {
+ "name": "talk_seconds",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "default": 0
+ },
+ "recording_id": {
+ "name": "recording_id",
+ "type": "uuid",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ },
+ "updated_at": {
+ "name": "updated_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ }
+ },
+ "indexes": {
+ "calls_started_at_index": {
+ "name": "calls_started_at_index",
+ "columns": [
+ {
+ "expression": "started_at",
+ "isExpression": false,
+ "asc": false,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ }
+ },
+ "foreignKeys": {
+ "calls_did_id_dids_id_fk": {
+ "name": "calls_did_id_dids_id_fk",
+ "tableFrom": "calls",
+ "tableTo": "dids",
+ "columnsFrom": [
+ "did_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "set null",
+ "onUpdate": "no action"
+ },
+ "calls_answered_by_extension_id_extensions_id_fk": {
+ "name": "calls_answered_by_extension_id_extensions_id_fk",
+ "tableFrom": "calls",
+ "tableTo": "extensions",
+ "columnsFrom": [
+ "answered_by_extension_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "set null",
+ "onUpdate": "no action"
+ },
+ "calls_recording_id_recordings_id_fk": {
+ "name": "calls_recording_id_recordings_id_fk",
+ "tableFrom": "calls",
+ "tableTo": "recordings",
+ "columnsFrom": [
+ "recording_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "set null",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {
+ "calls_linkedid_unique": {
+ "name": "calls_linkedid_unique",
+ "nullsNotDistinct": false,
+ "columns": [
+ "linkedid"
+ ]
+ }
+ },
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.cdr": {
+ "name": "cdr",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "bigserial",
+ "primaryKey": true,
+ "notNull": true
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ },
+ "uniqueid": {
+ "name": "uniqueid",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "linkedid": {
+ "name": "linkedid",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "start_at": {
+ "name": "start_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "answer_at": {
+ "name": "answer_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "end_at": {
+ "name": "end_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "duration": {
+ "name": "duration",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "billsec": {
+ "name": "billsec",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "src": {
+ "name": "src",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "dst": {
+ "name": "dst",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "dcontext": {
+ "name": "dcontext",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "clid": {
+ "name": "clid",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "channel": {
+ "name": "channel",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "dstchannel": {
+ "name": "dstchannel",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "lastapp": {
+ "name": "lastapp",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "lastdata": {
+ "name": "lastdata",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "disposition": {
+ "name": "disposition",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "accountcode": {
+ "name": "accountcode",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "userfield": {
+ "name": "userfield",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "sp_did": {
+ "name": "sp_did",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "sp_direction": {
+ "name": "sp_direction",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "sp_recording": {
+ "name": "sp_recording",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "raw": {
+ "name": "raw",
+ "type": "jsonb",
+ "primaryKey": false,
+ "notNull": true
+ }
+ },
+ "indexes": {
+ "cdr_linkedid_index": {
+ "name": "cdr_linkedid_index",
+ "columns": [
+ {
+ "expression": "linkedid",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ }
+ },
+ "foreignKeys": {},
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.conferences": {
+ "name": "conferences",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "uuid",
+ "primaryKey": true,
+ "notNull": true,
+ "default": "gen_random_uuid()"
+ },
+ "number": {
+ "name": "number",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "name": {
+ "name": "name",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "pin_enc": {
+ "name": "pin_enc",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "admin_pin_enc": {
+ "name": "admin_pin_enc",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "max_members": {
+ "name": "max_members",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "default": 20
+ },
+ "record": {
+ "name": "record",
+ "type": "boolean",
+ "primaryKey": false,
+ "notNull": true,
+ "default": false
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ },
+ "updated_at": {
+ "name": "updated_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ }
+ },
+ "indexes": {},
+ "foreignKeys": {},
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {
+ "conferences_number_unique": {
+ "name": "conferences_number_unique",
+ "nullsNotDistinct": false,
+ "columns": [
+ "number"
+ ]
+ }
+ },
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.contact_shares": {
+ "name": "contact_shares",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "uuid",
+ "primaryKey": true,
+ "notNull": true,
+ "default": "gen_random_uuid()"
+ },
+ "from_user_id": {
+ "name": "from_user_id",
+ "type": "uuid",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "to_user_id": {
+ "name": "to_user_id",
+ "type": "uuid",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "source_id": {
+ "name": "source_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "contact": {
+ "name": "contact",
+ "type": "jsonb",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "status": {
+ "name": "status",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "'pending'"
+ },
+ "decided_at": {
+ "name": "decided_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ },
+ "updated_at": {
+ "name": "updated_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ }
+ },
+ "indexes": {
+ "contact_shares_to_user_id_index": {
+ "name": "contact_shares_to_user_id_index",
+ "columns": [
+ {
+ "expression": "to_user_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "where": "\"contact_shares\".\"status\" = 'pending'",
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ }
+ },
+ "foreignKeys": {
+ "contact_shares_from_user_id_users_id_fk": {
+ "name": "contact_shares_from_user_id_users_id_fk",
+ "tableFrom": "contact_shares",
+ "tableTo": "users",
+ "columnsFrom": [
+ "from_user_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "set null",
+ "onUpdate": "no action"
+ },
+ "contact_shares_to_user_id_users_id_fk": {
+ "name": "contact_shares_to_user_id_users_id_fk",
+ "tableFrom": "contact_shares",
+ "tableTo": "users",
+ "columnsFrom": [
+ "to_user_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {
+ "contact_shares_status_check": {
+ "name": "contact_shares_status_check",
+ "value": "\"contact_shares\".\"status\" in ('pending', 'accepted', 'denied')"
+ }
+ },
+ "isRLSEnabled": false
+ },
+ "public.contacts": {
+ "name": "contacts",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "uuid",
+ "primaryKey": true,
+ "notNull": true,
+ "default": "gen_random_uuid()"
+ },
+ "owner_user_id": {
+ "name": "owner_user_id",
+ "type": "uuid",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "name": {
+ "name": "name",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "company": {
+ "name": "company",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "email": {
+ "name": "email",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "numbers": {
+ "name": "numbers",
+ "type": "jsonb",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "notes": {
+ "name": "notes",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ },
+ "updated_at": {
+ "name": "updated_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ }
+ },
+ "indexes": {},
+ "foreignKeys": {
+ "contacts_owner_user_id_users_id_fk": {
+ "name": "contacts_owner_user_id_users_id_fk",
+ "tableFrom": "contacts",
+ "tableTo": "users",
+ "columnsFrom": [
+ "owner_user_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.contrack_call_log": {
+ "name": "contrack_call_log",
+ "schema": "",
+ "columns": {
+ "call_id": {
+ "name": "call_id",
+ "type": "uuid",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "user_id": {
+ "name": "user_id",
+ "type": "uuid",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "interaction_id": {
+ "name": "interaction_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "error": {
+ "name": "error",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "logged_at": {
+ "name": "logged_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ }
+ },
+ "indexes": {},
+ "foreignKeys": {
+ "contrack_call_log_call_id_calls_id_fk": {
+ "name": "contrack_call_log_call_id_calls_id_fk",
+ "tableFrom": "contrack_call_log",
+ "tableTo": "calls",
+ "columnsFrom": [
+ "call_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ },
+ "contrack_call_log_user_id_users_id_fk": {
+ "name": "contrack_call_log_user_id_users_id_fk",
+ "tableFrom": "contrack_call_log",
+ "tableTo": "users",
+ "columnsFrom": [
+ "user_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {
+ "contrack_call_log_call_id_user_id_pk": {
+ "name": "contrack_call_log_call_id_user_id_pk",
+ "columns": [
+ "call_id",
+ "user_id"
+ ]
+ }
+ },
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.devices": {
+ "name": "devices",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "uuid",
+ "primaryKey": true,
+ "notNull": true,
+ "default": "gen_random_uuid()"
+ },
+ "extension_id": {
+ "name": "extension_id",
+ "type": "uuid",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "mac": {
+ "name": "mac",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "vendor": {
+ "name": "vendor",
+ "type": "phone_vendor",
+ "typeSchema": "public",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "model": {
+ "name": "model",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "label": {
+ "name": "label",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "provisioning_password_enc": {
+ "name": "provisioning_password_enc",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "last_provisioned_at": {
+ "name": "last_provisioned_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "last_provisioned_ip": {
+ "name": "last_provisioned_ip",
+ "type": "inet",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ },
+ "updated_at": {
+ "name": "updated_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ }
+ },
+ "indexes": {},
+ "foreignKeys": {
+ "devices_extension_id_extensions_id_fk": {
+ "name": "devices_extension_id_extensions_id_fk",
+ "tableFrom": "devices",
+ "tableTo": "extensions",
+ "columnsFrom": [
+ "extension_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {
+ "devices_mac_unique": {
+ "name": "devices_mac_unique",
+ "nullsNotDistinct": false,
+ "columns": [
+ "mac"
+ ]
+ }
+ },
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.did_sms_users": {
+ "name": "did_sms_users",
+ "schema": "",
+ "columns": {
+ "did_id": {
+ "name": "did_id",
+ "type": "uuid",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "user_id": {
+ "name": "user_id",
+ "type": "uuid",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ },
+ "updated_at": {
+ "name": "updated_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ }
+ },
+ "indexes": {},
+ "foreignKeys": {
+ "did_sms_users_did_id_dids_id_fk": {
+ "name": "did_sms_users_did_id_dids_id_fk",
+ "tableFrom": "did_sms_users",
+ "tableTo": "dids",
+ "columnsFrom": [
+ "did_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ },
+ "did_sms_users_user_id_users_id_fk": {
+ "name": "did_sms_users_user_id_users_id_fk",
+ "tableFrom": "did_sms_users",
+ "tableTo": "users",
+ "columnsFrom": [
+ "user_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {
+ "did_sms_users_did_id_user_id_pk": {
+ "name": "did_sms_users_did_id_user_id_pk",
+ "columns": [
+ "did_id",
+ "user_id"
+ ]
+ }
+ },
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.dids": {
+ "name": "dids",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "uuid",
+ "primaryKey": true,
+ "notNull": true,
+ "default": "gen_random_uuid()"
+ },
+ "number": {
+ "name": "number",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "trunk_id": {
+ "name": "trunk_id",
+ "type": "uuid",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "label": {
+ "name": "label",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "destination": {
+ "name": "destination",
+ "type": "jsonb",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "caller_id_name_prefix": {
+ "name": "caller_id_name_prefix",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "record_inbound": {
+ "name": "record_inbound",
+ "type": "boolean",
+ "primaryKey": false,
+ "notNull": true,
+ "default": false
+ },
+ "sms_enabled": {
+ "name": "sms_enabled",
+ "type": "boolean",
+ "primaryKey": false,
+ "notNull": true,
+ "default": false
+ },
+ "e911_address": {
+ "name": "e911_address",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ },
+ "updated_at": {
+ "name": "updated_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ }
+ },
+ "indexes": {},
+ "foreignKeys": {
+ "dids_trunk_id_trunks_id_fk": {
+ "name": "dids_trunk_id_trunks_id_fk",
+ "tableFrom": "dids",
+ "tableTo": "trunks",
+ "columnsFrom": [
+ "trunk_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "restrict",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {
+ "dids_number_unique": {
+ "name": "dids_number_unique",
+ "nullsNotDistinct": false,
+ "columns": [
+ "number"
+ ]
+ }
+ },
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.emergency_events": {
+ "name": "emergency_events",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "uuid",
+ "primaryKey": true,
+ "notNull": true,
+ "default": "gen_random_uuid()"
+ },
+ "extension_number": {
+ "name": "extension_number",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "dialed": {
+ "name": "dialed",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "caller_id_used": {
+ "name": "caller_id_used",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "location": {
+ "name": "location",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "notified": {
+ "name": "notified",
+ "type": "jsonb",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "'[]'::jsonb"
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ },
+ "updated_at": {
+ "name": "updated_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ }
+ },
+ "indexes": {},
+ "foreignKeys": {},
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.extensions": {
+ "name": "extensions",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "uuid",
+ "primaryKey": true,
+ "notNull": true,
+ "default": "gen_random_uuid()"
+ },
+ "number": {
+ "name": "number",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "user_id": {
+ "name": "user_id",
+ "type": "uuid",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "name": {
+ "name": "name",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "sip_password_enc": {
+ "name": "sip_password_enc",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "webrtc_enabled": {
+ "name": "webrtc_enabled",
+ "type": "boolean",
+ "primaryKey": false,
+ "notNull": true,
+ "default": true
+ },
+ "max_contacts": {
+ "name": "max_contacts",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "default": 5
+ },
+ "ring_seconds": {
+ "name": "ring_seconds",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "default": 25
+ },
+ "voicemail_enabled": {
+ "name": "voicemail_enabled",
+ "type": "boolean",
+ "primaryKey": false,
+ "notNull": true,
+ "default": true
+ },
+ "voicemail_pin_enc": {
+ "name": "voicemail_pin_enc",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "voicemail_email": {
+ "name": "voicemail_email",
+ "type": "boolean",
+ "primaryKey": false,
+ "notNull": true,
+ "default": true
+ },
+ "voicemail_attach_audio": {
+ "name": "voicemail_attach_audio",
+ "type": "boolean",
+ "primaryKey": false,
+ "notNull": true,
+ "default": true
+ },
+ "voicemail_delete_after_email": {
+ "name": "voicemail_delete_after_email",
+ "type": "boolean",
+ "primaryKey": false,
+ "notNull": true,
+ "default": false
+ },
+ "outbound_class": {
+ "name": "outbound_class",
+ "type": "outbound_class",
+ "typeSchema": "public",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "'national'"
+ },
+ "outbound_caller_id_did_id": {
+ "name": "outbound_caller_id_did_id",
+ "type": "uuid",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "emergency_caller_id_did_id": {
+ "name": "emergency_caller_id_did_id",
+ "type": "uuid",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "emergency_location": {
+ "name": "emergency_location",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "record_mode": {
+ "name": "record_mode",
+ "type": "record_mode",
+ "typeSchema": "public",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "'never'"
+ },
+ "pickup_group": {
+ "name": "pickup_group",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "language": {
+ "name": "language",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "'en'"
+ },
+ "moh_class": {
+ "name": "moh_class",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "'default'"
+ },
+ "blf_numbers": {
+ "name": "blf_numbers",
+ "type": "text[]",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "'{}'"
+ },
+ "call_report": {
+ "name": "call_report",
+ "type": "call_report",
+ "typeSchema": "public",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "'off'"
+ },
+ "call_report_time": {
+ "name": "call_report_time",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "'08:00'"
+ },
+ "call_report_weekday": {
+ "name": "call_report_weekday",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "default": 1
+ },
+ "call_report_summaries": {
+ "name": "call_report_summaries",
+ "type": "boolean",
+ "primaryKey": false,
+ "notNull": true,
+ "default": false
+ },
+ "call_report_sent_at": {
+ "name": "call_report_sent_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ },
+ "updated_at": {
+ "name": "updated_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ }
+ },
+ "indexes": {},
+ "foreignKeys": {
+ "extensions_user_id_users_id_fk": {
+ "name": "extensions_user_id_users_id_fk",
+ "tableFrom": "extensions",
+ "tableTo": "users",
+ "columnsFrom": [
+ "user_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "set null",
+ "onUpdate": "no action"
+ },
+ "extensions_outbound_caller_id_did_id_dids_id_fk": {
+ "name": "extensions_outbound_caller_id_did_id_dids_id_fk",
+ "tableFrom": "extensions",
+ "tableTo": "dids",
+ "columnsFrom": [
+ "outbound_caller_id_did_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "set null",
+ "onUpdate": "no action"
+ },
+ "extensions_emergency_caller_id_did_id_dids_id_fk": {
+ "name": "extensions_emergency_caller_id_did_id_dids_id_fk",
+ "tableFrom": "extensions",
+ "tableTo": "dids",
+ "columnsFrom": [
+ "emergency_caller_id_did_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "set null",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {
+ "extensions_number_unique": {
+ "name": "extensions_number_unique",
+ "nullsNotDistinct": false,
+ "columns": [
+ "number"
+ ]
+ },
+ "extensions_userId_unique": {
+ "name": "extensions_userId_unique",
+ "nullsNotDistinct": false,
+ "columns": [
+ "user_id"
+ ]
+ }
+ },
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.generator_runs": {
+ "name": "generator_runs",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "bigserial",
+ "primaryKey": true,
+ "notNull": true
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ },
+ "trigger": {
+ "name": "trigger",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "files_changed": {
+ "name": "files_changed",
+ "type": "text[]",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "commands": {
+ "name": "commands",
+ "type": "text[]",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "ok": {
+ "name": "ok",
+ "type": "boolean",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "error": {
+ "name": "error",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "duration_ms": {
+ "name": "duration_ms",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true
+ }
+ },
+ "indexes": {},
+ "foreignKeys": {},
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.holidays": {
+ "name": "holidays",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "uuid",
+ "primaryKey": true,
+ "notNull": true,
+ "default": "gen_random_uuid()"
+ },
+ "name": {
+ "name": "name",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "start_date": {
+ "name": "start_date",
+ "type": "date",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "end_date": {
+ "name": "end_date",
+ "type": "date",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "recurring_yearly": {
+ "name": "recurring_yearly",
+ "type": "boolean",
+ "primaryKey": false,
+ "notNull": true,
+ "default": false
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ },
+ "updated_at": {
+ "name": "updated_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ }
+ },
+ "indexes": {},
+ "foreignKeys": {},
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.ivr_options": {
+ "name": "ivr_options",
+ "schema": "",
+ "columns": {
+ "ivr_id": {
+ "name": "ivr_id",
+ "type": "uuid",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "digit": {
+ "name": "digit",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "destination": {
+ "name": "destination",
+ "type": "jsonb",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ },
+ "updated_at": {
+ "name": "updated_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ }
+ },
+ "indexes": {},
+ "foreignKeys": {
+ "ivr_options_ivr_id_ivrs_id_fk": {
+ "name": "ivr_options_ivr_id_ivrs_id_fk",
+ "tableFrom": "ivr_options",
+ "tableTo": "ivrs",
+ "columnsFrom": [
+ "ivr_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {
+ "ivr_options_ivr_id_digit_pk": {
+ "name": "ivr_options_ivr_id_digit_pk",
+ "columns": [
+ "ivr_id",
+ "digit"
+ ]
+ }
+ },
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {
+ "ivr_options_digit_check": {
+ "name": "ivr_options_digit_check",
+ "value": "\"ivr_options\".\"digit\" ~ '^[0-9*#]$'"
+ }
+ },
+ "isRLSEnabled": false
+ },
+ "public.ivrs": {
+ "name": "ivrs",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "uuid",
+ "primaryKey": true,
+ "notNull": true,
+ "default": "gen_random_uuid()"
+ },
+ "number": {
+ "name": "number",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "name": {
+ "name": "name",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "greeting_sound_id": {
+ "name": "greeting_sound_id",
+ "type": "uuid",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "invalid_sound_id": {
+ "name": "invalid_sound_id",
+ "type": "uuid",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "timeout_seconds": {
+ "name": "timeout_seconds",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "default": 5
+ },
+ "max_retries": {
+ "name": "max_retries",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "default": 3
+ },
+ "allow_direct_dial": {
+ "name": "allow_direct_dial",
+ "type": "boolean",
+ "primaryKey": false,
+ "notNull": true,
+ "default": true
+ },
+ "timeout_destination": {
+ "name": "timeout_destination",
+ "type": "jsonb",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "invalid_destination": {
+ "name": "invalid_destination",
+ "type": "jsonb",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ },
+ "updated_at": {
+ "name": "updated_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ }
+ },
+ "indexes": {},
+ "foreignKeys": {
+ "ivrs_greeting_sound_id_sound_files_id_fk": {
+ "name": "ivrs_greeting_sound_id_sound_files_id_fk",
+ "tableFrom": "ivrs",
+ "tableTo": "sound_files",
+ "columnsFrom": [
+ "greeting_sound_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "restrict",
+ "onUpdate": "no action"
+ },
+ "ivrs_invalid_sound_id_sound_files_id_fk": {
+ "name": "ivrs_invalid_sound_id_sound_files_id_fk",
+ "tableFrom": "ivrs",
+ "tableTo": "sound_files",
+ "columnsFrom": [
+ "invalid_sound_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "set null",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {
+ "ivrs_number_unique": {
+ "name": "ivrs_number_unique",
+ "nullsNotDistinct": false,
+ "columns": [
+ "number"
+ ]
+ }
+ },
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.moh_classes": {
+ "name": "moh_classes",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "uuid",
+ "primaryKey": true,
+ "notNull": true,
+ "default": "gen_random_uuid()"
+ },
+ "name": {
+ "name": "name",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "shuffle": {
+ "name": "shuffle",
+ "type": "boolean",
+ "primaryKey": false,
+ "notNull": true,
+ "default": false
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ },
+ "updated_at": {
+ "name": "updated_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ }
+ },
+ "indexes": {},
+ "foreignKeys": {},
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {
+ "moh_classes_name_unique": {
+ "name": "moh_classes_name_unique",
+ "nullsNotDistinct": false,
+ "columns": [
+ "name"
+ ]
+ }
+ },
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.outbound_routes": {
+ "name": "outbound_routes",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "uuid",
+ "primaryKey": true,
+ "notNull": true,
+ "default": "gen_random_uuid()"
+ },
+ "name": {
+ "name": "name",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "position": {
+ "name": "position",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "enabled": {
+ "name": "enabled",
+ "type": "boolean",
+ "primaryKey": false,
+ "notNull": true,
+ "default": true
+ },
+ "patterns": {
+ "name": "patterns",
+ "type": "jsonb",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "required_class": {
+ "name": "required_class",
+ "type": "outbound_class",
+ "typeSchema": "public",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "trunk_ids": {
+ "name": "trunk_ids",
+ "type": "uuid[]",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "caller_id_did_id": {
+ "name": "caller_id_did_id",
+ "type": "uuid",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ },
+ "updated_at": {
+ "name": "updated_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ }
+ },
+ "indexes": {},
+ "foreignKeys": {
+ "outbound_routes_caller_id_did_id_dids_id_fk": {
+ "name": "outbound_routes_caller_id_did_id_dids_id_fk",
+ "tableFrom": "outbound_routes",
+ "tableTo": "dids",
+ "columnsFrom": [
+ "caller_id_did_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "set null",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.paging_group_members": {
+ "name": "paging_group_members",
+ "schema": "",
+ "columns": {
+ "paging_group_id": {
+ "name": "paging_group_id",
+ "type": "uuid",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "extension_id": {
+ "name": "extension_id",
+ "type": "uuid",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ },
+ "updated_at": {
+ "name": "updated_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ }
+ },
+ "indexes": {},
+ "foreignKeys": {
+ "paging_group_members_paging_group_id_paging_groups_id_fk": {
+ "name": "paging_group_members_paging_group_id_paging_groups_id_fk",
+ "tableFrom": "paging_group_members",
+ "tableTo": "paging_groups",
+ "columnsFrom": [
+ "paging_group_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ },
+ "paging_group_members_extension_id_extensions_id_fk": {
+ "name": "paging_group_members_extension_id_extensions_id_fk",
+ "tableFrom": "paging_group_members",
+ "tableTo": "extensions",
+ "columnsFrom": [
+ "extension_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {
+ "paging_group_members_paging_group_id_extension_id_pk": {
+ "name": "paging_group_members_paging_group_id_extension_id_pk",
+ "columns": [
+ "paging_group_id",
+ "extension_id"
+ ]
+ }
+ },
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.paging_groups": {
+ "name": "paging_groups",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "uuid",
+ "primaryKey": true,
+ "notNull": true,
+ "default": "gen_random_uuid()"
+ },
+ "number": {
+ "name": "number",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "name": {
+ "name": "name",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "duplex": {
+ "name": "duplex",
+ "type": "boolean",
+ "primaryKey": false,
+ "notNull": true,
+ "default": false
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ },
+ "updated_at": {
+ "name": "updated_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ }
+ },
+ "indexes": {},
+ "foreignKeys": {},
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {
+ "paging_groups_number_unique": {
+ "name": "paging_groups_number_unique",
+ "nullsNotDistinct": false,
+ "columns": [
+ "number"
+ ]
+ }
+ },
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.queue_calls": {
+ "name": "queue_calls",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "bigserial",
+ "primaryKey": true,
+ "notNull": true
+ },
+ "queue_id": {
+ "name": "queue_id",
+ "type": "uuid",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "uniqueid": {
+ "name": "uniqueid",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "linkedid": {
+ "name": "linkedid",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "joined_at": {
+ "name": "joined_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ },
+ "outcome": {
+ "name": "outcome",
+ "type": "queue_outcome",
+ "typeSchema": "public",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "wait_seconds": {
+ "name": "wait_seconds",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "agent_extension_id": {
+ "name": "agent_extension_id",
+ "type": "uuid",
+ "primaryKey": false,
+ "notNull": false
+ }
+ },
+ "indexes": {
+ "queue_calls_queue_id_joined_at_index": {
+ "name": "queue_calls_queue_id_joined_at_index",
+ "columns": [
+ {
+ "expression": "queue_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ },
+ {
+ "expression": "joined_at",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "queue_calls_uniqueid_index": {
+ "name": "queue_calls_uniqueid_index",
+ "columns": [
+ {
+ "expression": "uniqueid",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ }
+ },
+ "foreignKeys": {
+ "queue_calls_queue_id_queues_id_fk": {
+ "name": "queue_calls_queue_id_queues_id_fk",
+ "tableFrom": "queue_calls",
+ "tableTo": "queues",
+ "columnsFrom": [
+ "queue_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ },
+ "queue_calls_agent_extension_id_extensions_id_fk": {
+ "name": "queue_calls_agent_extension_id_extensions_id_fk",
+ "tableFrom": "queue_calls",
+ "tableTo": "extensions",
+ "columnsFrom": [
+ "agent_extension_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "set null",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.queue_member_state": {
+ "name": "queue_member_state",
+ "schema": "",
+ "columns": {
+ "queue_id": {
+ "name": "queue_id",
+ "type": "uuid",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "extension_id": {
+ "name": "extension_id",
+ "type": "uuid",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "paused": {
+ "name": "paused",
+ "type": "boolean",
+ "primaryKey": false,
+ "notNull": true,
+ "default": false
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ },
+ "updated_at": {
+ "name": "updated_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ }
+ },
+ "indexes": {},
+ "foreignKeys": {
+ "queue_member_state_queue_id_queues_id_fk": {
+ "name": "queue_member_state_queue_id_queues_id_fk",
+ "tableFrom": "queue_member_state",
+ "tableTo": "queues",
+ "columnsFrom": [
+ "queue_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ },
+ "queue_member_state_extension_id_extensions_id_fk": {
+ "name": "queue_member_state_extension_id_extensions_id_fk",
+ "tableFrom": "queue_member_state",
+ "tableTo": "extensions",
+ "columnsFrom": [
+ "extension_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {
+ "queue_member_state_queue_id_extension_id_pk": {
+ "name": "queue_member_state_queue_id_extension_id_pk",
+ "columns": [
+ "queue_id",
+ "extension_id"
+ ]
+ }
+ },
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.queue_members": {
+ "name": "queue_members",
+ "schema": "",
+ "columns": {
+ "queue_id": {
+ "name": "queue_id",
+ "type": "uuid",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "extension_id": {
+ "name": "extension_id",
+ "type": "uuid",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "penalty": {
+ "name": "penalty",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "default": 0
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ },
+ "updated_at": {
+ "name": "updated_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ }
+ },
+ "indexes": {},
+ "foreignKeys": {
+ "queue_members_queue_id_queues_id_fk": {
+ "name": "queue_members_queue_id_queues_id_fk",
+ "tableFrom": "queue_members",
+ "tableTo": "queues",
+ "columnsFrom": [
+ "queue_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ },
+ "queue_members_extension_id_extensions_id_fk": {
+ "name": "queue_members_extension_id_extensions_id_fk",
+ "tableFrom": "queue_members",
+ "tableTo": "extensions",
+ "columnsFrom": [
+ "extension_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {
+ "queue_members_queue_id_extension_id_pk": {
+ "name": "queue_members_queue_id_extension_id_pk",
+ "columns": [
+ "queue_id",
+ "extension_id"
+ ]
+ }
+ },
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.queues": {
+ "name": "queues",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "uuid",
+ "primaryKey": true,
+ "notNull": true,
+ "default": "gen_random_uuid()"
+ },
+ "number": {
+ "name": "number",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "name": {
+ "name": "name",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "strategy": {
+ "name": "strategy",
+ "type": "queue_strategy",
+ "typeSchema": "public",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "'rrmemory'"
+ },
+ "member_ring_seconds": {
+ "name": "member_ring_seconds",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "default": 15
+ },
+ "retry_seconds": {
+ "name": "retry_seconds",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "default": 5
+ },
+ "wrapup_seconds": {
+ "name": "wrapup_seconds",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "default": 10
+ },
+ "max_wait_seconds": {
+ "name": "max_wait_seconds",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "default": 300
+ },
+ "max_callers": {
+ "name": "max_callers",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "default": 0
+ },
+ "moh_class": {
+ "name": "moh_class",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "'default'"
+ },
+ "announce_position": {
+ "name": "announce_position",
+ "type": "boolean",
+ "primaryKey": false,
+ "notNull": true,
+ "default": true
+ },
+ "announce_every_seconds": {
+ "name": "announce_every_seconds",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "default": 60
+ },
+ "join_when_no_agents": {
+ "name": "join_when_no_agents",
+ "type": "boolean",
+ "primaryKey": false,
+ "notNull": true,
+ "default": false
+ },
+ "record": {
+ "name": "record",
+ "type": "boolean",
+ "primaryKey": false,
+ "notNull": true,
+ "default": false
+ },
+ "sla_seconds": {
+ "name": "sla_seconds",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "default": 20
+ },
+ "caller_id_name_prefix": {
+ "name": "caller_id_name_prefix",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "timeout_destination": {
+ "name": "timeout_destination",
+ "type": "jsonb",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ },
+ "updated_at": {
+ "name": "updated_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ }
+ },
+ "indexes": {},
+ "foreignKeys": {},
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {
+ "queues_number_unique": {
+ "name": "queues_number_unique",
+ "nullsNotDistinct": false,
+ "columns": [
+ "number"
+ ]
+ }
+ },
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.recordings": {
+ "name": "recordings",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "uuid",
+ "primaryKey": true,
+ "notNull": true,
+ "default": "gen_random_uuid()"
+ },
+ "linkedid": {
+ "name": "linkedid",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "file_path": {
+ "name": "file_path",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "started_at": {
+ "name": "started_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "duration_ms": {
+ "name": "duration_ms",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "size_bytes": {
+ "name": "size_bytes",
+ "type": "bigint",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "transcript": {
+ "name": "transcript",
+ "type": "jsonb",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "summary": {
+ "name": "summary",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "ai_done_at": {
+ "name": "ai_done_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "ai_error": {
+ "name": "ai_error",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ },
+ "updated_at": {
+ "name": "updated_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ }
+ },
+ "indexes": {},
+ "foreignKeys": {},
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {
+ "recordings_filePath_unique": {
+ "name": "recordings_filePath_unique",
+ "nullsNotDistinct": false,
+ "columns": [
+ "file_path"
+ ]
+ }
+ },
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.ring_group_members": {
+ "name": "ring_group_members",
+ "schema": "",
+ "columns": {
+ "ring_group_id": {
+ "name": "ring_group_id",
+ "type": "uuid",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "extension_id": {
+ "name": "extension_id",
+ "type": "uuid",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "position": {
+ "name": "position",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ },
+ "updated_at": {
+ "name": "updated_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ }
+ },
+ "indexes": {},
+ "foreignKeys": {
+ "ring_group_members_ring_group_id_ring_groups_id_fk": {
+ "name": "ring_group_members_ring_group_id_ring_groups_id_fk",
+ "tableFrom": "ring_group_members",
+ "tableTo": "ring_groups",
+ "columnsFrom": [
+ "ring_group_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ },
+ "ring_group_members_extension_id_extensions_id_fk": {
+ "name": "ring_group_members_extension_id_extensions_id_fk",
+ "tableFrom": "ring_group_members",
+ "tableTo": "extensions",
+ "columnsFrom": [
+ "extension_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {
+ "ring_group_members_ring_group_id_extension_id_pk": {
+ "name": "ring_group_members_ring_group_id_extension_id_pk",
+ "columns": [
+ "ring_group_id",
+ "extension_id"
+ ]
+ }
+ },
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.ring_groups": {
+ "name": "ring_groups",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "uuid",
+ "primaryKey": true,
+ "notNull": true,
+ "default": "gen_random_uuid()"
+ },
+ "number": {
+ "name": "number",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "name": {
+ "name": "name",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "strategy": {
+ "name": "strategy",
+ "type": "ring_strategy",
+ "typeSchema": "public",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "'ringall'"
+ },
+ "ring_seconds": {
+ "name": "ring_seconds",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "default": 25
+ },
+ "caller_id_name_prefix": {
+ "name": "caller_id_name_prefix",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "music_instead_of_ring": {
+ "name": "music_instead_of_ring",
+ "type": "boolean",
+ "primaryKey": false,
+ "notNull": true,
+ "default": false
+ },
+ "no_answer_destination": {
+ "name": "no_answer_destination",
+ "type": "jsonb",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ },
+ "updated_at": {
+ "name": "updated_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ }
+ },
+ "indexes": {},
+ "foreignKeys": {},
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {
+ "ring_groups_number_unique": {
+ "name": "ring_groups_number_unique",
+ "nullsNotDistinct": false,
+ "columns": [
+ "number"
+ ]
+ }
+ },
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.sessions": {
+ "name": "sessions",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true
+ },
+ "user_id": {
+ "name": "user_id",
+ "type": "uuid",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "expires_at": {
+ "name": "expires_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "ip": {
+ "name": "ip",
+ "type": "inet",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "user_agent": {
+ "name": "user_agent",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "method": {
+ "name": "method",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "'password'"
+ },
+ "idp_refresh_enc": {
+ "name": "idp_refresh_enc",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "idp_id_token_enc": {
+ "name": "idp_id_token_enc",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "idp_checked_at": {
+ "name": "idp_checked_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ },
+ "updated_at": {
+ "name": "updated_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ }
+ },
+ "indexes": {
+ "sessions_user_id_index": {
+ "name": "sessions_user_id_index",
+ "columns": [
+ {
+ "expression": "user_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ }
+ },
+ "foreignKeys": {
+ "sessions_user_id_users_id_fk": {
+ "name": "sessions_user_id_users_id_fk",
+ "tableFrom": "sessions",
+ "tableTo": "users",
+ "columnsFrom": [
+ "user_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {
+ "sessions_method_check": {
+ "name": "sessions_method_check",
+ "value": "\"sessions\".\"method\" in ('password', 'ldap', 'oidc')"
+ }
+ },
+ "isRLSEnabled": false
+ },
+ "public.settings": {
+ "name": "settings",
+ "schema": "",
+ "columns": {
+ "key": {
+ "name": "key",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true
+ },
+ "value": {
+ "name": "value",
+ "type": "jsonb",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ },
+ "updated_at": {
+ "name": "updated_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ }
+ },
+ "indexes": {},
+ "foreignKeys": {},
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.sms_messages": {
+ "name": "sms_messages",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "uuid",
+ "primaryKey": true,
+ "notNull": true,
+ "default": "gen_random_uuid()"
+ },
+ "did_id": {
+ "name": "did_id",
+ "type": "uuid",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "direction": {
+ "name": "direction",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "remote_number": {
+ "name": "remote_number",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "body": {
+ "name": "body",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "media": {
+ "name": "media",
+ "type": "jsonb",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "'[]'::jsonb"
+ },
+ "status": {
+ "name": "status",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "provider_id": {
+ "name": "provider_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "error": {
+ "name": "error",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "sent_by_user_id": {
+ "name": "sent_by_user_id",
+ "type": "uuid",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "read_at": {
+ "name": "read_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ },
+ "updated_at": {
+ "name": "updated_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ }
+ },
+ "indexes": {
+ "sms_messages_did_id_remote_number_created_at_index": {
+ "name": "sms_messages_did_id_remote_number_created_at_index",
+ "columns": [
+ {
+ "expression": "did_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ },
+ {
+ "expression": "remote_number",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ },
+ {
+ "expression": "created_at",
+ "isExpression": false,
+ "asc": false,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ }
+ },
+ "foreignKeys": {
+ "sms_messages_did_id_dids_id_fk": {
+ "name": "sms_messages_did_id_dids_id_fk",
+ "tableFrom": "sms_messages",
+ "tableTo": "dids",
+ "columnsFrom": [
+ "did_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ },
+ "sms_messages_sent_by_user_id_users_id_fk": {
+ "name": "sms_messages_sent_by_user_id_users_id_fk",
+ "tableFrom": "sms_messages",
+ "tableTo": "users",
+ "columnsFrom": [
+ "sent_by_user_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "set null",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {
+ "sms_messages_direction_check": {
+ "name": "sms_messages_direction_check",
+ "value": "\"sms_messages\".\"direction\" in ('in', 'out')"
+ }
+ },
+ "isRLSEnabled": false
+ },
+ "public.sound_files": {
+ "name": "sound_files",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "uuid",
+ "primaryKey": true,
+ "notNull": true,
+ "default": "gen_random_uuid()"
+ },
+ "name": {
+ "name": "name",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "kind": {
+ "name": "kind",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "moh_class_id": {
+ "name": "moh_class_id",
+ "type": "uuid",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "source": {
+ "name": "source",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "tts_text": {
+ "name": "tts_text",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "original_filename": {
+ "name": "original_filename",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "asterisk_name": {
+ "name": "asterisk_name",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "duration_ms": {
+ "name": "duration_ms",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "position": {
+ "name": "position",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "default": 0
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ },
+ "updated_at": {
+ "name": "updated_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ }
+ },
+ "indexes": {},
+ "foreignKeys": {
+ "sound_files_moh_class_id_moh_classes_id_fk": {
+ "name": "sound_files_moh_class_id_moh_classes_id_fk",
+ "tableFrom": "sound_files",
+ "tableTo": "moh_classes",
+ "columnsFrom": [
+ "moh_class_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {
+ "sound_files_asteriskName_unique": {
+ "name": "sound_files_asteriskName_unique",
+ "nullsNotDistinct": false,
+ "columns": [
+ "asterisk_name"
+ ]
+ }
+ },
+ "policies": {},
+ "checkConstraints": {
+ "sound_files_kind_check": {
+ "name": "sound_files_kind_check",
+ "value": "\"sound_files\".\"kind\" in ('prompt', 'moh')"
+ },
+ "sound_files_source_check": {
+ "name": "sound_files_source_check",
+ "value": "\"sound_files\".\"source\" in ('upload', 'tts', 'phone')"
+ }
+ },
+ "isRLSEnabled": false
+ },
+ "public.system_events": {
+ "name": "system_events",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "bigserial",
+ "primaryKey": true,
+ "notNull": true
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ },
+ "severity": {
+ "name": "severity",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "code": {
+ "name": "code",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "source": {
+ "name": "source",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "message": {
+ "name": "message",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ }
+ },
+ "indexes": {
+ "system_events_created_at_index": {
+ "name": "system_events_created_at_index",
+ "columns": [
+ {
+ "expression": "created_at",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ }
+ },
+ "foreignKeys": {},
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {
+ "system_events_severity_check": {
+ "name": "system_events_severity_check",
+ "value": "\"system_events\".\"severity\" in ('info', 'warning', 'error')"
+ }
+ },
+ "isRLSEnabled": false
+ },
+ "public.time_conditions": {
+ "name": "time_conditions",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "uuid",
+ "primaryKey": true,
+ "notNull": true,
+ "default": "gen_random_uuid()"
+ },
+ "name": {
+ "name": "name",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "timezone": {
+ "name": "timezone",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "open_ranges": {
+ "name": "open_ranges",
+ "type": "jsonb",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "use_holidays": {
+ "name": "use_holidays",
+ "type": "boolean",
+ "primaryKey": false,
+ "notNull": true,
+ "default": true
+ },
+ "override_digit": {
+ "name": "override_digit",
+ "type": "smallint",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "override_state": {
+ "name": "override_state",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "open_destination": {
+ "name": "open_destination",
+ "type": "jsonb",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "closed_destination": {
+ "name": "closed_destination",
+ "type": "jsonb",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "holiday_destination": {
+ "name": "holiday_destination",
+ "type": "jsonb",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ },
+ "updated_at": {
+ "name": "updated_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ }
+ },
+ "indexes": {},
+ "foreignKeys": {},
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {
+ "time_conditions_overrideDigit_unique": {
+ "name": "time_conditions_overrideDigit_unique",
+ "nullsNotDistinct": false,
+ "columns": [
+ "override_digit"
+ ]
+ }
+ },
+ "policies": {},
+ "checkConstraints": {
+ "time_conditions_override_digit_check": {
+ "name": "time_conditions_override_digit_check",
+ "value": "\"time_conditions\".\"override_digit\" between 1 and 9"
+ },
+ "time_conditions_override_state_check": {
+ "name": "time_conditions_override_state_check",
+ "value": "\"time_conditions\".\"override_state\" in ('open', 'closed')"
+ }
+ },
+ "isRLSEnabled": false
+ },
+ "public.trunks": {
+ "name": "trunks",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "uuid",
+ "primaryKey": true,
+ "notNull": true,
+ "default": "gen_random_uuid()"
+ },
+ "name": {
+ "name": "name",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "provider": {
+ "name": "provider",
+ "type": "trunk_provider",
+ "typeSchema": "public",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "'voipms'"
+ },
+ "enabled": {
+ "name": "enabled",
+ "type": "boolean",
+ "primaryKey": false,
+ "notNull": true,
+ "default": true
+ },
+ "server_host": {
+ "name": "server_host",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "server_port": {
+ "name": "server_port",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "default": 5060
+ },
+ "transport": {
+ "name": "transport",
+ "type": "sip_transport",
+ "typeSchema": "public",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "'udp'"
+ },
+ "auth_mode": {
+ "name": "auth_mode",
+ "type": "trunk_auth_mode",
+ "typeSchema": "public",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "'registration'"
+ },
+ "username": {
+ "name": "username",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "password_enc": {
+ "name": "password_enc",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "from_user": {
+ "name": "from_user",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "from_domain": {
+ "name": "from_domain",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "codecs": {
+ "name": "codecs",
+ "type": "text[]",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "'{ulaw}'"
+ },
+ "srtp": {
+ "name": "srtp",
+ "type": "boolean",
+ "primaryKey": false,
+ "notNull": true,
+ "default": false
+ },
+ "max_channels": {
+ "name": "max_channels",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "voipms_api_username": {
+ "name": "voipms_api_username",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "voipms_api_password_enc": {
+ "name": "voipms_api_password_enc",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ },
+ "updated_at": {
+ "name": "updated_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ }
+ },
+ "indexes": {},
+ "foreignKeys": {},
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {
+ "trunks_name_unique": {
+ "name": "trunks_name_unique",
+ "nullsNotDistinct": false,
+ "columns": [
+ "name"
+ ]
+ }
+ },
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.user_identities": {
+ "name": "user_identities",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "uuid",
+ "primaryKey": true,
+ "notNull": true,
+ "default": "gen_random_uuid()"
+ },
+ "user_id": {
+ "name": "user_id",
+ "type": "uuid",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "kind": {
+ "name": "kind",
+ "type": "identity_kind",
+ "typeSchema": "public",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "subject": {
+ "name": "subject",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "email": {
+ "name": "email",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "groups": {
+ "name": "groups",
+ "type": "text[]",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "'{}'"
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ },
+ "last_used_at": {
+ "name": "last_used_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": false
+ }
+ },
+ "indexes": {},
+ "foreignKeys": {
+ "user_identities_user_id_users_id_fk": {
+ "name": "user_identities_user_id_users_id_fk",
+ "tableFrom": "user_identities",
+ "tableTo": "users",
+ "columnsFrom": [
+ "user_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {
+ "user_identities_kind_subject_unique": {
+ "name": "user_identities_kind_subject_unique",
+ "nullsNotDistinct": false,
+ "columns": [
+ "kind",
+ "subject"
+ ]
+ },
+ "user_identities_userId_kind_unique": {
+ "name": "user_identities_userId_kind_unique",
+ "nullsNotDistinct": false,
+ "columns": [
+ "user_id",
+ "kind"
+ ]
+ }
+ },
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.user_runtime_state": {
+ "name": "user_runtime_state",
+ "schema": "",
+ "columns": {
+ "extension_id": {
+ "name": "extension_id",
+ "type": "uuid",
+ "primaryKey": true,
+ "notNull": true
+ },
+ "dnd": {
+ "name": "dnd",
+ "type": "boolean",
+ "primaryKey": false,
+ "notNull": true,
+ "default": false
+ },
+ "cfu": {
+ "name": "cfu",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "cfb": {
+ "name": "cfb",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "cfna": {
+ "name": "cfna",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ },
+ "updated_at": {
+ "name": "updated_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ }
+ },
+ "indexes": {},
+ "foreignKeys": {
+ "user_runtime_state_extension_id_extensions_id_fk": {
+ "name": "user_runtime_state_extension_id_extensions_id_fk",
+ "tableFrom": "user_runtime_state",
+ "tableTo": "extensions",
+ "columnsFrom": [
+ "extension_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.users": {
+ "name": "users",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "uuid",
+ "primaryKey": true,
+ "notNull": true,
+ "default": "gen_random_uuid()"
+ },
+ "email": {
+ "name": "email",
+ "type": "citext",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "display_name": {
+ "name": "display_name",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "password_hash": {
+ "name": "password_hash",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "role": {
+ "name": "role",
+ "type": "user_role",
+ "typeSchema": "public",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "'user'"
+ },
+ "is_active": {
+ "name": "is_active",
+ "type": "boolean",
+ "primaryKey": false,
+ "notNull": true,
+ "default": true
+ },
+ "totp_secret_enc": {
+ "name": "totp_secret_enc",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "totp_recovery_hashes": {
+ "name": "totp_recovery_hashes",
+ "type": "text[]",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "'{}'"
+ },
+ "totp_last_step": {
+ "name": "totp_last_step",
+ "type": "bigint",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "must_change_password": {
+ "name": "must_change_password",
+ "type": "boolean",
+ "primaryKey": false,
+ "notNull": true,
+ "default": false
+ },
+ "last_login_at": {
+ "name": "last_login_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "contrack_token_enc": {
+ "name": "contrack_token_enc",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "contrack_log_calls": {
+ "name": "contrack_log_calls",
+ "type": "boolean",
+ "primaryKey": false,
+ "notNull": true,
+ "default": true
+ },
+ "contrack_log_summaries": {
+ "name": "contrack_log_summaries",
+ "type": "boolean",
+ "primaryKey": false,
+ "notNull": true,
+ "default": false
+ },
+ "contrack_log_since": {
+ "name": "contrack_log_since",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ },
+ "updated_at": {
+ "name": "updated_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ }
+ },
+ "indexes": {},
+ "foreignKeys": {},
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {
+ "users_email_unique": {
+ "name": "users_email_unique",
+ "nullsNotDistinct": false,
+ "columns": [
+ "email"
+ ]
+ }
+ },
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.voicemail_messages": {
+ "name": "voicemail_messages",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "uuid",
+ "primaryKey": true,
+ "notNull": true,
+ "default": "gen_random_uuid()"
+ },
+ "extension_id": {
+ "name": "extension_id",
+ "type": "uuid",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "msg_id": {
+ "name": "msg_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "folder": {
+ "name": "folder",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "caller_number": {
+ "name": "caller_number",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "caller_name": {
+ "name": "caller_name",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "duration_s": {
+ "name": "duration_s",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "received_at": {
+ "name": "received_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "file_path": {
+ "name": "file_path",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "emailed_at": {
+ "name": "emailed_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "deleted_at": {
+ "name": "deleted_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "transcript": {
+ "name": "transcript",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ },
+ "updated_at": {
+ "name": "updated_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ }
+ },
+ "indexes": {},
+ "foreignKeys": {
+ "voicemail_messages_extension_id_extensions_id_fk": {
+ "name": "voicemail_messages_extension_id_extensions_id_fk",
+ "tableFrom": "voicemail_messages",
+ "tableTo": "extensions",
+ "columnsFrom": [
+ "extension_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {
+ "voicemail_messages_msgId_unique": {
+ "name": "voicemail_messages_msgId_unique",
+ "nullsNotDistinct": false,
+ "columns": [
+ "msg_id"
+ ]
+ }
+ },
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.webhook_deliveries": {
+ "name": "webhook_deliveries",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "bigserial",
+ "primaryKey": true,
+ "notNull": true
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ },
+ "webhook_id": {
+ "name": "webhook_id",
+ "type": "uuid",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "event": {
+ "name": "event",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "payload": {
+ "name": "payload",
+ "type": "jsonb",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "attempt": {
+ "name": "attempt",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "default": 0
+ },
+ "status": {
+ "name": "status",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "response_code": {
+ "name": "response_code",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "next_attempt_at": {
+ "name": "next_attempt_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": false
+ }
+ },
+ "indexes": {},
+ "foreignKeys": {
+ "webhook_deliveries_webhook_id_webhooks_id_fk": {
+ "name": "webhook_deliveries_webhook_id_webhooks_id_fk",
+ "tableFrom": "webhook_deliveries",
+ "tableTo": "webhooks",
+ "columnsFrom": [
+ "webhook_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.webhooks": {
+ "name": "webhooks",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "uuid",
+ "primaryKey": true,
+ "notNull": true,
+ "default": "gen_random_uuid()"
+ },
+ "url": {
+ "name": "url",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "secret_enc": {
+ "name": "secret_enc",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "events": {
+ "name": "events",
+ "type": "text[]",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "enabled": {
+ "name": "enabled",
+ "type": "boolean",
+ "primaryKey": false,
+ "notNull": true,
+ "default": true
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ },
+ "updated_at": {
+ "name": "updated_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ }
+ },
+ "indexes": {},
+ "foreignKeys": {},
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ }
+ },
+ "enums": {
+ "public.call_direction": {
+ "name": "call_direction",
+ "schema": "public",
+ "values": [
+ "inbound",
+ "outbound",
+ "internal"
+ ]
+ },
+ "public.call_report": {
+ "name": "call_report",
+ "schema": "public",
+ "values": [
+ "off",
+ "hourly",
+ "daily",
+ "weekly"
+ ]
+ },
+ "public.call_status": {
+ "name": "call_status",
+ "schema": "public",
+ "values": [
+ "answered",
+ "missed",
+ "voicemail",
+ "abandoned",
+ "busy",
+ "failed"
+ ]
+ },
+ "public.identity_kind": {
+ "name": "identity_kind",
+ "schema": "public",
+ "values": [
+ "oidc",
+ "ldap"
+ ]
+ },
+ "public.outbound_class": {
+ "name": "outbound_class",
+ "schema": "public",
+ "values": [
+ "internal",
+ "local",
+ "national",
+ "international"
+ ]
+ },
+ "public.phone_vendor": {
+ "name": "phone_vendor",
+ "schema": "public",
+ "values": [
+ "yealink",
+ "grandstream",
+ "polycom",
+ "generic"
+ ]
+ },
+ "public.queue_outcome": {
+ "name": "queue_outcome",
+ "schema": "public",
+ "values": [
+ "answered",
+ "abandoned",
+ "timed_out"
+ ]
+ },
+ "public.queue_strategy": {
+ "name": "queue_strategy",
+ "schema": "public",
+ "values": [
+ "ringall",
+ "leastrecent",
+ "fewestcalls",
+ "random",
+ "rrmemory",
+ "linear"
+ ]
+ },
+ "public.record_mode": {
+ "name": "record_mode",
+ "schema": "public",
+ "values": [
+ "never",
+ "inbound",
+ "outbound",
+ "all"
+ ]
+ },
+ "public.ring_strategy": {
+ "name": "ring_strategy",
+ "schema": "public",
+ "values": [
+ "ringall",
+ "hunt"
+ ]
+ },
+ "public.sip_transport": {
+ "name": "sip_transport",
+ "schema": "public",
+ "values": [
+ "udp",
+ "tcp",
+ "tls"
+ ]
+ },
+ "public.trunk_auth_mode": {
+ "name": "trunk_auth_mode",
+ "schema": "public",
+ "values": [
+ "registration",
+ "ip"
+ ]
+ },
+ "public.trunk_provider": {
+ "name": "trunk_provider",
+ "schema": "public",
+ "values": [
+ "voipms",
+ "generic"
+ ]
+ },
+ "public.user_role": {
+ "name": "user_role",
+ "schema": "public",
+ "values": [
+ "admin",
+ "user"
+ ]
+ }
+ },
+ "schemas": {},
+ "sequences": {},
+ "roles": {},
+ "policies": {},
+ "views": {},
+ "_meta": {
+ "columns": {},
+ "schemas": {},
+ "tables": {}
+ }
+}
\ No newline at end of file
diff --git a/apps/api/src/db/migrations/meta/_journal.json b/apps/api/src/db/migrations/meta/_journal.json
index 2997d7f..2e12f72 100644
--- a/apps/api/src/db/migrations/meta/_journal.json
+++ b/apps/api/src/db/migrations/meta/_journal.json
@@ -85,6 +85,13 @@
"when": 1792788393381,
"tag": "0011_contrack_call_log",
"breakpoints": true
+ },
+ {
+ "idx": 12,
+ "version": "7",
+ "when": 1792951905226,
+ "tag": "0012_sso",
+ "breakpoints": true
}
]
}
\ No newline at end of file
diff --git a/apps/api/src/db/schema.ts b/apps/api/src/db/schema.ts
index a3d9433..6849a51 100644
--- a/apps/api/src/db/schema.ts
+++ b/apps/api/src/db/schema.ts
@@ -19,6 +19,7 @@ import {
smallint,
text,
timestamp,
+ unique,
uuid,
} from 'drizzle-orm/pg-core';
@@ -53,7 +54,8 @@ export const users = pgTable('users', {
id: id(),
email: citext().notNull().unique(),
displayName: text().notNull(),
- passwordHash: text().notNull(),
+ /** argon2id; null: no password, signs in with OpenID only (12 §3.3). */
+ passwordHash: text(),
role: userRole().notNull().default('user'),
isActive: boolean().notNull().default(true),
totpSecretEnc: text(),
@@ -83,9 +85,40 @@ export const sessions = pgTable(
expiresAt: tstz().notNull(),
ip: inet(),
userAgent: text(),
+ /** How it was made (12 §2): password, ldap or oidc. */
+ method: text().notNull().default('password'),
+ /** OpenID sessions (12 §3.5): refresh token for the leaver check, ID token for signing out. */
+ idpRefreshEnc: text(),
+ idpIdTokenEnc: text(),
+ idpCheckedAt: tstz(),
...timestamps,
},
- (t) => [index().on(t.userId)],
+ (t) => [
+ index().on(t.userId),
+ check('sessions_method_check', sql`${t.method} in ('password', 'ldap', 'oidc')`),
+ ],
+);
+
+// One more way to sign in to a user (12 §2): a connected OpenID account or directory entry.
+export const identityKind = pgEnum('identity_kind', ['oidc', 'ldap']);
+export const userIdentities = pgTable(
+ 'user_identities',
+ {
+ id: id(),
+ userId: uuid()
+ .notNull()
+ .references(() => users.id, { onDelete: 'cascade' }),
+ kind: identityKind().notNull(),
+ /** oidc: " "; ldap: the entry's UUID attribute. */
+ subject: text().notNull(),
+ /** As the provider last said. */
+ email: text(),
+ /** oidc: groupsClaim values at the last sign-in (12 §3.4). */
+ groups: emptyTextArray(),
+ createdAt: createdAt(),
+ lastUsedAt: tstz(),
+ },
+ (t) => [unique().on(t.kind, t.subject), unique().on(t.userId, t.kind)],
);
export const apiTokens = pgTable('api_tokens', {
diff --git a/apps/api/src/jobs.ts b/apps/api/src/jobs.ts
index 998cc3a..6be84b3 100644
--- a/apps/api/src/jobs.ts
+++ b/apps/api/src/jobs.ts
@@ -27,6 +27,8 @@ import { backupDue, runBackup } from './services/backup.ts';
import { sendCallReports } from './services/callreport.ts';
import { contrackBook, contrackSettings } from './contacts/contrack.ts';
import { logCallsToContrack } from './contacts/timeline.ts';
+import { checkDirectory } from './sso/directory.ts';
+import { checkOidcSessions } from './sso/oidcCheck.ts';
import { logEvent } from './services/events.ts';
import { deliverDue } from './services/webhooks.ts';
import type { SendMail } from './services/mail.ts';
@@ -261,7 +263,19 @@ export function createJobs(d: JobDeps) {
await logCallsToContrack({ db: d.db, log: d.log, now: now() });
};
+ /** OpenID sessions refreshed at the provider; a leaver's end (12 §3.5). */
+ const oidcCheck = async () => {
+ await checkOidcSessions({ db: d.db, log: d.log, now: now() });
+ };
+
+ /** Directory accounts re-read; a leaver disabled (12 §4.5). */
+ const directoryCheck = async () => {
+ await checkDirectory({ db: d.db, log: d.log });
+ };
+
return {
+ oidcCheck,
+ directoryCheck,
daily,
backup,
publicIpWatch,
@@ -295,6 +309,8 @@ export function startJobs(d: JobDeps): () => void {
run('callReports', j.callReports);
if (minute % 5 === 0) run('contactsWarm', j.contactsWarm);
run('contrackLog', j.contrackLog);
+ if (minute % 5 === 0) run('oidcCheck', j.oidcCheck);
+ if (minute % 60 === 0) run('directoryCheck', j.directoryCheck);
run('callTranscripts', async () => {
await transcribeCalls({ db: d.db, dir: resolve(d.config.RECORDINGS_DIR), log: d.log });
});
diff --git a/apps/api/src/live/hub.ts b/apps/api/src/live/hub.ts
index e13d8bb..d76dda4 100644
--- a/apps/api/src/live/hub.ts
+++ b/apps/api/src/live/hub.ts
@@ -4,7 +4,7 @@ import type { FastifyInstance } from 'fastify';
import type { WebSocket } from '@fastify/websocket';
import { eq } from 'drizzle-orm';
import type { ExtensionLive, LiveMessage } from '@selfphoneng/shared';
-import { COOKIE, getSessionUser } from '../auth/sessions.ts';
+import { COOKIE, getSessionUser, mustChange } from '../auth/sessions.ts';
import { extensions } from '../db/schema.ts';
import type { LiveState } from './state.ts';
@@ -103,7 +103,7 @@ export async function liveRoutes(app: FastifyInstance) {
for (const c of clients)
void getSessionUser(app.db, c.token, false)
.then((f) => {
- if (!f || f.user.mustChangePassword || (f.user.role === 'admin') !== c.admin)
+ if (!f || mustChange(f) || (f.user.role === 'admin') !== c.admin)
c.ws.close(4401, 'unauthorized');
})
.catch(() => {}); // database down: keep the socket, check again next time
@@ -123,7 +123,7 @@ export async function liveRoutes(app: FastifyInstance) {
const token = req.cookies[COOKIE];
const found = token ? await getSessionUser(app.db, token) : null;
// Browsers can't read an HTTP 401 on an upgrade, so open and close with 4401 (04 §5.2).
- if (!found || found.user.mustChangePassword) return ws.close(4401, 'unauthorized');
+ if (!found || mustChange(found)) return ws.close(4401, 'unauthorized');
const mine = await app.db
.select({ n: extensions.number })
.from(extensions)
diff --git a/apps/api/src/routes/about.ts b/apps/api/src/routes/about.ts
index b03d756..dc4a5aa 100644
--- a/apps/api/src/routes/about.ts
+++ b/apps/api/src/routes/about.ts
@@ -7,6 +7,7 @@ import { readFile, stat } from 'node:fs/promises';
import type { FastifyInstance } from 'fastify';
import type { AboutDto } from '@selfphoneng/shared';
import { notFound } from '../errors.ts';
+import { SLOW } from '../auth/guards.ts';
export const REPO_URL = 'https://git.dou.bet/spng/selfphoneng';
/** In the api's working directory (/app in the image), like ./public. */
@@ -35,7 +36,7 @@ export async function aboutRoutes(app: FastifyInstance) {
};
});
- app.get('/api/about/source', async (_req, reply) => {
+ app.get('/api/about/source', { config: SLOW }, async (_req, reply) => {
if (!(await stat(SOURCE_ARCHIVE).catch(() => null)))
throw notFound('The source archive is only in builds made with Docker');
return reply
diff --git a/apps/api/src/routes/auth.ts b/apps/api/src/routes/auth.ts
index 285d53b..564ea74 100644
--- a/apps/api/src/routes/auth.ts
+++ b/apps/api/src/routes/auth.ts
@@ -1,14 +1,16 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
// /api/auth/* (04 §2, §7.3).
import { and, eq, isNull, lt, or, sql } from 'drizzle-orm';
-import type { FastifyInstance } from 'fastify';
+import type { FastifyInstance, FastifyRequest } from 'fastify';
import {
ChangePasswordSchema,
LoginSchema,
PasswordConfirmSchema,
TotpEnableSchema,
APP_NAME,
+ type AuthMethods,
type AuthUser,
+ type SignInWays,
type TotpSetup,
type TotpStatus,
} from '@selfphoneng/shared';
@@ -22,7 +24,11 @@ import {
otpauthUrl,
} from '../auth/totp.ts';
import type { Db } from '../db/client.ts';
-import { extensions, users } from '../db/schema.ts';
+import { extensions, sessions, userIdentities, users } from '../db/schema.ts';
+import { endSessionUrl, oidcError } from '../sso/oidc.ts';
+import { directorySignIn, directoryWarning } from '../sso/directory.ts';
+import { ldapAuthenticate } from '../sso/ldap.ts';
+import { getSetting } from '../services/settings.ts';
import {
unauthorized,
rateLimited,
@@ -49,11 +55,32 @@ import {
setSessionCookie,
} from '../auth/sessions.ts';
+/** 12 §2: the user's password and connected accounts, from their user_identities rows. */
+export const signInWays = (
+ u: { passwordHash: string | null },
+ ids: { kind: 'oidc' | 'ldap'; email: string | null; groups: string[] }[],
+): SignInWays => {
+ const oidc = ids.find((i) => i.kind === 'oidc');
+ return {
+ password: u.passwordHash !== null,
+ oidc: oidc ? { email: oidc.email, groups: oidc.groups } : null,
+ ldap: ids.some((i) => i.kind === 'ldap'),
+ };
+};
+
export async function toAuthUser(db: Db, u: CurrentUser): Promise {
const [ext] = await db
.select({ id: extensions.id, number: extensions.number, name: extensions.name })
.from(extensions)
.where(eq(extensions.userId, u.id));
+ const ids = await db
+ .select({
+ kind: userIdentities.kind,
+ email: userIdentities.email,
+ groups: userIdentities.groups,
+ })
+ .from(userIdentities)
+ .where(eq(userIdentities.userId, u.id));
return {
id: u.id,
email: u.email,
@@ -61,6 +88,7 @@ export async function toAuthUser(db: Db, u: CurrentUser): Promise {
role: u.role,
mustChangePassword: u.mustChangePassword,
extension: ext ?? null,
+ signIn: signInWays(u, ids),
};
}
@@ -112,13 +140,24 @@ export async function authRoutes(app: FastifyInstance) {
return { secret, otpauthUrl: url, qr: await QRCode.toDataURL(url, { margin: 2, width: 240 }) };
});
+ /**
+ * A signed-in person's own password, asked again (two-step on/off, recovery codes, a new
+ * password). Wrong ones count in the login limiter, so a stolen session can't guess it.
+ */
+ async function myPasswordOk(req: FastifyRequest, hash: string | null, password: string) {
+ if (isLoginBlocked(req.ip, req.user!.email)) throw rateLimited();
+ if (hash && (await verifyPassword(hash, password))) return true;
+ recordLoginFailure(req.ip, req.user!.email);
+ return false;
+ }
+
app.post('/api/auth/totp/enable', { preHandler: requireAuth }, async (req) => {
adminOnly(req.user!);
const { secret, code, password } = TotpEnableSchema.parse(req.body);
const u = await myRow(req.user!.id);
if (u.totpSecretEnc) throw conflict('Two-step sign-in is already on');
// Like turning it off: a stolen session alone can't lock the owner out with its own secret.
- if (!(await verifyPassword(u.passwordHash, password))) throw badRequest('Wrong password');
+ if (!(await myPasswordOk(req, u.passwordHash, password))) throw badRequest('Wrong password');
const step = checkTotp(secret, code, null);
if (step === null)
throw badRequest('That code doesn’t match. Check the phone’s clock and try again.');
@@ -139,15 +178,15 @@ export async function authRoutes(app: FastifyInstance) {
});
/** Turning it off or making new recovery codes needs the password again (on does too). */
- async function confirmPassword(id: string, body: unknown) {
- const { password } = PasswordConfirmSchema.parse(body);
- const u = await myRow(id);
- if (!(await verifyPassword(u.passwordHash, password))) throw badRequest('Wrong password');
+ async function confirmPassword(req: FastifyRequest) {
+ const { password } = PasswordConfirmSchema.parse(req.body);
+ const u = await myRow(req.user!.id);
+ if (!(await myPasswordOk(req, u.passwordHash, password))) throw badRequest('Wrong password');
return u;
}
app.post('/api/auth/totp/disable', { preHandler: requireAuth }, async (req) => {
- await confirmPassword(req.user!.id, req.body);
+ await confirmPassword(req);
await db
.update(users)
.set({ totpSecretEnc: null, totpLastStep: null, totpRecoveryHashes: [] })
@@ -164,7 +203,7 @@ export async function authRoutes(app: FastifyInstance) {
});
app.post('/api/auth/totp/recovery-codes', { preHandler: requireAuth }, async (req) => {
- const u = await confirmPassword(req.user!.id, req.body);
+ const u = await confirmPassword(req);
if (!u.totpSecretEnc) throw badRequest('Two-step sign-in is off');
const { codes, hashes } = newRecoveryCodes();
await db.update(users).set({ totpRecoveryHashes: hashes }).where(eq(users.id, u.id));
@@ -179,15 +218,61 @@ export async function authRoutes(app: FastifyInstance) {
return { recoveryCodes: codes };
});
+ /** The login page's choices (12 §2). Public: says nothing about any user. */
+ app.get('/api/auth/methods', async (): Promise => {
+ const mode = await getSetting(db, 'auth.mode');
+ const oidc = await getSetting(db, 'auth.oidc');
+ return {
+ mode,
+ ldap: Boolean((await getSetting(db, 'auth.ldap'))?.enabled),
+ oidc: oidc && mode !== 'password' ? { label: oidc.label } : null,
+ };
+ });
+
app.post('/api/auth/login', async (req, reply) => {
- const body = LoginSchema.parse(req.body);
- if (isLoginBlocked(req.ip, body.email)) throw rateLimited();
- const [user] = await db.select().from(users).where(eq(users.email, body.email));
- const ok = user?.isActive
+ const { login, ...body } = LoginSchema.parse(req.body);
+ if (isLoginBlocked(req.ip, login)) throw rateLimited();
+ let [user] = await db.select().from(users).where(eq(users.email, login));
+ // No password (OpenID only, 12 §3.3) fails like a wrong one, after the same work.
+ let ok = user?.passwordHash
? await verifyPassword(user.passwordHash, body.password)
: (await burnVerify(body.password), false);
- if (!user || !ok) {
- recordLoginFailure(req.ip, body.email);
+ let method: 'password' | 'ldap' = 'password';
+ // Not this password here: the directory's, when it's on (12 §4.2).
+ const ldap = ok ? null : await getSetting(db, 'auth.ldap');
+ if (ldap?.enabled) {
+ let entry;
+ try {
+ entry = await ldapAuthenticate(ldap, login, body.password);
+ } catch (err) {
+ // Counted like a wrong password: a 503 must not be a free guess at the local one.
+ recordLoginFailure(req.ip, login);
+ await audit(db, {
+ actorUserId: user?.id,
+ actorIp: req.ip,
+ action: 'login_failed',
+ entityType: 'user',
+ entityId: user?.id,
+ summary: `Failed login for ${login} (the directory can't be reached)`,
+ });
+ req.log.warn({ event: 'auth.ldap', err: (err as Error).message }, 'directory unavailable');
+ await directoryWarning(
+ db,
+ `A directory sign-in failed: the directory couldn't be reached (${(err as Error).message}).`,
+ );
+ throw new HttpError(
+ 503,
+ 'directory_unavailable',
+ 'The directory can’t be reached. Passwords set in SelfPhoneNG still work.',
+ );
+ }
+ const found = entry && (await directorySignIn(db, req.log, entry, req.ip));
+ if (entry && !found)
+ req.log.warn({ event: 'auth.failed', ip: req.ip }, 'directory account has no user here');
+ if (found) [user, ok, method] = [found, true, 'ldap'];
+ }
+ if (!user || !ok || !user.isActive) {
+ recordLoginFailure(req.ip, login);
req.log.warn({ event: 'auth.failed', ip: req.ip }, 'login failed');
await audit(db, {
actorUserId: user?.id,
@@ -195,16 +280,19 @@ export async function authRoutes(app: FastifyInstance) {
action: 'login_failed',
entityType: 'user',
entityId: user?.id,
- summary: `Failed login for ${body.email}`,
+ summary: `Failed login for ${login}`,
});
throw unauthorized('Wrong email or password');
}
+ // OpenID only (12 §1.1): passwords are for admins, the way in when the provider is down.
+ if (user.role !== 'admin' && (await getSetting(db, 'auth.mode')) === 'oidc')
+ throw forbidden('Use the sign-in button instead', 'password_sign_in_off');
// Two-step sign-in (08 §2.4): the password was right; now the code.
if (user.totpSecretEnc) {
if (!body.totp)
throw new HttpError(401, 'totp_required', 'Enter the code from your authenticator app');
if (!(await useSecondFactor(user, body.totp))) {
- recordLoginFailure(req.ip, body.email);
+ recordLoginFailure(req.ip, login);
req.log.warn({ event: 'auth.failed', ip: req.ip }, 'login failed: code');
await audit(db, {
actorUserId: user.id,
@@ -212,12 +300,18 @@ export async function authRoutes(app: FastifyInstance) {
action: 'login_failed',
entityType: 'user',
entityId: user.id,
- summary: `Failed login for ${body.email} (wrong authenticator or recovery code)`,
+ summary: `Failed login for ${login} (wrong authenticator or recovery code)`,
});
throw new HttpError(401, 'totp_invalid', 'That code is wrong or was used already');
}
}
- const token = await createSession(db, user.id, req.ip, req.headers['user-agent'] ?? null);
+ const token = await createSession(
+ db,
+ user.id,
+ req.ip,
+ req.headers['user-agent'] ?? null,
+ method,
+ );
setSessionCookie(reply, token);
await db.update(users).set({ lastLoginAt: new Date() }).where(eq(users.id, user.id));
await audit(db, {
@@ -226,14 +320,30 @@ export async function authRoutes(app: FastifyInstance) {
action: 'login',
entityType: 'user',
entityId: user.id,
- summary: `${user.email} logged in`,
+ summary: `${user.email} logged in${method === 'ldap' ? ' with the directory' : ''}`,
});
return { user: await toAuthUser(db, user) };
});
app.post('/api/auth/logout', { preHandler: requireAuth }, async (req, reply) => {
+ const [sess] = req.sessionId
+ ? await db.select().from(sessions).where(eq(sessions.id, req.sessionId))
+ : [];
await deleteSession(db, req.cookies[COOKIE]!);
clearSessionCookie(reply);
+ // Signed in with OpenID: sign out there too (12 §3.5). A provider problem doesn't stop ours.
+ let redirect: string | null = null;
+ const oidc = sess?.idpIdTokenEnc ? await getSetting(db, 'auth.oidc') : null;
+ if (oidc)
+ try {
+ redirect = await endSessionUrl(
+ oidc,
+ app.config.PUBLIC_HOSTNAME,
+ decrypt(sess!.idpIdTokenEnc!),
+ );
+ } catch (err) {
+ req.log.warn({ event: 'auth.oidc', err: oidcError(err) }, 'no sign-out at the provider');
+ }
await audit(db, {
actorUserId: req.user!.id,
actorIp: req.ip,
@@ -242,7 +352,7 @@ export async function authRoutes(app: FastifyInstance) {
entityId: req.user!.id,
summary: `${req.user!.email} logged out`,
});
- return { ok: true };
+ return redirect ? { ok: true, redirect } : { ok: true };
});
app.get('/api/auth/me', { preHandler: requireAuth }, async (req) => ({
@@ -252,7 +362,9 @@ export async function authRoutes(app: FastifyInstance) {
app.post('/api/auth/change-password', { preHandler: requireAuth }, async (req) => {
const body = ChangePasswordSchema.parse(req.body);
const user = req.user!;
- if (!(await verifyPassword(user.passwordHash, body.currentPassword)))
+ if (!user.passwordHash)
+ throw badRequest('You have no password here. Ask an admin to reset it to get one.');
+ if (!(await myPasswordOk(req, user.passwordHash, body.currentPassword)))
throw badRequest('Current password is wrong');
if (body.newPassword === body.currentPassword)
throw badRequest('Choose a password different from the current one');
diff --git a/apps/api/src/routes/contacts.ts b/apps/api/src/routes/contacts.ts
index 01f821b..4d021e6 100644
--- a/apps/api/src/routes/contacts.ts
+++ b/apps/api/src/routes/contacts.ts
@@ -16,7 +16,7 @@ import {
type ContactShareDto,
type ListResponse,
} from '@selfphoneng/shared';
-import { requireAdmin, requireAuth } from '../auth/guards.ts';
+import { NO_LIMIT, SLOW, requireAdmin, requireAuth } from '../auth/guards.ts';
import {
addToBook,
assertBuiltin,
@@ -162,33 +162,39 @@ export async function contactRoutes(app: FastifyInstance) {
* The dialplan's caller-name lookup (11 §6 C4): only Asterisk on this machine (no proxy in
* between) with the token. Never waits on Contrack. Not logged: the token is in the query.
*/
- app.get('/api/internal/caller-name', { logLevel: 'silent' }, async (req, reply) => {
- const q = z
- .object({
- ext: z.string().regex(/^\d{1,10}$/),
- number: z.string().regex(/^\d{3,20}$/),
- token: z.string().max(100),
- })
- .safeParse(req.query);
- const local = ['127.0.0.1', '::1', '::ffff:127.0.0.1'].includes(req.socket.remoteAddress ?? '');
- if (
- !local ||
- req.headers['x-forwarded-for'] ||
- !q.success ||
- !sameToken(q.data.token, callerNameToken(app.config.APP_SECRET))
- )
- return reply.code(404).send('');
- const [e] = await db
- .select({ userId: extensions.userId })
- .from(extensions)
- .where(eq(extensions.number, q.data.ext));
- const names = await contactNames(db, e?.userId ?? null, [q.data.number], { wait: false }).catch(
- () => new Map(),
- );
- return reply
- .type('text/plain; charset=utf-8')
- .send(phoneSafeName(names.get(q.data.number) ?? ''));
- });
+ app.get(
+ '/api/internal/caller-name',
+ { logLevel: 'silent', config: NO_LIMIT },
+ async (req, reply) => {
+ const q = z
+ .object({
+ ext: z.string().regex(/^\d{1,10}$/),
+ number: z.string().regex(/^\d{3,20}$/),
+ token: z.string().max(100),
+ })
+ .safeParse(req.query);
+ const local = ['127.0.0.1', '::1', '::ffff:127.0.0.1'].includes(
+ req.socket.remoteAddress ?? '',
+ );
+ if (
+ !local ||
+ req.headers['x-forwarded-for'] ||
+ !q.success ||
+ !sameToken(q.data.token, callerNameToken(app.config.APP_SECRET))
+ )
+ return reply.code(404).send('');
+ const [e] = await db
+ .select({ userId: extensions.userId })
+ .from(extensions)
+ .where(eq(extensions.number, q.data.ext));
+ const names = await contactNames(db, e?.userId ?? null, [q.data.number], {
+ wait: false,
+ }).catch(() => new Map());
+ return reply
+ .type('text/plain; charset=utf-8')
+ .send(phoneSafeName(names.get(q.data.number) ?? ''));
+ },
+ );
/** Copy the builtin book into Contrack (11 §5, C6): mine, or (admin) the company's. */
app.post(
@@ -214,19 +220,23 @@ export async function contactRoutes(app: FastifyInstance) {
);
/** Settings → Contacts → Test (11 §4.3): the form's address, and its token or the saved one. */
- app.post('/api/contacts/contrack/test', { preHandler: requireAdmin }, async (req) => {
- const b = z
- .object({ url: z.string().trim().min(1), companyToken: z.string().trim().optional() })
- .parse(req.body);
- const saved = await getSetting(db, 'contacts.contrack');
- const token =
- b.companyToken || (saved?.companyTokenEnc ? decrypt(saved.companyTokenEnc) : null);
- try {
- return { ok: true, detail: await contrackCheck(b.url, token) };
- } catch (err) {
- return { ok: false, error: (err as Error).message };
- }
- });
+ app.post(
+ '/api/contacts/contrack/test',
+ { preHandler: requireAdmin, config: SLOW },
+ async (req) => {
+ const b = z
+ .object({ url: z.string().trim().min(1), companyToken: z.string().trim().optional() })
+ .parse(req.body);
+ const saved = await getSetting(db, 'contacts.contrack');
+ const token =
+ b.companyToken || (saved?.companyTokenEnc ? decrypt(saved.companyTokenEnc) : null);
+ try {
+ return { ok: true, detail: await contrackCheck(b.url, token) };
+ } catch (err) {
+ return { ok: false, error: (err as Error).message };
+ }
+ },
+ );
// ───── sharing: an offer each, accepted or denied by the recipient (11 §2.1) ─────
app.post('/api/me/contacts/share', { preHandler: requireAuth }, async (req) => {
diff --git a/apps/api/src/routes/integrations.ts b/apps/api/src/routes/integrations.ts
index fd2cda3..48c62c2 100644
--- a/apps/api/src/routes/integrations.ts
+++ b/apps/api/src/routes/integrations.ts
@@ -13,7 +13,7 @@ import {
type WebhookDto,
type WebhookEvent,
} from '@selfphoneng/shared';
-import { requireAdmin } from '../auth/guards.ts';
+import { SLOW, requireAdmin } from '../auth/guards.ts';
import { generateToken, hashToken } from '../auth/tokens.ts';
import { decrypt, encrypt } from '../crypto/secrets.ts';
import { apiTokens, users, webhookDeliveries, webhooks } from '../db/schema.ts';
@@ -103,7 +103,7 @@ export async function integrationRoutes(app: FastifyInstance) {
});
/** Sends a `webhook.test` event now, not queued: the answer shows right away. */
- app.post('/api/webhooks/:id/test', async (req) => {
+ app.post('/api/webhooks/:id/test', { config: SLOW }, async (req) => {
const h = await getHook(IdParams.parse(req.params).id);
const body = JSON.stringify({
id: crypto.randomUUID(),
diff --git a/apps/api/src/routes/me.ts b/apps/api/src/routes/me.ts
index 02e35c1..25a16da 100644
--- a/apps/api/src/routes/me.ts
+++ b/apps/api/src/routes/me.ts
@@ -180,6 +180,9 @@ export async function meRoutes(app: FastifyInstance) {
/** Log my calls in Contrack, with or without their summaries (11 §4.4). */
app.patch('/api/me/contrack', async (req) => {
const o = ContrackOptionsSchema.parse(req.body);
+ // An empty update would be `UPDATE … SET` with nothing to set: a 500, not a 400.
+ if (o.logCalls === undefined && o.logSummaries === undefined)
+ throw badRequest('Nothing to update');
const [u] = await db
.select({ logCalls: users.contrackLogCalls })
.from(users)
diff --git a/apps/api/src/routes/oidc.ts b/apps/api/src/routes/oidc.ts
new file mode 100644
index 0000000..5c894c4
--- /dev/null
+++ b/apps/api/src/routes/oidc.ts
@@ -0,0 +1,333 @@
+// SPDX-License-Identifier: AGPL-3.0-or-later
+// Sign in with OpenID Connect, connect and disconnect an OpenID account (12 §3.2, §3.3).
+import { and, eq, gt, isNull, notExists } from 'drizzle-orm';
+import type { FastifyInstance, FastifyReply, FastifyRequest } from 'fastify';
+import { z } from 'zod';
+import { decrypt, encrypt } from '../crypto/secrets.ts';
+import { sessions, userIdentities, users } from '../db/schema.ts';
+import {
+ HttpError,
+ badRequest,
+ forbidden,
+ notFound,
+ rateLimited,
+ unauthorized,
+} from '../errors.ts';
+import { requireAdmin, requireAuth } from '../auth/guards.ts';
+import { isLoginBlocked, recordLoginFailure } from '../auth/login-limiter.ts';
+import { createSession, setSessionCookie } from '../auth/sessions.ts';
+import { audit } from '../services/audit.ts';
+import { getSetting } from '../services/settings.ts';
+import { decide, verifiedEmail } from '../sso/claims.ts';
+import { connectDirectory } from '../sso/directory.ts';
+import { ldapAuthenticate } from '../sso/ldap.ts';
+import { applyClaims } from '../sso/oidcCheck.ts';
+import {
+ finishSignIn,
+ oidcError,
+ startSignIn,
+ type OidcSettings,
+ type PendingSignIn,
+} from '../sso/oidc.ts';
+
+export const OIDC_COOKIE = 'sp_oidc';
+const COOKIE_PATH = '/api/auth/oidc';
+
+/**
+ * Same-site paths only: never send the browser to another origin. No backslash or control
+ * character anywhere: browsers drop tabs and newlines from URLs, so "//evil.example"
+ * would become "//evil.example".
+ */
+export const safeNext = (p: string | undefined) =>
+ // eslint-disable-next-line no-control-regex -- control characters are what it refuses
+ p && /^\/(?!\/)[^\\\x00-\x1f\x7f]*$/.test(p) ? p : undefined;
+
+const withParam = (path: string, k: string, v: string) =>
+ `${path}${path.includes('?') ? '&' : '?'}${k}=${v}`;
+
+export async function oidcRoutes(app: FastifyInstance) {
+ const { db } = app;
+ const host = app.config.PUBLIC_HOSTNAME;
+
+ /** The provider's settings, when OpenID is on (12 §1.1: not in `password` mode). */
+ async function enabled(): Promise {
+ const s = await getSetting(db, 'auth.oidc');
+ if (!s || (await getSetting(db, 'auth.mode')) === 'password') throw notFound();
+ return s;
+ }
+
+ /** The sp_oidc cookie: what the callback checks the provider's answer against. */
+ async function begin(reply: FastifyReply, s: OidcSettings, next: string, connect: string | null) {
+ const { url, pending } = await startSignIn(s, host, next, connect);
+ reply.setCookie(OIDC_COOKIE, encrypt(JSON.stringify(pending)), {
+ httpOnly: true,
+ secure: true,
+ sameSite: 'lax',
+ path: COOKIE_PATH,
+ maxAge: 600,
+ });
+ return url.href;
+ }
+
+ app.get('/api/auth/oidc/start', async (req, reply) => {
+ const s = await enabled();
+ const q = z.object({ next: z.string().max(500).optional() }).parse(req.query);
+ try {
+ return reply.redirect(await begin(reply, s, safeNext(q.next) ?? '/', null));
+ } catch (err) {
+ req.log.warn({ event: 'auth.oidc', err: oidcError(err) }, 'OpenID discovery failed');
+ return reply.redirect(withParam('/login', 'sso_error', 'provider'));
+ }
+ });
+
+ /**
+ * Connect from My account (12 §3.3). A POST, so it needs the CSRF header: another site can't
+ * start it and connect whatever provider account the browser is signed in to. The page then
+ * sends the browser to `url`.
+ */
+ app.post('/api/auth/oidc/connect', { preHandler: requireAuth }, async (req, reply) => {
+ const s = await enabled();
+ if (!req.sessionId) throw unauthorized(); // an API token can't connect an account
+ const q = z.object({ next: z.string().max(500).optional() }).parse(req.body ?? {});
+ const next =
+ safeNext(q.next) ?? (req.user!.role === 'admin' ? '/admin/account' : '/me/settings');
+ try {
+ return { url: await begin(reply, s, next, req.sessionId) };
+ } catch (err) {
+ req.log.warn({ event: 'auth.oidc', err: oidcError(err) }, 'OpenID discovery failed');
+ throw new HttpError(502, 'provider', `${s.label} can't be reached`);
+ }
+ });
+
+ // Not logged by Fastify: the query holds the authorization code (12 §1).
+ app.get('/api/auth/oidc/callback', { logLevel: 'silent' }, async (req, reply) => {
+ const s = await enabled();
+ let pending: PendingSignIn | null = null;
+ try {
+ pending = JSON.parse(decrypt(req.cookies[OIDC_COOKIE] ?? '')) as PendingSignIn;
+ } catch {
+ // missing or not ours: `expired` below
+ }
+ reply.clearCookie(OIDC_COOKIE, { path: COOKIE_PATH });
+ const connecting = pending?.connectSessionId ?? null;
+
+ const fail = async (code: string, reason: string, userId?: string) => {
+ if (!connecting) recordLoginFailure(req.ip, null);
+ req.log.warn({ event: 'auth.failed', ip: req.ip, reason }, 'OpenID sign-in failed');
+ await audit(db, {
+ actorUserId: userId,
+ actorIp: req.ip,
+ action: 'login_failed',
+ entityType: 'user',
+ entityId: userId,
+ summary: `Failed ${s.label} sign-in (${code}: ${reason.slice(0, 200)})`,
+ });
+ return reply.redirect(withParam(connecting ? pending!.next : '/login', 'sso_error', code));
+ };
+
+ if (!connecting && isLoginBlocked(req.ip, null)) return fail('too_many', 'rate limited');
+ if (!pending || pending.exp < Date.now()) return fail('expired', 'no sign-in in progress');
+ const q = req.query as Record;
+ if (typeof q.error === 'string')
+ return fail(q.error === 'access_denied' ? 'denied' : 'provider', `provider: ${q.error}`);
+
+ let r;
+ try {
+ r = await finishSignIn(s, new URL(`https://${host}${req.url}`), pending);
+ } catch (err) {
+ return fail('provider', oidcError(err));
+ }
+ const d = decide(s, r.claims);
+ const email = typeof r.claims.email === 'string' ? r.claims.email : null;
+ if (!d.allowed) return fail('not_allowed', `${email ?? r.subject} not in the allowed groups`);
+ const [owner] = await db
+ .select({ userId: userIdentities.userId })
+ .from(userIdentities)
+ .where(and(eq(userIdentities.kind, 'oidc'), eq(userIdentities.subject, r.subject)));
+
+ if (connecting) {
+ // Connect (12 §3.3): to the user of the session that asked, if it's still signed in.
+ const [sess] = await db
+ .select({ userId: sessions.userId, email: users.email })
+ .from(sessions)
+ .innerJoin(users, eq(users.id, sessions.userId))
+ .where(
+ and(
+ eq(sessions.id, connecting),
+ gt(sessions.expiresAt, new Date()),
+ eq(users.isActive, true),
+ ),
+ );
+ if (!sess) return fail('expired', 'the session that asked has ended');
+ if (owner && owner.userId !== sess.userId)
+ return fail('taken', `${email ?? r.subject} belongs to another user`, sess.userId);
+ await db.transaction(async (tx) => {
+ await tx
+ .delete(userIdentities)
+ .where(and(eq(userIdentities.userId, sess.userId), eq(userIdentities.kind, 'oidc')));
+ await tx.insert(userIdentities).values({
+ userId: sess.userId,
+ kind: 'oidc',
+ subject: r.subject,
+ email,
+ groups: d.groups,
+ });
+ });
+ await audit(db, {
+ actorUserId: sess.userId,
+ actorIp: req.ip,
+ action: 'sso_connected',
+ entityType: 'user',
+ entityId: sess.userId,
+ summary: `${sess.email} connected ${s.label} account ${email ?? r.subject}`,
+ });
+ return reply.redirect(withParam(pending.next, 'sso', 'connected'));
+ }
+
+ let userId = owner?.userId;
+ if (!userId) {
+ // A user without a password or an OpenID account yet, by verified email (12 §3.3 step 2).
+ const v = verifiedEmail(r.claims, s.trustEmail);
+ const [u] = v
+ ? await db
+ .select({ id: users.id })
+ .from(users)
+ .where(
+ and(
+ eq(users.email, v),
+ isNull(users.passwordHash),
+ notExists(
+ db
+ .select({ id: userIdentities.id })
+ .from(userIdentities)
+ .where(
+ and(eq(userIdentities.userId, users.id), eq(userIdentities.kind, 'oidc')),
+ ),
+ ),
+ ),
+ )
+ : [];
+ if (!u) return fail('not_connected', `${email ?? r.subject} isn't connected to a user`);
+ await db
+ .insert(userIdentities)
+ .values({ userId: u.id, kind: 'oidc', subject: r.subject, email, groups: d.groups });
+ await audit(db, {
+ actorUserId: u.id,
+ actorIp: req.ip,
+ action: 'sso_connected',
+ entityType: 'user',
+ entityId: u.id,
+ summary: `${v} connected ${s.label} by verified email`,
+ });
+ userId = u.id;
+ }
+ const [user] = await db.select().from(users).where(eq(users.id, userId));
+ if (!user!.isActive) return fail('disabled', `${user!.email} is disabled`, userId);
+
+ await applyClaims(db, req.log, s, userId, d, email, req.ip);
+ await db
+ .update(userIdentities)
+ .set({ lastUsedAt: new Date() })
+ .where(and(eq(userIdentities.userId, userId), eq(userIdentities.kind, 'oidc')));
+ const token = await createSession(
+ db,
+ userId,
+ req.ip,
+ req.headers['user-agent'] ?? null,
+ 'oidc',
+ { refreshToken: r.refreshToken, idToken: r.idToken },
+ );
+ setSessionCookie(reply, token);
+ await db.update(users).set({ lastLoginAt: new Date() }).where(eq(users.id, userId));
+ await audit(db, {
+ actorUserId: userId,
+ actorIp: req.ip,
+ action: 'login',
+ entityType: 'user',
+ entityId: userId,
+ summary: `${user!.email} signed in with ${s.label}`,
+ });
+ req.log.info({ event: 'auth.oidc', userId }, 'OpenID sign-in');
+ return reply.redirect(pending.next);
+ });
+
+ /**
+ * Disconnect an OpenID or directory account (12 §3.3, §4.3): refused when it would leave
+ * them no way in under the current mode. Sessions made with it end.
+ */
+ async function disconnect(
+ req: FastifyRequest,
+ reply: FastifyReply,
+ userId: string,
+ kind: 'oidc' | 'ldap',
+ ) {
+ const [u] = await db.select().from(users).where(eq(users.id, userId));
+ if (!u) throw notFound('User not found');
+ const ids = await db
+ .select({ kind: userIdentities.kind })
+ .from(userIdentities)
+ .where(eq(userIdentities.userId, userId));
+ if (!ids.some((i) => i.kind === kind)) throw notFound('No such account is connected');
+ const left = ids.filter((i) => i.kind !== kind);
+ const mode = await getSetting(db, 'auth.mode');
+ const password = u.passwordHash !== null || left.some((i) => i.kind === 'ldap');
+ const openid = mode !== 'password' && left.some((i) => i.kind === 'oidc');
+ if (!openid && !(password && (mode !== 'oidc' || u.role === 'admin')))
+ throw new HttpError(409, 'last_way_in', 'That would leave no way to sign in');
+ await db
+ .delete(userIdentities)
+ .where(and(eq(userIdentities.userId, userId), eq(userIdentities.kind, kind)));
+ await db.delete(sessions).where(and(eq(sessions.userId, userId), eq(sessions.method, kind)));
+ const what = kind === 'oidc' ? 'OpenID' : 'directory';
+ await audit(db, {
+ actorUserId: req.user!.id,
+ actorIp: req.ip,
+ action: 'sso_disconnected',
+ entityType: 'user',
+ entityId: userId,
+ summary: `Disconnected the ${what} account of ${u.email}`,
+ });
+ return reply.send({ ok: true });
+ }
+
+ const Kind = z.object({ kind: z.enum(['oidc', 'ldap']) });
+ app.delete('/api/me/identities/:kind', { preHandler: requireAuth }, (req, reply) => {
+ const { kind } = Kind.parse(req.params);
+ // The directory account is how a leaver gets disabled (12 §4.5): only an admin drops it.
+ if (kind === 'ldap' && req.user!.role !== 'admin')
+ throw forbidden('Ask an admin to disconnect your directory account', 'admin_only');
+ return disconnect(req, reply, req.user!.id, kind);
+ });
+
+ /**
+ * Connect my directory account (12 §4.3): its username and password, checked by the directory,
+ * while signed in here. Counted like a sign-in by the login limiter.
+ */
+ app.post('/api/me/identities/ldap', { preHandler: requireAuth }, async (req) => {
+ if (!req.sessionId) throw unauthorized(); // an API token can't connect an account
+ const { login, password } = z
+ .object({ login: z.string().trim().min(1).max(254), password: z.string().min(1).max(200) })
+ .parse(req.body);
+ const ldap = await getSetting(db, 'auth.ldap');
+ if (!ldap?.enabled) throw notFound();
+ if (isLoginBlocked(req.ip, login)) throw rateLimited();
+ let entry;
+ try {
+ entry = await ldapAuthenticate(ldap, login, password);
+ } catch {
+ recordLoginFailure(req.ip, login);
+ throw new HttpError(503, 'directory_unavailable', 'The directory can’t be reached');
+ }
+ if (!entry) {
+ recordLoginFailure(req.ip, login);
+ throw badRequest('The directory refused that username or password');
+ }
+ if ((await connectDirectory(db, req.log, req.user!.id, entry, req.ip)) === 'taken')
+ throw new HttpError(409, 'taken', 'That directory account belongs to another user');
+ return { ok: true };
+ });
+ app.delete('/api/users/:id/identities/:kind', { preHandler: requireAdmin }, (req, reply) => {
+ const p = Kind.extend({ id: z.uuid() }).parse(req.params);
+ return disconnect(req, reply, p.id, p.kind);
+ });
+}
diff --git a/apps/api/src/routes/provisioning.ts b/apps/api/src/routes/provisioning.ts
index 17529db..b129b1c 100644
--- a/apps/api/src/routes/provisioning.ts
+++ b/apps/api/src/routes/provisioning.ts
@@ -14,6 +14,7 @@ import { allowProvRequest, recordProvFailure } from '../provisioning/limiter.ts'
import { grandstream, grandstreamPhonebook } from '../provisioning/templates/grandstream.ts';
import { yealink, yealinkCommon, yealinkPhonebook } from '../provisioning/templates/yealink.ts';
import { getSetting } from '../services/settings.ts';
+import { NO_LIMIT } from '../auth/guards.ts';
/** File name → what it is. Anything else is a plain 404 (phones ask for files we don't serve). */
const FILES: {
@@ -73,7 +74,7 @@ export async function provisioningRoutes(app: FastifyInstance) {
const deny = (reply: FastifyReply, code: number, text: string) =>
reply.code(code).header('Cache-Control', 'no-store').type('text/plain').send(text);
- app.get('/prov/*', async (req, reply) => {
+ app.get('/prov/*', { config: NO_LIMIT }, async (req, reply) => {
if (!(await getSetting(db, 'provisioning.enabled'))) return deny(reply, 404, 'Not found');
const local = await isLocal(req.ip);
if ((await getSetting(db, 'provisioning.lanOnly')) && !local)
diff --git a/apps/api/src/routes/settings.ts b/apps/api/src/routes/settings.ts
index 1d94319..5ab0a51 100644
--- a/apps/api/src/routes/settings.ts
+++ b/apps/api/src/routes/settings.ts
@@ -7,10 +7,16 @@ import {
AiEndpointSchema,
AiTtsEndpointSchema,
ContrackInputSchema,
+ LdapInputSchema,
+ OidcInputSchema,
SettingsSchemas,
SmtpInputSchema,
type AiTestResult,
+ type AuthMode,
type Destination,
+ type LdapTestResult,
+ type OidcInput,
+ type OidcTestResult,
type SettingKey,
type SettingsDto,
type Warning,
@@ -18,7 +24,7 @@ import {
import type { Db } from '../db/client.ts';
import { dids, extensions, trunks } from '../db/schema.ts';
import { HttpError, badRequest } from '../errors.ts';
-import { requireAdmin } from '../auth/guards.ts';
+import { SLOW, requireAdmin } from '../auth/guards.ts';
import { encrypt } from '../crypto/secrets.ts';
import { audit } from '../services/audit.ts';
import { assertDestinationExists } from '../services/destinations.ts';
@@ -30,6 +36,9 @@ import { detectPublicIp } from '../jobs.ts';
import type { SendMail } from '../services/mail.ts';
import { AiError, assertLocalUrl, testEndpoint } from '../ai/client.ts';
import { forgetBook } from '../contacts/contrack.ts';
+import { assertLdap, testLdap, type LdapSettings } from '../sso/ldap.ts';
+import { directoryUserFor } from '../sso/directory.ts';
+import { assertIssuer, oidcConfig, oidcError, testOidc, type OidcSettings } from '../sso/oidc.ts';
declare module 'fastify' {
interface FastifyInstance {
@@ -42,14 +51,46 @@ declare module 'fastify' {
const READ_ONLY = new Set([
'system.restartRequired',
'system.transportHash',
+ 'system.directoryWarning',
'sms.webhookToken',
'setup.completed',
]);
async function toDto(db: Db): Promise {
- const { smtp, 'contacts.contrack': ctk, ...rest } = await getAllSettings(db);
+ const {
+ smtp,
+ 'contacts.contrack': ctk,
+ 'auth.oidc': oidc,
+ 'auth.ldap': ldap,
+ ...rest
+ } = await getAllSettings(db);
return {
...rest,
+ 'auth.ldap': ldap && {
+ enabled: ldap.enabled,
+ preset: ldap.preset,
+ url: ldap.url,
+ startTls: ldap.startTls,
+ bindDn: ldap.bindDn,
+ baseDn: ldap.baseDn,
+ userFilter: ldap.userFilter,
+ idAttribute: ldap.idAttribute,
+ emailAttribute: ldap.emailAttribute,
+ nameAttribute: ldap.nameAttribute,
+ adminFilter: ldap.adminFilter,
+ hasBindPassword: ldap.bindPasswordEnc !== null,
+ },
+ 'auth.oidc': oidc && {
+ label: oidc.label,
+ issuer: oidc.issuer,
+ clientId: oidc.clientId,
+ scopes: oidc.scopes,
+ groupsClaim: oidc.groupsClaim,
+ adminGroups: oidc.adminGroups,
+ allowedGroups: oidc.allowedGroups,
+ trustEmail: oidc.trustEmail,
+ hasClientSecret: oidc.clientSecretEnc !== null,
+ },
'contacts.contrack': ctk && {
url: ctk.url,
publicUrl: ctk.publicUrl,
@@ -100,6 +141,74 @@ async function checkRefs(db: Db, v: Record) {
export async function settingsRoutes(app: FastifyInstance) {
const { db } = app;
+
+ /** The stored form of `auth.ldap`: the bind password is write-only (empty keeps it). */
+ async function ldapFromInput(raw: unknown): Promise {
+ if (raw === null) return null;
+ const v = LdapInputSchema.parse(raw);
+ try {
+ assertLdap(v);
+ } catch (err) {
+ throw badRequest((err as Error).message);
+ }
+ const old = await getSetting(db, 'auth.ldap');
+ const url = v.url.replace(/\/$/, '');
+ // The stored password only goes back to the server and account it was typed for: else a
+ // changed address (or Test) would send it to whoever answers there.
+ const same = old?.url === url && old.bindDn === v.bindDn;
+ if (!v.bindPassword && !v.clearBindPassword && !same && v.bindDn && old?.bindPasswordEnc)
+ throw badRequest('The address or service account changed: enter its password again');
+ return {
+ enabled: v.enabled,
+ preset: v.preset,
+ url,
+ startTls: v.startTls,
+ bindDn: v.bindDn,
+ bindPasswordEnc: v.bindPassword
+ ? encrypt(v.bindPassword)
+ : v.clearBindPassword || !same
+ ? null
+ : (old?.bindPasswordEnc ?? null),
+ baseDn: v.baseDn,
+ userFilter: v.userFilter,
+ idAttribute: v.idAttribute,
+ emailAttribute: v.emailAttribute,
+ nameAttribute: v.nameAttribute,
+ adminFilter: v.adminFilter,
+ };
+ }
+
+ /** The stored form of `auth.oidc`: the secret is write-only (empty keeps the stored one). */
+ async function oidcFromInput(input: OidcInput | null): Promise {
+ if (!input) return null;
+ try {
+ assertIssuer(input.issuer);
+ } catch (err) {
+ throw badRequest((err as Error).message);
+ }
+ const old = await getSetting(db, 'auth.oidc');
+ // As for the directory: the stored secret only goes to the provider it was typed for.
+ const same = old?.issuer === input.issuer && old.clientId === input.clientId;
+ if (!input.clientSecret && !input.clearClientSecret && !same && old?.clientSecretEnc)
+ throw badRequest('The issuer or client ID changed: enter the client secret again');
+ return {
+ label: input.label,
+ issuer: input.issuer,
+ clientId: input.clientId,
+ clientSecretEnc: input.clientSecret
+ ? encrypt(input.clientSecret)
+ : input.clearClientSecret
+ ? null
+ : same
+ ? (old.clientSecretEnc ?? null)
+ : null,
+ scopes: input.scopes,
+ groupsClaim: input.groupsClaim,
+ adminGroups: input.adminGroups,
+ allowedGroups: input.allowedGroups,
+ trustEmail: input.trustEmail,
+ };
+ }
app.addHook('preHandler', requireAdmin);
app.get('/api/settings', () => toDto(db));
@@ -120,7 +229,12 @@ export async function settingsRoutes(app: FastifyInstance) {
secure: input.secure,
username: input.username,
from: input.from,
- passwordEnc: input.password ? encrypt(input.password) : (old?.passwordEnc ?? ''),
+ // Kept only for the same server and account: a new host doesn't get the old password.
+ passwordEnc: input.password
+ ? encrypt(input.password)
+ : old?.host === input.host && old.username === input.username
+ ? old.passwordEnc
+ : '',
};
} else if (key === 'contacts.contrack') {
// The company token is write-only too (11 §4.3).
@@ -142,6 +256,10 @@ export async function settingsRoutes(app: FastifyInstance) {
: (old?.companyTokenEnc ?? ''),
};
forgetBook(null);
+ } else if (key === 'auth.ldap') {
+ values[key] = await ldapFromInput(raw);
+ } else if (key === 'auth.oidc') {
+ values[key] = await oidcFromInput(raw === null ? null : OidcInputSchema.parse(raw));
} else values[key] = SettingsSchemas[key as SettingKey].parse(raw);
}
await checkRefs(db, values);
@@ -155,6 +273,21 @@ export async function settingsRoutes(app: FastifyInstance) {
}
}
const before = await getAllSettings(db);
+ // 12 §1.1: mixed and oidc need a working OpenID Connect setup.
+ if ('auth.mode' in values || 'auth.oidc' in values) {
+ const mode = (values['auth.mode'] ?? before['auth.mode']) as AuthMode;
+ const oidc = (
+ 'auth.oidc' in values ? values['auth.oidc'] : before['auth.oidc']
+ ) as OidcSettings | null;
+ if (mode !== 'password') {
+ if (!oidc) throw badRequest('Set up OpenID Connect first');
+ try {
+ await oidcConfig(oidc);
+ } catch (err) {
+ throw badRequest(`OpenID Connect doesn't work: ${oidcError(err)}`);
+ }
+ }
+ }
// SIP ports may not land on another listener (Asterisk would fail to start the transport).
const c = app.config;
const [sip, tls] = [
@@ -195,7 +328,7 @@ export async function settingsRoutes(app: FastifyInstance) {
return toDto(db);
});
- app.post('/api/settings/smtp/test', async (req) => {
+ app.post('/api/settings/smtp/test', { config: SLOW }, async (req) => {
const { to } = z.object({ to: z.email() }).parse(req.body);
try {
await app.sendMail({
@@ -210,7 +343,7 @@ export async function settingsRoutes(app: FastifyInstance) {
});
/** Settings → Emergency → Test notifications (06 §6.5): email + ring-out, never dials 911. */
- app.post('/api/settings/emergency/test', async (req) => {
+ app.post('/api/settings/emergency/test', { config: SLOW }, async (req) => {
const [ext] = await db.select().from(extensions).where(eq(extensions.userId, req.user!.id));
const notified = await notifyEmergency(
{ db, ami: app.ami, live: app.live, send: app.sendMail, log: req.log },
@@ -232,8 +365,46 @@ export async function settingsRoutes(app: FastifyInstance) {
return { notified };
});
+ /**
+ * Settings → Sign-in → Directory → Test, from the unsaved form (12 §4.1). The test password
+ * is used once and never stored or logged.
+ */
+ app.post(
+ '/api/settings/auth/ldap/test',
+ { config: SLOW },
+ async (req): Promise => {
+ const { testLogin, testPassword, ...form } = LdapInputSchema.extend({
+ testLogin: z.string().trim().max(254).optional(),
+ testPassword: z.string().max(200).optional(),
+ }).parse(req.body);
+ const s = (await ldapFromInput(form))!;
+ try {
+ const r = await testLdap(s, testLogin || undefined, testPassword);
+ if (!r.user) return { ok: true, matching: r.matching };
+ const matches = await directoryUserFor(db, r.user);
+ return {
+ ok: true,
+ matching: r.matching,
+ user: { ...r.user, matches: matches?.email ?? null },
+ };
+ } catch (err) {
+ return { ok: false, error: (err as Error).message };
+ }
+ },
+ );
+
+ /** Settings → Sign-in → Test, from the unsaved form (12 §3.1). */
+ app.post(
+ '/api/settings/auth/oidc/test',
+ { config: SLOW },
+ async (req): Promise => {
+ const s = await oidcFromInput(OidcInputSchema.parse(req.body));
+ return testOidc(s!, app.config.PUBLIC_HOSTNAME);
+ },
+ );
+
/** The server's public IPv4, as an outside service sees it (only sent when asked). */
- app.post('/api/settings/network/detect-public-ip', async () => {
+ app.post('/api/settings/network/detect-public-ip', { config: SLOW }, async () => {
const ip = await detectPublicIp();
if (ip) return { ip };
throw new HttpError(502, 'unavailable', "Couldn't detect the public IP. Enter it by hand.");
@@ -255,7 +426,7 @@ export async function settingsRoutes(app: FastifyInstance) {
});
/** Settings → AI → Test (spec 10): the values in the form, saved or not. */
- app.post('/api/ai/test', async (req): Promise => {
+ app.post('/api/ai/test', { config: SLOW }, async (req): Promise => {
const { kind } = z.object({ kind: z.enum(['llm', 'stt', 'tts']) }).parse(req.body);
const ep = (kind === 'tts' ? AiTtsEndpointSchema : AiEndpointSchema).parse(
(req.body as { endpoint?: unknown }).endpoint,
@@ -311,6 +482,12 @@ export async function settingsRoutes(app: FastifyInstance) {
message: "Today's backup hasn't been made. Check the api log, or press Back up now.",
link: '/admin/settings/backups',
});
+ if (s['system.directoryWarning'])
+ items.push({
+ code: 'directory_check',
+ message: s['system.directoryWarning'],
+ link: '/admin/settings/sign-in',
+ });
if (!s.smtp)
items.push({
code: 'smtp_missing',
diff --git a/apps/api/src/routes/system.ts b/apps/api/src/routes/system.ts
index a5fc66b..82c9208 100644
--- a/apps/api/src/routes/system.ts
+++ b/apps/api/src/routes/system.ts
@@ -13,7 +13,7 @@ import type {
SystemStatus,
} from '@selfphoneng/shared';
import type { FastifyInstance } from 'fastify';
-import { requireAdmin } from '../auth/guards.ts';
+import { SLOW, requireAdmin } from '../auth/guards.ts';
import { audit } from '../services/audit.ts';
import { getAllSettings } from '../services/settings.ts';
import { systemEvents } from '../db/schema.ts';
@@ -175,14 +175,18 @@ export async function systemRoutes(app: FastifyInstance) {
app.get('/api/system/diagnostics', async (): Promise => ({
checks: await insideChecks(diag()),
}));
- app.post('/api/system/diagnostics/network', async (req): Promise => {
- const { mode } = z
- .object({ mode: z.enum(['quick', 'complete']).default('quick') })
- .parse(req.body ?? {});
- if (!(await getAllSettings(app.db))['network.probeUrl'])
- throw badRequest('No network test probe set (Settings → Network)');
- return networkTest(diag(), mode);
- });
+ app.post(
+ '/api/system/diagnostics/network',
+ { config: SLOW },
+ async (req): Promise => {
+ const { mode } = z
+ .object({ mode: z.enum(['quick', 'complete']).default('quick') })
+ .parse(req.body ?? {});
+ if (!(await getAllSettings(app.db))['network.probeUrl'])
+ throw badRequest('No network test probe set (Settings → Network)');
+ return networkTest(diag(), mode);
+ },
+ );
app.get('/api/system/generated-files', async () => ({
files: await Promise.all(
diff --git a/apps/api/src/routes/trunks.ts b/apps/api/src/routes/trunks.ts
index 7844f4e..6bb3e2d 100644
--- a/apps/api/src/routes/trunks.ts
+++ b/apps/api/src/routes/trunks.ts
@@ -18,7 +18,7 @@ import {
import type { Db } from '../db/client.ts';
import { dids, outboundRoutes, trunks } from '../db/schema.ts';
import { badRequest, conflict, HttpError, notFound } from '../errors.ts';
-import { requireAdmin } from '../auth/guards.ts';
+import { SLOW, requireAdmin } from '../auth/guards.ts';
import { decrypt, encrypt } from '../crypto/secrets.ts';
import {
getDids,
@@ -202,7 +202,7 @@ export async function trunkRoutes(app: FastifyInstance) {
return { ok: true };
});
- app.post('/api/trunks/:id/test', async (req): Promise => {
+ app.post('/api/trunks/:id/test', { config: SLOW }, async (req): Promise => {
const { id } = IdParams.parse(req.params);
const t = await getTrunk(db, id);
if (!t.enabled) throw badRequest('Turn the trunk on first');
diff --git a/apps/api/src/routes/users.ts b/apps/api/src/routes/users.ts
index 9ea2bf8..371d095 100644
--- a/apps/api/src/routes/users.ts
+++ b/apps/api/src/routes/users.ts
@@ -1,6 +1,6 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
// /api/users (04 §7.3). Admin only.
-import { and, asc, count, eq, ne } from 'drizzle-orm';
+import { and, asc, count, eq, isNotNull, ne } from 'drizzle-orm';
import type { FastifyInstance } from 'fastify';
import { z } from 'zod';
import {
@@ -10,17 +10,30 @@ import {
type UserDto,
} from '@selfphoneng/shared';
import type { Db } from '../db/client.ts';
-import { extensions, users } from '../db/schema.ts';
-import { badRequest, conflict, notFound } from '../errors.ts';
+import { extensions, userIdentities, users } from '../db/schema.ts';
+import { HttpError, badRequest, conflict, notFound } from '../errors.ts';
import { requireAdmin } from '../auth/guards.ts';
import { hashPassword } from '../auth/passwords.ts';
import { deleteUserSessions } from '../auth/sessions.ts';
import { randomAlnum } from '../crypto/secrets.ts';
import { audit } from '../services/audit.ts';
+import { getSetting } from '../services/settings.ts';
+import { signInWays } from './auth.ts';
type UserRow = typeof users.$inferSelect;
+type IdentityRow = {
+ userId: string;
+ kind: 'oidc' | 'ldap';
+ email: string | null;
+ groups: string[];
+};
-export const toUserDto = (u: UserRow, extension: UserDto['extension'] = null): UserDto => ({
+export const toUserDto = (
+ u: UserRow,
+ extension: UserDto['extension'] = null,
+ ids: IdentityRow[] = [],
+ from: RoleSources = { oidc: null, ldap: false },
+): UserDto => ({
id: u.id,
email: u.email,
displayName: u.displayName,
@@ -31,8 +44,39 @@ export const toUserDto = (u: UserRow, extension: UserDto['extension'] = null): U
lastLoginAt: u.lastLoginAt?.toISOString() ?? null,
createdAt: u.createdAt.toISOString(),
extension,
+ signIn: signInWays(u, ids),
+ roleFrom: roleFrom(ids, from),
});
+/** The providers that set roles now (12 §1): adminGroups / adminFilter set and in use. */
+type RoleSources = { oidc: string | null; ldap: boolean };
+async function roleSources(db: Db): Promise {
+ const [oidc, ldap, mode] = await Promise.all([
+ getSetting(db, 'auth.oidc'),
+ getSetting(db, 'auth.ldap'),
+ getSetting(db, 'auth.mode'),
+ ]);
+ return {
+ oidc: oidc && mode !== 'password' && oidc.adminGroups.length > 0 ? oidc.label : null,
+ ldap: Boolean(ldap?.enabled && ldap.adminFilter),
+ };
+}
+const roleFrom = (ids: { kind: string }[], from: RoleSources) =>
+ (from.oidc && ids.some((i) => i.kind === 'oidc') && from.oidc) ||
+ (from.ldap && ids.some((i) => i.kind === 'ldap') ? 'the directory' : null);
+
+/** Connected accounts (12 §2), for one user or all of them. */
+const identitiesOf = (db: Db, userId?: string): Promise =>
+ db
+ .select({
+ userId: userIdentities.userId,
+ kind: userIdentities.kind,
+ email: userIdentities.email,
+ groups: userIdentities.groups,
+ })
+ .from(userIdentities)
+ .where(userId ? eq(userIdentities.userId, userId) : undefined);
+
const extOf = async (db: Db, userId: string) => {
const [e] = await db
.select({ id: extensions.id, number: extensions.number })
@@ -56,13 +100,27 @@ async function getUser(db: Db, id: string): Promise {
return u;
}
-/** Throws if removing `id` from the active admins would leave none. */
-async function assertNotLastAdmin(db: Db, id: string) {
+/**
+ * Removing `id` from the active admins would leave none with a password: the way in when the
+ * identity provider is down (12 §1).
+ */
+export async function isLastAdmin(db: Db, id: string): Promise {
const [r] = await db
.select({ n: count() })
.from(users)
- .where(and(eq(users.role, 'admin'), eq(users.isActive, true), ne(users.id, id)));
- if (!r || r.n === 0) throw conflict('This is the last active admin');
+ .where(
+ and(
+ eq(users.role, 'admin'),
+ eq(users.isActive, true),
+ isNotNull(users.passwordHash),
+ ne(users.id, id),
+ ),
+ );
+ return !r || r.n === 0;
+}
+
+async function assertNotLastAdmin(db: Db, id: string) {
+ if (await isLastAdmin(db, id)) throw conflict('This is the last active admin with a password');
}
export async function userRoutes(app: FastifyInstance) {
@@ -76,9 +134,16 @@ export async function userRoutes(app: FastifyInstance) {
.from(users)
.leftJoin(extensions, eq(extensions.userId, users.id))
.orderBy(asc(users.displayName), asc(users.id));
+ const ids = await identitiesOf(db);
+ const from = await roleSources(db);
return {
items: rows.map((r) =>
- toUserDto(r.u, r.extId && r.extNumber ? { id: r.extId, number: r.extNumber } : null),
+ toUserDto(
+ r.u,
+ r.extId && r.extNumber ? { id: r.extId, number: r.extNumber } : null,
+ ids.filter((i) => i.userId === r.u.id),
+ from,
+ ),
),
nextCursor: null,
};
@@ -86,17 +151,27 @@ export async function userRoutes(app: FastifyInstance) {
app.get('/api/users/:id', async (req) => {
const { id } = IdParams.parse(req.params);
- return toUserDto(await getUser(db, id), await extOf(db, id));
+ return toUserDto(
+ await getUser(db, id),
+ await extOf(db, id),
+ await identitiesOf(db, id),
+ await roleSources(db),
+ );
});
app.post('/api/users', async (req, reply) => {
const body = UserCreateSchema.parse(req.body);
- const password = temporaryPassword();
+ // OpenID only (12 §3.3): no password; their first sign-in matches them by verified email.
+ const password = (await getSetting(db, 'auth.mode')) === 'oidc' ? null : temporaryPassword();
let created: UserRow;
try {
[created] = (await db
.insert(users)
- .values({ ...body, passwordHash: await hashPassword(password), mustChangePassword: true })
+ .values({
+ ...body,
+ passwordHash: password && (await hashPassword(password)),
+ mustChangePassword: password !== null,
+ })
.returning()) as [UserRow];
} catch (e) {
if (isUniqueViolation(e)) throw conflict('A user with this email already exists');
@@ -120,6 +195,12 @@ export async function userRoutes(app: FastifyInstance) {
const body = UserUpdateSchema.parse(req.body);
if (Object.keys(body).length === 0) throw badRequest('Nothing to update');
const before = await getUser(db, id);
+ const ids = await identitiesOf(db, id);
+ const from = await roleSources(db);
+ // 12 §1: a provider that sets the role would undo this at the next sign-in or check.
+ const managed = roleFrom(ids, from);
+ if (body.role !== undefined && body.role !== before.role && managed)
+ throw new HttpError(409, 'role_managed', `The role comes from ${managed}`);
const losesAdmin =
before.role === 'admin' &&
before.isActive &&
@@ -150,7 +231,7 @@ export async function userRoutes(app: FastifyInstance) {
after: Object.fromEntries(changed.map((k) => [k, after[k]])),
},
});
- return toUserDto(after, await extOf(db, id));
+ return toUserDto(after, await extOf(db, id), ids, from);
});
app.delete('/api/users/:id', async (req) => {
diff --git a/apps/api/src/sso/claims.test.ts b/apps/api/src/sso/claims.test.ts
new file mode 100644
index 0000000..37487a7
--- /dev/null
+++ b/apps/api/src/sso/claims.test.ts
@@ -0,0 +1,42 @@
+// SPDX-License-Identifier: AGPL-3.0-or-later
+import { describe, expect, it } from 'vitest';
+import { claimValues, decide, verifiedEmail } from './claims.ts';
+
+describe('OpenID claims (12 §3.4)', () => {
+ const keycloak = {
+ groups: ['/pbx-admins', 'pbx-users'],
+ realm_access: { roles: ['offline_access', 'pbx-admin'] },
+ resource_access: { selfphoneng: { roles: ['operator'] } },
+ 'urn:zitadel:iam:org:project:roles': { 'pbx-admin': { '123': 'example.com' } },
+ hd: 'example.com',
+ };
+
+ it('reads dotted paths, whole keys first', () => {
+ expect(claimValues(keycloak, 'groups')).toEqual(['/pbx-admins', 'pbx-users']);
+ expect(claimValues(keycloak, 'realm_access.roles')).toContain('pbx-admin');
+ expect(claimValues(keycloak, 'resource_access.selfphoneng.roles')).toEqual(['operator']);
+ expect(claimValues(keycloak, 'urn:zitadel:iam:org:project:roles')).toEqual(['pbx-admin']);
+ expect(claimValues(keycloak, 'hd')).toEqual(['example.com']);
+ expect(claimValues(keycloak, 'missing.path')).toEqual([]);
+ expect(claimValues({ 'a.b': ['x'], a: { b: ['y'] } }, 'a.b')).toEqual(['x']);
+ });
+
+ it('decides allowed and role, comparing names exactly', () => {
+ const rules = { groupsClaim: 'groups', adminGroups: [], allowedGroups: [] };
+ expect(decide(rules, keycloak)).toMatchObject({ allowed: true, admin: null });
+ expect(decide({ ...rules, adminGroups: ['pbx-admins'] }, keycloak).admin).toBe(false);
+ expect(decide({ ...rules, adminGroups: ['/pbx-admins'] }, keycloak).admin).toBe(true);
+ expect(decide({ ...rules, allowedGroups: ['sales'] }, keycloak).allowed).toBe(false);
+ expect(decide({ ...rules, allowedGroups: ['sales', 'pbx-users'] }, keycloak).allowed).toBe(
+ true,
+ );
+ });
+
+ it('takes an email only when verified, or trusted', () => {
+ expect(verifiedEmail({ email: 'a@x.com', email_verified: true }, false)).toBe('a@x.com');
+ expect(verifiedEmail({ email: 'a@x.com', email_verified: 'true' }, false)).toBeNull();
+ expect(verifiedEmail({ email: 'a@x.com' }, false)).toBeNull();
+ expect(verifiedEmail({ email: 'a@x.com' }, true)).toBe('a@x.com');
+ expect(verifiedEmail({}, true)).toBeNull();
+ });
+});
diff --git a/apps/api/src/sso/claims.ts b/apps/api/src/sso/claims.ts
new file mode 100644
index 0000000..0266111
--- /dev/null
+++ b/apps/api/src/sso/claims.ts
@@ -0,0 +1,52 @@
+// SPDX-License-Identifier: AGPL-3.0-or-later
+// OpenID claims → groups, allowed, role (12 §3.4). Pure: no I/O.
+
+type Claims = Record;
+
+/**
+ * The value at a dotted path. The whole remaining path is tried as one key first, so claim
+ * names with dots or colons (Zitadel's "urn:zitadel:iam:org:project:roles") work, and
+ * "resource_access.selfphoneng.roles" walks down.
+ */
+export function claimAt(claims: unknown, path: string): unknown {
+ if (!claims || typeof claims !== 'object') return undefined;
+ const o = claims as Claims;
+ if (path in o) return o[path];
+ const dot = path.indexOf('.');
+ return dot < 0 ? undefined : claimAt(o[path.slice(0, dot)], path.slice(dot + 1));
+}
+
+/** A string → [it]; an array → its strings; an object → its keys (Zitadel's roles). */
+export function claimValues(claims: unknown, path: string): string[] {
+ const v = claimAt(claims, path);
+ if (typeof v === 'string') return [v];
+ if (Array.isArray(v)) return v.filter((x): x is string => typeof x === 'string');
+ if (v && typeof v === 'object') return Object.keys(v);
+ return [];
+}
+
+export type OidcRules = { groupsClaim: string; adminGroups: string[]; allowedGroups: string[] };
+
+export type ClaimsDecision = {
+ groups: string[];
+ allowed: boolean;
+ /** null: the role is managed in SelfPhoneNG (no adminGroups). */
+ admin: boolean | null;
+};
+
+export function decide(rules: OidcRules, claims: Claims): ClaimsDecision {
+ const groups = claimValues(claims, rules.groupsClaim);
+ const any = (list: string[]) => list.some((g) => groups.includes(g));
+ return {
+ groups,
+ allowed: rules.allowedGroups.length === 0 || any(rules.allowedGroups),
+ admin: rules.adminGroups.length === 0 ? null : any(rules.adminGroups),
+ };
+}
+
+/** The email, when the provider says it's verified (or the admin trusts its emails, 12 §3.3). */
+export function verifiedEmail(claims: Claims, trustEmail: boolean): string | null {
+ const email = typeof claims.email === 'string' ? claims.email.trim() : '';
+ if (!email) return null;
+ return claims.email_verified === true || trustEmail ? email : null;
+}
diff --git a/apps/api/src/sso/directory.ts b/apps/api/src/sso/directory.ts
new file mode 100644
index 0000000..efbcbe5
--- /dev/null
+++ b/apps/api/src/sso/directory.ts
@@ -0,0 +1,196 @@
+// SPDX-License-Identifier: AGPL-3.0-or-later
+// A directory entry → its SelfPhoneNG user (12 §4.3): connected before, or by the directory's
+// email. Never a new user (owner, 2026-10-25).
+import { and, eq } from 'drizzle-orm';
+import { deleteUserSessions } from '../auth/sessions.ts';
+import type { Db } from '../db/client.ts';
+import { userIdentities, users } from '../db/schema.ts';
+import { isLastAdmin } from '../routes/users.ts';
+import { audit } from '../services/audit.ts';
+import { getSetting, setSetting } from '../services/settings.ts';
+import { lookupIds, type DirectoryEntry, type LdapSettings } from './ldap.ts';
+import { applyRole } from './role.ts';
+
+type UserRow = typeof users.$inferSelect;
+
+/** Who this entry is here, without changing anything (Settings → Test says it too). */
+export async function directoryUserFor(db: Db, e: DirectoryEntry): Promise {
+ const [byId] = await db
+ .select({ u: users })
+ .from(userIdentities)
+ .innerJoin(users, eq(users.id, userIdentities.userId))
+ .where(and(eq(userIdentities.kind, 'ldap'), eq(userIdentities.subject, e.id)));
+ if (byId) return byId.u;
+ if (!e.email) return null;
+ const [byEmail] = await db.select().from(users).where(eq(users.email, e.email));
+ return byEmail && byEmailOk(byEmail) ? byEmail : null;
+}
+
+/**
+ * Who a directory email may connect to on its own (12 §4.3 step 2): not an admin, and nobody
+ * who chose their own password (none, or only the temporary one from Users). Anyone else
+ * connects on My settings with both passwords: a directory that lets people edit their own
+ * `mail` must not hand out the admin's account.
+ */
+const byEmailOk = (u: UserRow) =>
+ u.role !== 'admin' && (u.passwordHash === null || u.mustChangePassword);
+
+/** Connect a directory account the person proved (My settings, 12 §4.3). */
+export async function connectDirectory(
+ db: Db,
+ log: { info(o: object, m: string): void },
+ userId: string,
+ e: DirectoryEntry,
+ ip: string,
+): Promise<'connected' | 'taken'> {
+ const [owner] = await db
+ .select({ userId: userIdentities.userId })
+ .from(userIdentities)
+ .where(and(eq(userIdentities.kind, 'ldap'), eq(userIdentities.subject, e.id)));
+ if (owner && owner.userId !== userId) return 'taken';
+ await db.transaction(async (tx) => {
+ await tx
+ .delete(userIdentities)
+ .where(and(eq(userIdentities.userId, userId), eq(userIdentities.kind, 'ldap')));
+ await tx
+ .insert(userIdentities)
+ .values({ userId, kind: 'ldap', subject: e.id, email: e.email, lastUsedAt: new Date() });
+ });
+ await audit(db, {
+ actorUserId: userId,
+ actorIp: ip,
+ action: 'ldap_connected',
+ entityType: 'user',
+ entityId: userId,
+ summary: `Connected directory account ${e.dn}`,
+ });
+ await applyRole(db, log, userId, e.admin, 'the directory', ip);
+ return 'connected';
+}
+
+/**
+ * The user this directory sign-in is for, connected now if it was matched by email (the
+ * directory is the company's own and the person just proved its password), with the role from
+ * adminFilter. Null: nobody here has this directory account or email.
+ */
+export async function directorySignIn(
+ db: Db,
+ log: { info(o: object, m: string): void },
+ e: DirectoryEntry,
+ ip: string,
+): Promise {
+ const u = await directoryUserFor(db, e);
+ if (!u) return null;
+ const [mine] = await db
+ .select({ subject: userIdentities.subject })
+ .from(userIdentities)
+ .where(and(eq(userIdentities.userId, u.id), eq(userIdentities.kind, 'ldap')));
+ if (!mine) {
+ await db.insert(userIdentities).values({
+ userId: u.id,
+ kind: 'ldap',
+ subject: e.id,
+ email: e.email,
+ lastUsedAt: new Date(),
+ });
+ await audit(db, {
+ actorUserId: u.id,
+ actorIp: ip,
+ action: 'ldap_connected',
+ entityType: 'user',
+ entityId: u.id,
+ summary: `${u.email} connected directory account ${e.dn}`,
+ });
+ } else if (mine.subject !== e.id) {
+ // Their email matches, but another directory account is already theirs: not this one.
+ log.info({ userId: u.id }, `directory entry ${e.dn} matches by email but isn't theirs`);
+ return null;
+ } else
+ await db
+ .update(userIdentities)
+ .set({ email: e.email, lastUsedAt: new Date() })
+ .where(and(eq(userIdentities.userId, u.id), eq(userIdentities.kind, 'ldap')));
+ await applyRole(db, log, u.id, e.admin, 'the directory', ip);
+ const [after] = await db.select().from(users).where(eq(users.id, u.id));
+ return after!;
+}
+
+/** The dashboard's directory warning (12 §4.2, §4.5); null clears it. */
+export const directoryWarning = (db: Db, message: string | null) =>
+ setSetting(db, 'system.directoryWarning', message);
+
+type Log = { info(o: object, m: string): void; warn?(o: object, m: string): void };
+
+/**
+ * One run of the directoryCheck job (hourly, 12 §4.5): every active user with a directory
+ * account still in it gets its email and role; one who isn't is disabled. Never re-enables.
+ * `lookup` is for tests.
+ */
+export async function checkDirectory(d: {
+ db: Db;
+ log: Log;
+ lookup?: (s: LdapSettings, ids: string[]) => Promise
{user.twoStep && }
+ {user.signIn.oidc && }
+ {user.signIn.ldap && }
)}
@@ -584,6 +597,43 @@ function EmergencyTab({ ext, onSaved }: { ext: ExtensionDto; onSaved: () => void
);
}
+/** A connected OpenID or directory account (12 §5): what it sent, and Disconnect. */
+function ConnectedAccount({ user, kind }: { user: UserDto; kind: 'oidc' | 'ldap' }) {
+ const qc = useQueryClient();
+ const oidcLabel = useAuthMethods().data?.oidc?.label ?? 'OpenID';
+ const label = kind === 'oidc' ? oidcLabel : 'Directory';
+ const disconnect = useMutation({
+ mutationFn: () => usersApi.disconnect(user.id, kind),
+ onSuccess: () => qc.invalidateQueries({ queryKey: ['users'] }),
+ });
+ const oidc = user.signIn.oidc;
+ return (
+
+ {kind === 'oidc' && oidc ? (
+ <>
+
+ {label}: {oidc.email ?? 'connected'}
+
+
+ Groups at the last sign-in: {oidc.groups.length ? oidc.groups.join(', ') : 'none'}
+
+ >
+ ) : (
+
+ {label}: connected, their directory password works
+ here
+
+ )}
+
+
+
+ {disconnect.error &&
{disconnect.error.message}}
+
+ );
+}
+
/** For a user who lost their phone and recovery codes (08 §2.4). */
function ResetTwoStep({ user }: { user: UserDto }) {
const qc = useQueryClient();
diff --git a/apps/web/src/pages/admin/UsersPage.tsx b/apps/web/src/pages/admin/UsersPage.tsx
index 6842c66..bb2544f 100644
--- a/apps/web/src/pages/admin/UsersPage.tsx
+++ b/apps/web/src/pages/admin/UsersPage.tsx
@@ -9,6 +9,7 @@ import { Alert, Button } from '../../components/ui.tsx';
import { UserDrawer } from './UserDrawer.tsx';
import { StatusDot } from '../../components/StatusDot.tsx';
import { useLive } from '../../live/store.ts';
+import { useAuthMethods } from '../../components/SignInCard.tsx';
const fmtDate = (iso: string | null) => (iso ? new Date(iso).toLocaleString() : 'Never');
@@ -129,6 +130,7 @@ function UsersTable({
onDelete: (u: UserDto) => void;
}) {
const live = useLive((s) => s.extensions);
+ const oidcLabel = useAuthMethods().data?.oidc?.label ?? 'OpenID';
if (users.length === 0) return No users yet.
;
const ext = (u: UserDto) =>
u.extension ? (
@@ -151,6 +153,11 @@ function UsersTable({
)}
);
+ // 12 §5: every way this user can sign in.
+ const ways = (u: UserDto) =>
+ [u.signIn.password && 'Password', u.signIn.oidc && oidcLabel, u.signIn.ldap && 'Directory']
+ .filter(Boolean)
+ .join(', ') || '–';
const actions = (u: UserDto) => (