chore: update repo url

This commit is contained in:
jubnl
2026-07-18 16:38:12 +02:00
parent acbf59ef12
commit 631fea3c51
34 changed files with 529 additions and 272 deletions
@@ -1,18 +1,19 @@
import { Injectable } from '@nestjs/common';
import semver from 'semver';
import fs from 'node:fs';
import path from 'node:path';
import { db } from '../../../db/database';
import { discoverPlugins } from '../install/discovery';
import { hostSatisfies, hostVersion, normalizedHost } from '../install/host-compat';
import type { PluginDependency } from '../install/manifest';
import { pluginCodeDir, pluginsCodeRoot, pluginsDataRoot } from '../paths';
import { parseJsonText, parseManifest } from '../install/manifest';
import { scanForNativeBinaries } from '../install/native-scan';
import { extractArchive } from '../install/safe-extract';
import { safeDownload, sha256Matches } from '../install/safe-fetch';
import { verifyAuthorSignature, SignatureError } from '../install/verify-signature';
import { pluginCodeDir, pluginsCodeRoot, pluginsDataRoot } from '../paths';
import { clearUpdateBlock, isSignatureCode, setUpdateBlock, RETRUSTABLE_CODE } from '../signature-status';
import { extractArchive } from '../install/safe-extract';
import { scanForNativeBinaries } from '../install/native-scan';
import { parseJsonText, parseManifest } from '../install/manifest';
import { hostSatisfies, hostVersion, normalizedHost } from '../install/host-compat';
import { discoverPlugins } from '../install/discovery';
import { Injectable } from '@nestjs/common';
import fs from 'node:fs';
import path from 'node:path';
import semver from 'semver';
/**
* TREK-side of the plugin registry (#plugins, M5). Fetches the single aggregated
@@ -25,7 +26,7 @@ import { discoverPlugins } from '../install/discovery';
const REGISTRY_URL =
process.env.TREK_PLUGIN_REGISTRY_URL ||
'https://raw.githubusercontent.com/mauriceboe/TREK-Plugins/main/dist/index.json';
'https://raw.githubusercontent.com/iiketrek/TREK-Plugins/main/dist/index.json';
const CACHE_TTL = 30 * 60 * 1000;
const MANIFEST_MAX_BYTES = 256 * 1024;
// Sideload upload ceiling — matches the SDK `pack` limit (50 MB) plus zip overhead.
@@ -151,7 +152,10 @@ export class PluginRegistryService {
try {
const url = force ? `${REGISTRY_URL}${REGISTRY_URL.includes('?') ? '&' : '?'}_=${Date.now()}` : REGISTRY_URL;
const headers: Record<string, string> = { 'User-Agent': 'TREK-Server' };
if (force) { headers['Cache-Control'] = 'no-cache'; headers.Pragma = 'no-cache'; }
if (force) {
headers['Cache-Control'] = 'no-cache';
headers.Pragma = 'no-cache';
}
const resp = await fetch(url, { headers });
if (!resp.ok) throw new Error(`registry ${resp.status}`);
const data = (await resp.json()) as Registry;
@@ -172,16 +176,40 @@ export class PluginRegistryService {
* `authorPublicKey` is exposed in full — it is a public key, and the re-trust
* round-trip compares it exactly (a truncated fingerprint would be a weak check).
*/
async browse(force = false): Promise<Array<Omit<RegistryEntry, 'versions'> & { latest: string | null; minTrekVersion: string | null; requiredAddons: string[]; pluginDependencies: PluginDependency[]; screenshotUrl: string | null; signed: boolean; authorPublicKey: string | null } & HostCompat>> {
async browse(
force = false,
): Promise<
Array<
Omit<RegistryEntry, 'versions'> & {
latest: string | null;
minTrekVersion: string | null;
requiredAddons: string[];
pluginDependencies: PluginDependency[];
screenshotUrl: string | null;
signed: boolean;
authorPublicKey: string | null;
} & HostCompat
>
> {
const reg = await this.fetchRegistry(force);
return reg.plugins.map((p) => {
const latest = p.versions[0] ?? null;
return {
id: p.id, name: p.name, author: p.author, description: p.description, repo: p.repo,
homepage: p.homepage, tags: p.tags, type: p.type, icon: p.icon, reviewedAt: p.reviewedAt ?? null,
id: p.id,
name: p.name,
author: p.author,
description: p.description,
repo: p.repo,
homepage: p.homepage,
tags: p.tags,
type: p.type,
icon: p.icon,
reviewedAt: p.reviewedAt ?? null,
downloadCount: p.downloadCount ?? null,
latest: latest?.version ?? null, minTrekVersion: latest?.minTrekVersion ?? null,
requiredAddons: latest?.requiredAddons ?? [], pluginDependencies: latest?.pluginDependencies ?? [],
latest: latest?.version ?? null,
minTrekVersion: latest?.minTrekVersion ?? null,
requiredAddons: latest?.requiredAddons ?? [],
pluginDependencies: latest?.pluginDependencies ?? [],
screenshotUrl: latest ? rawFileUrl(p.repo, latest.commitSha, 'docs/screenshot.png') : null,
signed: !!p.authorPublicKey && !!latest?.signature,
authorPublicKey: p.authorPublicKey ?? null,
@@ -230,7 +258,10 @@ export class PluginRegistryService {
let manifest: ManifestPreview | null = null;
if (latest) {
try {
const { bytes } = await safeDownload(rawFileUrl(entry.repo, latest.commitSha, 'trek-plugin.json'), MANIFEST_MAX_BYTES);
const { bytes } = await safeDownload(
rawFileUrl(entry.repo, latest.commitSha, 'trek-plugin.json'),
MANIFEST_MAX_BYTES,
);
manifest = previewManifest(parseJsonText(bytes.toString('utf8')));
} catch {
// Soft-fail: the detail view still renders from registry metadata alone.
@@ -238,12 +269,22 @@ export class PluginRegistryService {
}
const data = {
id: entry.id, name: entry.name, author: entry.author, description: entry.description,
repo: entry.repo, homepage: entry.homepage ?? null, tags: entry.tags ?? [], type: entry.type,
reviewedAt: entry.reviewedAt ?? null, downloadCount: entry.downloadCount ?? null,
latest: latest?.version ?? null, minTrekVersion: latest?.minTrekVersion ?? null,
size: latest?.size ?? null, publishedAt: latest?.publishedAt ?? null,
requiredAddons: latest?.requiredAddons ?? [], pluginDependencies: latest?.pluginDependencies ?? [],
id: entry.id,
name: entry.name,
author: entry.author,
description: entry.description,
repo: entry.repo,
homepage: entry.homepage ?? null,
tags: entry.tags ?? [],
type: entry.type,
reviewedAt: entry.reviewedAt ?? null,
downloadCount: entry.downloadCount ?? null,
latest: latest?.version ?? null,
minTrekVersion: latest?.minTrekVersion ?? null,
size: latest?.size ?? null,
publishedAt: latest?.publishedAt ?? null,
requiredAddons: latest?.requiredAddons ?? [],
pluginDependencies: latest?.pluginDependencies ?? [],
screenshotUrl: latest ? rawFileUrl(entry.repo, latest.commitSha, 'docs/screenshot.png') : null,
signed: !!entry.authorPublicKey && !!latest?.signature,
authorPublicKey: entry.authorPublicKey ?? null,
@@ -336,7 +377,10 @@ export class PluginRegistryService {
* blessed key that doesn't actually sign the code is refused like any other bad
* signature. It is never a way to install something unverified.
*/
async install(id: string, opts?: { version?: string; constraint?: string; retrustKey?: string }): Promise<{ id: string; version: string }> {
async install(
id: string,
opts?: { version?: string; constraint?: string; retrustKey?: string },
): Promise<{ id: string; version: string }> {
const reg = await this.fetchRegistry();
const entry = reg.plugins.find((p) => p.id === id);
if (!entry) throw new RegistryError(`plugin ${id} not in registry`);
@@ -366,7 +410,10 @@ export class PluginRegistryService {
if (!pluginRoot) throw new RegistryError('archive contains no trek-plugin.json');
// 4. re-validate the bundled manifest + 5. native re-scan
const manifest = parseManifest(parseJsonText(fs.readFileSync(path.join(pluginRoot, 'trek-plugin.json'), 'utf8')), { requireTrek: true });
const manifest = parseManifest(
parseJsonText(fs.readFileSync(path.join(pluginRoot, 'trek-plugin.json'), 'utf8')),
{ requireTrek: true },
);
if (manifest.id !== id) throw new RegistryError(`manifest id "${manifest.id}" != "${id}"`);
// The artifact's OWN range is the authoritative compat check. The index metadata
// gated above is only what the registry says about this version, and it is weaker:
@@ -385,7 +432,11 @@ export class PluginRegistryService {
// 7. register INACTIVE (record provenance)
discoverPlugins(db);
db.prepare('UPDATE plugins SET source_repo = ?, source_commit = ?, sha256 = ?, reviewed_at = ? WHERE id = ?').run(
entry.repo, ver.commitSha, ver.sha256, entry.reviewedAt ?? null, id,
entry.repo,
ver.commitSha,
ver.sha256,
entry.reviewedAt ?? null,
id,
);
// Pin the author key on first successful install of a signed plugin (TOFU) —
// and, after a re-trust, re-pin to the new key the admin blessed. Only ever set
@@ -410,8 +461,13 @@ export class PluginRegistryService {
* can't be installed; they're collected and returned so the caller can prompt the
* admin to enable them. Cycle-safe. Already-installed plugins are left untouched.
*/
async installWithDependencies(id: string, constraint?: string): Promise<{ installed: string[]; requiredAddons: string[] }> {
const installedNow = new Set((db.prepare('SELECT id FROM plugins').all() as Array<{ id: string }>).map((r) => r.id));
async installWithDependencies(
id: string,
constraint?: string,
): Promise<{ installed: string[]; requiredAddons: string[] }> {
const installedNow = new Set(
(db.prepare('SELECT id FROM plugins').all() as Array<{ id: string }>).map((r) => r.id),
);
const done = new Set<string>();
const installed: string[] = [];
const requiredAddons = new Set<string>();
@@ -451,7 +507,9 @@ export class PluginRegistryService {
extractArchive(bytes, stagingDir);
const root = locateManifestDir(stagingDir);
if (!root) throw new RegistryError('archive contains no trek-plugin.json');
const manifest = parseManifest(parseJsonText(fs.readFileSync(path.join(root, 'trek-plugin.json'), 'utf8')), { requireTrek: true });
const manifest = parseManifest(parseJsonText(fs.readFileSync(path.join(root, 'trek-plugin.json'), 'utf8')), {
requireTrek: true,
});
assertHostCompatible(manifest.trekRange, manifest.id);
if (scanForNativeBinaries(root).length) throw new RegistryError('artifact contains native binaries');
return { id: manifest.id, version: manifest.version, root, stagingDir };
@@ -514,17 +572,31 @@ export class PluginRegistryService {
* key, over the artifact bytes. A key an admin blesses must still sign the code it
* ships — a re-trust moves the pin from one VERIFIED key to another verified key.
*/
private verifySignatureAndTofu(id: string, bytes: Buffer, entry: RegistryEntry, ver: RegistryVersion, retrustKey?: string): void {
const pinned = (db.prepare('SELECT author_pubkey FROM plugins WHERE id = ?').get(id) as { author_pubkey?: string } | undefined)?.author_pubkey ?? null;
private verifySignatureAndTofu(
id: string,
bytes: Buffer,
entry: RegistryEntry,
ver: RegistryVersion,
retrustKey?: string,
): void {
const pinned =
(db.prepare('SELECT author_pubkey FROM plugins WHERE id = ?').get(id) as { author_pubkey?: string } | undefined)
?.author_pubkey ?? null;
if (!entry.authorPublicKey && !ver.signature) {
if (pinned) {
throw new RegistryError('this plugin was signed before but the update is unsigned — refusing', 'SIGNATURE_MISSING');
throw new RegistryError(
'this plugin was signed before but the update is unsigned — refusing',
'SIGNATURE_MISSING',
);
}
return; // unsigned throughout: sha256 is the only pin
}
if (!entry.authorPublicKey || !ver.signature) {
throw new RegistryError('incomplete signature: an author key and a version signature must both be present', 'SIGNATURE_INCOMPLETE');
throw new RegistryError(
'incomplete signature: an author key and a version signature must both be present',
'SIGNATURE_INCOMPLETE',
);
}
if (pinned && pinned !== entry.authorPublicKey && retrustKey !== entry.authorPublicKey) {
throw new RegistryError(
@@ -574,7 +646,10 @@ export class PluginRegistryService {
const pinned = row.author_pubkey ?? null;
if (!pinned || !entry.authorPublicKey || pinned === entry.authorPublicKey) {
throw new RegistryError("this plugin's signing key has not changed — there is nothing to re-trust", 'RETRUST_NOT_APPLICABLE');
throw new RegistryError(
"this plugin's signing key has not changed — there is nothing to re-trust",
'RETRUST_NOT_APPLICABLE',
);
}
if (entry.authorPublicKey !== publicKey) {
throw new RegistryError(
@@ -615,10 +690,7 @@ function hostCompatible(v: RegistryVersion, host: string | null): boolean {
*/
export function assertHostCompatible(range: string | null, id: string): void {
if (hostSatisfies(range)) return;
throw new RegistryError(
`${id} requires TREK ${range} — this is TREK ${hostVersion()}`,
'TREK_VERSION_INCOMPATIBLE',
);
throw new RegistryError(`${id} requires TREK ${range} — this is TREK ${hostVersion()}`, 'TREK_VERSION_INCOMPATIBLE');
}
/**
@@ -660,7 +732,10 @@ function previewManifest(raw: unknown): ManifestPreview {
const pluginDependencies = Array.isArray(m.pluginDependencies)
? m.pluginDependencies
.filter((d): d is Record<string, unknown> => !!d && typeof d === 'object')
.map((d) => ({ id: typeof d.id === 'string' ? d.id : '', version: typeof d.version === 'string' ? d.version : '' }))
.map((d) => ({
id: typeof d.id === 'string' ? d.id : '',
version: typeof d.version === 'string' ? d.version : '',
}))
.filter((d) => d.id && d.version)
: [];
return {
+330 -150
View File
@@ -1,22 +1,23 @@
import bcrypt from 'bcryptjs';
import { avatarUrl } from './avatarUrl';
import crypto from 'crypto';
import path from 'path';
import fs from 'fs';
import { db } from '../db/database';
import { User, Addon } from '../types';
import { updateJwtSecret } from '../config';
import { maybe_encrypt_api_key, decrypt_api_key } from './apiKeyCrypto';
import { getAllPermissions, savePermissions as savePerms, PERMISSION_ACTIONS } from './permissions';
import { revokeUserSessions, revokeUserSessionsForClient } from '../mcp';
import { deleteUserCompletely } from './userCleanupService';
import { emitUserDeleted } from '../plugin-user-lifecycle';
import { validatePassword } from './passwordPolicy';
import { getPhotoProviderConfig } from './memories/helpersService';
import { ADDON_IDS } from '../addons';
import { prepareLlmAddonConfigForWrite, maskLlmAddonConfig } from './llmConfig';
import { send as sendNotification } from './notificationService';
import { updateJwtSecret } from '../config';
import { db } from '../db/database';
import { revokeUserSessions, revokeUserSessionsForClient } from '../mcp';
import { emitUserDeleted } from '../plugin-user-lifecycle';
import { User, Addon } from '../types';
import { maybe_encrypt_api_key, decrypt_api_key } from './apiKeyCrypto';
import { resolveAuthToggles } from './authService';
import { avatarUrl } from './avatarUrl';
import { prepareLlmAddonConfigForWrite, maskLlmAddonConfig } from './llmConfig';
import { getPhotoProviderConfig } from './memories/helpersService';
import { send as sendNotification } from './notificationService';
import { validatePassword } from './passwordPolicy';
import { getAllPermissions, savePermissions as savePerms, PERMISSION_ACTIONS } from './permissions';
import { deleteUserCompletely } from './userCleanupService';
import bcrypt from 'bcryptjs';
import crypto from 'crypto';
import fs from 'fs';
import path from 'path';
// ── Helpers ────────────────────────────────────────────────────────────────
@@ -36,9 +37,11 @@ export function compareVersions(a: string, b: string): number {
const preN = n !== null && Number.isFinite(n) ? n : null;
return { parts, preN };
};
const pa = parse(a), pb = parse(b);
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;
const na = pa.parts[i] || 0,
nb = pb.parts[i] || 0;
if (na > nb) return 1;
if (na < nb) return -1;
}
@@ -54,8 +57,13 @@ export function compareVersions(a: string, b: string): number {
export const isDocker = (() => {
try {
return fs.existsSync('/.dockerenv') || (fs.existsSync('/proc/1/cgroup') && fs.readFileSync('/proc/1/cgroup', 'utf8').includes('docker'));
} catch { return false; }
return (
fs.existsSync('/.dockerenv') ||
(fs.existsSync('/proc/1/cgroup') && fs.readFileSync('/proc/1/cgroup', 'utf8').includes('docker'))
);
} catch {
return false;
}
})();
// ── User CRUD ──────────────────────────────────────────────────────────────
@@ -63,15 +71,21 @@ export const isDocker = (() => {
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 })[];
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 => ({
} catch {
/* */
}
return users.map((u) => ({
...u,
avatar_url: avatarUrl(u),
created_at: utcSuffix(u.created_at),
@@ -98,7 +112,9 @@ export function createUser(data: { username: string; email: string; password: st
}
// 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);
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);
@@ -106,13 +122,13 @@ export function createUser(data: { username: string; email: string; password: st
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 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);
const user = db
.prepare('SELECT id, username, email, role, created_at, updated_at FROM users WHERE id = ?')
.get(result.lastInsertRowid);
return {
user,
@@ -134,11 +150,15 @@ export function updateUser(id: string, data: { username?: string; email?: string
}
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);
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);
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 };
}
@@ -153,12 +173,15 @@ export function updateUser(id: string, data: { username?: string; email?: string
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;
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(`
db.prepare(
`
UPDATE users SET
username = COALESCE(?, username),
email = COALESCE(?, email),
@@ -166,11 +189,12 @@ export function updateUser(id: string, data: { username?: string; email?: string
password_hash = COALESCE(?, password_hash),
updated_at = CURRENT_TIMESTAMP
WHERE id = ?
`).run(username || null, email || null, role || null, passwordHash, 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 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');
@@ -190,7 +214,9 @@ export function deleteUser(id: string, currentUserId: number) {
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;
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);
@@ -201,7 +227,9 @@ export function deleteUser(id: string, currentUserId: number) {
// ── 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 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;
@@ -212,7 +240,7 @@ export function getStats() {
export function getPermissions() {
const current = getAllPermissions();
const actions = PERMISSION_ACTIONS.map(a => ({
const actions = PERMISSION_ACTIONS.map((a) => ({
key: a.key,
level: current[a.key],
defaultLevel: a.defaultLevel,
@@ -246,13 +274,17 @@ export function getAuditLog(query: { limit?: string; offset?: string }) {
ip: string | null;
};
const rows = db.prepare(`
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[];
`,
)
.all(limit, offset) as Row[];
const total = (db.prepare('SELECT COUNT(*) as c FROM audit_log').get() as { c: number }).c;
@@ -265,7 +297,8 @@ export function getAuditLog(query: { limit?: string; offset?: string }) {
details = { _parse_error: true };
}
}
const created_at = r.created_at && !r.created_at.endsWith('Z') ? r.created_at.replace(' ', 'T') + 'Z' : r.created_at;
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 };
});
@@ -275,7 +308,8 @@ export function getAuditLog(query: { limit?: string; offset?: string }) {
// ── 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 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'),
@@ -296,10 +330,14 @@ export function updateOidcSettings(data: {
}): { 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 };
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 || '');
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) ?? '');
@@ -328,10 +366,9 @@ export function saveDemoBaseline(): { error?: string; status?: number; message?:
export async function getGithubReleases(perPage: string = '10', page: string = '1') {
try {
const resp = await fetch(
`https://api.github.com/repos/mauriceboe/TREK/releases?per_page=${perPage}&page=${page}`,
{ headers: { 'Accept': 'application/vnd.github.v3+json', 'User-Agent': 'TREK-Server' } }
);
const resp = await fetch(`https://api.github.com/repos/iiketrek/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 : [];
@@ -364,41 +401,59 @@ export async function checkVersion(): Promise<VersionInfo> {
const currentVersion: string = process.env.APP_VERSION || 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 };
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/mauriceboe/TREK/releases?per_page=100',
{ headers: { 'Accept': 'application/vnd.github.v3+json', 'User-Agent': 'TREK-Server' } }
);
const resp = await fetch('https://api.github.com/repos/iiketrek/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) : [];
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/, '') }));
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 };
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/mauriceboe/TREK/releases/latest',
{ headers: { 'Accept': 'application/vnd.github.v3+json', 'User-Agent': 'TREK-Server' } }
);
const resp = await fetch('https://api.github.com/repos/iiketrek/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 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 };
result = {
current: currentVersion,
latest,
update_available,
release_url: data.html_url || '',
is_docker: isDocker,
is_prerelease: false,
};
}
} catch {
return fallback;
@@ -413,10 +468,17 @@ export async function checkAndNotifyVersion(): Promise<void> {
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;
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);
db.prepare('INSERT OR REPLACE INTO app_settings (key, value) VALUES (?, ?)').run(
'last_notified_version',
result.latest,
);
await sendNotification({
event: 'version_available',
@@ -433,13 +495,17 @@ export async function checkAndNotifyVersion(): Promise<void> {
// ── Invite Tokens ──────────────────────────────────────────────────────────
export function listInvites() {
return db.prepare(`
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();
`,
)
.all();
}
/** Trips an admin can bind an invite to — id + title only, for the picker (#1402). */
@@ -447,7 +513,10 @@ 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 }) {
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');
@@ -465,18 +534,22 @@ export function createInvite(createdBy: number, data: { max_uses?: string | numb
}
}
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 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(`
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);
`,
)
.get(inviteId);
return { invite, inviteId, uses, expiresInDays: data.expires_in_days ?? null, tripId };
}
@@ -491,57 +564,82 @@ export function deleteInvite(id: string) {
// ── Bag Tracking ───────────────────────────────────────────────────────────
export function getBagTracking() {
const row = db.prepare("SELECT value FROM app_settings WHERE key = 'bag_tracking_enabled'").get() as { value: string } | undefined;
const row = db.prepare("SELECT value FROM app_settings WHERE key = 'bag_tracking_enabled'").get() as
| { value: string }
| undefined;
return { enabled: row?.value === 'true' };
}
export function updateBagTracking(enabled: boolean) {
db.prepare("INSERT OR REPLACE INTO app_settings (key, value) VALUES ('bag_tracking_enabled', ?)").run(enabled ? 'true' : 'false');
db.prepare("INSERT OR REPLACE INTO app_settings (key, value) VALUES ('bag_tracking_enabled', ?)").run(
enabled ? 'true' : 'false',
);
return { enabled: !!enabled };
}
// ── Places Photos ─────────────────────────────────────────────────────────
export function getPlacesPhotos() {
const row = db.prepare("SELECT value FROM app_settings WHERE key = 'places_photos_enabled'").get() as { value: string } | undefined;
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');
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;
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');
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;
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');
db.prepare("INSERT OR REPLACE INTO app_settings (key, value) VALUES ('places_details_enabled', ?)").run(
enabled ? 'true' : 'false',
);
return { enabled: !!enabled };
}
// ── Collab Features ───────────────────────────────────────────────────────
const COLLAB_FEATURE_KEYS = ['collab_chat_enabled', 'collab_notes_enabled', 'collab_polls_enabled', 'collab_whatsnext_enabled'] as const;
const COLLAB_FEATURE_KEYS = [
'collab_chat_enabled',
'collab_notes_enabled',
'collab_polls_enabled',
'collab_whatsnext_enabled',
] as const;
export function getCollabFeatures() {
const rows = db.prepare("SELECT key, value FROM app_settings WHERE key IN ('collab_chat_enabled', 'collab_notes_enabled', 'collab_polls_enabled', 'collab_whatsnext_enabled')").all() as { key: string; value: string }[];
const rows = db
.prepare(
"SELECT key, value FROM app_settings WHERE key IN ('collab_chat_enabled', 'collab_notes_enabled', 'collab_polls_enabled', 'collab_whatsnext_enabled')",
)
.all() as { key: string; value: string }[];
const map: Record<string, string> = {};
for (const r of rows) map[r.key] = r.value;
return {
@@ -552,10 +650,20 @@ export function getCollabFeatures() {
};
}
export function updateCollabFeatures(features: { chat?: boolean; notes?: boolean; polls?: boolean; whatsnext?: boolean }) {
const mapping: Record<string, string> = { chat: 'collab_chat_enabled', notes: 'collab_notes_enabled', polls: 'collab_polls_enabled', whatsnext: 'collab_whatsnext_enabled' };
export function updateCollabFeatures(features: {
chat?: boolean;
notes?: boolean;
polls?: boolean;
whatsnext?: boolean;
}) {
const mapping: Record<string, string> = {
chat: 'collab_chat_enabled',
notes: 'collab_notes_enabled',
polls: 'collab_polls_enabled',
whatsnext: 'collab_whatsnext_enabled',
};
const before = getCollabFeatures();
const stmt = db.prepare("INSERT OR REPLACE INTO app_settings (key, value) VALUES (?, ?)");
const stmt = db.prepare('INSERT OR REPLACE INTO app_settings (key, value) VALUES (?, ?)');
for (const [feat, key] of Object.entries(mapping)) {
if (features[feat] !== undefined) stmt.run(key, features[feat] ? 'true' : 'false');
}
@@ -563,38 +671,50 @@ export function updateCollabFeatures(features: { chat?: boolean; notes?: boolean
// Collab flags gate MCP tool/resource registration, so callers must know
// whether anything actually flipped — a no-op save must not tear down every
// live MCP session (#1414).
const changed = (Object.keys(after) as Array<keyof typeof after>).some(k => after[k] !== before[k]);
const changed = (Object.keys(after) as Array<keyof typeof after>).some((k) => after[k] !== before[k]);
return { features: after, changed };
}
// ── Packing Templates ──────────────────────────────────────────────────────
export function listPackingTemplates() {
return db.prepare(`
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();
`,
)
.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(`
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);
`,
)
.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 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 };
}
@@ -619,20 +739,29 @@ 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);
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);
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);
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);
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 {};
@@ -642,17 +771,24 @@ export function deleteTemplateCategory(templateId: string, catId: string) {
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);
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);
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);
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) };
}
@@ -672,16 +808,31 @@ export function isAddonEnabled(addonId: string): boolean {
export function listAddons() {
const addons = db.prepare('SELECT * FROM addons ORDER BY sort_order, id').all() as Addon[];
const providers = db.prepare(`
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(`
`,
)
.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<{
`,
)
.all() as Array<{
provider_id: string;
field_key: string;
label: string;
@@ -701,14 +852,15 @@ export function listAddons() {
}
return [
...addons.map(a => ({
...addons.map((a) => ({
...a,
enabled: !!a.enabled,
config: a.id === ADDON_IDS.LLM_PARSING
? maskLlmAddonConfig(JSON.parse(a.config || '{}'))
: JSON.parse(a.config || '{}'),
config:
a.id === ADDON_IDS.LLM_PARSING
? maskLlmAddonConfig(JSON.parse(a.config || '{}'))
: JSON.parse(a.config || '{}'),
})),
...providers.map(p => ({
...providers.map((p) => ({
id: p.id,
name: p.name,
description: p.description,
@@ -716,7 +868,7 @@ export function listAddons() {
icon: p.icon,
enabled: !!p.enabled,
config: getPhotoProviderConfig(p.id),
fields: (fieldsByProvider.get(p.id) || []).map(f => ({
fields: (fieldsByProvider.get(p.id) || []).map((f) => ({
key: f.field_key,
label: f.label,
input_type: f.input_type,
@@ -734,44 +886,52 @@ export function listAddons() {
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;
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.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;
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);
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 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 || '{}'),
}
...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,
}
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
@@ -779,31 +939,45 @@ export function updateAddon(id: string, data: { enabled?: boolean; config?: Reco
// 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,
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 },
auditDetails: {
enabled: data.enabled !== undefined ? !!data.enabled : undefined,
config_changed: data.config !== undefined,
},
};
}
// ── MCP Tokens ─────────────────────────────────────────────────────────────
export function listMcpTokens() {
return db.prepare(`
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();
`,
)
.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;
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);
@@ -813,7 +987,9 @@ export function deleteMcpToken(id: string) {
// ── OAuth Sessions ─────────────────────────────────────────────────────────
export function listOAuthSessions() {
const rows = db.prepare(`
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
@@ -822,12 +998,16 @@ export function listOAuthSessions() {
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) }));
`,
)
.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;
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);
+5 -3
View File
@@ -1,5 +1,5 @@
import path from 'path';
import { existsSync, promises as fs } from 'fs';
import path from 'path';
/**
* In-app Help/Wiki content, sourced from the `wiki/**` directory that ships with
@@ -13,7 +13,7 @@ import { existsSync, promises as fs } from 'fs';
* GitHub directly either way; images are proxied through /api/help/asset.
*/
const REPO = 'mauriceboe/TREK';
const REPO = 'iiketrek/TREK';
const RAW_BASE = `https://raw.githubusercontent.com/${REPO}/main/wiki`;
const TTL_MS = 60 * 60 * 1000; // remote fallback only: refresh from GitHub at most hourly
const SLUG_RE = /^[A-Za-z0-9][A-Za-z0-9._-]*$/;
@@ -33,7 +33,9 @@ const WIKI_DIR = process.env.TREK_WIKI_DIR ?? path.join(__dirname, '..', '..', '
const useLocalWiki = existsSync(path.join(WIKI_DIR, '_Sidebar.md'));
if (!useLocalWiki) {
console.warn(`[help] wiki not found at ${WIKI_DIR} — falling back to the GitHub wiki (help may not match this version)`);
console.warn(
`[help] wiki not found at ${WIKI_DIR} — falling back to the GitHub wiki (help may not match this version)`,
);
}
export class WikiNotFound extends Error {