mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-10-18 21:16:47 +00:00
d203d8cccd
The send() dispatcher (services/notificationService.ts) and the in-app store (services/inAppNotifications.ts) fold together into the DI-native NotificationsService over DatabaseService + RealtimeService; the prefs matrix, transports, channel registry and inAppNotificationActions stay plain infra modules. A 1-export notifications.bridge covers scheduler, legacy adminService/memories and the six lazy fire-and-forget sends; AdminController and the plugin RPC host (24th factory dep) inject. The 5-tool registrar + notifications-in-app resource move to notifications.mcp.ts. Tests move with case IDs preserved (NSVC-*, INOTIF-*, + NSVC-020 bridge pin); ~18 suites repoint path mocks/warm-ups; the module e2e goes DI-native. Also carries the verified-quirk fixes that would normally trail as a separate fix(server) commit (file-overlap with the fold): toUtcIso on all three created_at paths (INOTIF-013/014), respond claims the response CAS before running the action handler with release-on-failure (INOTIF-011/012), synology's fire-and-forget send gains .catch, the dispatch-failure log unwraps Error messages (NSVC-021), and the MCP resource description drops its false 'unread first' claim.
969 lines
37 KiB
TypeScript
969 lines
37 KiB
TypeScript
import { ADDON_IDS } from '../addons';
|
|
import { readEnv } from '../app-config';
|
|
import { updateJwtSecret } from '../config';
|
|
import { db } from '../db/database';
|
|
// Import from sessionManager directly, NOT the ../mcp barrel: the barrel pulls
|
|
// the whole tools fan-out (and via the domain bridges, the Nest services) into
|
|
// every consumer of this module — a services→mcp→nest module cycle.
|
|
import { revokeUserSessions, revokeUserSessionsForClient } from '../mcp/sessionManager';
|
|
import { emitUserDeleted } from '../plugin-user-lifecycle';
|
|
import { User, Addon } from '../types';
|
|
import { maybe_encrypt_api_key, decrypt_api_key } from './apiKeyCrypto';
|
|
import { resolveAuthToggles } from '../nest/auth/auth.bridge';
|
|
import { avatarUrl } from './avatarUrl';
|
|
import { prepareLlmAddonConfigForWrite, maskLlmAddonConfig } from './llmConfig';
|
|
import { getPhotoProviderConfig } from './memories/helpersService';
|
|
import { send as sendNotification } from '../nest/notifications/notifications.bridge';
|
|
import { validatePassword } from './passwordPolicy';
|
|
import { getAllPermissions, savePermissions as savePerms, PERMISSION_ACTIONS } from '../nest/permissions/permissions.bridge';
|
|
import { deleteUserCompletely } from './userCleanupService';
|
|
|
|
import bcrypt from 'bcryptjs';
|
|
import crypto from 'crypto';
|
|
import fs from 'fs';
|
|
import path from 'path';
|
|
|
|
// ── Helpers ────────────────────────────────────────────────────────────────
|
|
|
|
// bcrypt cost factor for user passwords — kept in sync with authService.
|
|
const BCRYPT_COST = 12;
|
|
|
|
function utcSuffix(ts: string | null | undefined): string | null {
|
|
if (!ts) return null;
|
|
return ts.endsWith('Z') ? ts : ts.replace(' ', 'T') + 'Z';
|
|
}
|
|
|
|
export function compareVersions(a: string, b: string): number {
|
|
const parse = (v: string) => {
|
|
const [base, pre] = v.split('-pre.');
|
|
const parts = base.split('.').map(Number);
|
|
const n = pre !== undefined ? parseInt(pre, 10) : null;
|
|
const preN = n !== null && Number.isFinite(n) ? n : null;
|
|
return { parts, preN };
|
|
};
|
|
const pa = parse(a),
|
|
pb = parse(b);
|
|
for (let i = 0; i < Math.max(pa.parts.length, pb.parts.length); i++) {
|
|
const na = pa.parts[i] || 0,
|
|
nb = pb.parts[i] || 0;
|
|
if (na > nb) return 1;
|
|
if (na < nb) return -1;
|
|
}
|
|
// Equal base: stable > prerelease; higher preN wins among prereleases
|
|
if (pa.preN === null && pb.preN !== null) return 1;
|
|
if (pa.preN !== null && pb.preN === null) return -1;
|
|
if (pa.preN !== null && pb.preN !== null) {
|
|
if (pa.preN > pb.preN) return 1;
|
|
if (pa.preN < pb.preN) return -1;
|
|
}
|
|
return 0;
|
|
}
|
|
|
|
export const isDocker = (() => {
|
|
try {
|
|
return (
|
|
fs.existsSync('/.dockerenv') ||
|
|
(fs.existsSync('/proc/1/cgroup') && fs.readFileSync('/proc/1/cgroup', 'utf8').includes('docker'))
|
|
);
|
|
} catch {
|
|
return false;
|
|
}
|
|
})();
|
|
|
|
// ── User CRUD ──────────────────────────────────────────────────────────────
|
|
|
|
export function listUsers() {
|
|
// Guests (#1362) are accountless trip participants, not real users — keep them out
|
|
// of admin user management entirely.
|
|
const users = db
|
|
.prepare(
|
|
'SELECT id, username, email, role, avatar, created_at, updated_at, last_login FROM users WHERE COALESCE(is_guest, 0) = 0 ORDER BY created_at DESC',
|
|
)
|
|
.all() as (Pick<User, 'id' | 'username' | 'email' | 'role' | 'created_at' | 'updated_at' | 'last_login'> & {
|
|
avatar?: string | null;
|
|
})[];
|
|
let onlineUserIds = new Set<number>();
|
|
try {
|
|
const { getOnlineUserIds } = require('../websocket');
|
|
onlineUserIds = getOnlineUserIds();
|
|
} catch {
|
|
/* */
|
|
}
|
|
return users.map((u) => ({
|
|
...u,
|
|
avatar_url: avatarUrl(u),
|
|
created_at: utcSuffix(u.created_at),
|
|
updated_at: utcSuffix(u.updated_at as string),
|
|
last_login: utcSuffix(u.last_login),
|
|
online: onlineUserIds.has(u.id),
|
|
}));
|
|
}
|
|
|
|
export function createUser(data: { username: string; email: string; password: string; role?: string }) {
|
|
const username = data.username?.trim();
|
|
const email = data.email?.trim();
|
|
const password = data.password?.trim();
|
|
|
|
if (!username || !email || !password) {
|
|
return { error: 'Username, email and password are required', status: 400 };
|
|
}
|
|
|
|
const pwCheck = validatePassword(password);
|
|
if (!pwCheck.ok) return { error: pwCheck.reason, status: 400 };
|
|
|
|
if (data.role && !['user', 'admin'].includes(data.role)) {
|
|
return { error: 'Invalid role', status: 400 };
|
|
}
|
|
|
|
// Guests (#1362) live in a reserved synthetic namespace; never let one block a real account.
|
|
const existingUsername = db
|
|
.prepare('SELECT id FROM users WHERE username = ? AND COALESCE(is_guest, 0) = 0')
|
|
.get(username);
|
|
if (existingUsername) return { error: 'Username already taken', status: 409 };
|
|
|
|
const existingEmail = db.prepare('SELECT id FROM users WHERE email = ? AND COALESCE(is_guest, 0) = 0').get(email);
|
|
if (existingEmail) return { error: 'Email already taken', status: 409 };
|
|
|
|
const passwordHash = bcrypt.hashSync(password, BCRYPT_COST);
|
|
|
|
const result = db
|
|
.prepare('INSERT INTO users (username, email, password_hash, role) VALUES (?, ?, ?, ?)')
|
|
.run(username, email, passwordHash, data.role || 'user');
|
|
|
|
const user = db
|
|
.prepare('SELECT id, username, email, role, created_at, updated_at FROM users WHERE id = ?')
|
|
.get(result.lastInsertRowid);
|
|
|
|
return {
|
|
user,
|
|
insertedId: Number(result.lastInsertRowid),
|
|
auditDetails: { username, email, role: data.role || 'user' },
|
|
};
|
|
}
|
|
|
|
export function updateUser(id: string, data: { username?: string; email?: string; role?: string; password?: string }) {
|
|
const username = typeof data.username === 'string' ? data.username.trim() : data.username;
|
|
const email = typeof data.email === 'string' ? data.email.trim() : data.email;
|
|
const { role, password } = data;
|
|
const user = db.prepare('SELECT * FROM users WHERE id = ?').get(id) as User | undefined;
|
|
|
|
if (!user) return { error: 'User not found', status: 404 };
|
|
|
|
if (role && !['user', 'admin'].includes(role)) {
|
|
return { error: 'Invalid role', status: 400 };
|
|
}
|
|
|
|
if (username && username !== user.username) {
|
|
const conflict = db
|
|
.prepare('SELECT id FROM users WHERE username = ? AND id != ? AND COALESCE(is_guest, 0) = 0')
|
|
.get(username, id);
|
|
if (conflict) return { error: 'Username already taken', status: 409 };
|
|
}
|
|
if (email && email !== user.email) {
|
|
const conflict = db
|
|
.prepare('SELECT id FROM users WHERE email = ? AND id != ? AND COALESCE(is_guest, 0) = 0')
|
|
.get(email, id);
|
|
if (conflict) return { error: 'Email already taken', status: 409 };
|
|
}
|
|
|
|
if (password) {
|
|
const pwCheck = validatePassword(password);
|
|
if (!pwCheck.ok) return { error: pwCheck.reason, status: 400 };
|
|
}
|
|
const passwordHash = password ? bcrypt.hashSync(password, BCRYPT_COST) : null;
|
|
|
|
// Don't let the admin UI demote the last remaining admin — that would leave the
|
|
// instance with no one able to manage it (and on OIDC-only setups, no recovery). #1274
|
|
if (role && role !== 'admin') {
|
|
const current = db.prepare('SELECT role FROM users WHERE id = ?').get(id) as { role?: string } | undefined;
|
|
if (current?.role === 'admin') {
|
|
const adminCount = (
|
|
db.prepare("SELECT COUNT(*) as count FROM users WHERE role = 'admin'").get() as { count: number }
|
|
).count;
|
|
if (adminCount <= 1) return { error: 'Cannot remove the last admin', status: 400 };
|
|
}
|
|
}
|
|
|
|
db.prepare(
|
|
`
|
|
UPDATE users SET
|
|
username = COALESCE(?, username),
|
|
email = COALESCE(?, email),
|
|
role = COALESCE(?, role),
|
|
password_hash = COALESCE(?, password_hash),
|
|
updated_at = CURRENT_TIMESTAMP
|
|
WHERE id = ?
|
|
`,
|
|
).run(username || null, email || null, role || null, passwordHash, id);
|
|
|
|
const updated = db
|
|
.prepare('SELECT id, username, email, role, created_at, updated_at FROM users WHERE id = ?')
|
|
.get(id);
|
|
|
|
const changed: string[] = [];
|
|
if (username) changed.push('username');
|
|
if (email) changed.push('email');
|
|
if (role) changed.push('role');
|
|
if (password) changed.push('password');
|
|
|
|
return {
|
|
user: updated,
|
|
previousEmail: user.email,
|
|
changed,
|
|
};
|
|
}
|
|
|
|
export function deleteUser(id: string, currentUserId: number) {
|
|
if (parseInt(id) === currentUserId) {
|
|
return { error: 'Cannot delete own account', status: 400 };
|
|
}
|
|
|
|
const userToDel = db.prepare('SELECT id, email FROM users WHERE id = ?').get(id) as
|
|
| { id: number; email: string }
|
|
| undefined;
|
|
if (!userToDel) return { error: 'User not found', status: 404 };
|
|
|
|
deleteUserCompletely(userToDel.id);
|
|
emitUserDeleted(userToDel.id); // let plugins erase their own per-user data
|
|
return { email: userToDel.email };
|
|
}
|
|
|
|
// ── Stats ──────────────────────────────────────────────────────────────────
|
|
|
|
export function getStats() {
|
|
const totalUsers = (
|
|
db.prepare('SELECT COUNT(*) as count FROM users WHERE COALESCE(is_guest, 0) = 0').get() as { count: number }
|
|
).count;
|
|
const totalTrips = (db.prepare('SELECT COUNT(*) as count FROM trips').get() as { count: number }).count;
|
|
const totalPlaces = (db.prepare('SELECT COUNT(*) as count FROM places').get() as { count: number }).count;
|
|
const totalFiles = (db.prepare('SELECT COUNT(*) as count FROM trip_files').get() as { count: number }).count;
|
|
return { totalUsers, totalTrips, totalPlaces, totalFiles };
|
|
}
|
|
|
|
// ── Permissions ────────────────────────────────────────────────────────────
|
|
|
|
export function getPermissions() {
|
|
const current = getAllPermissions();
|
|
const actions = PERMISSION_ACTIONS.map((a) => ({
|
|
key: a.key,
|
|
level: current[a.key],
|
|
defaultLevel: a.defaultLevel,
|
|
allowedLevels: a.allowedLevels,
|
|
}));
|
|
return { permissions: actions };
|
|
}
|
|
|
|
export function savePermissions(permissions: Record<string, string>) {
|
|
const { skipped } = savePerms(permissions);
|
|
return { permissions: getAllPermissions(), skipped };
|
|
}
|
|
|
|
// ── Audit Log ──────────────────────────────────────────────────────────────
|
|
|
|
export function getAuditLog(query: { limit?: string; offset?: string }) {
|
|
const limitRaw = parseInt(String(query.limit || '100'), 10);
|
|
const offsetRaw = parseInt(String(query.offset || '0'), 10);
|
|
const limit = Math.min(Math.max(Number.isFinite(limitRaw) ? limitRaw : 100, 1), 500);
|
|
const offset = Math.max(Number.isFinite(offsetRaw) ? offsetRaw : 0, 0);
|
|
|
|
type Row = {
|
|
id: number;
|
|
created_at: string;
|
|
user_id: number | null;
|
|
username: string | null;
|
|
user_email: string | null;
|
|
action: string;
|
|
resource: string | null;
|
|
details: string | null;
|
|
ip: string | null;
|
|
};
|
|
|
|
const rows = db
|
|
.prepare(
|
|
`
|
|
SELECT a.id, a.created_at, a.user_id, u.username, u.email as user_email, a.action, a.resource, a.details, a.ip
|
|
FROM audit_log a
|
|
LEFT JOIN users u ON u.id = a.user_id
|
|
ORDER BY a.id DESC
|
|
LIMIT ? OFFSET ?
|
|
`,
|
|
)
|
|
.all(limit, offset) as Row[];
|
|
|
|
const total = (db.prepare('SELECT COUNT(*) as c FROM audit_log').get() as { c: number }).c;
|
|
|
|
const entries = rows.map((r) => {
|
|
let details: Record<string, unknown> | null = null;
|
|
if (r.details) {
|
|
try {
|
|
details = JSON.parse(r.details) as Record<string, unknown>;
|
|
} catch {
|
|
details = { _parse_error: true };
|
|
}
|
|
}
|
|
const created_at =
|
|
r.created_at && !r.created_at.endsWith('Z') ? r.created_at.replace(' ', 'T') + 'Z' : r.created_at;
|
|
return { ...r, created_at, details };
|
|
});
|
|
|
|
return { entries, total, limit, offset };
|
|
}
|
|
|
|
// ── OIDC Settings ──────────────────────────────────────────────────────────
|
|
|
|
export function getOidcSettings() {
|
|
const get = (key: string) =>
|
|
(db.prepare('SELECT value FROM app_settings WHERE key = ?').get(key) as { value: string } | undefined)?.value || '';
|
|
const secret = decrypt_api_key(get('oidc_client_secret'));
|
|
return {
|
|
issuer: get('oidc_issuer'),
|
|
client_id: get('oidc_client_id'),
|
|
client_secret_set: !!secret,
|
|
display_name: get('oidc_display_name'),
|
|
oidc_only: get('oidc_only') === 'true',
|
|
discovery_url: get('oidc_discovery_url'),
|
|
};
|
|
}
|
|
|
|
export function updateOidcSettings(data: {
|
|
issuer?: string;
|
|
client_id?: string;
|
|
client_secret?: string;
|
|
display_name?: string;
|
|
discovery_url?: string;
|
|
}): { error?: string; status?: number; success?: boolean } {
|
|
// Lockout prevention: can't remove OIDC config when password login is disabled
|
|
if ((data.issuer === '' || data.client_id === '') && !resolveAuthToggles().password_login) {
|
|
return {
|
|
error: 'Cannot remove SSO configuration while password login is disabled. Enable password login first.',
|
|
status: 400,
|
|
};
|
|
}
|
|
|
|
const set = (key: string, val: string) =>
|
|
db.prepare('INSERT OR REPLACE INTO app_settings (key, value) VALUES (?, ?)').run(key, val || '');
|
|
set('oidc_issuer', data.issuer ?? '');
|
|
set('oidc_client_id', data.client_id ?? '');
|
|
if (data.client_secret !== undefined) set('oidc_client_secret', maybe_encrypt_api_key(data.client_secret) ?? '');
|
|
set('oidc_display_name', data.display_name ?? '');
|
|
set('oidc_discovery_url', data.discovery_url ?? '');
|
|
return { success: true };
|
|
}
|
|
|
|
// ── Demo Baseline ──────────────────────────────────────────────────────────
|
|
|
|
export function saveDemoBaseline(): { error?: string; status?: number; message?: string } {
|
|
if (!readEnv().demo.enabled) {
|
|
return { error: 'Not found', status: 404 };
|
|
}
|
|
try {
|
|
const { saveBaseline } = require('../demo/demo-reset');
|
|
saveBaseline();
|
|
return { message: 'Demo baseline saved. Hourly resets will restore to this state.' };
|
|
} catch (err: unknown) {
|
|
console.error(err);
|
|
return { error: 'Failed to save baseline', status: 500 };
|
|
}
|
|
}
|
|
|
|
// ── GitHub Integration ─────────────────────────────────────────────────────
|
|
|
|
export async function getGithubReleases(perPage: string = '10', page: string = '1') {
|
|
try {
|
|
const resp = await fetch(`https://api.github.com/repos/liketrek/TREK/releases?per_page=${perPage}&page=${page}`, {
|
|
headers: { Accept: 'application/vnd.github.v3+json', 'User-Agent': 'TREK-Server' },
|
|
});
|
|
if (!resp.ok) return [];
|
|
const data = await resp.json();
|
|
return Array.isArray(data) ? data : [];
|
|
} catch {
|
|
return [];
|
|
}
|
|
}
|
|
|
|
interface VersionInfo {
|
|
current: string;
|
|
latest: string;
|
|
update_available: boolean;
|
|
release_url?: string;
|
|
is_docker: boolean;
|
|
is_prerelease: boolean;
|
|
}
|
|
|
|
const VERSION_CACHE_TTL = 5 * 60 * 1000; // 5 minutes
|
|
let _versionCache: { data: VersionInfo; expiresAt: number } | null = null;
|
|
|
|
/** Test-only: clear the in-memory version cache. */
|
|
export function __clearVersionCacheForTests(): void {
|
|
_versionCache = null;
|
|
}
|
|
|
|
export async function checkVersion(): Promise<VersionInfo> {
|
|
if (_versionCache && Date.now() < _versionCache.expiresAt) {
|
|
return _versionCache.data;
|
|
}
|
|
|
|
const currentVersion: string = readEnv().app.appVersion || require('../../package.json').version;
|
|
const isPrerelease = currentVersion.includes('-pre.');
|
|
const fallback: VersionInfo = {
|
|
current: currentVersion,
|
|
latest: currentVersion,
|
|
update_available: false,
|
|
is_docker: isDocker,
|
|
is_prerelease: isPrerelease,
|
|
};
|
|
let result: VersionInfo;
|
|
try {
|
|
if (isPrerelease) {
|
|
// Fetch release list and find the newest prerelease
|
|
const resp = await fetch('https://api.github.com/repos/liketrek/TREK/releases?per_page=100', {
|
|
headers: { Accept: 'application/vnd.github.v3+json', 'User-Agent': 'TREK-Server' },
|
|
});
|
|
if (!resp.ok) {
|
|
return fallback;
|
|
}
|
|
const data = (await resp.json()) as Array<{ tag_name?: string; html_url?: string; prerelease?: boolean }>;
|
|
const prereleases = Array.isArray(data) ? data.filter((r) => r.prerelease) : [];
|
|
if (!prereleases.length) {
|
|
return fallback;
|
|
}
|
|
// Pre-compute stripped versions, then sort descending
|
|
const tagged = prereleases.map((r) => ({ r, v: (r.tag_name || '').replace(/^v/, '') }));
|
|
tagged.sort((a, b) => compareVersions(b.v, a.v));
|
|
const latest = tagged[0].v;
|
|
const update_available = !!latest && latest !== currentVersion && compareVersions(latest, currentVersion) > 0;
|
|
result = {
|
|
current: currentVersion,
|
|
latest,
|
|
update_available,
|
|
release_url: tagged[0].r.html_url || '',
|
|
is_docker: isDocker,
|
|
is_prerelease: true,
|
|
};
|
|
} else {
|
|
const resp = await fetch('https://api.github.com/repos/liketrek/TREK/releases/latest', {
|
|
headers: { Accept: 'application/vnd.github.v3+json', 'User-Agent': 'TREK-Server' },
|
|
});
|
|
if (!resp.ok) {
|
|
return fallback;
|
|
}
|
|
const data = (await resp.json()) as { tag_name?: string; html_url?: string };
|
|
const latest = (data.tag_name || '').replace(/^v/, '');
|
|
const update_available = !!latest && latest !== currentVersion && compareVersions(latest, currentVersion) > 0;
|
|
result = {
|
|
current: currentVersion,
|
|
latest,
|
|
update_available,
|
|
release_url: data.html_url || '',
|
|
is_docker: isDocker,
|
|
is_prerelease: false,
|
|
};
|
|
}
|
|
} catch {
|
|
return fallback;
|
|
}
|
|
|
|
_versionCache = { data: result, expiresAt: Date.now() + VERSION_CACHE_TTL };
|
|
return result;
|
|
}
|
|
|
|
export async function checkAndNotifyVersion(): Promise<void> {
|
|
try {
|
|
const result = await checkVersion();
|
|
if (!result.update_available) return;
|
|
|
|
const lastNotified = (
|
|
db.prepare('SELECT value FROM app_settings WHERE key = ?').get('last_notified_version') as
|
|
| { value: string }
|
|
| undefined
|
|
)?.value;
|
|
if (lastNotified === result.latest) return;
|
|
|
|
db.prepare('INSERT OR REPLACE INTO app_settings (key, value) VALUES (?, ?)').run(
|
|
'last_notified_version',
|
|
result.latest,
|
|
);
|
|
|
|
await sendNotification({
|
|
event: 'version_available',
|
|
actorId: null,
|
|
scope: 'admin',
|
|
targetId: 0,
|
|
params: { version: result.latest },
|
|
});
|
|
} catch {
|
|
// Silently ignore — version check is non-critical
|
|
}
|
|
}
|
|
|
|
// ── Invite Tokens ──────────────────────────────────────────────────────────
|
|
|
|
export function listInvites() {
|
|
return db
|
|
.prepare(
|
|
`
|
|
SELECT i.*, u.username as created_by_name, t.title as trip_title
|
|
FROM invite_tokens i
|
|
JOIN users u ON i.created_by = u.id
|
|
LEFT JOIN trips t ON i.trip_id = t.id
|
|
ORDER BY i.created_at DESC
|
|
`,
|
|
)
|
|
.all();
|
|
}
|
|
|
|
/** Trips an admin can bind an invite to — id + title only, for the picker (#1402). */
|
|
export function listTripsForInvite() {
|
|
return db.prepare('SELECT id, title FROM trips ORDER BY title COLLATE NOCASE ASC').all();
|
|
}
|
|
|
|
export function createInvite(
|
|
createdBy: number,
|
|
data: { max_uses?: string | number; expires_in_days?: string | number; trip_id?: string | number | null },
|
|
) {
|
|
const rawUses = parseInt(String(data.max_uses));
|
|
const uses = rawUses === 0 ? 0 : Math.min(Math.max(rawUses || 1, 1), 5);
|
|
const token = crypto.randomBytes(16).toString('hex');
|
|
const expiresAt = data.expires_in_days
|
|
? new Date(Date.now() + parseInt(String(data.expires_in_days)) * 86400000).toISOString()
|
|
: null;
|
|
|
|
// Optional trip binding: only persist a trip that actually exists, so a stale
|
|
// or forged id can never bind (and never auto-adds anyone on registration).
|
|
let tripId: number | null = null;
|
|
if (data.trip_id != null && String(data.trip_id).trim() !== '') {
|
|
const parsed = parseInt(String(data.trip_id));
|
|
if (Number.isInteger(parsed) && db.prepare('SELECT id FROM trips WHERE id = ?').get(parsed)) {
|
|
tripId = parsed;
|
|
}
|
|
}
|
|
|
|
const ins = db
|
|
.prepare('INSERT INTO invite_tokens (token, max_uses, expires_at, created_by, trip_id) VALUES (?, ?, ?, ?, ?)')
|
|
.run(token, uses, expiresAt, createdBy, tripId);
|
|
|
|
const inviteId = Number(ins.lastInsertRowid);
|
|
const invite = db
|
|
.prepare(
|
|
`
|
|
SELECT i.*, u.username as created_by_name, t.title as trip_title
|
|
FROM invite_tokens i
|
|
JOIN users u ON i.created_by = u.id
|
|
LEFT JOIN trips t ON i.trip_id = t.id
|
|
WHERE i.id = ?
|
|
`,
|
|
)
|
|
.get(inviteId);
|
|
|
|
return { invite, inviteId, uses, expiresInDays: data.expires_in_days ?? null, tripId };
|
|
}
|
|
|
|
export function deleteInvite(id: string) {
|
|
const invite = db.prepare('SELECT id FROM invite_tokens WHERE id = ?').get(id);
|
|
if (!invite) return { error: 'Invite not found', status: 404 };
|
|
db.prepare('DELETE FROM invite_tokens WHERE id = ?').run(id);
|
|
return {};
|
|
}
|
|
|
|
// ── Places Photos ─────────────────────────────────────────────────────────
|
|
|
|
export function getPlacesPhotos() {
|
|
const row = db.prepare("SELECT value FROM app_settings WHERE key = 'places_photos_enabled'").get() as
|
|
| { value: string }
|
|
| undefined;
|
|
return { enabled: row?.value !== 'false' };
|
|
}
|
|
|
|
export function updatePlacesPhotos(enabled: boolean) {
|
|
db.prepare("INSERT OR REPLACE INTO app_settings (key, value) VALUES ('places_photos_enabled', ?)").run(
|
|
enabled ? 'true' : 'false',
|
|
);
|
|
return { enabled: !!enabled };
|
|
}
|
|
|
|
// ── Places Autocomplete ────────────────────────────────────────────────────
|
|
|
|
export function getPlacesAutocomplete() {
|
|
const row = db.prepare("SELECT value FROM app_settings WHERE key = 'places_autocomplete_enabled'").get() as
|
|
| { value: string }
|
|
| undefined;
|
|
return { enabled: row?.value !== 'false' };
|
|
}
|
|
|
|
export function updatePlacesAutocomplete(enabled: boolean) {
|
|
db.prepare("INSERT OR REPLACE INTO app_settings (key, value) VALUES ('places_autocomplete_enabled', ?)").run(
|
|
enabled ? 'true' : 'false',
|
|
);
|
|
return { enabled: !!enabled };
|
|
}
|
|
|
|
// ── Places Details ─────────────────────────────────────────────────────────
|
|
|
|
export function getPlacesDetails() {
|
|
const row = db.prepare("SELECT value FROM app_settings WHERE key = 'places_details_enabled'").get() as
|
|
| { value: string }
|
|
| undefined;
|
|
return { enabled: row?.value !== 'false' };
|
|
}
|
|
|
|
export function updatePlacesDetails(enabled: boolean) {
|
|
db.prepare("INSERT OR REPLACE INTO app_settings (key, value) VALUES ('places_details_enabled', ?)").run(
|
|
enabled ? 'true' : 'false',
|
|
);
|
|
return { enabled: !!enabled };
|
|
}
|
|
|
|
// ── Packing Templates ──────────────────────────────────────────────────────
|
|
|
|
export function listPackingTemplates() {
|
|
return db
|
|
.prepare(
|
|
`
|
|
SELECT pt.*, u.username as created_by_name,
|
|
(SELECT COUNT(*) FROM packing_template_items ti JOIN packing_template_categories tc ON ti.category_id = tc.id WHERE tc.template_id = pt.id) as item_count,
|
|
(SELECT COUNT(*) FROM packing_template_categories WHERE template_id = pt.id) as category_count
|
|
FROM packing_templates pt
|
|
JOIN users u ON pt.created_by = u.id
|
|
ORDER BY pt.created_at DESC
|
|
`,
|
|
)
|
|
.all();
|
|
}
|
|
|
|
export function getPackingTemplate(id: string) {
|
|
const template = db.prepare('SELECT * FROM packing_templates WHERE id = ?').get(id);
|
|
if (!template) return { error: 'Template not found', status: 404 };
|
|
const categories = db
|
|
.prepare('SELECT * FROM packing_template_categories WHERE template_id = ? ORDER BY sort_order, id')
|
|
.all(id) as any[];
|
|
const items = db
|
|
.prepare(
|
|
`
|
|
SELECT ti.* FROM packing_template_items ti
|
|
JOIN packing_template_categories tc ON ti.category_id = tc.id
|
|
WHERE tc.template_id = ? ORDER BY ti.sort_order, ti.id
|
|
`,
|
|
)
|
|
.all(id);
|
|
return { template, categories, items };
|
|
}
|
|
|
|
export function createPackingTemplate(name: string, createdBy: number) {
|
|
if (!name?.trim()) return { error: 'Name is required', status: 400 };
|
|
const result = db
|
|
.prepare('INSERT INTO packing_templates (name, created_by) VALUES (?, ?)')
|
|
.run(name.trim(), createdBy);
|
|
const template = db.prepare('SELECT * FROM packing_templates WHERE id = ?').get(result.lastInsertRowid);
|
|
return { template };
|
|
}
|
|
|
|
export function updatePackingTemplate(id: string, data: { name?: string }) {
|
|
const template = db.prepare('SELECT * FROM packing_templates WHERE id = ?').get(id);
|
|
if (!template) return { error: 'Template not found', status: 404 };
|
|
if (data.name?.trim()) db.prepare('UPDATE packing_templates SET name = ? WHERE id = ?').run(data.name.trim(), id);
|
|
return { template: db.prepare('SELECT * FROM packing_templates WHERE id = ?').get(id) };
|
|
}
|
|
|
|
export function deletePackingTemplate(id: string) {
|
|
const template = db.prepare('SELECT * FROM packing_templates WHERE id = ?').get(id) as { name?: string } | undefined;
|
|
if (!template) return { error: 'Template not found', status: 404 };
|
|
db.prepare('DELETE FROM packing_templates WHERE id = ?').run(id);
|
|
return { name: template.name };
|
|
}
|
|
|
|
// Template categories
|
|
|
|
export function createTemplateCategory(templateId: string, name: string) {
|
|
if (!name?.trim()) return { error: 'Category name is required', status: 400 };
|
|
const template = db.prepare('SELECT * FROM packing_templates WHERE id = ?').get(templateId);
|
|
if (!template) return { error: 'Template not found', status: 404 };
|
|
const maxOrder = db
|
|
.prepare('SELECT MAX(sort_order) as max FROM packing_template_categories WHERE template_id = ?')
|
|
.get(templateId) as { max: number | null };
|
|
const result = db
|
|
.prepare('INSERT INTO packing_template_categories (template_id, name, sort_order) VALUES (?, ?, ?)')
|
|
.run(templateId, name.trim(), (maxOrder.max ?? -1) + 1);
|
|
return { category: db.prepare('SELECT * FROM packing_template_categories WHERE id = ?').get(result.lastInsertRowid) };
|
|
}
|
|
|
|
export function updateTemplateCategory(templateId: string, catId: string, data: { name?: string }) {
|
|
const cat = db
|
|
.prepare('SELECT * FROM packing_template_categories WHERE id = ? AND template_id = ?')
|
|
.get(catId, templateId);
|
|
if (!cat) return { error: 'Category not found', status: 404 };
|
|
if (data.name?.trim())
|
|
db.prepare('UPDATE packing_template_categories SET name = ? WHERE id = ?').run(data.name.trim(), catId);
|
|
return { category: db.prepare('SELECT * FROM packing_template_categories WHERE id = ?').get(catId) };
|
|
}
|
|
|
|
export function deleteTemplateCategory(templateId: string, catId: string) {
|
|
const cat = db
|
|
.prepare('SELECT * FROM packing_template_categories WHERE id = ? AND template_id = ?')
|
|
.get(catId, templateId);
|
|
if (!cat) return { error: 'Category not found', status: 404 };
|
|
db.prepare('DELETE FROM packing_template_categories WHERE id = ?').run(catId);
|
|
return {};
|
|
}
|
|
|
|
// Template items
|
|
|
|
export function createTemplateItem(templateId: string, catId: string, name: string) {
|
|
if (!name?.trim()) return { error: 'Item name is required', status: 400 };
|
|
const cat = db
|
|
.prepare('SELECT * FROM packing_template_categories WHERE id = ? AND template_id = ?')
|
|
.get(catId, templateId);
|
|
if (!cat) return { error: 'Category not found', status: 404 };
|
|
const maxOrder = db
|
|
.prepare('SELECT MAX(sort_order) as max FROM packing_template_items WHERE category_id = ?')
|
|
.get(catId) as { max: number | null };
|
|
const result = db
|
|
.prepare('INSERT INTO packing_template_items (category_id, name, sort_order) VALUES (?, ?, ?)')
|
|
.run(catId, name.trim(), (maxOrder.max ?? -1) + 1);
|
|
return { item: db.prepare('SELECT * FROM packing_template_items WHERE id = ?').get(result.lastInsertRowid) };
|
|
}
|
|
|
|
export function updateTemplateItem(itemId: string, data: { name?: string }) {
|
|
const item = db.prepare('SELECT * FROM packing_template_items WHERE id = ?').get(itemId);
|
|
if (!item) return { error: 'Item not found', status: 404 };
|
|
if (data.name?.trim())
|
|
db.prepare('UPDATE packing_template_items SET name = ? WHERE id = ?').run(data.name.trim(), itemId);
|
|
return { item: db.prepare('SELECT * FROM packing_template_items WHERE id = ?').get(itemId) };
|
|
}
|
|
|
|
export function deleteTemplateItem(itemId: string) {
|
|
const item = db.prepare('SELECT * FROM packing_template_items WHERE id = ?').get(itemId);
|
|
if (!item) return { error: 'Item not found', status: 404 };
|
|
db.prepare('DELETE FROM packing_template_items WHERE id = ?').run(itemId);
|
|
return {};
|
|
}
|
|
|
|
// ── Addons ─────────────────────────────────────────────────────────────────
|
|
// isAddonEnabled and the bag-tracking/collab-features flags moved to
|
|
// nest/addons/addons.service.ts (bridge: nest/addons/addons.bridge.ts) —
|
|
// finding `admin-1`. listAddons/updateAddon + photo-provider CRUD stay here
|
|
// until the Wave-5 admin migration.
|
|
|
|
export function listAddons() {
|
|
const addons = db.prepare('SELECT * FROM addons ORDER BY sort_order, id').all() as Addon[];
|
|
const providers = db
|
|
.prepare(
|
|
`
|
|
SELECT id, name, description, icon, enabled, sort_order
|
|
FROM photo_providers
|
|
ORDER BY sort_order, id
|
|
`,
|
|
)
|
|
.all() as Array<{
|
|
id: string;
|
|
name: string;
|
|
description?: string | null;
|
|
icon: string;
|
|
enabled: number;
|
|
sort_order: number;
|
|
}>;
|
|
const fields = db
|
|
.prepare(
|
|
`
|
|
SELECT provider_id, field_key, label, input_type, placeholder, required, secret, settings_key, payload_key, sort_order
|
|
FROM photo_provider_fields
|
|
ORDER BY sort_order, id
|
|
`,
|
|
)
|
|
.all() as Array<{
|
|
provider_id: string;
|
|
field_key: string;
|
|
label: string;
|
|
input_type: string;
|
|
placeholder?: string | null;
|
|
required: number;
|
|
secret: number;
|
|
settings_key?: string | null;
|
|
payload_key?: string | null;
|
|
sort_order: number;
|
|
}>;
|
|
const fieldsByProvider = new Map<string, typeof fields>();
|
|
for (const field of fields) {
|
|
const arr = fieldsByProvider.get(field.provider_id) || [];
|
|
arr.push(field);
|
|
fieldsByProvider.set(field.provider_id, arr);
|
|
}
|
|
|
|
return [
|
|
...addons.map((a) => ({
|
|
...a,
|
|
enabled: !!a.enabled,
|
|
config:
|
|
a.id === ADDON_IDS.LLM_PARSING
|
|
? maskLlmAddonConfig(JSON.parse(a.config || '{}'))
|
|
: JSON.parse(a.config || '{}'),
|
|
})),
|
|
...providers.map((p) => ({
|
|
id: p.id,
|
|
name: p.name,
|
|
description: p.description,
|
|
type: 'photo_provider',
|
|
icon: p.icon,
|
|
enabled: !!p.enabled,
|
|
config: getPhotoProviderConfig(p.id),
|
|
fields: (fieldsByProvider.get(p.id) || []).map((f) => ({
|
|
key: f.field_key,
|
|
label: f.label,
|
|
input_type: f.input_type,
|
|
placeholder: f.placeholder || '',
|
|
required: !!f.required,
|
|
secret: !!f.secret,
|
|
settings_key: f.settings_key || null,
|
|
payload_key: f.payload_key || null,
|
|
sort_order: f.sort_order,
|
|
})),
|
|
sort_order: p.sort_order,
|
|
})),
|
|
];
|
|
}
|
|
|
|
export function updateAddon(id: string, data: { enabled?: boolean; config?: Record<string, unknown> }) {
|
|
const addon = db.prepare('SELECT * FROM addons WHERE id = ?').get(id) as Addon | undefined;
|
|
const provider = db.prepare('SELECT * FROM photo_providers WHERE id = ?').get(id) as
|
|
| { id: string; name: string; description?: string | null; icon: string; enabled: number; sort_order: number }
|
|
| undefined;
|
|
if (!addon && !provider) return { error: 'Addon not found', status: 404 };
|
|
|
|
if (addon) {
|
|
if (data.enabled !== undefined)
|
|
db.prepare('UPDATE addons SET enabled = ? WHERE id = ?').run(data.enabled ? 1 : 0, id);
|
|
if (data.config !== undefined) {
|
|
// The AI-parsing addon holds an API key — encrypt it at rest and preserve
|
|
// the stored key when the client echoes the mask sentinel (see llmConfig.ts).
|
|
const configToStore =
|
|
id === ADDON_IDS.LLM_PARSING
|
|
? prepareLlmAddonConfigForWrite(data.config, JSON.parse(addon.config || '{}'))
|
|
: data.config;
|
|
db.prepare('UPDATE addons SET config = ? WHERE id = ?').run(JSON.stringify(configToStore), id);
|
|
}
|
|
} else {
|
|
if (data.enabled !== undefined)
|
|
db.prepare('UPDATE photo_providers SET enabled = ? WHERE id = ?').run(data.enabled ? 1 : 0, id);
|
|
}
|
|
|
|
const updatedAddon = db.prepare('SELECT * FROM addons WHERE id = ?').get(id) as Addon | undefined;
|
|
const updatedProvider = db.prepare('SELECT * FROM photo_providers WHERE id = ?').get(id) as
|
|
| { id: string; name: string; description?: string | null; icon: string; enabled: number; sort_order: number }
|
|
| undefined;
|
|
const updated = updatedAddon
|
|
? {
|
|
...updatedAddon,
|
|
enabled: !!updatedAddon.enabled,
|
|
config:
|
|
updatedAddon.id === ADDON_IDS.LLM_PARSING
|
|
? maskLlmAddonConfig(JSON.parse(updatedAddon.config || '{}'))
|
|
: JSON.parse(updatedAddon.config || '{}'),
|
|
}
|
|
: updatedProvider
|
|
? {
|
|
id: updatedProvider.id,
|
|
name: updatedProvider.name,
|
|
description: updatedProvider.description,
|
|
type: 'photo_provider',
|
|
icon: updatedProvider.icon,
|
|
enabled: !!updatedProvider.enabled,
|
|
config: getPhotoProviderConfig(updatedProvider.id),
|
|
sort_order: updatedProvider.sort_order,
|
|
}
|
|
: null;
|
|
|
|
// Only these addons gate MCP tool/resource/prompt registration (see
|
|
// registerTools/registerResources) — and only a real enabled-flip changes
|
|
// what a session would register. Config-only saves, photo providers and
|
|
// MCP-irrelevant addons must not tear down every live session (#1414).
|
|
const MCP_RELEVANT_ADDONS = new Set<string>([
|
|
ADDON_IDS.MCP,
|
|
ADDON_IDS.PACKING,
|
|
ADDON_IDS.BUDGET,
|
|
ADDON_IDS.COLLAB,
|
|
ADDON_IDS.ATLAS,
|
|
ADDON_IDS.VACAY,
|
|
ADDON_IDS.JOURNEY,
|
|
]);
|
|
const enabledChanged = !!addon && data.enabled !== undefined && (data.enabled ? 1 : 0) !== addon.enabled;
|
|
|
|
return {
|
|
addon: updated,
|
|
mcpAffected: enabledChanged && MCP_RELEVANT_ADDONS.has(id),
|
|
auditDetails: {
|
|
enabled: data.enabled !== undefined ? !!data.enabled : undefined,
|
|
config_changed: data.config !== undefined,
|
|
},
|
|
};
|
|
}
|
|
|
|
// ── MCP Tokens ─────────────────────────────────────────────────────────────
|
|
|
|
export function listMcpTokens() {
|
|
return db
|
|
.prepare(
|
|
`
|
|
SELECT t.id, t.name, t.token_prefix, t.created_at, t.last_used_at, t.user_id, u.username
|
|
FROM mcp_tokens t
|
|
JOIN users u ON u.id = t.user_id
|
|
ORDER BY t.created_at DESC
|
|
`,
|
|
)
|
|
.all();
|
|
}
|
|
|
|
export function deleteMcpToken(id: string) {
|
|
const token = db.prepare('SELECT id, user_id FROM mcp_tokens WHERE id = ?').get(id) as
|
|
| { id: number; user_id: number }
|
|
| undefined;
|
|
if (!token) return { error: 'Token not found', status: 404 };
|
|
db.prepare('DELETE FROM mcp_tokens WHERE id = ?').run(id);
|
|
revokeUserSessions(token.user_id);
|
|
return {};
|
|
}
|
|
|
|
// ── OAuth Sessions ─────────────────────────────────────────────────────────
|
|
|
|
export function listOAuthSessions() {
|
|
const rows = db
|
|
.prepare(
|
|
`
|
|
SELECT ot.id, ot.client_id, oc.name AS client_name, ot.user_id, u.username,
|
|
ot.scopes, ot.access_token_expires_at, ot.refresh_token_expires_at, ot.created_at
|
|
FROM oauth_tokens ot
|
|
JOIN oauth_clients oc ON ot.client_id = oc.client_id
|
|
JOIN users u ON u.id = ot.user_id
|
|
WHERE ot.revoked_at IS NULL
|
|
AND ot.refresh_token_expires_at > CURRENT_TIMESTAMP
|
|
ORDER BY ot.created_at DESC
|
|
`,
|
|
)
|
|
.all() as (Record<string, unknown> & { scopes: string })[];
|
|
return rows.map((r) => ({ ...r, scopes: JSON.parse(r.scopes) }));
|
|
}
|
|
|
|
export function revokeOAuthSession(id: string) {
|
|
const row = db.prepare('SELECT id, user_id, client_id FROM oauth_tokens WHERE id = ?').get(id) as
|
|
| { id: number; user_id: number; client_id: string }
|
|
| undefined;
|
|
if (!row) return { error: 'Session not found', status: 404 };
|
|
db.prepare('UPDATE oauth_tokens SET revoked_at = CURRENT_TIMESTAMP WHERE id = ?').run(id);
|
|
revokeUserSessionsForClient(row.user_id, row.client_id);
|
|
return {};
|
|
}
|
|
|
|
// ── JWT Rotation ───────────────────────────────────────────────────────────
|
|
|
|
export function rotateJwtSecret(): { error?: string; status?: number } {
|
|
const newSecret = crypto.randomBytes(32).toString('hex');
|
|
const dataDir = path.resolve(__dirname, '../../data');
|
|
const secretFile = path.join(dataDir, '.jwt_secret');
|
|
try {
|
|
if (!fs.existsSync(dataDir)) fs.mkdirSync(dataDir, { recursive: true });
|
|
fs.writeFileSync(secretFile, newSecret, { mode: 0o600 });
|
|
} catch (err: unknown) {
|
|
return { error: 'Failed to persist new JWT secret to disk', status: 500 };
|
|
}
|
|
updateJwtSecret(newSecret);
|
|
return {};
|
|
}
|