refactor(server): fold the in-container bridge edges

This commit is contained in:
jubnl
2026-08-11 16:32:02 +02:00
parent 0718bd1b0c
commit 2b2c6462ec
18 changed files with 101 additions and 43 deletions
+1 -1
View File
@@ -6,7 +6,7 @@ import fs from 'fs';
import Database from 'better-sqlite3';
import { db, closeDb, reinitialize } from '../../db/database';
import { VALID_INTERVALS } from './auto-backup.settings';
import { invalidatePermissionsCache } from '../permissions/permissions.bridge';
import { invalidatePermissionsCache } from '../permissions/permissions-cache';
import { pluginsCodeRoot, pluginsDataRoot } from '../plugins/paths';
import { stageExtractedPluginTrees, applyStagedRestoreNow } from '../plugins/plugin-backup';
import { snapshotAllPluginDataDbs } from '../plugins/host/plugin-data.service';
@@ -0,0 +1,27 @@
import type { PermissionLevel } from './permissions.service';
/**
* The permissions cache — module state on purpose, in its own home (the
* oauth.pending-codes.ts precedent) so every path that must see one shared
* cache actually does: the container PermissionsService singleton, the
* permissions.bridge instance the MCP _shared helper reaches, and the plain
* backup restore path (backup.impl.ts), which is free functions by design and
* has nothing to hang an injected service off. A restore rewrites
* app_settings under the request path's feet; invalidating here is what keeps
* every reader consistent afterwards.
*/
let cache: Map<string, PermissionLevel> | null = null;
export function getPermissionsCache(): Map<string, PermissionLevel> | null {
return cache;
}
/** Install a fresh cache map and return it (the loader fills it in place). */
export function setPermissionsCache(next: Map<string, PermissionLevel>): Map<string, PermissionLevel> {
cache = next;
return next;
}
export function invalidatePermissionsCache(): void {
cache = null;
}
@@ -9,19 +9,15 @@ import { PermissionsService } from './permissions.service';
* Inside the container, inject PermissionsService instead. Delete this file
* when the MCP/OAuth mount moves behind the container.
*
* The permissions cache is module-scoped in permissions.service.ts, so this
* instance and the container singleton share one cache — invalidating through
* either flushes both (backup restores depend on this).
* The permissions cache is module-scoped in permissions-cache.ts, so this
* instance and the container singleton share one cache (backup restores
* invalidate it through that module directly).
*
* Module-level construction is safe: `db` is the reinitialize-proof Proxy onto
* the shared better-sqlite3 singleton.
*/
const permissions = new PermissionsService(new DatabaseService(db));
export function invalidatePermissionsCache(): void {
permissions.invalidatePermissionsCache();
}
export function checkPermission(
actionKey: string,
userRole: string,
@@ -1,6 +1,11 @@
import { Injectable } from '@nestjs/common';
import { DatabaseService } from '../database/database.service';
import { logError } from '../audit/audit-log.logger';
import {
getPermissionsCache,
setPermissionsCache,
invalidatePermissionsCache as invalidateSharedCache,
} from './permissions-cache';
/**
* Permission levels (hierarchical, higher includes lower):
@@ -58,21 +63,22 @@ export const PERMISSION_ACTIONS: PermissionAction[] = [
const ACTIONS_MAP = new Map(PERMISSION_ACTIONS.map(a => [a.key, a]));
// In-memory cache, invalidated on save. Deliberately MODULE-scoped, not
// instance state: two instances exist at runtime (the container singleton and
// the permissions.bridge instance for out-of-container consumers), and legacy
// callers — backupService invalidating after a restore, integration tests —
// must flush the same cache the request path reads. Both instances wrap the
// same shared connection, so a single cache is also the correct data shape.
let cache: Map<string, PermissionLevel> | null = null;
// The in-memory cache is deliberately MODULE-scoped, not instance state, and
// lives in ./permissions-cache: two service instances exist at runtime (the
// container singleton and the permissions.bridge instance for
// out-of-container consumers), and the backup restore path — plain functions,
// no DI — must flush the same cache the request path reads. Both instances
// wrap the same shared connection, so a single cache is also the correct
// data shape.
@Injectable()
export class PermissionsService {
constructor(private readonly dbs: DatabaseService) {}
private loadPermissions(): Map<string, PermissionLevel> {
if (cache) return cache;
cache = new Map<string, PermissionLevel>();
const cached = getPermissionsCache();
if (cached) return cached;
const cache = setPermissionsCache(new Map<string, PermissionLevel>());
try {
const rows = this.dbs.all<{ key: string; value: string }>(
"SELECT key, value FROM app_settings WHERE key LIKE 'perm_%'"
@@ -98,7 +104,7 @@ export class PermissionsService {
}
invalidatePermissionsCache(): void {
cache = null;
invalidateSharedCache();
}
getPermissionLevel(actionKey: string): PermissionLevel {
@@ -1,9 +1,11 @@
import { Module } from '@nestjs/common';
import { AddonsModule } from '../addons/addons.module';
import { SystemNoticesController } from './system-notices.controller';
import { SystemNoticesService } from './system-notices.service';
/** System-notices domain (L2 leaf module). Registered in AppModule. */
/** System-notices domain. Registered in AppModule. */
@Module({
imports: [AddonsModule],
controllers: [SystemNoticesController],
providers: [SystemNoticesService],
})
@@ -1,16 +1,21 @@
import { Injectable } from '@nestjs/common';
import type { SystemNoticeDto } from '@trek/shared';
import { getActiveNoticesFor, dismissNotice } from '../../systemNotices/service';
import { AddonsService } from '../addons/addons.service';
/**
* Thin Nest wrapper around the existing system-notices service. The condition
* evaluation, version gating, sorting and dismissal persistence all stay in the
* upstream service — this only adapts it for DI, so behaviour is unchanged.
* upstream service — this only adapts it for DI (and threads the injected
* addon-enablement check into the condition context, so the plain modules
* underneath carry no bridge import), so behaviour is unchanged.
*/
@Injectable()
export class SystemNoticesService {
constructor(private readonly addons: AddonsService) {}
getActiveFor(userId: number): SystemNoticeDto[] {
return getActiveNoticesFor(userId) as SystemNoticeDto[];
return getActiveNoticesFor(userId, (addonId) => this.addons.isAddonEnabled(addonId)) as SystemNoticeDto[];
}
dismiss(userId: number, noticeId: string): boolean {
+6 -2
View File
@@ -1,11 +1,15 @@
import semver from 'semver';
import { isAddonEnabled } from '../nest/addons/addons.bridge.js';
import type { NoticeCondition, SystemNotice } from './types.js';
interface ConditionContext {
user: { login_count: number; first_seen_version: string; role: string; noTrips: number };
currentAppVersion: string;
now: Date;
/**
* Addon-enablement check, threaded in by the caller (the Nest service passes
* its injected AddonsService) so this plain module carries no bridge import.
*/
addonEnabled: (addonId: string) => boolean;
}
// Custom predicate registry — extensible without modifying this file
@@ -48,7 +52,7 @@ function evaluateOne(condition: NoticeCondition, ctx: ConditionContext): boolean
return condition.roles.includes(ctx.user.role as 'admin' | 'user');
case 'addonEnabled':
return isAddonEnabled(condition.addonId);
return ctx.addonEnabled(condition.addonId);
case 'custom': {
const fn = customPredicates.get(condition.id);
+5 -2
View File
@@ -36,7 +36,10 @@ function severityWeight(s: string): number {
return s === 'critical' ? 2 : s === 'warn' ? 1 : 0;
}
export function getActiveNoticesFor(userId: number): SystemNoticeDTO[] {
export function getActiveNoticesFor(
userId: number,
addonEnabled: (addonId: string) => boolean
): SystemNoticeDTO[] {
const user = db.prepare(
'SELECT login_count, first_seen_version, role FROM users WHERE id = ?'
).get(userId) as { login_count: number; first_seen_version: string; role: string } | undefined;
@@ -56,7 +59,7 @@ export function getActiveNoticesFor(userId: number): SystemNoticeDTO[] {
const now = new Date();
const currentAppVersion = getCurrentAppVersion();
const ctx = { user: { ...user, noTrips: tripCount }, currentAppVersion, now };
const ctx = { user: { ...user, noTrips: tripCount }, currentAppVersion, now, addonEnabled };
const appVer = semver.coerce(currentAppVersion)?.version ?? '0.0.0';
const isStillDismissed = (n: SystemNotice): boolean => {