refactor(server): shrink the seam bridges to their live exports

This commit is contained in:
jubnl
2026-08-11 16:23:46 +02:00
parent 7880eba6fb
commit 0718bd1b0c
14 changed files with 87 additions and 181 deletions
+11 -20
View File
@@ -3,19 +3,18 @@ import { DatabaseService } from '../database/database.service';
import { AddonsService } from './addons.service';
/**
* Non-Nest entry point for the addons domain — for code running OUTSIDE the
* Nest container (the legacy MCP registrars/resources, systemNotices, the
* pre-init platform.routes Express router, the module-level `when:` gate
* closures in the DI-discovered *.mcp.ts files, and the legacy
* oauthService/airtrailSync/thumbnailService modules). Exports the legacy
* services/adminService names 1:1 so repointing a consumer is an
* import-path-only diff. Inside the container, inject AddonsService instead.
* Delete this file when the last legacy consumer migrates.
* Non-Nest entry point for the addons domain — pinned by the MCP/OAuth
* transport (src/mcp/index.ts) and the pre-init platform.routes Express
* router, both mounted BEFORE app.init(), so the container is not available
* to them; systemNotices/conditions.ts reads it too until its enablement
* check is threaded in from the DI side. Inside the container, inject
* AddonsService instead (the `when:` gates in *.mcp.ts files do, via
* addon-gate.ts). Delete this file when the MCP/OAuth mount moves behind the
* container.
*
* Only the enablement READS are bridged — the paired writers
* (updateBagTracking/updateCollabFeatures) have in-container consumers only.
* The reads are uncached per-call queries on purpose: admin toggles must stay
* immediately visible without invalidation wiring.
* Only the enablement READ is bridged, as an uncached per-call query on
* purpose: admin toggles must stay immediately visible without invalidation
* wiring.
*
* Module-level construction is safe: `db` is the reinitialize-proof Proxy onto
* the shared better-sqlite3 singleton.
@@ -25,11 +24,3 @@ const addons = new AddonsService(new DatabaseService(db));
export function isAddonEnabled(addonId: string): boolean {
return addons.isAddonEnabled(addonId);
}
export function getBagTracking(): { enabled: boolean } {
return addons.getBagTracking();
}
export function getCollabFeatures(): { chat: boolean; notes: boolean; polls: boolean; whatsnext: boolean } {
return addons.getCollabFeatures();
}
+2 -2
View File
@@ -12,8 +12,8 @@ import { cacheKeyFor, getCached, nominatimFetch, setCached } from '../geo/nomina
// per-country store, geocode/region caches, country poly/box indexes, the
// shared Nominatim throttle and the geocodingInFlight dedup set) are
// file-scoped so their lifetime stays process-global no matter how many
// AtlasService instances exist (container singleton + auth.bridge.ts +
// test helpers) — instance state would duplicate the multi-MB indexes the
// AtlasService instances exist (container singleton + test helpers) —
// instance state would duplicate the multi-MB indexes the
// #1576 OOM fix exists to avoid and split the ≥1.1s Nominatim throttle.
// Relocated verbatim from the legacy services/atlasService.ts fold.
+1 -1
View File
@@ -46,7 +46,7 @@ function countryCodeFromRegionCode(regionCode: string): string {
* The pure-geo machinery (bundled admin0/admin1 boundaries, point-in-polygon
* indexes, Nominatim geocoding and its caches) lives in ./atlas-geo — a plain
* module so the multi-MB caches stay process-global across the container
* instance, the auth.bridge.ts instance, and test helpers (#1576).
* instance and test helpers (#1576).
* Returns native service shapes; the client-facing contracts live in
* @trek/shared. No broadcasts: atlas rows are uid-scoped, not trip-scoped.
*/
+6 -10
View File
@@ -3,13 +3,12 @@ import { DatabaseService } from '../database/database.service';
import { AuditService } from './audit.service';
/**
* Non-Nest entry point for the audit domain — for code running OUTSIDE the
* Nest container (src/index.ts lazy-requires, src/mcp/index.ts and
* oauthProvider.ts, and the legacy airtrail/immich/oauth services). Exports
* the full legacy services/auditLog surface 1:1 so repointing a consumer is an
* import-path-only diff (log*-only consumers import audit-log.logger
* directly). Inside the container, inject AuditService instead. Delete this
* file when the last legacy consumer migrates.
* Non-Nest entry point for the audit domain — pinned by the MCP/OAuth
* transport (src/mcp/index.ts and oauthProvider.ts), which platform.routes.ts
* mounts on the Express app BEFORE app.init(), so the container is not
* available to it. Inside the container, inject AuditService instead; log*-only
* consumers import audit-log.logger directly. Delete this file when the
* MCP/OAuth mount moves behind the container.
*
* Module-level construction is safe: `db` is the reinitialize-proof Proxy onto
* the shared better-sqlite3 singleton.
@@ -21,6 +20,3 @@ export function writeAudit(entry: Parameters<AuditService['writeAudit']>[0]): vo
}
export { getClientIp } from './client-ip';
// LOG_LEVEL has no external consumers today — re-exported purely for 1:1
// surface parity with the legacy module.
export { LOG_LEVEL, logInfo, logDebug, logError, logWarn } from './audit-log.logger';
+13 -40
View File
@@ -11,31 +11,23 @@ import { User } from '../../types';
import { EphemeralTokenService } from './ephemeral-token.service';
/**
* Non-Nest entry point for the auth domain — for code running OUTSIDE the
* Nest container: the legacy MCP transport (mcp/index.ts token verification),
* the legacy tool registrars (mcp/tools/journey|notifications|transports), the
* not-yet-migrated services/adminService, and — as the one in-container
* exception —
* atlas.mcp.ts. The cycle that used to make that mandatory is gone
* (getTravelStats moved to AtlasService, so AuthModule no longer imports
* AtlasModule); what keeps it here is cost, not correctness: a direct import
* would drag the whole auth chain into every Atlas consumer for one
* isDemoUser call. Same open trade-off as places.mcp.ts keeping its
* assignments.bridge imports.
* Non-Nest entry point for the auth domain — pinned by the MCP transport
* (src/mcp/index.ts token verification), which platform.routes.ts mounts on
* the Express app BEFORE app.init(), so the container is not available to it.
* Inside the container, inject AuthService/TokenService instead. Delete this
* file when the MCP/OAuth mount moves behind the container.
*
* Exports the legacy services/authService names 1:1 so repointing a consumer
* is an import-path-only diff. Inside the container, inject AuthService
* instead. Delete exports as their last legacy consumer migrates.
*
* stripUserForClient is pure and re-exported straight from auth.helpers.ts;
* avatarUrl keeps the legacy re-export shape (services/avatarUrl). The
* pending-MFA and reset-throttle maps are module-scoped in auth.service.ts,
* so this instance and the container singleton share one copy.
* Only two exports remain: verifyMcpToken lives on TokenService, but
* verifyJwtToken is login identity and deliberately stays on AuthService
* (see token.service.ts), so the whole AuthService collaborator graph below
* exists to serve that one call. WebauthnConfigService and UserCleanupService
* are constructed only because AuthService takes them — nothing this file
* exports reaches either one. The pending-MFA and reset-throttle maps are
* module-scoped in auth.service.ts, so this instance and the container
* singleton share one copy.
*
* Module-level construction is safe: `db` is the reinitialize-proof Proxy onto
* the shared better-sqlite3 singleton (same pattern as permissions.bridge.ts).
* WebauthnConfigService and UserCleanupService are constructed only because
* AuthService takes them — nothing this file exports reaches either one.
*/
const dbs = new DatabaseService(db);
const permissions = new PermissionsService(dbs);
@@ -51,13 +43,6 @@ const auth = new AuthService(
const tokens = new TokenService(dbs, new EphemeralTokenService());
export { stripUserForClient } from './auth.helpers';
export { avatarUrl } from '../common/avatarUrl';
export function isDemoUser(userId: number): boolean {
return auth.isDemoUser(userId);
}
export function verifyMcpToken(rawToken: string): User | null {
return tokens.verifyMcpToken(rawToken);
}
@@ -65,15 +50,3 @@ export function verifyMcpToken(rawToken: string): User | null {
export function verifyJwtToken(token: string): User | null {
return auth.verifyJwtToken(token);
}
export function resolveAuthToggles() {
return auth.resolveAuthToggles();
}
export function generateToken(user: { id: number | bigint; password_version?: number }, rememberMe = false) {
return auth.generateToken(user, rememberMe);
}
export function createWsToken(userId: number) {
return tokens.createWsToken(userId);
}
+2 -7
View File
@@ -19,13 +19,12 @@ import { OauthService } from './oauth.service';
* pending authorization codes — is module-scoped in oauth.pending-codes.ts, so
* this instance and the container singleton see the same map. That is load-
* bearing: the consent controller writes the code through the container and
* the SDK reads it back through here.
* the SDK reads it back through here (which is why consumeAuthCode is bridged
* and createAuthCode is not).
*/
const dbs = new DatabaseService(db);
const oauth = new OauthService(dbs, new AddonsService(dbs), new AuditService(dbs));
export type { OAuthTokenInfo, PendingCode } from './oauth.service';
export function getUserByAccessToken(rawToken: string) {
return oauth.getUserByAccessToken(rawToken);
}
@@ -34,10 +33,6 @@ export function createOAuthClient(...args: Parameters<OauthService['createOAuthC
return oauth.createOAuthClient(...args);
}
export function createAuthCode(params: Parameters<OauthService['createAuthCode']>[0]) {
return oauth.createAuthCode(params);
}
export function consumeAuthCode(code: string) {
return oauth.consumeAuthCode(code);
}
@@ -1,44 +1,27 @@
import { db } from '../../db/database';
import { DatabaseService } from '../database/database.service';
import { PermissionsService } from './permissions.service';
import type { PermissionLevel } from './permissions.service';
/**
* Non-Nest entry point for the permissions domain — for code running OUTSIDE
* the Nest container (the legacy MCP _shared.ts hasTripPermission helper and
* the legacy adminService/authService/backupService).
* Exports the legacy services/permissions names 1:1 so repointing a consumer
* is an import-path-only diff. Inside the container, inject PermissionsService
* instead. Delete this file when the last legacy consumer migrates.
* Non-Nest entry point for the permissions domain — pinned by the MCP
* transport's hasTripPermission helper (src/mcp/tools/_shared.ts), which the
* pre-app.init() MCP/OAuth mount evaluates before the container exists.
* 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 (backupService restores depend on this).
* either flushes both (backup restores depend on this).
*
* 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 { PERMISSION_ACTIONS } from './permissions.service';
export type { PermissionLevel, PermissionAction } from './permissions.service';
export function invalidatePermissionsCache(): void {
permissions.invalidatePermissionsCache();
}
export function getPermissionLevel(actionKey: string): PermissionLevel {
return permissions.getPermissionLevel(actionKey);
}
export function getAllPermissions(): Record<string, PermissionLevel> {
return permissions.getAllPermissions();
}
export function savePermissions(settings: Record<string, string>): { skipped: string[] } {
return permissions.savePermissions(settings);
}
export function checkPermission(
actionKey: string,
userRole: string,
+15 -1
View File
@@ -58,7 +58,21 @@ import { runMigrations } from '../../src/db/migrations';
import { resetTestDb, resetRateLimits } from '../helpers/test-db';
import { createUser } from '../helpers/factories';
import { authCookie } from '../helpers/auth';
import { createOAuthClient, createAuthCode, getUserByAccessToken } from '../../src/nest/oauth/oauth.bridge';
import { createOAuthClient, getUserByAccessToken } from '../../src/nest/oauth/oauth.bridge';
import { OauthService } from '../../src/nest/oauth/oauth.service';
import { DatabaseService } from '../../src/nest/database/database.service';
import { AddonsService } from '../../src/nest/addons/addons.service';
import { AuditService } from '../../src/nest/audit/audit.service';
// In production the consent controller writes pending codes through the
// container instance and the SDK-mounted routes read them back through the
// bridge. createAuthCode is not bridged (only the read side is), so the tests
// write codes the same way the consent controller does: through a service
// instance. The pending-code map is module-scoped in oauth.pending-codes.ts,
// so the routes under test see every code written here.
const oauthDbs = new DatabaseService(testDb);
const containerSideOauth = new OauthService(oauthDbs, new AddonsService(oauthDbs), new AuditService(oauthDbs));
const createAuthCode = containerSideOauth.createAuthCode.bind(containerSideOauth);
let nestApp: INestApplication;
let app: Application;
+9 -1
View File
@@ -1,6 +1,8 @@
import { runMigrations } from '../../../src/db/migrations';
import { createTables } from '../../../src/db/schema';
import { invalidatePermissionsCache, savePermissions } from '../../../src/nest/permissions/permissions.bridge';
import { invalidatePermissionsCache } from '../../../src/nest/permissions/permissions.bridge';
import { PermissionsService } from '../../../src/nest/permissions/permissions.service';
import { DatabaseService } from '../../../src/nest/database/database.service';
import { addTripMember, createDay, createTrip, createUser } from '../../helpers/factories';
import { createMcpHarness, parseToolResult, type McpHarness } from '../../helpers/mcp-harness';
import { resetTestDb } from '../../helpers/test-db';
@@ -44,6 +46,12 @@ import { ReservationsService } from '../../../src/nest/reservations/reservations
import type { TransitPlace } from '../../../src/nest/transit/transit.helpers';
import { TransitService } from '../../../src/nest/transit/transit.service';
// savePermissions is no longer bridged; write through a service instance — the
// permissions cache is module-scoped, so the MCP _shared checkPermission path
// sees the write immediately.
const permissionsService = new PermissionsService(new DatabaseService(testDb));
const savePermissions = permissionsService.savePermissions.bind(permissionsService);
// The transit tools live on the DI-discovered transit.mcp.ts since the transit
// fold; the test registry builds a real TransitService (and injects a real
// ReservationsService over the mocked db proxy), so stub the provider methods
+5 -22
View File
@@ -2,12 +2,11 @@
* addons.bridge delegation — one case per bridged export (the permissions
* bridge-test precedent). The bridge constructs a module-level AddonsService
* over the reinitialize-proof db Proxy, so a real in-memory SQLite exercises
* the byte-identical SQL relocated from services/adminService (`admin-1`),
* including the polarity quirks: isAddonEnabled reads the addons integer
* column, bag tracking is opt-in (=== 'true'), collab flags opt-out
* (!== 'false').
* the byte-identical SQL relocated from services/adminService (`admin-1`).
* The bag-tracking/collab polarity quirks are pinned on the service itself
* (addons.service.test.ts) — those reads are no longer bridged.
*/
import { describe, it, expect, vi, beforeAll, beforeEach } from 'vitest';
import { describe, it, expect, vi, beforeAll } from 'vitest';
// ── DB setup ──────────────────────────────────────────────────────────────────
// The db/database mock builds a real in-memory SQLite; the test body reaches it
@@ -25,17 +24,13 @@ vi.mock('../../../src/db/database', async () => {
import { db as testDb } from '../../../src/db/database';
import { createTables } from '../../../src/db/schema';
import { runMigrations } from '../../../src/db/migrations';
import { isAddonEnabled, getBagTracking, getCollabFeatures } from '../../../src/nest/addons/addons.bridge';
import { isAddonEnabled } from '../../../src/nest/addons/addons.bridge';
beforeAll(() => {
createTables(testDb);
runMigrations(testDb);
});
beforeEach(() => {
testDb.prepare("DELETE FROM app_settings WHERE key IN ('bag_tracking_enabled', 'collab_chat_enabled', 'collab_notes_enabled', 'collab_polls_enabled', 'collab_whatsnext_enabled')").run();
});
describe('addons.bridge', () => {
it('isAddonEnabled reflects the addons row and is false for an unknown id', () => {
testDb
@@ -46,16 +41,4 @@ describe('addons.bridge', () => {
expect(isAddonEnabled('test-addon')).toBe(false);
expect(isAddonEnabled('no-such-addon')).toBe(false);
});
it('getBagTracking is opt-in: absent row is OFF, only the string true enables', () => {
expect(getBagTracking()).toEqual({ enabled: false });
testDb.prepare("INSERT OR REPLACE INTO app_settings (key, value) VALUES ('bag_tracking_enabled', 'true')").run();
expect(getBagTracking()).toEqual({ enabled: true });
});
it('getCollabFeatures is opt-out: absent rows are ON, only the string false disables', () => {
expect(getCollabFeatures()).toEqual({ chat: true, notes: true, polls: true, whatsnext: true });
testDb.prepare("INSERT OR REPLACE INTO app_settings (key, value) VALUES ('collab_polls_enabled', 'false')").run();
expect(getCollabFeatures()).toEqual({ chat: true, notes: true, polls: false, whatsnext: true });
});
});
+1 -5
View File
@@ -42,7 +42,7 @@ import { DatabaseService } from '../../../src/nest/database/database.service';
import { AuditService } from '../../../src/nest/audit/audit.service';
import { getClientIp } from '../../../src/nest/audit/client-ip';
import { logInfo, logDebug, logError } from '../../../src/nest/audit/audit-log.logger';
import { writeAudit as bridgeWriteAudit, getClientIp as bridgeGetClientIp, logInfo as bridgeLogInfo } from '../../../src/nest/audit/audit.bridge';
import { writeAudit as bridgeWriteAudit, getClientIp as bridgeGetClientIp } from '../../../src/nest/audit/audit.bridge';
const svc = new AuditService(new DatabaseService(testDb));
@@ -193,8 +193,4 @@ describe('audit.bridge delegation', () => {
it('AUDIT-SVC-016: getClientIp is the same pure helper', () => {
expect(bridgeGetClientIp).toBe(getClientIp);
});
it('AUDIT-SVC-017: the log helpers re-export the logger module', () => {
expect(bridgeLogInfo).toBe(logInfo);
});
});
+2 -29
View File
@@ -929,11 +929,6 @@ describe('auth quirk fixes', () => {
// ---------------------------------------------------------------------------
describe('auth.bridge delegation', () => {
it('AUTH-BR-001: isDemoUser returns false outside demo mode', () => {
const { user } = createUser(testDb);
expect(authBridge.isDemoUser(user.id)).toBe(false);
});
it('AUTH-BR-002: verifyMcpToken resolves a freshly created token to its user', () => {
const { user } = createUser(testDb);
const created = tokens.createMcpToken(user.id, 'bridge-case');
@@ -943,32 +938,10 @@ describe('auth.bridge delegation', () => {
expect(authBridge.verifyMcpToken('trek_no_such_token')).toBeNull();
});
it('AUTH-BR-003: generateToken + verifyJwtToken round-trip through the pv gate', () => {
it('AUTH-BR-003: verifyJwtToken round-trips a service-minted token through the pv gate', () => {
const { user } = createUser(testDb);
const token = authBridge.generateToken({ id: user.id });
const token = svc.generateToken({ id: user.id });
expect(authBridge.verifyJwtToken(token)?.id).toBe(user.id);
expect(authBridge.verifyJwtToken('not-a-jwt')).toBeNull();
});
it('AUTH-BR-004: resolveAuthToggles returns the container defaults', () => {
vi.stubEnv('OIDC_ONLY', '');
expect(authBridge.resolveAuthToggles().password_login).toBe(true);
vi.unstubAllEnvs();
});
it('AUTH-BR-005: createWsToken surfaces the 503 when the ephemeral store is down', () => {
const { user } = createUser(testDb);
// createEphemeralToken is mocked to return undefined in this suite.
expect(authBridge.createWsToken(user.id)).toEqual({ error: 'Service unavailable', status: 503 });
});
it('AUTH-BR-006: stripUserForClient re-export strips secrets', () => {
const stripped = authBridge.stripUserForClient({ id: 1, password_hash: 'x' } as never);
expect(stripped).not.toHaveProperty('password_hash');
});
it('AUTH-BR-007: avatarUrl re-export keeps the legacy shape', () => {
expect(authBridge.avatarUrl({ avatar: 'a.png' })).toBe('/uploads/avatars/a.png');
expect(authBridge.avatarUrl({ avatar: null })).toBeNull();
});
});
@@ -45,9 +45,6 @@ import { DatabaseService } from '../../../src/nest/database/database.service';
import { PermissionsService, PERMISSION_ACTIONS } from '../../../src/nest/permissions/permissions.service';
import {
checkPermission as bridgeCheckPermission,
getPermissionLevel as bridgeGetPermissionLevel,
getAllPermissions as bridgeGetAllPermissions,
savePermissions as bridgeSavePermissions,
invalidatePermissionsCache as bridgeInvalidatePermissionsCache,
} from '../../../src/nest/permissions/permissions.bridge';
@@ -251,29 +248,17 @@ describe('permissions.bridge delegation', () => {
expect(bridgeCheckPermission('trip_delete', 'user', 10, 20, true)).toBe(false);
});
it('PERM-SVC-018: savePermissions via the bridge writes the row and reports skips', () => {
const result = bridgeSavePermissions({ trip_edit: 'trip_member', bogus: 'trip_member' });
expect(result.skipped).toEqual(['bogus']);
const row = testDb.prepare('SELECT value FROM app_settings WHERE key = ?').get('perm_trip_edit') as { value: string };
expect(row.value).toBe('trip_member');
});
it('PERM-SVC-019: getPermissionLevel/getAllPermissions delegate through the bridge', () => {
expect(bridgeGetPermissionLevel('trip_create')).toBe('everybody');
const all = bridgeGetAllPermissions();
expect(all.trip_delete).toBe('trip_owner');
expect(Object.keys(all)).toHaveLength(PERMISSION_ACTIONS.length);
});
it('PERM-SVC-020: the cache is module-scoped — shared by the DI and bridge instances', () => {
// Save through the DI instance; the bridge instance sees it immediately.
// Save through the DI instance; the bridge instance sees it immediately
// (checkPermission for a plain member flips with the stored level).
svc.savePermissions({ trip_edit: 'trip_member' });
expect(bridgeGetPermissionLevel('trip_edit')).toBe('trip_member');
expect(bridgeCheckPermission('trip_edit', 'user', 10, 20, true)).toBe(true);
// Raw SQL write, then invalidate through the BRIDGE — the DI instance
// must serve the fresh value (backup-restore relies on exactly this).
testDb.prepare('INSERT OR REPLACE INTO app_settings (key, value) VALUES (?, ?)').run('perm_trip_edit', 'trip_owner');
expect(svc.getPermissionLevel('trip_edit')).toBe('trip_member'); // still cached
bridgeInvalidatePermissionsCache();
expect(svc.getPermissionLevel('trip_edit')).toBe('trip_owner');
expect(bridgeCheckPermission('trip_edit', 'user', 10, 20, true)).toBe(false);
});
});
+10 -1
View File
@@ -55,7 +55,16 @@ import { resetTestDb, resetRateLimits } from '../helpers/test-db';
import { createUser, createTrip } from '../helpers/factories';
import { authCookie } from '../helpers/auth';
import { createEphemeralToken } from '../../src/nest/auth/ephemeral-tokens';
import { createWsToken } from '../../src/nest/auth/auth.bridge';
import { TokenService } from '../../src/nest/tokens/token.service';
import { DatabaseService } from '../../src/nest/database/database.service';
import { EphemeralTokenService } from '../../src/nest/auth/ephemeral-token.service';
// The gateway consumes ws-tokens through its injected TokenService; the
// ephemeral store is module-scoped on purpose, so a directly-constructed
// instance mints tokens the app under test accepts (TokenService is a leaf —
// its own unit suite constructs it the same way).
const tokenService = new TokenService(new DatabaseService(testDb), new EphemeralTokenService());
const createWsToken = tokenService.createWsToken.bind(tokenService);
let server: http.Server;
let wsUrl: string;