mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-10-18 21:16:47 +00:00
refactor(nest): inject DatabaseService in db-using services
This commit is contained in:
@@ -1,5 +1,5 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { db } from '../../db/database';
|
||||
import { DatabaseService } from '../database/database.service';
|
||||
import type { Addon } from '../../types';
|
||||
import { getBagTracking, getCollabFeatures } from '../../services/adminService';
|
||||
import { getPhotoProviderConfig } from '../../services/memories/helpersService';
|
||||
@@ -12,11 +12,17 @@ import { getPhotoProviderConfig } from '../../services/memories/helpersService';
|
||||
*/
|
||||
@Injectable()
|
||||
export class AddonsService {
|
||||
constructor(private readonly dbs: DatabaseService) {}
|
||||
|
||||
private get db() {
|
||||
return this.dbs.connection;
|
||||
}
|
||||
|
||||
list() {
|
||||
const addons = db
|
||||
const addons = this.db
|
||||
.prepare('SELECT id, name, type, icon, enabled FROM addons WHERE enabled = 1 ORDER BY sort_order')
|
||||
.all() as Pick<Addon, 'id' | 'name' | 'type' | 'icon' | 'enabled'>[];
|
||||
const providers = db
|
||||
const providers = this.db
|
||||
.prepare(
|
||||
`SELECT id, name, icon, enabled, sort_order
|
||||
FROM photo_providers
|
||||
@@ -24,7 +30,7 @@ export class AddonsService {
|
||||
ORDER BY sort_order, id`,
|
||||
)
|
||||
.all() as Array<{ id: string; name: string; icon: string; enabled: number; sort_order: number }>;
|
||||
const fields = db
|
||||
const fields = this.db
|
||||
.prepare(
|
||||
`SELECT provider_id, field_key, label, input_type, placeholder, hint, required, secret, settings_key, payload_key, sort_order
|
||||
FROM photo_provider_fields
|
||||
|
||||
@@ -8,7 +8,7 @@ import { createBudgetItem, freezeForeignRate } from '../../services/budgetServic
|
||||
import { isAddonEnabled } from '../../services/adminService';
|
||||
import { ADDON_IDS } from '../../addons';
|
||||
import { searchNominatim } from '../../services/mapsService';
|
||||
import { db } from '../../db/database';
|
||||
import { DatabaseService } from '../database/database.service';
|
||||
import type { User } from '../../types';
|
||||
import { KitineraryExtractorService } from './kitinerary-extractor.service';
|
||||
import { LlmParseService } from '../llm-parse/llm-parse.service';
|
||||
@@ -17,25 +17,30 @@ import { typeToCostCategory } from '@trek/shared';
|
||||
import type { BookingImportPreviewItem, BookingImportPreviewResponse, BookingImportConfirmResponse, BookingImportMode, BookingImportFileReport, Reservation } from '@trek/shared';
|
||||
import type { ParsedBookingItem, KiReservation } from './kitinerary.types';
|
||||
|
||||
function resolveDayId(tripId: string, iso: string | null | undefined): number | null {
|
||||
if (!iso) return null;
|
||||
const date = iso.slice(0, 10);
|
||||
if (!/^\d{4}-\d{2}-\d{2}$/.test(date)) return null;
|
||||
const exact = db.prepare('SELECT id FROM days WHERE trip_id = ? AND date = ? LIMIT 1').get(tripId, date) as { id: number } | undefined;
|
||||
if (exact) return exact.id;
|
||||
// Clamp to the nearest trip day so an out-of-range / unmatched check-in still
|
||||
// resolves and the accommodation row is inserted.
|
||||
const nearest = db.prepare('SELECT id FROM days WHERE trip_id = ? ORDER BY ABS(JULIANDAY(date) - JULIANDAY(?)) ASC, date ASC LIMIT 1').get(tripId, date) as { id: number } | undefined;
|
||||
return nearest?.id ?? null;
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class BookingImportService {
|
||||
constructor(
|
||||
private readonly extractor: KitineraryExtractorService,
|
||||
private readonly llmParse: LlmParseService,
|
||||
private readonly dbs: DatabaseService,
|
||||
) {}
|
||||
|
||||
private get db() {
|
||||
return this.dbs.connection;
|
||||
}
|
||||
|
||||
private resolveDayId(tripId: string, iso: string | null | undefined): number | null {
|
||||
if (!iso) return null;
|
||||
const date = iso.slice(0, 10);
|
||||
if (!/^\d{4}-\d{2}-\d{2}$/.test(date)) return null;
|
||||
const exact = this.db.prepare('SELECT id FROM days WHERE trip_id = ? AND date = ? LIMIT 1').get(tripId, date) as { id: number } | undefined;
|
||||
if (exact) return exact.id;
|
||||
// Clamp to the nearest trip day so an out-of-range / unmatched check-in still
|
||||
// resolves and the accommodation row is inserted.
|
||||
const nearest = this.db.prepare('SELECT id FROM days WHERE trip_id = ? ORDER BY ABS(JULIANDAY(date) - JULIANDAY(?)) ASC, date ASC LIMIT 1').get(tripId, date) as { id: number } | undefined;
|
||||
return nearest?.id ?? null;
|
||||
}
|
||||
|
||||
isAvailable(): boolean {
|
||||
return this.extractor.isAvailable();
|
||||
}
|
||||
@@ -202,8 +207,8 @@ export class BookingImportService {
|
||||
// the accommodation row is actually inserted (createReservation gates on them).
|
||||
let createAccommodation: { place_id?: number; start_day_id?: number; end_day_id?: number; check_in?: string; check_out?: string; confirmation?: string } | undefined;
|
||||
if (item.type === 'hotel' && _accommodation) {
|
||||
const startDayId = resolveDayId(tripId, _accommodation.check_in);
|
||||
const endDayId = resolveDayId(tripId, _accommodation.check_out);
|
||||
const startDayId = this.resolveDayId(tripId, _accommodation.check_in);
|
||||
const endDayId = this.resolveDayId(tripId, _accommodation.check_out);
|
||||
createAccommodation = {
|
||||
place_id: placeId,
|
||||
start_day_id: startDayId ?? undefined,
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { db } from '../../db/database';
|
||||
import { DatabaseService } from '../database/database.service';
|
||||
import { broadcast } from '../../websocket';
|
||||
import { checkPermission } from '../../services/permissions';
|
||||
import type { User } from '../../types';
|
||||
@@ -15,6 +15,12 @@ type Trip = NonNullable<ReturnType<typeof svc.verifyTripAccess>>;
|
||||
*/
|
||||
@Injectable()
|
||||
export class BudgetService {
|
||||
constructor(private readonly dbs: DatabaseService) {}
|
||||
|
||||
private get db() {
|
||||
return this.dbs.connection;
|
||||
}
|
||||
|
||||
verifyTripAccess(tripId: string, userId: number) {
|
||||
return svc.verifyTripAccess(tripId, userId);
|
||||
}
|
||||
@@ -106,14 +112,14 @@ export class BudgetService {
|
||||
*/
|
||||
syncReservationPrice(tripId: string, reservationId: number, totalPrice: number, socketId: string | undefined): void {
|
||||
try {
|
||||
const reservation = db.prepare(
|
||||
const reservation = this.db.prepare(
|
||||
'SELECT id, metadata FROM reservations WHERE id = ? AND trip_id = ?',
|
||||
).get(reservationId, tripId) as { id: number; metadata: string | null } | undefined;
|
||||
if (!reservation) return;
|
||||
const meta = reservation.metadata ? JSON.parse(reservation.metadata) : {};
|
||||
meta.price = String(totalPrice);
|
||||
db.prepare('UPDATE reservations SET metadata = ? WHERE id = ?').run(JSON.stringify(meta), reservation.id);
|
||||
const updatedRes = db.prepare('SELECT * FROM reservations WHERE id = ?').get(reservation.id);
|
||||
this.db.prepare('UPDATE reservations SET metadata = ? WHERE id = ?').run(JSON.stringify(meta), reservation.id);
|
||||
const updatedRes = this.db.prepare('SELECT * FROM reservations WHERE id = ?').get(reservation.id);
|
||||
broadcast(tripId, 'reservation:updated', { reservation: updatedRes }, socketId);
|
||||
} catch (err) {
|
||||
console.error('[budget] Failed to sync price to reservation:', err);
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { db } from '../../db/database';
|
||||
import { DatabaseService } from '../database/database.service';
|
||||
import { broadcast } from '../../websocket';
|
||||
import { checkPermission } from '../../services/permissions';
|
||||
import type { User } from '../../types';
|
||||
@@ -14,6 +14,12 @@ type Trip = NonNullable<ReturnType<typeof svc.verifyTripAccess>>;
|
||||
*/
|
||||
@Injectable()
|
||||
export class CollabService {
|
||||
constructor(private readonly dbs: DatabaseService) {}
|
||||
|
||||
private get db() {
|
||||
return this.dbs.connection;
|
||||
}
|
||||
|
||||
verifyTripAccess(tripId: string, userId: number) {
|
||||
return svc.verifyTripAccess(tripId, userId);
|
||||
}
|
||||
@@ -54,7 +60,7 @@ export class CollabService {
|
||||
/** Fire-and-forget collab notification (mirrors the route's dynamic import). */
|
||||
notifyCollab(tripId: string, actor: User, preview?: string): void {
|
||||
import('../../services/notificationService').then(({ send }) => {
|
||||
const tripInfo = db.prepare('SELECT title FROM trips WHERE id = ?').get(tripId) as { title: string } | undefined;
|
||||
const tripInfo = this.db.prepare('SELECT title FROM trips WHERE id = ?').get(tripId) as { title: string } | undefined;
|
||||
const params: Record<string, string> = { trip: tripInfo?.title || 'Untitled', actor: actor.email, tripId: String(tripId) };
|
||||
if (preview !== undefined) params.preview = preview;
|
||||
send({ event: 'collab_message', actorId: actor.id, scope: 'trip', targetId: Number(tripId), params }).catch(() => {});
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { randomUUID } from 'crypto';
|
||||
import { db } from '../../db/database';
|
||||
import { DatabaseService } from '../database/database.service';
|
||||
import { exportICS } from '../../services/tripService';
|
||||
|
||||
const ninetyDaysAgo = () => {
|
||||
@@ -15,10 +15,16 @@ function feedUrl(token: string, scope: 'trip' | 'user', base: string): string {
|
||||
|
||||
@Injectable()
|
||||
export class FeedsService {
|
||||
constructor(private readonly dbs: DatabaseService) {}
|
||||
|
||||
private get db() {
|
||||
return this.dbs.connection;
|
||||
}
|
||||
|
||||
// ── Trip feed token ─────────────────────────────────────────────────────
|
||||
|
||||
private tripTokenRow(tripId: string, userId: number) {
|
||||
return db
|
||||
return this.db
|
||||
.prepare(
|
||||
'SELECT feed_token FROM trips WHERE id = ? AND (user_id = ? OR id IN (SELECT trip_id FROM trip_members WHERE user_id = ?))',
|
||||
)
|
||||
@@ -35,26 +41,26 @@ export class FeedsService {
|
||||
const row = this.tripTokenRow(tripId, userId);
|
||||
if (row?.feed_token) return { feed_url: feedUrl(row.feed_token, 'trip', base) };
|
||||
const token = randomUUID();
|
||||
db.prepare('UPDATE trips SET feed_token = ? WHERE id = ?').run(token, tripId);
|
||||
this.db.prepare('UPDATE trips SET feed_token = ? WHERE id = ?').run(token, tripId);
|
||||
return { feed_url: feedUrl(token, 'trip', base) };
|
||||
}
|
||||
|
||||
/** Rotate: always issue a fresh token, invalidating the previous URL. */
|
||||
rotateTripToken(tripId: string, base: string): { feed_url: string } {
|
||||
const token = randomUUID();
|
||||
db.prepare('UPDATE trips SET feed_token = ? WHERE id = ?').run(token, tripId);
|
||||
this.db.prepare('UPDATE trips SET feed_token = ? WHERE id = ?').run(token, tripId);
|
||||
return { feed_url: feedUrl(token, 'trip', base) };
|
||||
}
|
||||
|
||||
/** Disable: clear the token so the public URL stops resolving. */
|
||||
disableTripToken(tripId: string): void {
|
||||
db.prepare('UPDATE trips SET feed_token = NULL WHERE id = ?').run(tripId);
|
||||
this.db.prepare('UPDATE trips SET feed_token = NULL WHERE id = ?').run(tripId);
|
||||
}
|
||||
|
||||
// ── User (all-trips) feed token ──────────────────────────────────────────
|
||||
|
||||
getUserToken(userId: number, base: string): { feed_url: string | null } {
|
||||
const row = db.prepare('SELECT feed_token FROM users WHERE id = ?').get(userId) as
|
||||
const row = this.db.prepare('SELECT feed_token FROM users WHERE id = ?').get(userId) as
|
||||
| { feed_token: string | null }
|
||||
| undefined;
|
||||
return { feed_url: row?.feed_token ? feedUrl(row.feed_token, 'user', base) : null };
|
||||
@@ -64,24 +70,24 @@ export class FeedsService {
|
||||
const existing = this.getUserToken(userId, base);
|
||||
if (existing.feed_url) return { feed_url: existing.feed_url };
|
||||
const token = randomUUID();
|
||||
db.prepare('UPDATE users SET feed_token = ? WHERE id = ?').run(token, userId);
|
||||
this.db.prepare('UPDATE users SET feed_token = ? WHERE id = ?').run(token, userId);
|
||||
return { feed_url: feedUrl(token, 'user', base) };
|
||||
}
|
||||
|
||||
rotateUserToken(userId: number, base: string): { feed_url: string } {
|
||||
const token = randomUUID();
|
||||
db.prepare('UPDATE users SET feed_token = ? WHERE id = ?').run(token, userId);
|
||||
this.db.prepare('UPDATE users SET feed_token = ? WHERE id = ?').run(token, userId);
|
||||
return { feed_url: feedUrl(token, 'user', base) };
|
||||
}
|
||||
|
||||
disableUserToken(userId: number): void {
|
||||
db.prepare('UPDATE users SET feed_token = NULL WHERE id = ?').run(userId);
|
||||
this.db.prepare('UPDATE users SET feed_token = NULL WHERE id = ?').run(userId);
|
||||
}
|
||||
|
||||
// ── ICS generation ───────────────────────────────────────────────────────
|
||||
|
||||
buildTripIcs(token: string): { ics: string; filename: string } | null {
|
||||
const row = db.prepare('SELECT id FROM trips WHERE feed_token = ?').get(token) as
|
||||
const row = this.db.prepare('SELECT id FROM trips WHERE feed_token = ?').get(token) as
|
||||
| { id: number }
|
||||
| undefined;
|
||||
if (!row) return null;
|
||||
@@ -101,7 +107,7 @@ export class FeedsService {
|
||||
}
|
||||
|
||||
buildUserIcs(token: string): { ics: string; calName: string } | null {
|
||||
const user = db.prepare('SELECT id, username FROM users WHERE feed_token = ?').get(token) as
|
||||
const user = this.db.prepare('SELECT id, username FROM users WHERE feed_token = ?').get(token) as
|
||||
| { id: number; username: string }
|
||||
| undefined;
|
||||
if (!user) return null;
|
||||
@@ -110,7 +116,7 @@ export class FeedsService {
|
||||
// "All Trips" means every trip the user can open — trips they own AND trips shared with
|
||||
// them as a member — mirroring the single-trip feed's access (tripTokenRow/assertAccess).
|
||||
// A membership WHERE on trips selects each row once, so owned + member trips don't dupe.
|
||||
const trips = db
|
||||
const trips = this.db
|
||||
.prepare(
|
||||
`SELECT id FROM trips
|
||||
WHERE (user_id = ? OR id IN (SELECT trip_id FROM trip_members WHERE user_id = ?))
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { db } from '../../db/database';
|
||||
import { DatabaseService } from '../database/database.service';
|
||||
import * as svc from '../../services/journeyService';
|
||||
import * as share from '../../services/journeyShareService';
|
||||
import { uploadToImmich, streamImmichAsset } from '../../services/memories/immichService';
|
||||
@@ -16,6 +16,12 @@ import type { Response } from 'express';
|
||||
*/
|
||||
@Injectable()
|
||||
export class JourneyService {
|
||||
constructor(private readonly dbs: DatabaseService) {}
|
||||
|
||||
private get db() {
|
||||
return this.dbs.connection;
|
||||
}
|
||||
|
||||
journeyAddonEnabled(): boolean {
|
||||
return isAddonEnabled(ADDON_IDS.JOURNEY);
|
||||
}
|
||||
@@ -73,7 +79,7 @@ export class JourneyService {
|
||||
|
||||
// Immich mirror (only when the user opted in via integration settings)
|
||||
immichAutoUploadEnabled(userId: number): boolean {
|
||||
const prefs = db.prepare('SELECT immich_auto_upload FROM users WHERE id = ?').get(userId) as { immich_auto_upload?: number } | undefined;
|
||||
const prefs = this.db.prepare('SELECT immich_auto_upload FROM users WHERE id = ?').get(userId) as { immich_auto_upload?: number } | undefined;
|
||||
return !!prefs?.immich_auto_upload;
|
||||
}
|
||||
uploadToImmich(userId: number, relativePath: string, originalName: string) { return uploadToImmich(userId, relativePath, originalName); }
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { db } from '../../db/database';
|
||||
import { DatabaseService } from '../database/database.service';
|
||||
import { broadcast } from '../../websocket';
|
||||
import { checkPermission } from '../../services/permissions';
|
||||
import type { User } from '../../types';
|
||||
@@ -17,6 +17,12 @@ type Trip = NonNullable<ReturnType<typeof svc.verifyTripAccess>>;
|
||||
*/
|
||||
@Injectable()
|
||||
export class PackingService {
|
||||
constructor(private readonly dbs: DatabaseService) {}
|
||||
|
||||
private get db() {
|
||||
return this.dbs.connection;
|
||||
}
|
||||
|
||||
verifyTripAccess(tripId: string, userId: number) {
|
||||
return svc.verifyTripAccess(tripId, userId);
|
||||
}
|
||||
@@ -63,7 +69,7 @@ export class PackingService {
|
||||
/** Reads an item's current privacy fields (#858) before an update, so the
|
||||
* controller can detect a public↔private transition and route the broadcast. */
|
||||
getItemPrivacy(tripId: string, id: string): PrivacyFields | undefined {
|
||||
return db.prepare('SELECT is_private, owner_id FROM packing_items WHERE id = ? AND trip_id = ?').get(id, tripId) as PrivacyFields | undefined;
|
||||
return this.db.prepare('SELECT is_private, owner_id FROM packing_items WHERE id = ? AND trip_id = ?').get(id, tripId) as PrivacyFields | undefined;
|
||||
}
|
||||
|
||||
createItem(tripId: string, data: Parameters<typeof svc.createItem>[1], ownerId?: number) {
|
||||
@@ -146,7 +152,7 @@ export class PackingService {
|
||||
notifyTagged(tripId: string, actor: User, category: string, userIds: unknown): void {
|
||||
if (!Array.isArray(userIds) || userIds.length === 0) return;
|
||||
import('../../services/notificationService').then(({ send }) => {
|
||||
const tripInfo = db.prepare('SELECT title FROM trips WHERE id = ?').get(tripId) as { title: string } | undefined;
|
||||
const tripInfo = this.db.prepare('SELECT title FROM trips WHERE id = ?').get(tripId) as { title: string } | undefined;
|
||||
send({
|
||||
event: 'packing_tagged',
|
||||
actorId: actor.id,
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { readEnv } from '../../../app-config';
|
||||
import { db, canAccessTrip } from '../../../db/database';
|
||||
import { DatabaseService } from '../../database/database.service';
|
||||
import { broadcast, broadcastToUser } from '../../../websocket';
|
||||
import { listBudgetItems } from '../../../services/budgetService';
|
||||
import { listItems as listPackingItemsSvc, createItem as createPackingItemSvc, updateItem as updatePackingItemSvc, deleteItem as deletePackingItemSvc, listBags, createBag as createBagSvc, updateBag as updateBagSvc, deleteBag as deleteBagSvc, setBagMembers } from '../../../services/packingService';
|
||||
@@ -56,9 +57,11 @@ function canEditTripAs(action: string, tripId: number, userId: number): boolean
|
||||
}
|
||||
|
||||
// Reused for costs.create so a plugin write frozen-FX and members/payers logic
|
||||
// matches a normal web-app budget write exactly (it has no injected deps).
|
||||
const budgetSvc = new BudgetService();
|
||||
const reservationsSvc = new ReservationsService();
|
||||
// matches a normal web-app budget write exactly. This factory file runs outside
|
||||
// the Nest container, so the shared connection is handed over manually.
|
||||
const dbs = new DatabaseService(db);
|
||||
const budgetSvc = new BudgetService(dbs);
|
||||
const reservationsSvc = new ReservationsService(dbs);
|
||||
// The booking notification the REST controller sends after a create/update/delete
|
||||
// is fire-and-forget, so it never blocks the plugin write.
|
||||
function notifyBooking(actingUserId: number, tripId: number, booking: string, type: string): void {
|
||||
@@ -424,7 +427,7 @@ export function createRealRpcHost(id: string, granted: ReadonlySet<string>, rout
|
||||
// The acting user's own decrypted value for one of this plugin's user-scope settings.
|
||||
getUserSetting: (pluginId, userId, key) => readUserSettingDecrypted(pluginId, userId, key),
|
||||
// A short-lived OAuth access token for the acting user (host-brokered; refreshes).
|
||||
getOAuthToken: (pluginId, userId) => new PluginOAuthService().getAccessToken(pluginId, userId, Date.now()),
|
||||
getOAuthToken: (pluginId, userId) => new PluginOAuthService(dbs).getAccessToken(pluginId, userId, Date.now()),
|
||||
// Persistent scheduler (jobs:run). Caps bound the abuse surface: a plugin can't
|
||||
// hoard timers, name-bomb, ship a huge payload, or busy-loop a recurring task.
|
||||
schedulerSet: (name, dueAt, everyMs, payload) => {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import crypto from 'node:crypto';
|
||||
import { db } from '../../db/database';
|
||||
import { DatabaseService } from '../database/database.service';
|
||||
import { encrypt_api_key, decrypt_api_key } from '../../services/apiKeyCrypto';
|
||||
import { getAppUrl } from '../../services/notifications';
|
||||
import { isPrivateIp } from './install/safe-fetch';
|
||||
@@ -65,10 +65,16 @@ function assertSafeHttps(urlStr: string, what: string): URL {
|
||||
|
||||
@Injectable()
|
||||
export class PluginOAuthService {
|
||||
constructor(private readonly dbs: DatabaseService) {}
|
||||
|
||||
private get db() {
|
||||
return this.dbs.connection;
|
||||
}
|
||||
|
||||
/** The plugin's decrypted OAuth provider config from its INSTANCE settings, or null
|
||||
* when any required piece is missing/blank. */
|
||||
providerConfig(pluginId: string): OAuthProviderConfig | null {
|
||||
const row = db.prepare('SELECT config FROM plugins WHERE id = ?').get(pluginId) as { config: string } | undefined;
|
||||
const row = this.db.prepare('SELECT config FROM plugins WHERE id = ?').get(pluginId) as { config: string } | undefined;
|
||||
if (!row) return null;
|
||||
let cfg: Record<string, unknown>;
|
||||
try {
|
||||
@@ -92,7 +98,7 @@ export class PluginOAuthService {
|
||||
/** Whether the acting user has a stored token for this plugin. */
|
||||
status(pluginId: string, userId: number): { configured: boolean; connected: boolean } {
|
||||
const configured = this.providerConfig(pluginId) !== null;
|
||||
const tok = db.prepare('SELECT 1 FROM plugin_oauth_tokens WHERE plugin_id = ? AND user_id = ? AND access_token IS NOT NULL').get(pluginId, userId);
|
||||
const tok = this.db.prepare('SELECT 1 FROM plugin_oauth_tokens WHERE plugin_id = ? AND user_id = ? AND access_token IS NOT NULL').get(pluginId, userId);
|
||||
return { configured, connected: !!tok };
|
||||
}
|
||||
|
||||
@@ -108,8 +114,8 @@ export class PluginOAuthService {
|
||||
const state = b64url(crypto.randomBytes(24));
|
||||
|
||||
// Drop this user's stale states for the plugin, then store the fresh one.
|
||||
db.prepare('DELETE FROM plugin_oauth_state WHERE plugin_id = ? AND user_id = ?').run(pluginId, userId);
|
||||
db.prepare('INSERT INTO plugin_oauth_state (state, plugin_id, user_id, verifier, created_at) VALUES (?, ?, ?, ?, ?)').run(
|
||||
this.db.prepare('DELETE FROM plugin_oauth_state WHERE plugin_id = ? AND user_id = ?').run(pluginId, userId);
|
||||
this.db.prepare('INSERT INTO plugin_oauth_state (state, plugin_id, user_id, verifier, created_at) VALUES (?, ?, ?, ?, ?)').run(
|
||||
state,
|
||||
pluginId,
|
||||
userId,
|
||||
@@ -129,16 +135,16 @@ export class PluginOAuthService {
|
||||
|
||||
/** Complete the callback: verify state, exchange the code, store the tokens. */
|
||||
async completeCallback(pluginId: string, userId: number, code: string, state: string, nowMs: number): Promise<void> {
|
||||
const row = db.prepare('SELECT verifier, user_id, created_at FROM plugin_oauth_state WHERE state = ? AND plugin_id = ?').get(state, pluginId) as
|
||||
const row = this.db.prepare('SELECT verifier, user_id, created_at FROM plugin_oauth_state WHERE state = ? AND plugin_id = ?').get(state, pluginId) as
|
||||
| { verifier: string; user_id: number; created_at: number }
|
||||
| undefined;
|
||||
// State must exist, belong to THIS user, and be fresh — this binds the callback to
|
||||
// the connect request and blocks CSRF / a replayed/foreign state.
|
||||
if (!row || row.user_id !== userId || nowMs - row.created_at > STATE_TTL_MS) {
|
||||
db.prepare('DELETE FROM plugin_oauth_state WHERE state = ?').run(state);
|
||||
this.db.prepare('DELETE FROM plugin_oauth_state WHERE state = ?').run(state);
|
||||
throw new Error('invalid or expired OAuth state');
|
||||
}
|
||||
db.prepare('DELETE FROM plugin_oauth_state WHERE state = ?').run(state); // single-use
|
||||
this.db.prepare('DELETE FROM plugin_oauth_state WHERE state = ?').run(state); // single-use
|
||||
|
||||
const cfg = this.providerConfig(pluginId);
|
||||
if (!cfg) throw new Error('OAuth is not configured for this plugin');
|
||||
@@ -155,7 +161,7 @@ export class PluginOAuthService {
|
||||
/** A valid access token for the acting user, refreshing it if it is expiring. Null when
|
||||
* the user hasn't connected. The plugin never receives the refresh token. */
|
||||
async getAccessToken(pluginId: string, userId: number, nowMs: number): Promise<string | null> {
|
||||
const row = db.prepare('SELECT access_token, refresh_token, expires_at FROM plugin_oauth_tokens WHERE plugin_id = ? AND user_id = ?').get(pluginId, userId) as
|
||||
const row = this.db.prepare('SELECT access_token, refresh_token, expires_at FROM plugin_oauth_tokens WHERE plugin_id = ? AND user_id = ?').get(pluginId, userId) as
|
||||
| { access_token: string | null; refresh_token: string | null; expires_at: number | null }
|
||||
| undefined;
|
||||
if (!row || !row.access_token) return null;
|
||||
@@ -177,8 +183,8 @@ export class PluginOAuthService {
|
||||
}
|
||||
|
||||
disconnect(pluginId: string, userId: number): void {
|
||||
db.prepare('DELETE FROM plugin_oauth_tokens WHERE plugin_id = ? AND user_id = ?').run(pluginId, userId);
|
||||
db.prepare('DELETE FROM plugin_oauth_state WHERE plugin_id = ? AND user_id = ?').run(pluginId, userId);
|
||||
this.db.prepare('DELETE FROM plugin_oauth_tokens WHERE plugin_id = ? AND user_id = ?').run(pluginId, userId);
|
||||
this.db.prepare('DELETE FROM plugin_oauth_state WHERE plugin_id = ? AND user_id = ?').run(pluginId, userId);
|
||||
}
|
||||
|
||||
// --- internals ---
|
||||
@@ -209,7 +215,7 @@ export class PluginOAuthService {
|
||||
private storeToken(pluginId: string, userId: number, token: { access_token?: string; refresh_token?: string; expires_in?: number; scope?: string }, nowMs: number): void {
|
||||
if (!token.access_token) throw new Error('token endpoint returned no access_token');
|
||||
const expiresAt = token.expires_in ? nowMs + token.expires_in * 1000 : null;
|
||||
db.prepare(
|
||||
this.db.prepare(
|
||||
`INSERT INTO plugin_oauth_tokens (plugin_id, user_id, access_token, refresh_token, expires_at, scope, updated_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?, datetime('now'))
|
||||
ON CONFLICT(plugin_id, user_id) DO UPDATE SET
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { Injectable, type OnModuleInit, type OnModuleDestroy } from '@nestjs/common';
|
||||
import semver from 'semver';
|
||||
import { db } from '../../db/database';
|
||||
import { DatabaseService } from '../database/database.service';
|
||||
import { pluginsEnabled } from './kill-switch';
|
||||
import { setPluginEventSink } from '../../plugin-event-sink';
|
||||
import { setUserDeletedSink } from '../../plugin-user-lifecycle';
|
||||
@@ -133,18 +134,18 @@ export class PluginRuntimeService implements OnModuleInit, OnModuleDestroy {
|
||||
private readonly supervisor = new PluginSupervisor((id, granted) => createRealRpcHost(id, granted, this), {
|
||||
// Both hooks run from child lifecycle EventEmitter callbacks (exit / stderr 'data'),
|
||||
// so a throw here becomes an uncaughtException that has no host-side handler. During a
|
||||
// restore the core DB is briefly CLOSED (closeDb → the db proxy throws on access), so a
|
||||
// restore the core DB is briefly CLOSED (closeDb → the this.db proxy throws on access), so a
|
||||
// status/log write in that window would otherwise take the whole process down mid-
|
||||
// restore. Swallow any DB error — a missed status row / log line is never worth a crash.
|
||||
onStatus: (id, status, error) => {
|
||||
try {
|
||||
db.prepare('UPDATE plugins SET status = ?, last_error = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ?').run(status, error ?? null, id);
|
||||
this.db.prepare('UPDATE plugins SET status = ?, last_error = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ?').run(status, error ?? null, id);
|
||||
} catch { /* DB unavailable (e.g. mid-restore) — a status write must never crash the host */ }
|
||||
},
|
||||
onLog: (id, level, msg) => {
|
||||
if (level !== 'error' && level !== 'warn') return;
|
||||
try {
|
||||
db.prepare('INSERT INTO plugin_error_log (plugin_id, level, message) VALUES (?, ?, ?)').run(id, level, msg);
|
||||
this.db.prepare('INSERT INTO plugin_error_log (plugin_id, level, message) VALUES (?, ?, ?)').run(id, level, msg);
|
||||
// Retention: a crash-looping plugin emits a stderr line per restart, so an
|
||||
// uncapped table grows without bound in the shared trek.db. Keep only the
|
||||
// most recent LOG_RETENTION rows per plugin (the admin view shows 200).
|
||||
@@ -162,9 +163,17 @@ export class PluginRuntimeService implements OnModuleInit, OnModuleDestroy {
|
||||
// Coalesces overlapping erasure drains (the sweep and enqueue both trigger one).
|
||||
private drainInFlight: Promise<void> | null = null;
|
||||
|
||||
// Optional at the type level so tests can `new PluginRuntimeService()` without a
|
||||
// registry; Nest always injects the real one (the provider is in the module).
|
||||
constructor(private readonly registry?: PluginRegistryService) {}
|
||||
// The registry stays optional at the type level so tests can construct the
|
||||
// service without one; Nest always injects the real one (the provider is in
|
||||
// the module).
|
||||
constructor(
|
||||
private readonly dbs: DatabaseService,
|
||||
private readonly registry?: PluginRegistryService,
|
||||
) {}
|
||||
|
||||
private get db() {
|
||||
return this.dbs.connection;
|
||||
}
|
||||
|
||||
onModuleInit(): void {
|
||||
if (!pluginsEnabled()) return;
|
||||
@@ -199,7 +208,7 @@ export class PluginRuntimeService implements OnModuleInit, OnModuleDestroy {
|
||||
// boot must NEVER block app init, even in a context without plugin tables
|
||||
// (e.g. a slimmed-down test app that only imports AdminModule).
|
||||
try {
|
||||
discoverPlugins(db);
|
||||
discoverPlugins(this.db);
|
||||
const installed = this.installedDepRows();
|
||||
const enabledIds = [...installed.values()].filter((r) => r.enabled).map((r) => r.id);
|
||||
let order: string[];
|
||||
@@ -246,13 +255,13 @@ export class PluginRuntimeService implements OnModuleInit, OnModuleDestroy {
|
||||
if (active.length === 0) return;
|
||||
const now = Date.now();
|
||||
const ph = active.map(() => '?').join(',');
|
||||
const due = db
|
||||
const due = this.db
|
||||
.prepare(`SELECT id, plugin_id, name, payload, every_ms FROM plugin_scheduled_tasks WHERE due_at <= ? AND plugin_id IN (${ph}) ORDER BY due_at LIMIT 200`)
|
||||
.all(now, ...active) as Array<{ id: number; plugin_id: string; name: string; payload: string; every_ms: number | null }>;
|
||||
for (const t of due) {
|
||||
if (!this.supervisor.isActive(t.plugin_id)) continue; // leave for a later sweep
|
||||
if (t.every_ms) db.prepare('UPDATE plugin_scheduled_tasks SET due_at = ? WHERE id = ?').run(now + t.every_ms, t.id);
|
||||
else db.prepare('DELETE FROM plugin_scheduled_tasks WHERE id = ?').run(t.id);
|
||||
if (t.every_ms) this.db.prepare('UPDATE plugin_scheduled_tasks SET due_at = ? WHERE id = ?').run(now + t.every_ms, t.id);
|
||||
else this.db.prepare('DELETE FROM plugin_scheduled_tasks WHERE id = ?').run(t.id);
|
||||
let payload: unknown = null;
|
||||
try { payload = JSON.parse(t.payload); } catch { /* corrupt payload -> null */ }
|
||||
this.supervisor.deliverScheduled(t.plugin_id, t.name, payload);
|
||||
@@ -268,8 +277,8 @@ export class PluginRuntimeService implements OnModuleInit, OnModuleDestroy {
|
||||
* throws — a bookkeeping error must not fail the account deletion that triggered it. */
|
||||
private enqueueUserErasure(userId: number): void {
|
||||
try {
|
||||
const rows = db.prepare('SELECT id, permissions FROM plugins').all() as Array<{ id: string; permissions: string | null }>;
|
||||
const insert = db.prepare('INSERT OR IGNORE INTO plugin_user_erasure_queue (plugin_id, user_id) VALUES (?, ?)');
|
||||
const rows = this.db.prepare('SELECT id, permissions FROM plugins').all() as Array<{ id: string; permissions: string | null }>;
|
||||
const insert = this.db.prepare('INSERT OR IGNORE INTO plugin_user_erasure_queue (plugin_id, user_id) VALUES (?, ?)');
|
||||
for (const r of rows) {
|
||||
let perms: unknown;
|
||||
try { perms = JSON.parse(r.permissions ?? '[]'); } catch { perms = []; }
|
||||
@@ -304,12 +313,12 @@ export class PluginRuntimeService implements OnModuleInit, OnModuleDestroy {
|
||||
// honour the erasure (see uninstall()); reaping on registry-absence alone would wipe
|
||||
// exactly those preserved obligations. A deleteData=true uninstall already clears the
|
||||
// rows itself, so this only ever needs to catch a truly orphaned data dir.
|
||||
const orphans = db
|
||||
const orphans = this.db
|
||||
.prepare('SELECT DISTINCT plugin_id FROM plugin_user_erasure_queue WHERE plugin_id NOT IN (SELECT id FROM plugins)')
|
||||
.all() as Array<{ plugin_id: string }>;
|
||||
for (const { plugin_id } of orphans) {
|
||||
if (!fs.existsSync(pluginDataDir(plugin_id))) {
|
||||
db.prepare('DELETE FROM plugin_user_erasure_queue WHERE plugin_id = ?').run(plugin_id);
|
||||
this.db.prepare('DELETE FROM plugin_user_erasure_queue WHERE plugin_id = ?').run(plugin_id);
|
||||
}
|
||||
}
|
||||
// Only ACTIVE plugins can be delivered to; scope the window to them so a backlog
|
||||
@@ -317,13 +326,13 @@ export class PluginRuntimeService implements OnModuleInit, OnModuleDestroy {
|
||||
const active = this.supervisor.activeIds();
|
||||
if (active.length === 0) return;
|
||||
const ph = active.map(() => '?').join(',');
|
||||
const pending = db
|
||||
const pending = this.db
|
||||
.prepare(`SELECT id, plugin_id, user_id FROM plugin_user_erasure_queue WHERE plugin_id IN (${ph}) ORDER BY id LIMIT 200`)
|
||||
.all(...active) as Array<{ id: number; plugin_id: string; user_id: number }>;
|
||||
for (const row of pending) {
|
||||
if (!this.supervisor.isActive(row.plugin_id)) continue; // retry after it reactivates
|
||||
const done = await this.supervisor.deliverUserErasure(row.plugin_id, row.user_id);
|
||||
if (done) db.prepare('DELETE FROM plugin_user_erasure_queue WHERE id = ?').run(row.id);
|
||||
if (done) this.db.prepare('DELETE FROM plugin_user_erasure_queue WHERE id = ?').run(row.id);
|
||||
}
|
||||
} catch {
|
||||
/* a drain pass must never break the runtime */
|
||||
@@ -336,7 +345,7 @@ export class PluginRuntimeService implements OnModuleInit, OnModuleDestroy {
|
||||
async exportUserData(userId: number): Promise<Array<{ pluginId: string; data?: unknown; pending?: boolean; settings?: Record<string, unknown>; oauthConnected?: boolean }>> {
|
||||
const out: Array<{ pluginId: string; data?: unknown; pending?: boolean; settings?: Record<string, unknown>; oauthConnected?: boolean }> = [];
|
||||
if (!pluginsEnabled()) return out;
|
||||
const rows = db.prepare('SELECT id, permissions FROM plugins').all() as Array<{ id: string; permissions: string | null }>;
|
||||
const rows = this.db.prepare('SELECT id, permissions FROM plugins').all() as Array<{ id: string; permissions: string | null }>;
|
||||
for (const r of rows) {
|
||||
if (this.supervisor.isActive(r.id)) {
|
||||
const res = await this.supervisor.collectUserExport(r.id, userId);
|
||||
@@ -356,7 +365,7 @@ export class PluginRuntimeService implements OnModuleInit, OnModuleDestroy {
|
||||
// Fold in the host-side per-user data TREK stores itself (what erasePluginUserData
|
||||
// deletes) so an access request isn't asymmetric with erasure: the user's plugin
|
||||
// settings (secret fields masked) and which plugins they OAuth-linked. Raw tokens
|
||||
// are never exported. This is supplementary to each plugin's own-db export above, so
|
||||
// are never exported. This is supplementary to each plugin's own-this.db export above, so
|
||||
// an unexpected failure here must not drop that primary data — it's best-effort.
|
||||
try {
|
||||
const byId = new Map(out.map((o) => [o.pluginId, o]));
|
||||
@@ -366,12 +375,12 @@ export class PluginRuntimeService implements OnModuleInit, OnModuleDestroy {
|
||||
return e;
|
||||
};
|
||||
const secretKeys = new Map<string, Set<string>>();
|
||||
for (const f of db.prepare("SELECT plugin_id, field_key FROM plugin_settings_fields WHERE scope = 'user' AND secret = 1").all() as Array<{ plugin_id: string; field_key: string }>) {
|
||||
for (const f of this.db.prepare("SELECT plugin_id, field_key FROM plugin_settings_fields WHERE scope = 'user' AND secret = 1").all() as Array<{ plugin_id: string; field_key: string }>) {
|
||||
let s = secretKeys.get(f.plugin_id);
|
||||
if (!s) { s = new Set(); secretKeys.set(f.plugin_id, s); }
|
||||
s.add(f.field_key);
|
||||
}
|
||||
for (const c of db.prepare('SELECT plugin_id, config FROM plugin_user_config WHERE user_id = ?').all(userId) as Array<{ plugin_id: string; config: string }>) {
|
||||
for (const c of this.db.prepare('SELECT plugin_id, config FROM plugin_user_config WHERE user_id = ?').all(userId) as Array<{ plugin_id: string; config: string }>) {
|
||||
let cfg: Record<string, unknown> = {};
|
||||
try { cfg = JSON.parse(c.config || '{}'); } catch { /* ignore */ }
|
||||
const secrets = secretKeys.get(c.plugin_id) ?? new Set<string>();
|
||||
@@ -379,7 +388,7 @@ export class PluginRuntimeService implements OnModuleInit, OnModuleDestroy {
|
||||
for (const [k, v] of Object.entries(cfg)) masked[k] = secrets.has(k) ? '***' : v;
|
||||
entryFor(c.plugin_id).settings = masked;
|
||||
}
|
||||
for (const t of db.prepare('SELECT DISTINCT plugin_id FROM plugin_oauth_tokens WHERE user_id = ?').all(userId) as Array<{ plugin_id: string }>) {
|
||||
for (const t of this.db.prepare('SELECT DISTINCT plugin_id FROM plugin_oauth_tokens WHERE user_id = ?').all(userId) as Array<{ plugin_id: string }>) {
|
||||
entryFor(t.plugin_id).oauthConnected = true;
|
||||
}
|
||||
} catch (err) {
|
||||
@@ -395,7 +404,7 @@ export class PluginRuntimeService implements OnModuleInit, OnModuleDestroy {
|
||||
* requires. Returns the ids actually deactivated.
|
||||
*/
|
||||
async deactivateForDisabledAddon(addonId: string): Promise<string[]> {
|
||||
const rows = db.prepare('SELECT id, version, enabled, dependencies FROM plugins').all() as PluginDepRow[];
|
||||
const rows = this.db.prepare('SELECT id, version, enabled, dependencies FROM plugins').all() as PluginDepRow[];
|
||||
const directlyAffected = rows
|
||||
.filter((r) => r.enabled && parseDependencies(r.dependencies).requiredAddons.includes(addonId))
|
||||
.map((r) => r.id);
|
||||
@@ -409,7 +418,7 @@ export class PluginRuntimeService implements OnModuleInit, OnModuleDestroy {
|
||||
|
||||
/** Re-scan the plugins volume on demand (admin action). */
|
||||
rescan(): { discovered: string[]; skipped: string[] } {
|
||||
return discoverPlugins(db);
|
||||
return discoverPlugins(this.db);
|
||||
}
|
||||
|
||||
async onModuleDestroy(): Promise<void> {
|
||||
@@ -457,7 +466,7 @@ export class PluginRuntimeService implements OnModuleInit, OnModuleDestroy {
|
||||
|
||||
/** All plugin rows projected to what the dependency helpers reason over. */
|
||||
private installedDepRows(): Map<string, PluginDepRow> {
|
||||
const rows = db.prepare('SELECT id, version, enabled, dependencies FROM plugins').all() as PluginDepRow[];
|
||||
const rows = this.db.prepare('SELECT id, version, enabled, dependencies FROM plugins').all() as PluginDepRow[];
|
||||
return new Map(rows.map((r) => [r.id, r]));
|
||||
}
|
||||
|
||||
@@ -467,7 +476,7 @@ export class PluginRuntimeService implements OnModuleInit, OnModuleDestroy {
|
||||
* permission re-consent → required addon disabled → missing/mismatched plugin dependency.
|
||||
*/
|
||||
private assertActivatable(id: string, installed: Map<string, PluginDepRow>, consentWiden: boolean): void {
|
||||
const row = db.prepare('SELECT permissions, granted_permissions, dependencies, trek_range FROM plugins WHERE id = ?').get(id) as
|
||||
const row = this.db.prepare('SELECT permissions, granted_permissions, dependencies, trek_range FROM plugins WHERE id = ?').get(id) as
|
||||
| { permissions: string; granted_permissions: string; dependencies: string | null; trek_range: string | null }
|
||||
| undefined;
|
||||
if (!row) throw new Error(`plugin ${id} not found`);
|
||||
@@ -523,13 +532,13 @@ export class PluginRuntimeService implements OnModuleInit, OnModuleDestroy {
|
||||
|
||||
/** Mark a (pre-validated) plugin enabled and spawn its child. */
|
||||
private async spawnActivated(id: string): Promise<void> {
|
||||
const row = db.prepare('SELECT permissions, config FROM plugins WHERE id = ?').get(id) as
|
||||
const row = this.db.prepare('SELECT permissions, config FROM plugins WHERE id = ?').get(id) as
|
||||
| { permissions: string; config: string }
|
||||
| undefined;
|
||||
if (!row) throw new Error(`plugin ${id} not found`);
|
||||
const declared = parseArray(row.permissions).filter(isKnownPermission);
|
||||
// Mark it enabled (admin intent) so it reboots after restarts/crashes.
|
||||
db.prepare('UPDATE plugins SET granted_permissions = ?, enabled = 1 WHERE id = ?').run(JSON.stringify(declared), id);
|
||||
this.db.prepare('UPDATE plugins SET granted_permissions = ?, enabled = 1 WHERE id = ?').run(JSON.stringify(declared), id);
|
||||
const config = decryptConfig(parseObject(row.config));
|
||||
const manifestHosts = declared.filter((p) => p.startsWith(HTTP_OUTBOUND)).map((p) => p.slice(HTTP_OUTBOUND.length));
|
||||
// Union in the hosts the ADMIN added post-install. A plugin that talks to a
|
||||
@@ -550,7 +559,7 @@ export class PluginRuntimeService implements OnModuleInit, OnModuleDestroy {
|
||||
|
||||
/** Does this plugin's manifest declare that it needs operator-supplied hosts? */
|
||||
wantsOperatorEgress(id: string): boolean {
|
||||
const row = db.prepare('SELECT operator_egress FROM plugins WHERE id = ?').get(id) as { operator_egress: number } | undefined;
|
||||
const row = this.db.prepare('SELECT operator_egress FROM plugins WHERE id = ?').get(id) as { operator_egress: number } | undefined;
|
||||
return row?.operator_egress === 1;
|
||||
}
|
||||
|
||||
@@ -572,9 +581,9 @@ export class PluginRuntimeService implements OnModuleInit, OnModuleDestroy {
|
||||
if (host === '*' || !EGRESS_HOST_RE.test(host)) throw new ForbiddenResource(`invalid host "${raw}"`);
|
||||
if (!clean.includes(host)) clean.push(host);
|
||||
}
|
||||
db.transaction(() => {
|
||||
db.prepare('DELETE FROM plugin_egress_hosts WHERE plugin_id = ?').run(id);
|
||||
const ins = db.prepare('INSERT OR IGNORE INTO plugin_egress_hosts (plugin_id, host) VALUES (?, ?)');
|
||||
this.db.transaction(() => {
|
||||
this.db.prepare('DELETE FROM plugin_egress_hosts WHERE plugin_id = ?').run(id);
|
||||
const ins = this.db.prepare('INSERT OR IGNORE INTO plugin_egress_hosts (plugin_id, host) VALUES (?, ?)');
|
||||
for (const h of clean) ins.run(id, h);
|
||||
})();
|
||||
// Re-spawn so a live child actually gets the new allow-list.
|
||||
@@ -588,7 +597,7 @@ export class PluginRuntimeService implements OnModuleInit, OnModuleDestroy {
|
||||
async deactivate(id: string): Promise<void> {
|
||||
await this.supervisor.disable(id);
|
||||
closePluginDataDb(id);
|
||||
db.prepare("UPDATE plugins SET status = 'inactive', enabled = 0, updated_at = CURRENT_TIMESTAMP WHERE id = ?").run(id);
|
||||
this.db.prepare("UPDATE plugins SET status = 'inactive', enabled = 0, updated_at = CURRENT_TIMESTAMP WHERE id = ?").run(id);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -601,7 +610,7 @@ export class PluginRuntimeService implements OnModuleInit, OnModuleDestroy {
|
||||
* restart of update()/sideload() never disables a plugin's dependents.
|
||||
*/
|
||||
async deactivateWithDependents(id: string): Promise<string[]> {
|
||||
const rows = db.prepare('SELECT id, version, enabled, dependencies FROM plugins').all() as PluginDepRow[];
|
||||
const rows = this.db.prepare('SELECT id, version, enabled, dependencies FROM plugins').all() as PluginDepRow[];
|
||||
const enabledById = new Map(rows.map((r) => [r.id, r.enabled]));
|
||||
// findDependentsTransitive returns nearest-first; reverse so the deepest dependent
|
||||
// (the furthest caller) stops before the plugin it depends on.
|
||||
@@ -623,7 +632,7 @@ export class PluginRuntimeService implements OnModuleInit, OnModuleDestroy {
|
||||
* currently-running child untouched (it keeps serving the old code from memory).
|
||||
*/
|
||||
async update(id: string, opts?: { version?: string; retrustKey?: string }): Promise<{ version: string; activated: boolean; newPermissions: string[]; newEgress: string[] }> {
|
||||
const before = db.prepare('SELECT enabled, granted_permissions, version FROM plugins WHERE id = ?').get(id) as
|
||||
const before = this.db.prepare('SELECT enabled, granted_permissions, version FROM plugins WHERE id = ?').get(id) as
|
||||
| { enabled: number; granted_permissions: string; version: string | null }
|
||||
| undefined;
|
||||
if (!before) throw new Error(`plugin ${id} not found`);
|
||||
@@ -642,7 +651,7 @@ export class PluginRuntimeService implements OnModuleInit, OnModuleDestroy {
|
||||
const res = await this.registry.install(id, { version: target, retrustKey: opts?.retrustKey });
|
||||
|
||||
const declared = parseArray(
|
||||
(db.prepare('SELECT permissions FROM plugins WHERE id = ?').get(id) as { permissions: string }).permissions,
|
||||
(this.db.prepare('SELECT permissions FROM plugins WHERE id = ?').get(id) as { permissions: string }).permissions,
|
||||
).filter(isKnownPermission);
|
||||
const newGrants = declared.filter((p) => !granted.has(p));
|
||||
const newEgress = newGrants.filter((p) => p.startsWith(HTTP_OUTBOUND)).map((p) => p.slice(HTTP_OUTBOUND.length)).filter(Boolean);
|
||||
@@ -700,7 +709,7 @@ export class PluginRuntimeService implements OnModuleInit, OnModuleDestroy {
|
||||
actor: { userId: number | null; ip?: string | null },
|
||||
): Promise<{ version: string; activated: boolean; newPermissions: string[]; newEgress: string[] }> {
|
||||
if (!this.registry) throw new Error('registry service unavailable');
|
||||
const before = db.prepare('SELECT author_pubkey FROM plugins WHERE id = ?').get(id) as { author_pubkey?: string | null } | undefined;
|
||||
const before = this.db.prepare('SELECT author_pubkey FROM plugins WHERE id = ?').get(id) as { author_pubkey?: string | null } | undefined;
|
||||
const entry = await this.registry.assertRetrustable(id, publicKey); // throws unless SIGNATURE_KEY_CHANGED
|
||||
|
||||
const res = await this.update(id, { version, retrustKey: publicKey });
|
||||
@@ -735,7 +744,7 @@ export class PluginRuntimeService implements OnModuleInit, OnModuleDestroy {
|
||||
if (!this.registry) throw new Error('registry service unavailable');
|
||||
const staged = this.registry.stageUpload(bytes);
|
||||
try {
|
||||
const replaced = !!db.prepare('SELECT id FROM plugins WHERE id = ?').get(staged.id);
|
||||
const replaced = !!this.db.prepare('SELECT id FROM plugins WHERE id = ?').get(staged.id);
|
||||
// Force any replaced plugin INACTIVE before the swap: stop a running child
|
||||
// (it holds file locks and would keep executing stale code) AND clear the
|
||||
// active flag, so replaced code can never keep running — or even show active
|
||||
@@ -772,7 +781,7 @@ export class PluginRuntimeService implements OnModuleInit, OnModuleDestroy {
|
||||
if (scanForNativeBinaries(sourceDir).length) throw new Error('directory contains native binaries');
|
||||
|
||||
const id = manifest.id;
|
||||
const existing = db.prepare('SELECT source_repo FROM plugins WHERE id = ?').get(id) as { source_repo?: string } | undefined;
|
||||
const existing = this.db.prepare('SELECT source_repo FROM plugins WHERE id = ?').get(id) as { source_repo?: string } | undefined;
|
||||
// Never clobber a REAL installed plugin (registry/sideload) — only re-point a link.
|
||||
if (existing && existing.source_repo !== DEV_LINK_SOURCE) {
|
||||
throw new Error(`a plugin '${id}' is already installed — uninstall it before dev-linking that id`);
|
||||
@@ -785,10 +794,10 @@ export class PluginRuntimeService implements OnModuleInit, OnModuleDestroy {
|
||||
removePluginCodeEntry(dest); // drop any prior link — never follows into the author's source
|
||||
fs.mkdirSync(path.dirname(dest), { recursive: true });
|
||||
fs.symlinkSync(sourceDir, dest, 'junction'); // Windows junction (no elevation); POSIX ignores the type -> dir symlink
|
||||
discoverPlugins(db); // registers/updates the row from the linked manifest, INACTIVE
|
||||
discoverPlugins(this.db); // registers/updates the row from the linked manifest, INACTIVE
|
||||
// Same as a sideload: the plugin has left the registry trust model, so a block that
|
||||
// described a refused REGISTRY update no longer describes the code that will run.
|
||||
db.prepare(
|
||||
this.db.prepare(
|
||||
`UPDATE plugins SET source_repo = ?, source_commit = NULL, sha256 = NULL, author_pubkey = NULL,
|
||||
update_block_code = NULL, update_block_detail = NULL, update_block_version = NULL,
|
||||
status = 'inactive', enabled = 0
|
||||
@@ -807,7 +816,7 @@ export class PluginRuntimeService implements OnModuleInit, OnModuleDestroy {
|
||||
*/
|
||||
async reload(id: string): Promise<void> {
|
||||
if (!devLinkEnabled()) throw new Error('dev-link is disabled (set TREK_PLUGINS_DEV_LINK=1)');
|
||||
const row = db.prepare('SELECT source_repo FROM plugins WHERE id = ?').get(id) as { source_repo?: string } | undefined;
|
||||
const row = this.db.prepare('SELECT source_repo FROM plugins WHERE id = ?').get(id) as { source_repo?: string } | undefined;
|
||||
if (!row) throw new Error(`plugin ${id} not found`);
|
||||
if (row.source_repo !== DEV_LINK_SOURCE) throw new Error(`plugin ${id} is not dev-linked`);
|
||||
const wasActive = this.isActive(id);
|
||||
@@ -851,16 +860,16 @@ export class PluginRuntimeService implements OnModuleInit, OnModuleDestroy {
|
||||
// Code always goes; the DB metadata + fields go so it disappears from the UI.
|
||||
// Link-safe: a dev-linked plugin only drops the symlink, never the author's source.
|
||||
removePluginCodeEntry(pluginCodeDir(id));
|
||||
db.prepare('DELETE FROM plugins WHERE id = ?').run(id);
|
||||
db.prepare('DELETE FROM plugin_settings_fields WHERE plugin_id = ?').run(id);
|
||||
try { db.prepare('DELETE FROM plugin_actions WHERE plugin_id = ?').run(id); } catch { /* table absent */ }
|
||||
this.db.prepare('DELETE FROM plugins WHERE id = ?').run(id);
|
||||
this.db.prepare('DELETE FROM plugin_settings_fields WHERE plugin_id = ?').run(id);
|
||||
try { this.db.prepare('DELETE FROM plugin_actions WHERE plugin_id = ?').run(id); } catch { /* table absent */ }
|
||||
// The admin's egress consent dies with the plugin. Unconditional: leaving it would
|
||||
// silently grant a LATER plugin that reuses this id the hosts the admin approved for
|
||||
// a different one.
|
||||
try { db.prepare('DELETE FROM plugin_egress_hosts WHERE plugin_id = ?').run(id); } catch { /* table absent */ }
|
||||
try { this.db.prepare('DELETE FROM plugin_egress_hosts WHERE plugin_id = ?').run(id); } catch { /* table absent */ }
|
||||
// Scheduled tasks are operational (not user data), so they go unconditionally —
|
||||
// a scheduled callback for a plugin that no longer exists must never fire.
|
||||
db.prepare('DELETE FROM plugin_scheduled_tasks WHERE plugin_id = ?').run(id);
|
||||
this.db.prepare('DELETE FROM plugin_scheduled_tasks WHERE plugin_id = ?').run(id);
|
||||
// If it was a notification channel, retire the channel too. Unconditional, for the
|
||||
// same reason as the settings fields: these are TREK's config ABOUT the plugin, and
|
||||
// leaving them means a later plugin that reuses this id silently inherits every
|
||||
@@ -868,23 +877,23 @@ export class PluginRuntimeService implements OnModuleInit, OnModuleDestroy {
|
||||
this.retireNotificationChannel(id);
|
||||
if (deleteData) {
|
||||
removePluginData(id);
|
||||
db.prepare('DELETE FROM plugin_error_log WHERE plugin_id = ?').run(id);
|
||||
db.prepare("DELETE FROM settings WHERE key LIKE ?").run(`plugin:${id}:%`);
|
||||
db.prepare('DELETE FROM plugin_entity_metadata WHERE plugin_id = ?').run(id);
|
||||
this.db.prepare('DELETE FROM plugin_error_log WHERE plugin_id = ?').run(id);
|
||||
this.db.prepare("DELETE FROM settings WHERE key LIKE ?").run(`plugin:${id}:%`);
|
||||
this.db.prepare('DELETE FROM plugin_entity_metadata WHERE plugin_id = ?').run(id);
|
||||
// Per-user secrets + OAuth tokens/state live in their own tables, NOT under
|
||||
// settings — without these a "delete all data" leaves encrypted API keys and
|
||||
// refresh tokens behind, silently re-adopted if a plugin with the same id is
|
||||
// reinstalled. The migration ledger goes too, so a reinstall re-runs cleanly.
|
||||
db.prepare('DELETE FROM plugin_user_config WHERE plugin_id = ?').run(id);
|
||||
db.prepare('DELETE FROM plugin_oauth_tokens WHERE plugin_id = ?').run(id);
|
||||
db.prepare('DELETE FROM plugin_oauth_state WHERE plugin_id = ?').run(id);
|
||||
db.prepare('DELETE FROM plugin_meta_migrations WHERE plugin_id = ?').run(id);
|
||||
db.prepare('DELETE FROM plugin_capability_audit WHERE plugin_id = ?').run(id);
|
||||
this.db.prepare('DELETE FROM plugin_user_config WHERE plugin_id = ?').run(id);
|
||||
this.db.prepare('DELETE FROM plugin_oauth_tokens WHERE plugin_id = ?').run(id);
|
||||
this.db.prepare('DELETE FROM plugin_oauth_state WHERE plugin_id = ?').run(id);
|
||||
this.db.prepare('DELETE FROM plugin_meta_migrations WHERE plugin_id = ?').run(id);
|
||||
this.db.prepare('DELETE FROM plugin_capability_audit WHERE plugin_id = ?').run(id);
|
||||
// The plugin's data dir is gone now, so any pending GDPR erasure for it is moot.
|
||||
// But when deleteData is FALSE we deliberately KEEP the queue rows: the data dir
|
||||
// (which may still hold a deleted user's rows) survives, so the erasure obligation
|
||||
// must survive too — a reinstall of the same id drains the queue and honours it.
|
||||
db.prepare('DELETE FROM plugin_user_erasure_queue WHERE plugin_id = ?').run(id);
|
||||
this.db.prepare('DELETE FROM plugin_user_erasure_queue WHERE plugin_id = ?').run(id);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -894,7 +903,7 @@ export class PluginRuntimeService implements OnModuleInit, OnModuleDestroy {
|
||||
|
||||
/** Declared outbound hosts (from http:outbound:<host> grants) for the frame CSP. */
|
||||
outboundHostsOf(id: string): string[] {
|
||||
const row = db.prepare('SELECT granted_permissions FROM plugins WHERE id = ?').get(id) as
|
||||
const row = this.db.prepare('SELECT granted_permissions FROM plugins WHERE id = ?').get(id) as
|
||||
| { granted_permissions: string }
|
||||
| undefined;
|
||||
if (!row) return [];
|
||||
@@ -972,8 +981,8 @@ export class PluginRuntimeService implements OnModuleInit, OnModuleDestroy {
|
||||
/** True if `caller` declares `target` as a plugin dependency whose range the
|
||||
* installed target version satisfies. */
|
||||
private dependsOnSatisfied(callerId: string, targetId: string): boolean {
|
||||
const caller = db.prepare('SELECT dependencies FROM plugins WHERE id = ?').get(callerId) as { dependencies: string | null } | undefined;
|
||||
const target = db.prepare('SELECT version FROM plugins WHERE id = ?').get(targetId) as { version: string | null } | undefined;
|
||||
const caller = this.db.prepare('SELECT dependencies FROM plugins WHERE id = ?').get(callerId) as { dependencies: string | null } | undefined;
|
||||
const target = this.db.prepare('SELECT version FROM plugins WHERE id = ?').get(targetId) as { version: string | null } | undefined;
|
||||
if (!caller || !target) return false;
|
||||
const dep = parseDependencies(caller.dependencies).pluginDependencies.find((d) => d.id === targetId);
|
||||
if (!dep) return false;
|
||||
@@ -993,7 +1002,7 @@ export class PluginRuntimeService implements OnModuleInit, OnModuleDestroy {
|
||||
// without the notification tables (a slimmed test app that imports only the plugin
|
||||
// module).
|
||||
try {
|
||||
db.prepare('DELETE FROM notification_channel_preferences WHERE channel = ?').run(pluginChannelId(id));
|
||||
this.db.prepare('DELETE FROM notification_channel_preferences WHERE channel = ?').run(pluginChannelId(id));
|
||||
} catch { /* no notifications schema here — nothing to retire */ }
|
||||
}
|
||||
|
||||
@@ -1009,7 +1018,7 @@ export class PluginRuntimeService implements OnModuleInit, OnModuleDestroy {
|
||||
notificationChannels(): ExternalChannel[] {
|
||||
if (!pluginsEnabled()) return [];
|
||||
return this.supervisor.providersOf('notificationChannel').map((id) => {
|
||||
const row = db.prepare('SELECT name, capabilities FROM plugins WHERE id = ?').get(id) as
|
||||
const row = this.db.prepare('SELECT name, capabilities FROM plugins WHERE id = ?').get(id) as
|
||||
| { name: string; capabilities: string }
|
||||
| undefined;
|
||||
let cap: { title?: string; events?: string[] } = {};
|
||||
@@ -1061,7 +1070,7 @@ export class PluginRuntimeService implements OnModuleInit, OnModuleDestroy {
|
||||
actionsOf(id: string): Array<{ key: string; label: string; hint?: string; danger: boolean }> {
|
||||
try {
|
||||
return (
|
||||
db.prepare('SELECT action_key, label, hint, danger FROM plugin_actions WHERE plugin_id = ? ORDER BY sort_order').all(id) as Array<{
|
||||
this.db.prepare('SELECT action_key, label, hint, danger FROM plugin_actions WHERE plugin_id = ? ORDER BY sort_order').all(id) as Array<{
|
||||
action_key: string; label: string; hint: string | null; danger: number;
|
||||
}>
|
||||
).map((r) => ({ key: r.action_key, label: r.label, hint: r.hint ?? undefined, danger: r.danger === 1 }));
|
||||
@@ -1097,7 +1106,7 @@ export class PluginRuntimeService implements OnModuleInit, OnModuleDestroy {
|
||||
|
||||
/** A plugin's declared `capabilities.provides`/`capabilities.emits` (from the DB). */
|
||||
private capabilityList(id: string, field: 'provides' | 'emits'): string[] {
|
||||
const row = db.prepare('SELECT capabilities FROM plugins WHERE id = ?').get(id) as { capabilities: string } | undefined;
|
||||
const row = this.db.prepare('SELECT capabilities FROM plugins WHERE id = ?').get(id) as { capabilities: string } | undefined;
|
||||
if (!row) return [];
|
||||
try {
|
||||
const c = JSON.parse(row.capabilities || '{}') as Record<string, unknown>;
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { db } from '../../db/database';
|
||||
import { DatabaseService } from '../database/database.service';
|
||||
import { pluginsEnabled } from './kill-switch';
|
||||
import { devLinkEnabled } from './dev-link';
|
||||
import { maybe_encrypt_api_key, decrypt_api_key } from '../../services/apiKeyCrypto';
|
||||
@@ -99,8 +100,14 @@ export interface PluginListItem {
|
||||
|
||||
@Injectable()
|
||||
export class PluginsService {
|
||||
constructor(private readonly dbs: DatabaseService) {}
|
||||
|
||||
private get db() {
|
||||
return this.dbs.connection;
|
||||
}
|
||||
|
||||
list(): { enabled: boolean; devLink: boolean; plugins: PluginListItem[] } {
|
||||
const rows = db
|
||||
const rows = this.db
|
||||
.prepare(
|
||||
`SELECT id, name, description, type, icon, version, status, enabled, last_error, reviewed_at, source_repo,
|
||||
permissions, capabilities, dependencies, operator_egress, trek_range,
|
||||
@@ -160,12 +167,12 @@ export class PluginsService {
|
||||
* the config with secrets masked for the client.
|
||||
*/
|
||||
updateInstanceConfig(id: string, patch: Record<string, unknown>): Record<string, unknown> {
|
||||
const row = db.prepare('SELECT config FROM plugins WHERE id = ?').get(id) as { config: string } | undefined;
|
||||
const row = this.db.prepare('SELECT config FROM plugins WHERE id = ?').get(id) as { config: string } | undefined;
|
||||
if (!row) throw new Error(`plugin ${id} not found`);
|
||||
|
||||
const secretKeys = new Set(
|
||||
(
|
||||
db
|
||||
this.db
|
||||
.prepare("SELECT field_key FROM plugin_settings_fields WHERE plugin_id = ? AND scope = 'instance' AND secret = 1")
|
||||
.all(id) as Array<{ field_key: string }>
|
||||
).map((r) => r.field_key),
|
||||
@@ -180,13 +187,13 @@ export class PluginsService {
|
||||
config[k] = v;
|
||||
}
|
||||
}
|
||||
db.prepare('UPDATE plugins SET config = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ?').run(JSON.stringify(config), id);
|
||||
this.db.prepare('UPDATE plugins SET config = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ?').run(JSON.stringify(config), id);
|
||||
return maskSecrets(config, secretKeys);
|
||||
}
|
||||
|
||||
/** The plugin's `scope:'user'` settings fields, in declared order (for the user form). */
|
||||
userSettingsFields(id: string): Array<Record<string, unknown>> {
|
||||
return db
|
||||
return this.db
|
||||
.prepare(
|
||||
`SELECT field_key AS key, label, input_type, placeholder, hint, required, secret, options
|
||||
FROM plugin_settings_fields WHERE plugin_id = ? AND scope = 'user' ORDER BY sort_order, id`,
|
||||
@@ -210,7 +217,7 @@ export class PluginsService {
|
||||
private userSecretKeys(id: string): Set<string> {
|
||||
return new Set(
|
||||
(
|
||||
db
|
||||
this.db
|
||||
.prepare("SELECT field_key FROM plugin_settings_fields WHERE plugin_id = ? AND scope = 'user' AND secret = 1")
|
||||
.all(id) as Array<{ field_key: string }>
|
||||
).map((r) => r.field_key),
|
||||
@@ -219,7 +226,7 @@ export class PluginsService {
|
||||
|
||||
/** A user's own config for a plugin, secrets masked (safe to send to the client). */
|
||||
getUserConfig(id: string, userId: number): Record<string, unknown> {
|
||||
const row = db.prepare('SELECT config FROM plugin_user_config WHERE plugin_id = ? AND user_id = ?').get(id, userId) as
|
||||
const row = this.db.prepare('SELECT config FROM plugin_user_config WHERE plugin_id = ? AND user_id = ?').get(id, userId) as
|
||||
| { config: string }
|
||||
| undefined;
|
||||
return maskSecrets(safeParse(row?.config ?? '{}'), this.userSecretKeys(id));
|
||||
@@ -229,12 +236,12 @@ export class PluginsService {
|
||||
* ciphertext). Only keys declared as `scope:'user'` fields are accepted. Returns masked. */
|
||||
updateUserConfig(id: string, userId: number, patch: Record<string, unknown>): Record<string, unknown> {
|
||||
const allowed = new Set(
|
||||
(db.prepare("SELECT field_key FROM plugin_settings_fields WHERE plugin_id = ? AND scope = 'user'").all(id) as Array<{ field_key: string }>).map(
|
||||
(this.db.prepare("SELECT field_key FROM plugin_settings_fields WHERE plugin_id = ? AND scope = 'user'").all(id) as Array<{ field_key: string }>).map(
|
||||
(r) => r.field_key,
|
||||
),
|
||||
);
|
||||
const secretKeys = this.userSecretKeys(id);
|
||||
const existing = db.prepare('SELECT config FROM plugin_user_config WHERE plugin_id = ? AND user_id = ?').get(id, userId) as
|
||||
const existing = this.db.prepare('SELECT config FROM plugin_user_config WHERE plugin_id = ? AND user_id = ?').get(id, userId) as
|
||||
| { config: string }
|
||||
| undefined;
|
||||
const config = safeParse(existing?.config ?? '{}');
|
||||
@@ -247,7 +254,7 @@ export class PluginsService {
|
||||
config[k] = v;
|
||||
}
|
||||
}
|
||||
db.prepare(
|
||||
this.db.prepare(
|
||||
`INSERT INTO plugin_user_config (plugin_id, user_id, config, updated_at) VALUES (?, ?, ?, datetime('now'))
|
||||
ON CONFLICT(plugin_id, user_id) DO UPDATE SET config = excluded.config, updated_at = excluded.updated_at`,
|
||||
).run(id, userId, JSON.stringify(config));
|
||||
@@ -257,7 +264,7 @@ export class PluginsService {
|
||||
/** A user's own config with secrets DECRYPTED — host-only, for runtime `ctx.settings`.
|
||||
* Never sent to a client; the acting user is resolved host-side. */
|
||||
getUserConfigDecrypted(id: string, userId: number): Record<string, unknown> {
|
||||
const row = db.prepare('SELECT config FROM plugin_user_config WHERE plugin_id = ? AND user_id = ?').get(id, userId) as
|
||||
const row = this.db.prepare('SELECT config FROM plugin_user_config WHERE plugin_id = ? AND user_id = ?').get(id, userId) as
|
||||
| { config: string }
|
||||
| undefined;
|
||||
const config = safeParse(row?.config ?? '{}');
|
||||
@@ -269,18 +276,18 @@ export class PluginsService {
|
||||
|
||||
/** A plugin's error log, newest first. */
|
||||
errors(id: string): Array<{ ts: string; level: string; message: string }> {
|
||||
return db
|
||||
return this.db
|
||||
.prepare('SELECT ts, level, message FROM plugin_error_log WHERE plugin_id = ? ORDER BY ts DESC, id DESC LIMIT 200')
|
||||
.all(id) as Array<{ ts: string; level: string; message: string }>;
|
||||
}
|
||||
|
||||
clearErrors(id: string): void {
|
||||
db.prepare('DELETE FROM plugin_error_log WHERE plugin_id = ?').run(id);
|
||||
this.db.prepare('DELETE FROM plugin_error_log WHERE plugin_id = ?').run(id);
|
||||
}
|
||||
|
||||
/** A plugin's hash-chained capability audit log, newest first. */
|
||||
auditLog(id: string): unknown[] {
|
||||
return readAudit(db, id);
|
||||
return readAudit(this.db, id);
|
||||
}
|
||||
|
||||
/** A plugin's broker budget usage for today (AI + notification counts vs caps). */
|
||||
@@ -290,11 +297,11 @@ export class PluginsService {
|
||||
|
||||
/** Read the instance config with secret fields masked. */
|
||||
getInstanceConfig(id: string): Record<string, unknown> {
|
||||
const row = db.prepare('SELECT config FROM plugins WHERE id = ?').get(id) as { config: string } | undefined;
|
||||
const row = this.db.prepare('SELECT config FROM plugins WHERE id = ?').get(id) as { config: string } | undefined;
|
||||
if (!row) throw new Error(`plugin ${id} not found`);
|
||||
const secretKeys = new Set(
|
||||
(
|
||||
db
|
||||
this.db
|
||||
.prepare("SELECT field_key FROM plugin_settings_fields WHERE plugin_id = ? AND scope = 'instance' AND secret = 1")
|
||||
.all(id) as Array<{ field_key: string }>
|
||||
).map((r) => r.field_key),
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { readEnv } from '../../../app-config';
|
||||
import { db } from '../../../db/database';
|
||||
import { DatabaseService } from '../../database/database.service';
|
||||
import { discoverPlugins } from '../install/discovery';
|
||||
import { hostSatisfies, hostVersion, normalizedHost } from '../install/host-compat';
|
||||
import type { PluginDependency } from '../install/manifest';
|
||||
@@ -147,6 +147,12 @@ export class RegistryError extends Error {
|
||||
|
||||
@Injectable()
|
||||
export class PluginRegistryService {
|
||||
constructor(private readonly dbs: DatabaseService) {}
|
||||
|
||||
private get db() {
|
||||
return this.dbs.connection;
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch the aggregated registry (cached, soft-fail, stale-serve). Pass
|
||||
* force=true (the admin "rescan" button) to bypass the 30-min cache and also
|
||||
@@ -435,8 +441,8 @@ export class PluginRegistryService {
|
||||
fs.renameSync(pluginRoot, dest);
|
||||
|
||||
// 7. register INACTIVE (record provenance)
|
||||
discoverPlugins(db);
|
||||
db.prepare('UPDATE plugins SET source_repo = ?, source_commit = ?, sha256 = ?, reviewed_at = ? WHERE id = ?').run(
|
||||
discoverPlugins(this.db);
|
||||
this.db.prepare('UPDATE plugins SET source_repo = ?, source_commit = ?, sha256 = ?, reviewed_at = ? WHERE id = ?').run(
|
||||
entry.repo,
|
||||
ver.commitSha,
|
||||
ver.sha256,
|
||||
@@ -448,7 +454,7 @@ export class PluginRegistryService {
|
||||
// to a key the artifact just verified under; NEVER cleared to NULL, because a
|
||||
// NULL pin re-opens the "was never signed" path that accepts an unsigned update.
|
||||
if (entry.authorPublicKey) {
|
||||
db.prepare('UPDATE plugins SET author_pubkey = ? WHERE id = ?').run(entry.authorPublicKey, id);
|
||||
this.db.prepare('UPDATE plugins SET author_pubkey = ? WHERE id = ?').run(entry.authorPublicKey, id);
|
||||
}
|
||||
// The plugin is now on new code that passed every check — whatever refusal was
|
||||
// recorded before no longer describes reality.
|
||||
@@ -471,7 +477,7 @@ export class PluginRegistryService {
|
||||
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),
|
||||
(this.db.prepare('SELECT id FROM plugins').all() as Array<{ id: string }>).map((r) => r.id),
|
||||
);
|
||||
const done = new Set<string>();
|
||||
const installed: string[] = [];
|
||||
@@ -536,7 +542,7 @@ export class PluginRegistryService {
|
||||
fs.mkdirSync(pluginsCodeRoot(), { recursive: true });
|
||||
fs.rmSync(dest, { recursive: true, force: true });
|
||||
fs.renameSync(staged.root, dest);
|
||||
discoverPlugins(db);
|
||||
discoverPlugins(this.db);
|
||||
// Provenance for a sideload, plus a hard INACTIVE floor: discoverPlugins keeps
|
||||
// an existing row's status, so replacing a plugin that was active must not
|
||||
// leave the new code marked active — the admin re-activates (and re-consents
|
||||
@@ -546,7 +552,7 @@ export class PluginRegistryService {
|
||||
// plugin has just left the registry trust model entirely — the code is now whatever
|
||||
// the admin uploaded. Leaving the block would have the row insist an update was
|
||||
// blocked over a signing key that no longer applies to the code that is running.
|
||||
db.prepare(
|
||||
this.db.prepare(
|
||||
`UPDATE plugins SET source_repo = ?, source_commit = ?, sha256 = ?, reviewed_at = ?, author_pubkey = NULL,
|
||||
update_block_code = NULL, update_block_detail = NULL, update_block_version = NULL,
|
||||
status = 'inactive', enabled = 0
|
||||
@@ -585,7 +591,7 @@ export class PluginRegistryService {
|
||||
retrustKey?: string,
|
||||
): void {
|
||||
const pinned =
|
||||
(db.prepare('SELECT author_pubkey FROM plugins WHERE id = ?').get(id) as { author_pubkey?: string } | undefined)
|
||||
(this.db.prepare('SELECT author_pubkey FROM plugins WHERE id = ?').get(id) as { author_pubkey?: string } | undefined)
|
||||
?.author_pubkey ?? null;
|
||||
|
||||
if (!entry.authorPublicKey && !ver.signature) {
|
||||
@@ -637,7 +643,7 @@ export class PluginRegistryService {
|
||||
* rendered, the admin would be blessing a key they never saw.
|
||||
*/
|
||||
async assertRetrustable(id: string, publicKey: string): Promise<RegistryEntry> {
|
||||
const row = db.prepare('SELECT source_repo, author_pubkey FROM plugins WHERE id = ?').get(id) as
|
||||
const row = this.db.prepare('SELECT source_repo, author_pubkey FROM plugins WHERE id = ?').get(id) as
|
||||
| { source_repo?: string | null; author_pubkey?: string | null }
|
||||
| undefined;
|
||||
if (!row) throw new RegistryError(`plugin ${id} not found`, 'NOT_FOUND');
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { db } from '../../db/database';
|
||||
import { DatabaseService } from '../database/database.service';
|
||||
import { broadcast } from '../../websocket';
|
||||
import { checkPermission } from '../../services/permissions';
|
||||
import type { User } from '../../types';
|
||||
@@ -19,6 +19,12 @@ type BudgetEntry = { total_price?: number; category?: string } | undefined;
|
||||
*/
|
||||
@Injectable()
|
||||
export class ReservationsService {
|
||||
constructor(private readonly dbs: DatabaseService) {}
|
||||
|
||||
private get db() {
|
||||
return this.dbs.connection;
|
||||
}
|
||||
|
||||
verifyTripAccess(tripId: string, userId: number) {
|
||||
return svc.verifyTripAccess(tripId, userId);
|
||||
}
|
||||
@@ -89,7 +95,7 @@ export class ReservationsService {
|
||||
// but only if it still carries the auto-derived category (so a manual pick in
|
||||
// the Costs editor is preserved). Runs regardless of create_budget_entry.
|
||||
if (type && currentType && type !== currentType) {
|
||||
const linked = db.prepare('SELECT id, category FROM budget_items WHERE trip_id = ? AND reservation_id = ?').get(tripId, id) as { id: number; category: string } | undefined;
|
||||
const linked = this.db.prepare('SELECT id, category FROM budget_items WHERE trip_id = ? AND reservation_id = ?').get(tripId, id) as { id: number; category: string } | undefined;
|
||||
if (linked) {
|
||||
const oldCat = typeToCostCategory(currentType);
|
||||
const newCat = typeToCostCategory(type);
|
||||
@@ -107,7 +113,7 @@ export class ReservationsService {
|
||||
|
||||
if (!(Number(entry.total_price) > 0)) {
|
||||
// Explicit clear (total_price 0/empty) — drop the linked item.
|
||||
const linked = db.prepare('SELECT id FROM budget_items WHERE trip_id = ? AND reservation_id = ?').get(tripId, id) as { id: number } | undefined;
|
||||
const linked = this.db.prepare('SELECT id FROM budget_items WHERE trip_id = ? AND reservation_id = ?').get(tripId, id) as { id: number } | undefined;
|
||||
if (linked) {
|
||||
deleteBudgetItem(linked.id, tripId);
|
||||
broadcast(tripId, 'budget:deleted', { itemId: linked.id }, socketId);
|
||||
@@ -118,13 +124,13 @@ export class ReservationsService {
|
||||
try {
|
||||
const itemName = title || currentTitle;
|
||||
const category = entry.category || type || currentType || 'Other';
|
||||
const existing = db.prepare('SELECT id FROM budget_items WHERE trip_id = ? AND reservation_id = ?').get(tripId, id) as { id: number } | undefined;
|
||||
const existing = this.db.prepare('SELECT id FROM budget_items WHERE trip_id = ? AND reservation_id = ?').get(tripId, id) as { id: number } | undefined;
|
||||
if (existing) {
|
||||
const updated = updateBudgetItem(existing.id, tripId, { name: itemName, category, total_price: entry.total_price });
|
||||
broadcast(tripId, 'budget:updated', { item: updated }, socketId);
|
||||
} else {
|
||||
const item = createBudgetItem(tripId, { name: itemName, category, total_price: entry.total_price });
|
||||
db.prepare('UPDATE budget_items SET reservation_id = ? WHERE id = ?').run(id, item.id);
|
||||
this.db.prepare('UPDATE budget_items SET reservation_id = ? WHERE id = ?').run(id, item.id);
|
||||
item.reservation_id = Number(id);
|
||||
broadcast(tripId, 'budget:created', { item }, socketId);
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { db, canAccessTrip } from '../../db/database';
|
||||
import { DatabaseService } from '../database/database.service';
|
||||
import { broadcast } from '../../websocket';
|
||||
import { checkPermission } from '../../services/permissions';
|
||||
import type { User } from '../../types';
|
||||
@@ -22,8 +22,14 @@ import { searchUnsplashPhotos, getUnsplashKey } from '../../services/unsplashSer
|
||||
*/
|
||||
@Injectable()
|
||||
export class TripsService {
|
||||
constructor(private readonly dbs: DatabaseService) {}
|
||||
|
||||
private get db() {
|
||||
return this.dbs.connection;
|
||||
}
|
||||
|
||||
canAccessTrip(tripId: string, userId: number) {
|
||||
return canAccessTrip(tripId, userId) as { user_id: number } | null | undefined;
|
||||
return this.dbs.canAccessTrip(tripId, userId) as { user_id: number } | null | undefined;
|
||||
}
|
||||
|
||||
can(action: string, role: string, ownerId: number | null, userId: number, isMember: boolean): boolean {
|
||||
@@ -84,7 +90,7 @@ export class TripsService {
|
||||
|
||||
/** Re-read a freshly copied trip in list shape (mirrors the route's TRIP_SELECT query). */
|
||||
getCopiedTrip(newTripId: number, userId: number) {
|
||||
return db.prepare(`${tripSvc.TRIP_SELECT} WHERE t.id = :tripId`).get({ userId, tripId: newTripId });
|
||||
return this.db.prepare(`${tripSvc.TRIP_SELECT} WHERE t.id = :tripId`).get({ userId, tripId: newTripId });
|
||||
}
|
||||
|
||||
listMembers(tripId: string, ownerId: number) {
|
||||
|
||||
@@ -8,6 +8,7 @@ import { describe, it, expect, beforeAll, afterAll, vi } from 'vitest';
|
||||
import request from 'supertest';
|
||||
import cookieParser from 'cookie-parser';
|
||||
import type { Server } from 'http';
|
||||
import { DatabaseModule } from '../../src/nest/database/database.module';
|
||||
import { Test } from '@nestjs/testing';
|
||||
import { seedUser, sessionCookie } from './harness';
|
||||
|
||||
@@ -46,7 +47,7 @@ describe('GET /api/addons e2e (real auth guard + temp SQLite)', () => {
|
||||
let app: Awaited<ReturnType<typeof build>>;
|
||||
|
||||
async function build() {
|
||||
const moduleRef = await Test.createTestingModule({ imports: [AddonsModule] }).compile();
|
||||
const moduleRef = await Test.createTestingModule({ imports: [DatabaseModule, AddonsModule] }).compile();
|
||||
const nest = moduleRef.createNestApplication();
|
||||
nest.use(cookieParser());
|
||||
nest.useGlobalFilters(new TrekExceptionFilter());
|
||||
|
||||
@@ -7,6 +7,7 @@ import { describe, it, expect, beforeAll, afterAll, beforeEach, vi } from 'vites
|
||||
import request from 'supertest';
|
||||
import cookieParser from 'cookie-parser';
|
||||
import type { Server } from 'http';
|
||||
import { DatabaseModule } from '../../src/nest/database/database.module';
|
||||
import { Test } from '@nestjs/testing';
|
||||
import { seedUser, sessionCookie } from './harness';
|
||||
|
||||
@@ -44,7 +45,7 @@ describe('Budget e2e (real auth guard + temp SQLite)', () => {
|
||||
let app: Awaited<ReturnType<typeof build>>;
|
||||
|
||||
async function build() {
|
||||
const moduleRef = await Test.createTestingModule({ imports: [BudgetModule] }).compile();
|
||||
const moduleRef = await Test.createTestingModule({ imports: [DatabaseModule, BudgetModule] }).compile();
|
||||
const nest = moduleRef.createNestApplication();
|
||||
nest.use(cookieParser());
|
||||
nest.useGlobalFilters(new TrekExceptionFilter());
|
||||
|
||||
@@ -9,6 +9,7 @@ import { describe, it, expect, beforeAll, afterAll, beforeEach, vi } from 'vites
|
||||
import request from 'supertest';
|
||||
import cookieParser from 'cookie-parser';
|
||||
import type { Server } from 'http';
|
||||
import { DatabaseModule } from '../../src/nest/database/database.module';
|
||||
import { Test } from '@nestjs/testing';
|
||||
import { seedUser, sessionCookie } from './harness';
|
||||
|
||||
@@ -51,7 +52,7 @@ describe('Collab e2e (real auth guard + temp SQLite)', () => {
|
||||
let app: Awaited<ReturnType<typeof build>>;
|
||||
|
||||
async function build() {
|
||||
const moduleRef = await Test.createTestingModule({ imports: [CollabModule] }).compile();
|
||||
const moduleRef = await Test.createTestingModule({ imports: [DatabaseModule, CollabModule] }).compile();
|
||||
const nest = moduleRef.createNestApplication();
|
||||
nest.use(cookieParser());
|
||||
nest.useGlobalFilters(new TrekExceptionFilter());
|
||||
|
||||
@@ -15,6 +15,7 @@ import { describe, it, expect, beforeAll, afterAll, beforeEach, vi } from 'vites
|
||||
import request from 'supertest';
|
||||
import cookieParser from 'cookie-parser';
|
||||
import type { Server } from 'http';
|
||||
import { DatabaseModule } from '../../src/nest/database/database.module';
|
||||
import { Test } from '@nestjs/testing';
|
||||
import { seedUser, sessionCookie } from './harness';
|
||||
|
||||
@@ -55,7 +56,7 @@ describe('Calendar-feed e2e (real auth guard + temp SQLite)', () => {
|
||||
let prevAppUrl: string | undefined;
|
||||
|
||||
async function build() {
|
||||
const moduleRef = await Test.createTestingModule({ imports: [FeedsModule] }).compile();
|
||||
const moduleRef = await Test.createTestingModule({ imports: [DatabaseModule, FeedsModule] }).compile();
|
||||
const nest = moduleRef.createNestApplication();
|
||||
nest.use(cookieParser());
|
||||
nest.useGlobalFilters(new TrekExceptionFilter());
|
||||
|
||||
@@ -9,6 +9,7 @@ import { describe, it, expect, beforeAll, afterAll, beforeEach, vi } from 'vites
|
||||
import request from 'supertest';
|
||||
import cookieParser from 'cookie-parser';
|
||||
import type { Server } from 'http';
|
||||
import { DatabaseModule } from '../../src/nest/database/database.module';
|
||||
import { Test } from '@nestjs/testing';
|
||||
import { seedUser, sessionCookie } from './harness';
|
||||
|
||||
@@ -51,7 +52,7 @@ describe('Journey e2e (real auth guard + temp SQLite)', () => {
|
||||
let app: Awaited<ReturnType<typeof build>>;
|
||||
|
||||
async function build() {
|
||||
const moduleRef = await Test.createTestingModule({ imports: [JourneyModule] }).compile();
|
||||
const moduleRef = await Test.createTestingModule({ imports: [DatabaseModule, JourneyModule] }).compile();
|
||||
const nest = moduleRef.createNestApplication();
|
||||
nest.use(cookieParser());
|
||||
nest.useGlobalFilters(new TrekExceptionFilter());
|
||||
|
||||
@@ -8,6 +8,7 @@ import { describe, it, expect, beforeAll, afterAll, beforeEach, vi } from 'vites
|
||||
import request from 'supertest';
|
||||
import cookieParser from 'cookie-parser';
|
||||
import type { Server } from 'http';
|
||||
import { DatabaseModule } from '../../src/nest/database/database.module';
|
||||
import { Test } from '@nestjs/testing';
|
||||
import { seedUser, sessionCookie } from './harness';
|
||||
|
||||
@@ -48,7 +49,7 @@ describe('Packing e2e (real auth guard + temp SQLite)', () => {
|
||||
let app: Awaited<ReturnType<typeof build>>;
|
||||
|
||||
async function build() {
|
||||
const moduleRef = await Test.createTestingModule({ imports: [PackingModule] }).compile();
|
||||
const moduleRef = await Test.createTestingModule({ imports: [DatabaseModule, PackingModule] }).compile();
|
||||
const nest = moduleRef.createNestApplication();
|
||||
nest.use(cookieParser());
|
||||
nest.useGlobalFilters(new TrekExceptionFilter());
|
||||
|
||||
@@ -8,6 +8,7 @@ import { describe, it, expect, beforeAll, afterAll, beforeEach, vi } from 'vites
|
||||
import request from 'supertest';
|
||||
import cookieParser from 'cookie-parser';
|
||||
import type { Server } from 'http';
|
||||
import { DatabaseModule } from '../../src/nest/database/database.module';
|
||||
import { Test } from '@nestjs/testing';
|
||||
import { seedUser, sessionCookie } from './harness';
|
||||
|
||||
@@ -59,7 +60,7 @@ describe('Trips e2e (real auth guard + temp SQLite)', () => {
|
||||
let app: Awaited<ReturnType<typeof build>>;
|
||||
|
||||
async function build() {
|
||||
const moduleRef = await Test.createTestingModule({ imports: [TripsModule] }).compile();
|
||||
const moduleRef = await Test.createTestingModule({ imports: [DatabaseModule, TripsModule] }).compile();
|
||||
const nest = moduleRef.createNestApplication();
|
||||
nest.use(cookieParser());
|
||||
nest.useGlobalFilters(new TrekExceptionFilter());
|
||||
|
||||
@@ -27,6 +27,8 @@ const { testDb } = vi.hoisted(() => {
|
||||
return { testDb: db };
|
||||
});
|
||||
vi.mock('../../../src/db/database', () => ({ db: testDb, canAccessTrip: () => undefined }));
|
||||
import { db as dbConn } from '../../../src/db/database';
|
||||
import { DatabaseService } from '../../../src/nest/database/database.service';
|
||||
vi.mock('../../../src/websocket', () => ({ broadcast: vi.fn(), broadcastToUser: vi.fn() }));
|
||||
|
||||
import { PluginRuntimeService } from '../../../src/nest/plugins/plugin-runtime.service';
|
||||
@@ -58,7 +60,7 @@ beforeAll(() => {
|
||||
process.env.TREK_PLUGINS_DATA_DIR = dataRoot;
|
||||
process.env.TREK_PLUGINS_ENABLED = 'true';
|
||||
process.env.TREK_PLUGINS_DEV_LINK = '1';
|
||||
runtime = new PluginRuntimeService();
|
||||
runtime = new PluginRuntimeService(new DatabaseService(dbConn));
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
|
||||
@@ -43,6 +43,8 @@ const { testDb } = vi.hoisted(() => {
|
||||
return { testDb: db };
|
||||
});
|
||||
vi.mock('../../../src/db/database', () => ({ db: testDb, canAccessTrip: () => undefined }));
|
||||
import { db as dbConn } from '../../../src/db/database';
|
||||
import { DatabaseService } from '../../../src/nest/database/database.service';
|
||||
vi.mock('../../../src/websocket', () => ({ broadcast: vi.fn(), broadcastToUser: vi.fn() }));
|
||||
|
||||
import { PluginRuntimeService, PluginDependencyError } from '../../../src/nest/plugins/plugin-runtime.service';
|
||||
@@ -77,7 +79,7 @@ beforeAll(() => {
|
||||
);
|
||||
|
||||
testDb.prepare("INSERT INTO plugins (id, status, permissions, config) VALUES ('counter','active','[\"db:own\"]','{}')").run();
|
||||
runtime = new PluginRuntimeService();
|
||||
runtime = new PluginRuntimeService(new DatabaseService(dbConn));
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
@@ -147,7 +149,7 @@ describe('PluginRuntimeService (M2 end-to-end)', () => {
|
||||
|
||||
it('onModuleInit is a no-op when the runtime is disabled', () => {
|
||||
process.env.TREK_PLUGINS_ENABLED = 'false';
|
||||
expect(() => new PluginRuntimeService().onModuleInit()).not.toThrow();
|
||||
expect(() => new PluginRuntimeService(new DatabaseService(dbConn)).onModuleInit()).not.toThrow();
|
||||
process.env.TREK_PLUGINS_ENABLED = 'true';
|
||||
});
|
||||
|
||||
@@ -170,7 +172,7 @@ describe('PluginRuntimeService (M2 end-to-end)', () => {
|
||||
fs.mkdirSync(path.join(codeRoot, 'messy', 'server'), { recursive: true });
|
||||
fs.writeFileSync(path.join(codeRoot, 'messy', 'server', 'index.js'), 'module.exports = { async onLoad() {} };');
|
||||
testDb.prepare("INSERT INTO plugins (id, status, permissions, config) VALUES ('messy','inactive','not-json','not-json')").run();
|
||||
const rt = new PluginRuntimeService();
|
||||
const rt = new PluginRuntimeService(new DatabaseService(dbConn));
|
||||
await rt.activate('messy'); // must not throw despite the garbage JSON
|
||||
expect(rt.isActive('messy')).toBe(true);
|
||||
await rt.deactivate('messy');
|
||||
@@ -249,7 +251,7 @@ describe('PluginRuntimeService (M2 end-to-end)', () => {
|
||||
// status='error' from a previous crash, but enabled=1 → must still boot
|
||||
testDb.prepare("INSERT INTO plugins (id, status, enabled, granted_permissions, config) VALUES ('booter','error',1,'[]','{}')").run();
|
||||
|
||||
const rt = new PluginRuntimeService();
|
||||
const rt = new PluginRuntimeService(new DatabaseService(dbConn));
|
||||
rt.onModuleInit(); // fire-and-forget spawn
|
||||
for (let i = 0; i < 40 && !rt.isActive('booter'); i++) await new Promise((r) => setTimeout(r, 50));
|
||||
expect(rt.isActive('booter')).toBe(true);
|
||||
@@ -261,7 +263,7 @@ describe('PluginRuntimeService (M2 end-to-end)', () => {
|
||||
fs.writeFileSync(path.join(codeRoot, 'sleeper', 'server', 'index.js'), 'module.exports = { async onLoad() {} };');
|
||||
testDb.prepare("INSERT INTO plugins (id, status, enabled, granted_permissions, config) VALUES ('sleeper','inactive',0,'[]','{}')").run();
|
||||
|
||||
const rt = new PluginRuntimeService();
|
||||
const rt = new PluginRuntimeService(new DatabaseService(dbConn));
|
||||
rt.onModuleInit();
|
||||
await new Promise((r) => setTimeout(r, 300));
|
||||
expect(rt.isActive('sleeper')).toBe(false);
|
||||
@@ -269,7 +271,7 @@ describe('PluginRuntimeService (M2 end-to-end)', () => {
|
||||
|
||||
it('outboundHostsOf extracts declared http:outbound hosts', () => {
|
||||
testDb.prepare("INSERT INTO plugins (id, status, granted_permissions, config) VALUES ('net','inactive','[\"db:own\",\"http:outbound:api.x.com\",\"http:outbound:*.y.com\"]','{}')").run();
|
||||
const rt = new PluginRuntimeService();
|
||||
const rt = new PluginRuntimeService(new DatabaseService(dbConn));
|
||||
expect(rt.outboundHostsOf('net')).toEqual(['api.x.com', '*.y.com']);
|
||||
expect(rt.outboundHostsOf('missing')).toEqual([]);
|
||||
});
|
||||
@@ -291,7 +293,7 @@ describe('PluginRuntimeService (M2 end-to-end)', () => {
|
||||
testDb.prepare("INSERT INTO plugin_scheduled_tasks (plugin_id, name, due_at) VALUES ('gone', 'poll', 0)").run();
|
||||
testDb.prepare("INSERT INTO plugin_user_erasure_queue (plugin_id, user_id) VALUES ('gone', 7)").run();
|
||||
|
||||
await new PluginRuntimeService().uninstall('gone', true);
|
||||
await new PluginRuntimeService(new DatabaseService(dbConn)).uninstall('gone', true);
|
||||
|
||||
expect(fs.existsSync(path.join(codeRoot, 'gone'))).toBe(false);
|
||||
expect(testDb.prepare("SELECT COUNT(*) c FROM plugins WHERE id='gone'").get()).toMatchObject({ c: 0 });
|
||||
@@ -310,7 +312,7 @@ describe('PluginRuntimeService (M2 end-to-end)', () => {
|
||||
// a deleted user's erasure is still pending for this plugin
|
||||
testDb.prepare("INSERT INTO plugin_user_erasure_queue (plugin_id, user_id) VALUES ('keepdata', 42)").run();
|
||||
|
||||
await new PluginRuntimeService().uninstall('keepdata', false); // keep the plugin's data
|
||||
await new PluginRuntimeService(new DatabaseService(dbConn)).uninstall('keepdata', false); // keep the plugin's data
|
||||
|
||||
// the plugin row is gone, but the erasure obligation is retained — a reinstall of the
|
||||
// same id will drain it and finally honour the deletion, instead of silently keeping
|
||||
@@ -325,12 +327,12 @@ describe('PluginRuntimeService (M2 end-to-end)', () => {
|
||||
"module.exports = { async onLoad() { await fetch('https://blocked.example/x'); } };");
|
||||
testDb.prepare("INSERT INTO plugins (id, status, permissions, config) VALUES ('nettry','inactive','[]','{}')").run();
|
||||
// no http:outbound granted -> egress guard blocks all outbound -> onLoad throws
|
||||
await expect(new PluginRuntimeService().activate('nettry')).rejects.toThrow(/egress/);
|
||||
await new PluginRuntimeService().deactivate('nettry').catch(() => {});
|
||||
await expect(new PluginRuntimeService(new DatabaseService(dbConn)).activate('nettry')).rejects.toThrow(/egress/);
|
||||
await new PluginRuntimeService(new DatabaseService(dbConn)).deactivate('nettry').catch(() => {});
|
||||
});
|
||||
|
||||
it('onModuleDestroy tears down cleanly', async () => {
|
||||
await expect(new PluginRuntimeService().onModuleDestroy()).resolves.toBeUndefined();
|
||||
await expect(new PluginRuntimeService(new DatabaseService(dbConn)).onModuleDestroy()).resolves.toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -352,7 +354,7 @@ describe('PluginRuntimeService.update (re-consent gate)', () => {
|
||||
|
||||
it('restarts transparently when the new version requests no new permissions', async () => {
|
||||
seed('upd-same', 1, ['db:own'], ['db:own']);
|
||||
const rt = new PluginRuntimeService(fakeRegistry(() => {})); // declared perms unchanged
|
||||
const rt = new PluginRuntimeService(new DatabaseService(dbConn), fakeRegistry(() => {})); // declared perms unchanged
|
||||
await rt.activate('upd-same');
|
||||
const res = await rt.update('upd-same');
|
||||
expect(res).toMatchObject({ activated: true, newPermissions: [], newEgress: [] });
|
||||
@@ -363,6 +365,7 @@ describe('PluginRuntimeService.update (re-consent gate)', () => {
|
||||
it('leaves the plugin inactive and reports the delta when new rights are requested', async () => {
|
||||
seed('upd-wider', 1, ['db:own'], ['db:own']);
|
||||
const rt = new PluginRuntimeService(
|
||||
new DatabaseService(dbConn),
|
||||
fakeRegistry(() => {
|
||||
testDb.prepare("UPDATE plugins SET permissions = ? WHERE id = 'upd-wider'")
|
||||
.run(JSON.stringify(['db:own', 'db:read:trips', 'http:outbound:api.new.com']));
|
||||
@@ -379,17 +382,17 @@ describe('PluginRuntimeService.update (re-consent gate)', () => {
|
||||
|
||||
it('a disabled plugin stays disabled even with no new permissions', async () => {
|
||||
seed('upd-off', 0, ['db:own'], ['db:own']);
|
||||
const res = await new PluginRuntimeService(fakeRegistry(() => {})).update('upd-off');
|
||||
const res = await new PluginRuntimeService(new DatabaseService(dbConn), fakeRegistry(() => {})).update('upd-off');
|
||||
expect(res).toMatchObject({ activated: false, newPermissions: [], newEgress: [] });
|
||||
});
|
||||
|
||||
it('throws for an unknown plugin id', async () => {
|
||||
await expect(new PluginRuntimeService(fakeRegistry(() => {})).update('ghost-upd')).rejects.toThrow(/not found/);
|
||||
await expect(new PluginRuntimeService(new DatabaseService(dbConn), fakeRegistry(() => {})).update('ghost-upd')).rejects.toThrow(/not found/);
|
||||
});
|
||||
|
||||
it('throws if no registry service is wired', async () => {
|
||||
seed('upd-noreg', 0, ['db:own'], ['db:own']);
|
||||
await expect(new PluginRuntimeService().update('upd-noreg')).rejects.toThrow(/registry service unavailable/);
|
||||
await expect(new PluginRuntimeService(new DatabaseService(dbConn)).update('upd-noreg')).rejects.toThrow(/registry service unavailable/);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -556,7 +559,7 @@ describe('PluginRuntimeService.retrust', () => {
|
||||
it('re-pins AND installs in one call: the new key is pinned, the version moved, the block gone', async () => {
|
||||
seed('rt-ok', 'OLDKEY');
|
||||
const registry = registryFor('rt-ok');
|
||||
const rt = new PluginRuntimeService(registry);
|
||||
const rt = new PluginRuntimeService(new DatabaseService(dbConn), registry);
|
||||
await rt.activate('rt-ok');
|
||||
|
||||
await rt.retrust('rt-ok', '2.0.0', 'NEWKEY', { userId: 1, ip: '10.0.0.1' });
|
||||
@@ -601,7 +604,7 @@ describe('PluginRuntimeService.retrust', () => {
|
||||
seed('rt-no', 'OLDKEY');
|
||||
const registry = registryFor('rt-no');
|
||||
vi.mocked(registry.assertRetrustable).mockRejectedValue(new Error('nothing to re-trust'));
|
||||
const rt = new PluginRuntimeService(registry);
|
||||
const rt = new PluginRuntimeService(new DatabaseService(dbConn), registry);
|
||||
|
||||
await expect(rt.retrust('rt-no', '2.0.0', 'NEWKEY', { userId: 1 })).rejects.toThrow(/nothing to re-trust/);
|
||||
|
||||
@@ -627,7 +630,7 @@ describe('PluginRuntimeService.retrust', () => {
|
||||
// exactly the "quietly stops updating" failure the block exists to prevent.
|
||||
it('activate does NOT clear a recorded update block', async () => {
|
||||
seed('rt-act', 'OLDKEY');
|
||||
const rt = new PluginRuntimeService(registryFor('rt-act'));
|
||||
const rt = new PluginRuntimeService(new DatabaseService(dbConn), registryFor('rt-act'));
|
||||
|
||||
await rt.deactivate('rt-act');
|
||||
await rt.activate('rt-act');
|
||||
@@ -663,7 +666,7 @@ describe('TREK host-version gating', () => {
|
||||
it('refuses to activate a plugin this TREK has outgrown, and says so with a code', async () => {
|
||||
process.env.APP_VERSION = '4.0.0';
|
||||
seedRanged('outgrown', '>=3.2.0 <4.0.0');
|
||||
const err = await new PluginRuntimeService().activate('outgrown').catch((e) => e);
|
||||
const err = await new PluginRuntimeService(new DatabaseService(dbConn)).activate('outgrown').catch((e) => e);
|
||||
expect(err).toBeInstanceOf(PluginDependencyError);
|
||||
expect(err).toMatchObject({ code: 'TREK_VERSION_INCOMPATIBLE' });
|
||||
expect(err.detail).toMatchObject({ trekRange: '>=3.2.0 <4.0.0', hostVersion: '4.0.0' });
|
||||
@@ -675,14 +678,14 @@ describe('TREK host-version gating', () => {
|
||||
it('refuses a plugin that never declared a range at all', async () => {
|
||||
process.env.APP_VERSION = '3.3.0';
|
||||
seedRanged('undeclared', null);
|
||||
await expect(new PluginRuntimeService().activate('undeclared')).rejects.toMatchObject({ code: 'TREK_VERSION_UNKNOWN' });
|
||||
await expect(new PluginRuntimeService(new DatabaseService(dbConn)).activate('undeclared')).rejects.toMatchObject({ code: 'TREK_VERSION_UNKNOWN' });
|
||||
cleanup('undeclared');
|
||||
});
|
||||
|
||||
it('activates normally when the range admits the host', async () => {
|
||||
process.env.APP_VERSION = '3.3.0';
|
||||
seedRanged('fits', '>=3.2.0 <4.0.0');
|
||||
const rt = new PluginRuntimeService();
|
||||
const rt = new PluginRuntimeService(new DatabaseService(dbConn));
|
||||
await rt.activate('fits');
|
||||
expect(rt.isActive('fits')).toBe(true);
|
||||
await rt.deactivate('fits');
|
||||
@@ -696,7 +699,7 @@ describe('TREK host-version gating', () => {
|
||||
// panel showing a green, running plugin that does not exist.
|
||||
process.env.APP_VERSION = '4.0.0';
|
||||
seedRanged('boot-broken', '>=3.2.0 <4.0.0', 1);
|
||||
const rt = new PluginRuntimeService();
|
||||
const rt = new PluginRuntimeService(new DatabaseService(dbConn));
|
||||
expect(() => rt.onModuleInit()).not.toThrow();
|
||||
for (let i = 0; i < 40; i++) {
|
||||
const row = testDb.prepare("SELECT enabled FROM plugins WHERE id='boot-broken'").get() as { enabled: number };
|
||||
@@ -714,7 +717,7 @@ describe('TREK host-version gating', () => {
|
||||
fs.mkdirSync(path.join(codeRoot, 'both-wrong', 'server'), { recursive: true });
|
||||
fs.writeFileSync(path.join(codeRoot, 'both-wrong', 'server', 'index.js'), 'module.exports = {};');
|
||||
testDb.prepare("INSERT INTO plugins (id, status, enabled, permissions, granted_permissions, config, trek_range) VALUES ('both-wrong','inactive',0,'[\"db:own\"]','[]','{}','>=3.2.0 <4.0.0')").run();
|
||||
await expect(new PluginRuntimeService().activate('both-wrong')).rejects.toMatchObject({ code: 'TREK_VERSION_INCOMPATIBLE' });
|
||||
await expect(new PluginRuntimeService(new DatabaseService(dbConn)).activate('both-wrong')).rejects.toMatchObject({ code: 'TREK_VERSION_INCOMPATIBLE' });
|
||||
cleanup('both-wrong');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -29,6 +29,8 @@ const { testDb } = vi.hoisted(() => {
|
||||
return { testDb: db };
|
||||
});
|
||||
vi.mock('../../../src/db/database', () => ({ db: testDb, canAccessTrip: () => undefined }));
|
||||
import { db as dbConn } from '../../../src/db/database';
|
||||
import { DatabaseService } from '../../../src/nest/database/database.service';
|
||||
|
||||
import { PluginRegistryService, RegistryError, __clearRegistryCacheForTests } from '../../../src/nest/plugins/registry/registry.service';
|
||||
|
||||
@@ -86,7 +88,7 @@ beforeEach(() => {
|
||||
testDb.exec('DELETE FROM plugins; DELETE FROM plugin_settings_fields; DELETE FROM plugin_error_log');
|
||||
__clearRegistryCacheForTests();
|
||||
vi.stubGlobal('fetch', vi.fn(async () => ({ ok: true, json: async () => REGISTRY }) as unknown as Response));
|
||||
svc = new PluginRegistryService();
|
||||
svc = new PluginRegistryService(new DatabaseService(dbConn));
|
||||
});
|
||||
afterEach(() => {
|
||||
vi.unstubAllGlobals();
|
||||
@@ -236,7 +238,7 @@ describe('PluginRegistryService', () => {
|
||||
it('fetchRegistry soft-fails to an empty registry on a cold cache', async () => {
|
||||
__clearRegistryCacheForTests();
|
||||
vi.stubGlobal('fetch', vi.fn(async () => { throw new Error('offline'); }));
|
||||
expect((await new PluginRegistryService().fetchRegistry()).plugins).toEqual([]);
|
||||
expect((await new PluginRegistryService(new DatabaseService(dbConn)).fetchRegistry()).plugins).toEqual([]);
|
||||
});
|
||||
|
||||
it('installs a pinned version end to end (verify -> extract -> register inactive)', async () => {
|
||||
|
||||
@@ -7,6 +7,8 @@ const { dbMock } = vi.hoisted(() => {
|
||||
return { dbMock: { prepare: vi.fn(() => stmt), _stmt: stmt } };
|
||||
});
|
||||
vi.mock('../../../src/db/database', () => ({ db: dbMock, closeDb: () => {}, reinitialize: () => {} }));
|
||||
import { db as dbConn } from '../../../src/db/database';
|
||||
import { DatabaseService } from '../../../src/nest/database/database.service';
|
||||
|
||||
const { getBagTracking, getCollabFeatures } = vi.hoisted(() => ({
|
||||
getBagTracking: vi.fn(() => ({ enabled: false })),
|
||||
@@ -20,7 +22,7 @@ vi.mock('../../../src/services/memories/helpersService', () => ({ getPhotoProvid
|
||||
import { AddonsService } from '../../../src/nest/addons/addons.service';
|
||||
|
||||
function svc() {
|
||||
return new AddonsService();
|
||||
return new AddonsService(new DatabaseService(dbConn));
|
||||
}
|
||||
|
||||
// Feed the three reads in order: addons, providers, fields.
|
||||
|
||||
@@ -4,6 +4,8 @@ import { HttpException } from '@nestjs/common';
|
||||
// Mock the heavy side-effect imports so the service module loads cleanly; the
|
||||
// preview() path under test only touches the extractor + llmParse deps.
|
||||
vi.mock('../../../../src/db/database', () => ({ db: { prepare: vi.fn() }, closeDb: () => {}, reinitialize: () => {} }));
|
||||
import { db as dbConn } from '../../../../src/db/database';
|
||||
import { DatabaseService } from '../../../../src/nest/database/database.service';
|
||||
vi.mock('../../../../src/websocket', () => ({ broadcast: vi.fn() }));
|
||||
vi.mock('../../../../src/services/permissions', () => ({ checkPermission: vi.fn(() => true) }));
|
||||
vi.mock('../../../../src/services/tripAccess', () => ({ verifyTripAccess: vi.fn() }));
|
||||
@@ -19,7 +21,7 @@ const file = (name = 'a.pdf') => ({ buffer: Buffer.from('x'), originalname: name
|
||||
function make(opts: { kit?: boolean; ai?: boolean; extract?: any; parse?: any }) {
|
||||
const extractor = { isAvailable: () => opts.kit ?? false, extract: vi.fn(opts.extract ?? (async () => [])) };
|
||||
const llmParse = { isAvailable: () => opts.ai ?? false, parse: vi.fn(opts.parse ?? (async () => ({ kiItems: [], warnings: [] }))) };
|
||||
return { svc: new BookingImportService(extractor as any, llmParse as any), extractor, llmParse };
|
||||
return { svc: new BookingImportService(extractor as any, llmParse as any, new DatabaseService(dbConn)), extractor, llmParse };
|
||||
}
|
||||
|
||||
beforeEach(() => vi.clearAllMocks());
|
||||
|
||||
@@ -6,6 +6,8 @@ const { dbMock } = vi.hoisted(() => {
|
||||
return { dbMock: { prepare: vi.fn(() => stmt), _stmt: stmt } };
|
||||
});
|
||||
vi.mock('../../../src/db/database', () => ({ db: dbMock, closeDb: () => {}, reinitialize: () => {} }));
|
||||
import { db as dbConn } from '../../../src/db/database';
|
||||
import { DatabaseService } from '../../../src/nest/database/database.service';
|
||||
|
||||
const { broadcast } = vi.hoisted(() => ({ broadcast: vi.fn() }));
|
||||
vi.mock('../../../src/websocket', () => ({ broadcast }));
|
||||
@@ -42,7 +44,7 @@ vi.mock('../../../src/services/budgetService', () => budget);
|
||||
import { BudgetService } from '../../../src/nest/budget/budget.service';
|
||||
|
||||
function svc() {
|
||||
return new BudgetService();
|
||||
return new BudgetService(new DatabaseService(dbConn));
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
|
||||
@@ -5,6 +5,8 @@ const { dbMock } = vi.hoisted(() => {
|
||||
return { dbMock: { prepare: vi.fn(() => stmt), _stmt: stmt } };
|
||||
});
|
||||
vi.mock('../../../src/db/database', () => ({ db: dbMock, closeDb: () => {}, reinitialize: () => {} }));
|
||||
import { db as dbConn } from '../../../src/db/database';
|
||||
import { DatabaseService } from '../../../src/nest/database/database.service';
|
||||
|
||||
const { broadcast } = vi.hoisted(() => ({ broadcast: vi.fn() }));
|
||||
vi.mock('../../../src/websocket', () => ({ broadcast }));
|
||||
@@ -29,7 +31,7 @@ vi.mock('../../../src/services/notificationService', () => ({ send }));
|
||||
import { PackingService } from '../../../src/nest/packing/packing.service';
|
||||
|
||||
function svc() {
|
||||
return new PackingService();
|
||||
return new PackingService(new DatabaseService(dbConn));
|
||||
}
|
||||
|
||||
beforeEach(() => vi.clearAllMocks());
|
||||
|
||||
@@ -6,6 +6,8 @@ const { dbMock } = vi.hoisted(() => {
|
||||
return { dbMock: { prepare: vi.fn(() => stmt), _stmt: stmt } };
|
||||
});
|
||||
vi.mock('../../../src/db/database', () => ({ db: dbMock, closeDb: () => {}, reinitialize: () => {} }));
|
||||
import { db as dbConn } from '../../../src/db/database';
|
||||
import { DatabaseService } from '../../../src/nest/database/database.service';
|
||||
|
||||
const { broadcast } = vi.hoisted(() => ({ broadcast: vi.fn() }));
|
||||
vi.mock('../../../src/websocket', () => ({ broadcast }));
|
||||
@@ -31,7 +33,7 @@ vi.mock('../../../src/services/reservationService', () => resv);
|
||||
import { ReservationsService } from '../../../src/nest/reservations/reservations.service';
|
||||
|
||||
function svc() {
|
||||
return new ReservationsService();
|
||||
return new ReservationsService(new DatabaseService(dbConn));
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
|
||||
@@ -6,6 +6,8 @@ const { dbMock } = vi.hoisted(() => {
|
||||
});
|
||||
const { canAccessTrip } = vi.hoisted(() => ({ canAccessTrip: vi.fn(() => ({ user_id: 1 })) }));
|
||||
vi.mock('../../../src/db/database', () => ({ db: dbMock, canAccessTrip, closeDb: () => {}, reinitialize: () => {} }));
|
||||
import { db as dbConn } from '../../../src/db/database';
|
||||
import { DatabaseService } from '../../../src/nest/database/database.service';
|
||||
|
||||
const { broadcast } = vi.hoisted(() => ({ broadcast: vi.fn() }));
|
||||
vi.mock('../../../src/websocket', () => ({ broadcast }));
|
||||
@@ -37,7 +39,7 @@ vi.mock('../../../src/services/fileService', () => ({ listFiles: () => [] }));
|
||||
import { TripsService } from '../../../src/nest/trips/trips.service';
|
||||
import { rebaseTripCurrency } from '../../../src/services/budgetService';
|
||||
|
||||
function svc() { return new TripsService(); }
|
||||
function svc() { return new TripsService(new DatabaseService(dbConn)); }
|
||||
beforeEach(() => vi.clearAllMocks());
|
||||
|
||||
describe('TripsService (wrapper delegation + bundle/copy/notify helpers)', () => {
|
||||
|
||||
@@ -20,6 +20,8 @@ const { testDb, dbMock } = vi.hoisted(() => {
|
||||
return { testDb: db, dbMock: { db, closeDb: () => {}, reinitialize: () => {}, canAccessTrip: () => null } };
|
||||
});
|
||||
vi.mock('../../../src/db/database', () => dbMock);
|
||||
import { db as dbConn } from '../../../src/db/database';
|
||||
import { DatabaseService } from '../../../src/nest/database/database.service';
|
||||
vi.mock('../../../src/config', () => ({ JWT_SECRET: 'x'.repeat(40), ENCRYPTION_KEY: 'a'.repeat(64), updateJwtSecret: () => {} }));
|
||||
|
||||
import { createTables } from '../../../src/db/schema';
|
||||
@@ -42,7 +44,7 @@ beforeEach(() => {
|
||||
testDb.prepare('DELETE FROM plugins').run();
|
||||
testDb.prepare('DELETE FROM plugin_egress_hosts').run();
|
||||
testDb.prepare('DELETE FROM plugin_actions').run();
|
||||
rt = new PluginRuntimeService();
|
||||
rt = new PluginRuntimeService(new DatabaseService(dbConn));
|
||||
});
|
||||
|
||||
describe('operator-supplied egress hosts', () => {
|
||||
@@ -156,13 +158,13 @@ describe('the admin list surfaces operator egress (so the chip can be shown)', (
|
||||
install('gotify', true);
|
||||
install('plain', false);
|
||||
|
||||
const before = new PluginsService().list().plugins;
|
||||
const before = new PluginsService(new DatabaseService(dbConn)).list().plugins;
|
||||
expect(before.find(p => p.id === 'gotify')).toMatchObject({ operatorEgress: true, egressHostCount: 0 });
|
||||
// A plugin that never asked for it must never invite the admin to add hosts.
|
||||
expect(before.find(p => p.id === 'plain')).toMatchObject({ operatorEgress: false, egressHostCount: 0 });
|
||||
|
||||
await rt.setOperatorEgressHosts('gotify', ['a.example.com', 'b.example.com']);
|
||||
const after = new PluginsService().list().plugins;
|
||||
const after = new PluginsService(new DatabaseService(dbConn)).list().plugins;
|
||||
expect(after.find(p => p.id === 'gotify')!.egressHostCount).toBe(2);
|
||||
delete process.env.TREK_PLUGINS_ENABLED;
|
||||
});
|
||||
|
||||
@@ -14,6 +14,8 @@ vi.mock('../../../src/services/notifications', () => ({ getAppUrl: () => 'https:
|
||||
|
||||
const { getDb } = vi.hoisted(() => ({ getDb: { current: null as unknown } }));
|
||||
vi.mock('../../../src/db/database', () => ({ get db() { return getDb.current; } }));
|
||||
import { db as dbConn } from '../../../src/db/database';
|
||||
import { DatabaseService } from '../../../src/nest/database/database.service';
|
||||
|
||||
// The token POST now runs through the SSRF guard (ssrfGuard.safeFetchLlm), which
|
||||
// resolves the host before fetching. Stub DNS so the fake provider.example host
|
||||
@@ -50,12 +52,12 @@ const NOW = 1_700_000_000_000;
|
||||
|
||||
describe('PluginOAuthService', () => {
|
||||
let svc: PluginOAuthService;
|
||||
beforeEach(() => { getDb.current = freshDb(); svc = new PluginOAuthService(); vi.restoreAllMocks(); dnsState.address = '93.184.216.34'; dnsState.family = 4; });
|
||||
beforeEach(() => { getDb.current = freshDb(); svc = new PluginOAuthService(new DatabaseService(dbConn)); vi.restoreAllMocks(); dnsState.address = '93.184.216.34'; dnsState.family = 4; });
|
||||
|
||||
it('providerConfig returns null unless every piece is present, decrypting the secrets', () => {
|
||||
expect(svc.providerConfig('p')).toMatchObject({ clientId: 'client-123', clientSecret: 'secret-abc', scopes: 'read write' });
|
||||
getDb.current = freshDb({ ...CFG, oauth_client_secret: '' });
|
||||
expect(new PluginOAuthService().providerConfig('p')).toBeNull();
|
||||
expect(new PluginOAuthService(new DatabaseService(dbConn)).providerConfig('p')).toBeNull();
|
||||
});
|
||||
|
||||
it('startConnect builds a PKCE authorize URL + persists a single fresh state per user', () => {
|
||||
@@ -76,18 +78,18 @@ describe('PluginOAuthService', () => {
|
||||
|
||||
it('rejects a non-https / loopback / metadata / internal authorize endpoint', () => {
|
||||
getDb.current = freshDb({ ...CFG, oauth_authorize_url: 'http://provider.example/authorize' });
|
||||
expect(() => new PluginOAuthService().startConnect('p', 42, NOW)).toThrow(/https/);
|
||||
expect(() => new PluginOAuthService(new DatabaseService(dbConn)).startConnect('p', 42, NOW)).toThrow(/https/);
|
||||
getDb.current = freshDb({ ...CFG, oauth_token_url: 'https://127.0.0.1/token' });
|
||||
expect(() => new PluginOAuthService().startConnect('p', 42, NOW)).toThrow(/loopback|private/);
|
||||
expect(() => new PluginOAuthService(new DatabaseService(dbConn)).startConnect('p', 42, NOW)).toThrow(/loopback|private/);
|
||||
// IPv6-literal loopback must not slip past the fast-fail
|
||||
getDb.current = freshDb({ ...CFG, oauth_token_url: 'https://[::1]/token' });
|
||||
expect(() => new PluginOAuthService().startConnect('p', 42, NOW)).toThrow(/loopback/);
|
||||
expect(() => new PluginOAuthService(new DatabaseService(dbConn)).startConnect('p', 42, NOW)).toThrow(/loopback/);
|
||||
// cloud-metadata by literal is refused too
|
||||
getDb.current = freshDb({ ...CFG, oauth_token_url: 'https://169.254.169.254/token' });
|
||||
expect(() => new PluginOAuthService().startConnect('p', 42, NOW)).toThrow(/loopback|metadata/);
|
||||
expect(() => new PluginOAuthService(new DatabaseService(dbConn)).startConnect('p', 42, NOW)).toThrow(/loopback|metadata/);
|
||||
// an internal name suffix is refused
|
||||
getDb.current = freshDb({ ...CFG, oauth_token_url: 'https://idp.internal/token' });
|
||||
expect(() => new PluginOAuthService().startConnect('p', 42, NOW)).toThrow(/local/);
|
||||
expect(() => new PluginOAuthService(new DatabaseService(dbConn)).startConnect('p', 42, NOW)).toThrow(/local/);
|
||||
});
|
||||
|
||||
it('completeCallback verifies state (single-use, user-bound, TTL), exchanges the code, encrypts tokens', async () => {
|
||||
|
||||
@@ -15,6 +15,8 @@ vi.mock('../../../src/services/apiKeyCrypto', () => ({
|
||||
|
||||
const { getDb } = vi.hoisted(() => ({ getDb: { current: null as unknown } }));
|
||||
vi.mock('../../../src/db/database', () => ({ get db() { return getDb.current; } }));
|
||||
import { db as dbConn } from '../../../src/db/database';
|
||||
import { DatabaseService } from '../../../src/nest/database/database.service';
|
||||
|
||||
import Database from 'better-sqlite3';
|
||||
import { PluginsService, readUserSettingDecrypted } from '../../../src/nest/plugins/plugins.service';
|
||||
@@ -35,7 +37,7 @@ function freshDb() {
|
||||
|
||||
describe('per-user plugin settings', () => {
|
||||
let svc: PluginsService;
|
||||
beforeEach(() => { getDb.current = freshDb(); svc = new PluginsService(); });
|
||||
beforeEach(() => { getDb.current = freshDb(); svc = new PluginsService(new DatabaseService(dbConn)); });
|
||||
|
||||
it('lists only the user-scope fields, in order', () => {
|
||||
const fields = svc.userSettingsFields('p');
|
||||
|
||||
@@ -17,6 +17,8 @@ const { testDb } = vi.hoisted(() => {
|
||||
return { testDb: db };
|
||||
});
|
||||
vi.mock('../../../src/db/database', () => ({ db: testDb }));
|
||||
import { db as dbConn } from '../../../src/db/database';
|
||||
import { DatabaseService } from '../../../src/nest/database/database.service';
|
||||
|
||||
import { PluginsService } from '../../../src/nest/plugins/plugins.service';
|
||||
import { PluginsController } from '../../../src/nest/plugins/plugins.controller';
|
||||
@@ -38,7 +40,7 @@ describe('PluginsService.list', () => {
|
||||
.run('flight', 'Flight', 'desc', 'widget', 'inactive', '1.0.0');
|
||||
process.env.TREK_PLUGINS_ENABLED = 'true';
|
||||
|
||||
const out = new PluginsService().list();
|
||||
const out = new PluginsService(new DatabaseService(dbConn)).list();
|
||||
expect(out.enabled).toBe(true);
|
||||
expect(out.plugins).toHaveLength(1);
|
||||
expect(out.plugins[0]).toMatchObject({ id: 'flight', name: 'Flight', status: 'inactive' });
|
||||
@@ -49,14 +51,14 @@ describe('PluginsService.list', () => {
|
||||
.prepare('INSERT INTO plugins (id, name, description, type, status, version) VALUES (?,?,?,?,?,?)')
|
||||
.run('flight', 'Flight', 'desc', 'widget', 'inactive', '1.0.0');
|
||||
|
||||
const out = new PluginsService().list();
|
||||
const out = new PluginsService(new DatabaseService(dbConn)).list();
|
||||
expect(out.enabled).toBe(true);
|
||||
expect(out.plugins).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('reports disabled when the kill switch is off (TREK_PLUGINS_ENABLED=false)', () => {
|
||||
process.env.TREK_PLUGINS_ENABLED = 'false';
|
||||
const out = new PluginsService().list();
|
||||
const out = new PluginsService(new DatabaseService(dbConn)).list();
|
||||
expect(out.enabled).toBe(false);
|
||||
expect(out.plugins).toEqual([]);
|
||||
});
|
||||
@@ -76,7 +78,7 @@ describe('PluginsService.list', () => {
|
||||
const key = 'RWTvBn0aBcDeFgHiJkLmNoPqRsTuVwXyZ0123456789abcd';
|
||||
insert('signed-one', 'acme/signed-one', key);
|
||||
|
||||
const p = new PluginsService().list().plugins[0];
|
||||
const p = new PluginsService(new DatabaseService(dbConn)).list().plugins[0];
|
||||
expect(p.signed).toBe(true);
|
||||
// Short head…tail, for eyeballing against what the author reads out over the
|
||||
// phone. NOT a confidentiality measure — the key is public, and the re-trust
|
||||
@@ -86,7 +88,7 @@ describe('PluginsService.list', () => {
|
||||
|
||||
it('reports unsigned for a registry plugin with no pinned key', () => {
|
||||
insert('plain', 'acme/plain', null);
|
||||
const p = new PluginsService().list().plugins[0];
|
||||
const p = new PluginsService(new DatabaseService(dbConn)).list().plugins[0];
|
||||
expect(p.signed).toBe(false);
|
||||
expect(p.keyFingerprint).toBeNull();
|
||||
});
|
||||
@@ -94,7 +96,7 @@ describe('PluginsService.list', () => {
|
||||
it('reports unsigned for a sideloaded and a dev-linked plugin (they carry no key)', () => {
|
||||
insert('uploaded', 'local:upload', null);
|
||||
insert('linked', 'local:link', null);
|
||||
const plugins = new PluginsService().list().plugins;
|
||||
const plugins = new PluginsService(new DatabaseService(dbConn)).list().plugins;
|
||||
expect(plugins.map((p) => [p.id, p.signed, p.source_repo])).toEqual([
|
||||
['linked', false, 'local:link'],
|
||||
['uploaded', false, 'local:upload'],
|
||||
@@ -108,14 +110,14 @@ describe('PluginsService.list', () => {
|
||||
.prepare('UPDATE plugins SET update_block_code = ?, update_block_detail = ?, update_block_version = ? WHERE id = ?')
|
||||
.run('SIGNATURE_KEY_CHANGED', 'the key changed', '2.0.0', 'blocked');
|
||||
|
||||
const byId = Object.fromEntries(new PluginsService().list().plugins.map((p) => [p.id, p]));
|
||||
const byId = Object.fromEntries(new PluginsService(new DatabaseService(dbConn)).list().plugins.map((p) => [p.id, p]));
|
||||
expect(byId.blocked.updateBlock).toEqual({ code: 'SIGNATURE_KEY_CHANGED', detail: 'the key changed', version: '2.0.0' });
|
||||
expect(byId.fine.updateBlock).toBeNull();
|
||||
});
|
||||
|
||||
it('never leaks the raw pinned key into the list response (only the fingerprint)', () => {
|
||||
insert('signed-one', 'acme/signed-one', 'RWTvBn0aBcDeFgHiJkLmNoPqRsTuVwXyZ0123456789abcd');
|
||||
const p = new PluginsService().list().plugins[0] as Record<string, unknown>;
|
||||
const p = new PluginsService(new DatabaseService(dbConn)).list().plugins[0] as Record<string, unknown>;
|
||||
expect(p.author_pubkey).toBeUndefined();
|
||||
});
|
||||
});
|
||||
@@ -133,7 +135,7 @@ describe('PluginsFeedController (client feed)', () => {
|
||||
it('returns active plugins when enabled, nothing when disabled', () => {
|
||||
testDb.prepare("INSERT INTO plugins (id, name, type, icon, status) VALUES ('w','W','widget','Box','active')").run();
|
||||
testDb.prepare("INSERT INTO plugins (id, name, type, icon, status) VALUES ('i','I','integration','Plug','inactive')").run();
|
||||
const feed = new PluginsFeedController();
|
||||
const feed = new PluginsFeedController(new DatabaseService(dbConn));
|
||||
|
||||
process.env.TREK_PLUGINS_ENABLED = 'true';
|
||||
const active = feed.list();
|
||||
@@ -147,7 +149,7 @@ describe('PluginsFeedController (client feed)', () => {
|
||||
testDb.prepare("INSERT INTO plugins (id, name, type, icon, status, capabilities) VALUES ('h','H','widget','Box','active','{\"widget\":{\"slot\":\"hero\"}}')").run();
|
||||
testDb.prepare("INSERT INTO plugins (id, name, type, icon, status, capabilities) VALUES ('b','B','widget','Box','active','not-json')").run();
|
||||
process.env.TREK_PLUGINS_ENABLED = 'true';
|
||||
const out = new PluginsFeedController().list();
|
||||
const out = new PluginsFeedController(new DatabaseService(dbConn)).list();
|
||||
expect(out.plugins.find((p) => p.id === 'h')?.slot).toBe('hero');
|
||||
expect(out.plugins.find((p) => p.id === 'b')?.slot).toBe('sidebar');
|
||||
});
|
||||
@@ -155,14 +157,14 @@ describe('PluginsFeedController (client feed)', () => {
|
||||
it('exposes the day-detail slot (a day-panel widget must not fall back to the dashboard)', () => {
|
||||
testDb.prepare("INSERT INTO plugins (id, name, type, icon, status, capabilities) VALUES ('d','D','widget','Box','active','{\"widget\":{\"slot\":\"day-detail\"}}')").run();
|
||||
process.env.TREK_PLUGINS_ENABLED = 'true';
|
||||
expect(new PluginsFeedController().list().plugins.find((p) => p.id === 'd')?.slot).toBe('day-detail');
|
||||
expect(new PluginsFeedController(new DatabaseService(dbConn)).list().plugins.find((p) => p.id === 'd')?.slot).toBe('day-detail');
|
||||
});
|
||||
|
||||
it('exposes settingsUi only when the capability is exactly true', () => {
|
||||
testDb.prepare("INSERT INTO plugins (id, name, type, icon, status, capabilities) VALUES ('su','S','widget','Box','active','{\"settingsUi\":true}')").run();
|
||||
testDb.prepare("INSERT INTO plugins (id, name, type, icon, status, capabilities) VALUES ('no','N','widget','Box','active','{\"settingsUi\":\"yes\"}')").run();
|
||||
process.env.TREK_PLUGINS_ENABLED = 'true';
|
||||
const out = new PluginsFeedController().list();
|
||||
const out = new PluginsFeedController(new DatabaseService(dbConn)).list();
|
||||
expect(out.plugins.find((p) => p.id === 'su')?.settingsUi).toBe(true);
|
||||
expect(out.plugins.find((p) => p.id === 'no')?.settingsUi).toBeUndefined();
|
||||
});
|
||||
@@ -170,7 +172,7 @@ describe('PluginsFeedController (client feed)', () => {
|
||||
it('exposes the reservation-detail slot (a booking-card widget must not fall back to the dashboard)', () => {
|
||||
testDb.prepare("INSERT INTO plugins (id, name, type, icon, status, capabilities) VALUES ('r','R','widget','Box','active','{\"widget\":{\"slot\":\"reservation-detail\"}}')").run();
|
||||
process.env.TREK_PLUGINS_ENABLED = 'true';
|
||||
expect(new PluginsFeedController().list().plugins.find((p) => p.id === 'r')?.slot).toBe('reservation-detail');
|
||||
expect(new PluginsFeedController(new DatabaseService(dbConn)).list().plugins.find((p) => p.id === 'r')?.slot).toBe('reservation-detail');
|
||||
});
|
||||
|
||||
it('exposes tripPage for trip-page plugins, re-validated against the replaceable-tab whitelist', () => {
|
||||
@@ -180,7 +182,7 @@ describe('PluginsFeedController (client feed)', () => {
|
||||
// the capability is meaningless off a trip-page and must not leak onto widgets
|
||||
testDb.prepare("INSERT INTO plugins (id, name, type, icon, status, capabilities) VALUES ('w2','W2','widget','Box','active','{\"tripPage\":{\"replaces\":[\"transports\"]}}')").run();
|
||||
process.env.TREK_PLUGINS_ENABLED = 'true';
|
||||
const out = new PluginsFeedController().list();
|
||||
const out = new PluginsFeedController(new DatabaseService(dbConn)).list();
|
||||
expect(out.plugins.find((p) => p.id === 't')?.tripPage).toEqual({ replaces: ['transports', 'buchungen'], position: 1 });
|
||||
expect(out.plugins.find((p) => p.id === 'evil')?.tripPage).toBeUndefined();
|
||||
expect(out.plugins.find((p) => p.id === 'w2')?.tripPage).toBeUndefined();
|
||||
@@ -236,7 +238,7 @@ describe('PluginsService instance config', () => {
|
||||
testDb.prepare("INSERT INTO plugins (id, name, status, config) VALUES ('x','X','inactive','{}')").run();
|
||||
testDb.prepare("INSERT INTO plugin_settings_fields (plugin_id, field_key, scope, secret) VALUES ('x','api_key','instance',1)").run();
|
||||
|
||||
const svc = new PluginsService();
|
||||
const svc = new PluginsService(new DatabaseService(dbConn));
|
||||
const masked = svc.updateInstanceConfig('x', { api_key: 'super-secret', server: 'https://h' });
|
||||
// client gets the masked view
|
||||
expect(masked.api_key).toBe('••••••••');
|
||||
@@ -257,8 +259,8 @@ describe('PluginsService instance config', () => {
|
||||
});
|
||||
|
||||
it('throws for an unknown plugin', () => {
|
||||
expect(() => new PluginsService().updateInstanceConfig('nope', {})).toThrow(/not found/);
|
||||
expect(() => new PluginsService().getInstanceConfig('nope')).toThrow(/not found/);
|
||||
expect(() => new PluginsService(new DatabaseService(dbConn)).updateInstanceConfig('nope', {})).toThrow(/not found/);
|
||||
expect(() => new PluginsService(new DatabaseService(dbConn)).getInstanceConfig('nope')).toThrow(/not found/);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -266,7 +268,7 @@ describe('PluginsService error log', () => {
|
||||
beforeEach(() => testDb.exec('DELETE FROM plugin_error_log'));
|
||||
it('lists and clears a plugin error log', () => {
|
||||
testDb.prepare("INSERT INTO plugin_error_log (plugin_id, level, message) VALUES ('p','error','boom')").run();
|
||||
const svc = new PluginsService();
|
||||
const svc = new PluginsService(new DatabaseService(dbConn));
|
||||
expect(svc.errors('p')).toEqual([{ ts: '2026-01-01', level: 'error', message: 'boom' }]);
|
||||
svc.clearErrors('p');
|
||||
expect(svc.errors('p')).toEqual([]);
|
||||
|
||||
@@ -11,6 +11,8 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { PluginSupervisor } from '../../../src/nest/plugins/supervisor/plugin-supervisor';
|
||||
import { PluginRuntimeService } from '../../../src/nest/plugins/plugin-runtime.service';
|
||||
import { DatabaseService } from '../../../src/nest/database/database.service';
|
||||
import { db } from '../../../src/db/database';
|
||||
|
||||
function makeSupervisor(): PluginSupervisor {
|
||||
// createRpcHost is never called on the providersOf path (no spawn).
|
||||
@@ -85,7 +87,7 @@ describe('providersOf enforces the hook:* grant', () => {
|
||||
|
||||
describe('runtime.invokeHook defense-in-depth', () => {
|
||||
it('refuses a plugin id that is not a granted provider of the hook, even if passed directly', async () => {
|
||||
const rt = new PluginRuntimeService();
|
||||
const rt = new PluginRuntimeService(new DatabaseService(db));
|
||||
// one legitimate granted provider exists, so providersOf('placeDetailProvider') = ['ok']
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
(rt as any).supervisor.running.set('ok', { id: 'ok', status: 'active', hooks: ['placeDetailProvider'], events: [], granted: new Set(['hook:place-detail-provider']) });
|
||||
|
||||
@@ -17,6 +17,8 @@ const { testDb, dbMock } = vi.hoisted(() => {
|
||||
return { testDb: db, dbMock: { db, closeDb: () => {}, reinitialize: () => {}, canAccessTrip: () => null } };
|
||||
});
|
||||
vi.mock('../../../src/db/database', () => dbMock);
|
||||
import { db as dbConn } from '../../../src/db/database';
|
||||
import { DatabaseService } from '../../../src/nest/database/database.service';
|
||||
vi.mock('../../../src/config', () => ({ JWT_SECRET: 'x'.repeat(40), ENCRYPTION_KEY: 'a'.repeat(64), updateJwtSecret: () => {} }));
|
||||
|
||||
import { createTables } from '../../../src/db/schema';
|
||||
@@ -120,7 +122,7 @@ describe('a plugin channel label is bounded by the host', () => {
|
||||
VALUES ('loud', 'Loud', 'active', 1, '1.0.0', '[]', '[]', ?, '{}')`,
|
||||
).run(JSON.stringify({ notificationChannel: { title: '🎉'.repeat(5) + 'A'.repeat(500) } }));
|
||||
|
||||
const rt = new PluginRuntimeService();
|
||||
const rt = new PluginRuntimeService(new DatabaseService(dbConn));
|
||||
// Stand the plugin up as a granted, active notificationChannel provider.
|
||||
(rt as unknown as { supervisor: { running: Map<string, unknown> } }).supervisor.running.set('loud', {
|
||||
id: 'loud', status: 'active', hooks: ['notificationChannel'], granted: new Set(['hook:notification-channel']),
|
||||
|
||||
Reference in New Issue
Block a user