refactor(plugins): contribution-hook controllers, the small plugin controllers and the plugins-table survivors through repositories

This commit is contained in:
jubnl
2026-09-24 03:53:44 +02:00
parent d522882590
commit b6de35a2ce
32 changed files with 633 additions and 163 deletions
@@ -2,6 +2,7 @@ import type { Days } from '../entities/Days.entity';
import { DayAssignments } from '../entities/DayAssignments.entity';
import { DayNotes } from '../entities/DayNotes.entity';
import { DayAccommodations } from '../entities/DayAccommodations.entity';
import { columnRef } from '../dialect/sql-functions';
import { toRow, type AssertRowKeys } from './_shared/rows';
import { TrekRepository } from './_shared/trek-repository';
@@ -53,6 +54,34 @@ export class DaysRepository extends TrekRepository<Days> {
return day ? (toRow(day) as DayRow) : undefined;
}
/**
* CT1/CT2 (Plan 3j Task 5, `contributions/day-schedule.controller.ts` /
* `day-tints.controller.ts`, byte-identical text) — `SELECT id FROM days
* WHERE trip_id = ?`, the trip's own day-id set (used only for a
* membership check against a plugin-contributed `dayId`).
*/
async listIdsByTrip(trip_id: number): Promise<number[]> {
const rows = await this.find({ trip: trip_id }, { fields: ['id'] });
return rows.map((r) => r.id);
}
/**
* MR9 (Plan 3j Task 5, `host/rpc/meta.rpc.ts#entityTrip`'s `'day'` arm,
* R12's "one method per target table, no dynamic identifier dispatch"
* precedent — `PlacesRepository.findTripId`/`ReservationsRepository
* .findTripId`'s own docstrings) — `SELECT trip_id FROM days WHERE id =
* ?`. `trip_id` is a `persist(false)` mirror of the `trip` relation —
* `columnRef`, not a bare select (the program-wide trap).
*/
async findTripId(id: number): Promise<number | undefined> {
const platform = this.getEntityManager().getPlatform();
const row = await this.qb('d')
.select([columnRef(platform, 'd.trip_id').as('trip_id')])
.where({ id })
.execute<{ trip_id: number } | undefined>('get', false);
return row?.trip_id;
}
/**
* The trip's highest `day_number`, 0 when it has no days.
*
@@ -1,4 +1,5 @@
import type { PluginEntityMetadata } from '../entities/PluginEntityMetadata.entity';
import { currentTimestamp } from '../dialect/sql-functions';
import { TrekRepository } from './_shared/trek-repository';
export class PluginEntityMetadataRepository extends TrekRepository<PluginEntityMetadata> {
@@ -6,4 +7,65 @@ export class PluginEntityMetadataRepository extends TrekRepository<PluginEntityM
async deleteAllForPlugin(pluginId: string): Promise<void> {
await this.nativeDelete({ plugin_id: pluginId });
}
// -----------------------------------------------------------------------
// MR1–MR6 (Plan 3j Task 5, `plugins/host/rpc/meta.rpc.ts`'s `meta.get`/
// `meta.set`/`meta.list`/`meta.delete` RPC handlers — a plugin's own
// namespaced key/value store on a core entity).
// -----------------------------------------------------------------------
/**
* MR1/MR2 — `SELECT value FROM plugin_entity_metadata WHERE plugin_id=?
* AND entity_type=? AND entity_id=? AND key=?` — MR2 (`set`'s pre-write
* existence check) is byte-identical text, existence-only; the caller
* checks `!== null` rather than this repository exposing a second,
* narrower method for the same 4-column read.
*/
async findValue(pluginId: string, entityType: string, entityId: number, key: string): Promise<string | null> {
const row = await this.findOne({ plugin_id: pluginId, entity_type: entityType, entity_id: entityId, key }, { fields: ['value'] });
return row?.value ?? null;
}
/** MR3 — `SELECT COUNT(*) AS n FROM plugin_entity_metadata WHERE plugin_id=? AND entity_type=? AND entity_id=?`, the `META_KEYS_MAX` per-entity quota. */
async countForEntity(pluginId: string, entityType: string, entityId: number): Promise<number> {
return await this.count({ plugin_id: pluginId, entity_type: entityType, entity_id: entityId });
}
/**
* MR4 — `` INSERT INTO plugin_entity_metadata (plugin_id, entity_type,
* entity_id, key, value, updated_at) VALUES (?, ?, ?, ?, ?, datetime('now'))
* ON CONFLICT(plugin_id, entity_type, entity_id, key) DO UPDATE SET value
* = excluded.value, updated_at = excluded.updated_at `` — the WIDEST
* composite-key upsert in the plan (4 key columns, the entity's own
* `uniques`). `PluginUserConfigRepository.upsertConfig`'s own precedent:
* `currentTimestamp(platform)` bound once so the inserted row and the
* `excluded.updated_at` merge target agree, matching the legacy
* single-statement `datetime('now')` call exactly.
*/
async upsertValue(pluginId: string, entityType: string, entityId: number, key: string, value: string): Promise<void> {
const platform = this.getEntityManager().getPlatform();
await this.upsert(
{ plugin_id: pluginId, entity_type: entityType, entity_id: entityId, key, value, updated_at: currentTimestamp(platform) },
{
onConflictFields: ['plugin_id', 'entity_type', 'entity_id', 'key'],
onConflictAction: 'merge',
onConflictMergeFields: ['value', 'updated_at'],
},
);
}
/** MR5 — `SELECT key, value FROM plugin_entity_metadata WHERE plugin_id=? AND entity_type=? AND entity_id=? ORDER BY key`. */
async listForEntity(pluginId: string, entityType: string, entityId: number): Promise<{ key: string; value: string }[]> {
const rows = await this.find(
{ plugin_id: pluginId, entity_type: entityType, entity_id: entityId },
{ fields: ['key', 'value'], orderBy: { key: 'asc' } },
);
return rows.map((r) => ({ key: r.key, value: r.value ?? '' }));
}
/** MR6 — `DELETE FROM plugin_entity_metadata WHERE plugin_id=? AND entity_type=? AND entity_id=? AND key=?`, returning whether a row existed (`.changes > 0`). */
async deleteValue(pluginId: string, entityType: string, entityId: number, key: string): Promise<boolean> {
const changed = await this.nativeDelete({ plugin_id: pluginId, entity_type: entityType, entity_id: entityId, key });
return changed > 0;
}
}
@@ -1,6 +1,15 @@
import type { PluginScheduledTasks } from '../entities/PluginScheduledTasks.entity';
import { TrekRepository } from './_shared/trek-repository';
/** HR7's insert-only shape (`schedulerSet`'s VALUES list — `payload`/`every_ms` are always caller-supplied, never defaulted the way `id`/`created_at` are). */
export interface PluginScheduledTaskUpsertInput {
plugin_id: string;
name: string;
due_at: number;
payload: string;
every_ms: number | null;
}
/** PR4 — the sweep's own due-task projection. */
export interface DueScheduledTaskRow {
id: number;
@@ -61,4 +70,47 @@ export class PluginScheduledTasksRepository extends TrekRepository<PluginSchedul
async deleteAllForPlugin(pluginId: string): Promise<void> {
await this.nativeDelete({ plugin_id: pluginId });
}
// -----------------------------------------------------------------------
// HR5–HR8 (Plan 3j Task 5, `plugins/host/rpc/host-surface.rpc.ts`'s
// `scheduler.set`/`scheduler.cancel` RPC handlers — a plugin's own
// request to schedule/cancel one of its named tasks, distinct from PR4–
// PR10's sweep-tick statements above).
// -----------------------------------------------------------------------
/** HR5 — `SELECT id FROM plugin_scheduled_tasks WHERE plugin_id = ? AND name = ?`, existence-only (the pre-quota check `schedulerSet` runs before counting). */
async existsForPluginAndName(pluginId: string, name: string): Promise<boolean> {
const row = await this.findOne({ plugin_id: pluginId, name }, { fields: ['id'] });
return row !== null;
}
/** HR6 — `SELECT COUNT(*) AS c FROM plugin_scheduled_tasks WHERE plugin_id = ?`, the `SCHED_MAX` DoS-quota count. */
async countForPlugin(pluginId: string): Promise<number> {
return await this.count({ plugin_id: pluginId });
}
/**
* HR7 — `` INSERT INTO plugin_scheduled_tasks (plugin_id, name, due_at,
* payload, every_ms) VALUES (?, ?, ?, ?, ?) ON CONFLICT (plugin_id, name)
* DO UPDATE SET due_at = excluded.due_at, payload = excluded.payload,
* every_ms = excluded.every_ms `` — the composite-key upsert on
* `(plugin_id, name)` (the entity's own `uniques`, `PluginScheduledTasks
* .entity.ts`): re-scheduling the same name replaces it in place. No
* COALESCE — every non-key column is re-assigned verbatim from `excluded`
* — so `em.upsert`'s `onConflictMergeFields` expresses it directly,
* `PluginUserConfigRepository.upsertConfig`'s own precedent for a plain
* (non-merging) composite upsert.
*/
async upsertTask(input: PluginScheduledTaskUpsertInput): Promise<void> {
await this.upsert(
{ plugin_id: input.plugin_id, name: input.name, due_at: input.due_at, payload: input.payload, every_ms: input.every_ms },
{ onConflictFields: ['plugin_id', 'name'], onConflictAction: 'merge', onConflictMergeFields: ['due_at', 'payload', 'every_ms'] },
);
}
/** HR8 — `DELETE FROM plugin_scheduled_tasks WHERE plugin_id = ? AND name = ?` (`scheduler.cancel`), returning whether a row existed (`.changes > 0`). */
async deleteByPluginAndName(pluginId: string, name: string): Promise<boolean> {
const changed = await this.nativeDelete({ plugin_id: pluginId, name });
return changed > 0;
}
}
@@ -54,6 +54,16 @@ export interface PluginNameCapabilitiesRow {
capabilities: string;
}
/** PFC1 — `plugins-feed.controller.ts#list`'s own client-facing feed projection. */
export interface PluginFeedRow {
id: string;
name: string;
type: string;
icon: string | null;
capabilities: string;
granted_permissions: string;
}
/** PS-list — `list()`'s admin projection, every column `PluginsService.list()` re-shapes. */
export interface PluginAdminRow {
id: string;
@@ -521,6 +531,46 @@ export class PluginsRepository extends TrekRepository<Plugins> {
const row = await this.findOne({ id }, { fields: ['source_repo', 'author_pubkey'] });
return row ? { source_repo: row.source_repo ?? null, author_pubkey: row.author_pubkey ?? null } : null;
}
// -----------------------------------------------------------------------
// Plan 3j Task 5 — the small plugin controllers' own statements (PFC1,
// PUC1/POC1). PAC1/PRF1 (the audit-read/append delegates) are Task 3's
// `plugin-audit.ts` conversion, not this repository's.
// -----------------------------------------------------------------------
/**
* PFC1 (`plugins-feed.controller.ts#list`) — `SELECT id, name, type, icon,
* capabilities, granted_permissions FROM plugins WHERE status = 'active'
* ORDER BY sort_order, name`. This row goes to the CLIENT, not a plugin
* process — R-facade's `withCamelCase` wrapper is never applied here (the
* client already speaks snake_case per the wire contract, D1); stays
* exactly as read.
*/
async findActiveFeedRows(): Promise<PluginFeedRow[]> {
const rows = await this.find(
{ status: 'active' },
{ fields: ['id', 'name', 'type', 'icon', 'capabilities', 'granted_permissions'], orderBy: [{ sort_order: 'asc' }, { name: 'asc' }] },
);
return rows.map((r) => ({
id: r.id ?? '',
name: r.name,
type: r.type,
icon: r.icon ?? null,
capabilities: r.capabilities,
granted_permissions: r.granted_permissions ?? '',
}));
}
/**
* PUC1/POC1 — `SELECT 1 FROM plugins WHERE id = ? AND status = 'active'`,
* byte-identical text, two call sites (`plugin-user-settings.controller.ts
* #activeWithUserFields`, `oauth/plugin-oauth.controller.ts#isActive`) —
* one shared method for both, existence-only.
*/
async existsActive(id: string): Promise<boolean> {
const row = await this.findOne({ id, status: 'active' }, { fields: ['id'] });
return row !== null;
}
}
/** DI3/DI4 — the manifest-derived column set `discoverPlugins#upsert` writes on both the new-plugin INSERT and the existing-plugin UPDATE branches (the UPDATE branch is a strict subset — see `updateManifestFields`). */
@@ -215,6 +215,52 @@ export class TripsRepository extends TrekRepository<Trips> {
.execute<{ id: number; user_id: number } | undefined>('get', false);
}
/**
* MR8 (Plan 3j Task 5, `host/rpc/meta.rpc.ts#entityTrip`'s `'trip'` arm) —
* `SELECT id FROM trips WHERE id = ?`, a bare existence probe (the entity
* type IS the trip, so the id resolves to itself once the row is
* confirmed to exist).
*/
async existsById(id: number): Promise<boolean> {
const row = await this.qb('t')
.select(['t.id'])
.where({ id })
.execute<{ id: number } | undefined>('get', false);
return !!row;
}
/**
* HR9 (Plan 3j Task 5, `host/rpc/host-surface.rpc.ts#sharesATrip`, private)
* — byte-for-byte the legacy self-joined statement:
* SELECT 1 FROM trips t
* LEFT JOIN trip_members m1 ON m1.trip_id = t.id AND m1.user_id = ?
* LEFT JOIN trip_members m2 ON m2.trip_id = t.id AND m2.user_id = ?
* WHERE (t.user_id = ? OR m1.user_id IS NOT NULL)
* AND (t.user_id = ? OR m2.user_id IS NOT NULL)
* LIMIT 1
* — "does ANY trip exist where both users are owner-or-member" (the
* `users.getById` plugin RPC's own access gate — a bespoke bilateral
* membership check, NOT `canAccessTrip`-shaped, which is why it lives here
* rather than reusing `accessibleTripsQuery` — that builder joins ONE
* user's membership; this needs two independent joins, one per user, so
* neither side's `$or` can be satisfied by the other's row.
*/
async sharesTripWith(userIdA: number, userIdB: number): Promise<boolean> {
const row = await this.qb('t')
.leftJoin('t.trip_members_collection', 'm1', { 'm1.user': userIdA })
.leftJoin('t.trip_members_collection', 'm2', { 'm2.user': userIdB })
.select(['t.id'])
.andWhere({
$and: [
{ $or: [{ 't.user': userIdA }, { 'm1.user': { $ne: null } }] },
{ $or: [{ 't.user': userIdB }, { 'm2.user': { $ne: null } }] },
],
})
.limit(1)
.execute<{ id: number } | undefined>('get', false);
return !!row;
}
/**
* `SELECT t.id FROM trips t LEFT JOIN trip_members m ON m.trip_id = t.id
* AND m.user_id = :userId WHERE (t.user_id = :userId OR m.user_id IS NOT NULL)
@@ -1509,6 +1509,22 @@ export class UsersRepository extends TrekRepository<Users> {
const row = await this.findOne({ id }, { fields: ['id', 'email', 'mfa_enabled'] });
return row ? { id: row.id, email: row.email, mfa_enabled: row.mfa_enabled ?? null } : null;
}
// ---------------------------------------------------------------------
// HR1 (Plan 3j Task 5, `plugins/host/rpc/host-surface.rpc.ts#getUser`)
// ---------------------------------------------------------------------
/**
* HR1 — `SELECT id, username, display_name, avatar FROM users WHERE id =
* ?`. Returned RAW to the calling plugin (the RPC handler's own access
* gate runs first — self, or `TripsRepository.sharesTripWith`) — this
* repository method itself does no shaping, matching `findProfileBasic`'s
* narrower-but-similarly-shaped precedent above.
*/
async findPublicIdentity(id: number): Promise<{ id: number; username: string; display_name: string | null; avatar: string | null } | null> {
const row = await this.findOne({ id }, { fields: ['id', 'username', 'display_name', 'avatar'] });
return row ? { id: row.id, username: row.username, display_name: row.display_name ?? null, avatar: row.avatar ?? null } : null;
}
}
/**
+12 -7
View File
@@ -1,12 +1,11 @@
import { Module } from '@nestjs/common';
import { MikroOrmModule } from '@mikro-orm/nestjs';
import { AllowedFileTypesModule } from '../files/allowed-file-types.module';
import { MemoriesModule } from '../memories/memories.module';
import { PluginGuardsModule } from '../plugins/host/plugin-guards.module';
import { StorageModule } from '../storage/storage.module';
import { DemoModule } from '../common/demo.module';
import { JourneyDomainModule } from './journey-domain.module';
import { JournalRpc } from './journal.rpc';
import { Users } from '../../db/entities/Users.entity';
/**
* The journal plugin surface, in its own container.
@@ -22,13 +21,19 @@ import { Users } from '../../db/entities/Users.entity';
* JourneyDomainModule is re-exported so importing this one is a superset of
* importing that one, and nothing that already depended on it has to change.
*
* `MikroOrmModule.forFeature([Users])` — Plan 3g Task 3: `JournalRpc`'s
* demo-mode gate (JR1) now injects `UsersRepository.getEmail`, and
* `@InjectRepository` resolves from THIS module's own `forFeature` graph,
* not `AuthModule`'s (which this module does not import).
* `DemoModule` is imported explicitly (Plan 3i Task 4 fix wave's own
* precedent, restated here — Plan 3j Task 5, SV8): `DemoService` is
* `@Global()`, but that broadcast only reaches a module graph that actually
* imports it somewhere — a hand-built e2e `TestingModule` that never pulls in
* `AppModule` otherwise leaves `JournalRpc`'s `DemoService` dependency
* unresolved. The earlier `MikroOrmModule.forFeature([Users])` entry (Plan 3g
* Task 3, `JournalRpc`'s own `UsersRepository.getEmail` injection) is gone —
* `DemoService.isDemoUserId` resolves `Users` through its OWN constructor
* (an injected `EntityManager`, not `@InjectRepository`), so this module no
* longer needs the entity registered for that call.
*/
@Module({
imports: [JourneyDomainModule, StorageModule, AllowedFileTypesModule, MemoriesModule, PluginGuardsModule, MikroOrmModule.forFeature([Users])],
imports: [JourneyDomainModule, StorageModule, AllowedFileTypesModule, MemoriesModule, PluginGuardsModule, DemoModule],
providers: [JournalRpc],
exports: [JourneyDomainModule],
})
+7 -12
View File
@@ -2,20 +2,16 @@ import pathMod from 'node:path';
import { randomUUID } from 'node:crypto';
import { Readable } from 'node:stream';
import { journalPluginPhotoInputSchema } from '@trek/shared';
import { InjectRepository } from '@mikro-orm/nestjs';
import { PluginController, PluginMethod } from '../plugins/host/rpc-kit/decorators';
import { PluginGuards } from '../plugins/host/plugin-guards.service';
import { BadParams, ForbiddenResource } from '../plugins/host/rpc-errors';
import { asPayload, num } from '../plugins/host/rpc-params';
import type { PluginRpcContext } from '../plugins/host/rpc-kit/types';
import { ADDON_IDS } from '../../addons';
import { readEnv } from '../../app-config';
import { isDemoEmail } from '../common/demo';
import { AllowedFileTypesService } from '../files/allowed-file-types.service';
import { PhotoCaptureBackfillService } from '../memories/photo-capture-backfill.service';
import { StorageService } from '../storage/storage.service';
import { Users } from '../../db/entities/Users.entity';
import type { UsersRepository } from '../../db/repositories/Users.repository';
import { DemoService } from '../common/demo.service';
import { JourneyDomainService } from './journey-domain.service';
/** 10MB decoded, the same cap the file surface applies to plugin uploads. */
@@ -48,7 +44,11 @@ export class JournalRpc {
private readonly storage: StorageService,
private readonly allowedTypes: AllowedFileTypesService,
private readonly captureBackfill: PhotoCaptureBackfillService,
@InjectRepository(Users) private readonly usersRepo: UsersRepository,
// SV8 (R-survivors) — Plan 3i: DemoService.isDemoUserId replaces the inline
// env + email lookup + isDemoEmail check (3g's own JR1 conversion of the
// SELECT onto UsersRepository.getEmail still duplicated the demo-gating
// LOGIC; this collapses it onto the shared primitive).
private readonly demo: DemoService,
) {}
@PluginMethod('journal.listMine', { permission: 'db:read:journal' })
@@ -147,12 +147,7 @@ export class JournalRpc {
// Mirrors the REST upload guard: a demo user must not write bytes to the
// shared demo instance, not even through a plugin's db:write:journal.
// JR1 — reuses `UsersRepository.getEmail` (already built by another
// domain's demo-mode-check conversion), not a near-duplicate.
if (readEnv().demo.enabled) {
const uploaderEmail = await this.usersRepo.getEmail(userId);
if (isDemoEmail(uploaderEmail)) throw new ForbiddenResource('Uploads are disabled in demo mode.');
}
if (await this.demo.isDemoUserId(userId)) throw new ForbiddenResource('Uploads are disabled in demo mode.'); // SV8 — Plan 3i
// basename first: a name is a name, never a path.
const original = pathMod.basename(input.name);
@@ -1,6 +1,9 @@
import { Controller, Get, Param, Req, UseGuards } from '@nestjs/common';
import type { Request } from 'express';
import { InjectRepository } from '@mikro-orm/nestjs';
import { DatabaseService } from '../../database/database.service';
import { Days } from '../../../db/entities/Days.entity';
import type { DaysRepository } from '../../../db/repositories/Days.repository';
import { JwtAuthGuard } from '../../auth/jwt-auth.guard';
import { pluginsEnabled } from '../kill-switch';
import { PluginHooks } from '../plugin-hooks.service';
@@ -81,6 +84,8 @@ export class DayScheduleController {
constructor(
private readonly hooks: PluginHooks,
private readonly dbs: DatabaseService,
// CT1 (Plan 3j Task 5) — the trip's day-id set, converted onto Days.repository.ts.
@InjectRepository(Days) private readonly days: DaysRepository,
) {}
@Get(':tripId')
@@ -95,8 +100,8 @@ export class DayScheduleController {
const ids = this.hooks.providersOf('dayScheduleProvider');
if (ids.length === 0) return { items: [] };
const dayRows = this.dbs.connection.prepare('SELECT id FROM days WHERE trip_id = ?').all(tripId) as Array<{ id: number }>;
const tripDayIds: ReadonlySet<number> = new Set(dayRows.map((d) => d.id));
const dayIds = await this.days.listIdsByTrip(tripId); // CT1 — Plan 3j
const tripDayIds: ReadonlySet<number> = new Set(dayIds);
const perProvider = await Promise.all(
ids.map(async (id): Promise<DayScheduleItem[]> => {
@@ -1,6 +1,9 @@
import { Controller, Get, Param, Req, UseGuards } from '@nestjs/common';
import type { Request } from 'express';
import { InjectRepository } from '@mikro-orm/nestjs';
import { DatabaseService } from '../../database/database.service';
import { Days } from '../../../db/entities/Days.entity';
import type { DaysRepository } from '../../../db/repositories/Days.repository';
import { JwtAuthGuard } from '../../auth/jwt-auth.guard';
import { pluginsEnabled } from '../kill-switch';
import { PluginHooks } from '../plugin-hooks.service';
@@ -158,6 +161,8 @@ export class DayTintsController {
constructor(
private readonly hooks: PluginHooks,
private readonly dbs: DatabaseService,
// CT2 (Plan 3j Task 5) — the trip's day-id set, converted onto Days.repository.ts.
@InjectRepository(Days) private readonly days: DaysRepository,
) {}
@Get(':tripId')
@@ -172,8 +177,8 @@ export class DayTintsController {
const ids = this.hooks.providersOf('dayTintProvider');
if (ids.length === 0) return { tints: [] };
const dayRows = this.dbs.connection.prepare('SELECT id FROM days WHERE trip_id = ?').all(tripId) as Array<{ id: number }>;
const tripDayIds: ReadonlySet<number> = new Set(dayRows.map((d) => d.id));
const dayIds = await this.days.listIdsByTrip(tripId); // CT2 — Plan 3j
const tripDayIds: ReadonlySet<number> = new Set(dayIds);
const perProvider = await Promise.all(
ids.map(async (id): Promise<DayTint[]> => {
@@ -1,6 +1,9 @@
import { Controller, Get, Param, Req, UseGuards } from '@nestjs/common';
import type { Request } from 'express';
import { InjectRepository } from '@mikro-orm/nestjs';
import { DatabaseService } from '../../database/database.service';
import { Places } from '../../../db/entities/Places.entity';
import type { PlacesRepository } from '../../../db/repositories/Places.repository';
import { JwtAuthGuard } from '../../auth/jwt-auth.guard';
import { pluginsEnabled } from '../kill-switch';
import { PluginHooks } from '../plugin-hooks.service';
@@ -64,6 +67,8 @@ export class PlaceDetailsController {
constructor(
private readonly hooks: PluginHooks,
private readonly dbs: DatabaseService,
// CT7 (Plan 3j Task 5) — the place's owning trip id, converted onto Places.repository.ts.
@InjectRepository(Places) private readonly places: PlacesRepository,
) {}
@Get(':placeId')
@@ -77,8 +82,8 @@ export class PlaceDetailsController {
if (!Number.isFinite(placeId) || userId == null) return { providers: [] };
// The place must belong to a trip the caller can access — same gate as a read.
const row = this.dbs.connection.prepare('SELECT trip_id FROM places WHERE id = ?').get(placeId) as { trip_id: number } | undefined;
if (!row || !(await this.dbs.canAccessTrip(row.trip_id, userId))) return { providers: [] };
const tripId = await this.places.findTripId(placeId); // CT7 — Plan 3j
if (tripId === undefined || !(await this.dbs.canAccessTrip(tripId, userId))) return { providers: [] };
const ids = this.hooks.providersOf('placeDetailProvider');
const results = await Promise.all(
@@ -5,6 +5,8 @@ import { AddonsModule } from '../../addons/addons.module';
import { JourneyDomainModule } from '../../journey/journey-domain.module';
import { JourneyEntries } from '../../../db/entities/JourneyEntries.entity';
import { Plugins } from '../../../db/entities/Plugins.entity';
import { Days } from '../../../db/entities/Days.entity';
import { Places } from '../../../db/entities/Places.entity';
import { PlaceDetailsController } from './place-details.controller';
import { PluginSearchController } from './plugin-search.controller';
import { TripWarningsController } from './trip-warnings.controller';
@@ -45,7 +47,10 @@ import { DemoModule } from '../../common/demo.module';
* entity needs its own registration here too. `Plugins` is the same shape
* (Plan 3j Task 3): `PluginRoutesController`'s `declaredProfiles` call now
* takes `PluginsRepository`, and `PluginsRuntimeModule`'s own registration
* of the same entity isn't exported either.
* of the same entity isn't exported either. `Days`/`Places` (Plan 3j Task 5,
* CT1/CT2/CT7): `DayScheduleController`/`DayTintsController`'s own day-id-set
* read and `PlaceDetailsController`'s own trip-id lookup, same reasoning —
* `DaysModule`/`PlacesModule` do not export `MikroOrmModule` either.
*
* `DemoModule` is imported explicitly (Plan 3i Task 4 fix wave): it is
* `@Global()`, but that broadcast only reaches a module graph that actually
@@ -54,7 +59,7 @@ import { DemoModule } from '../../common/demo.module';
* dependency unresolved.
*/
@Module({
imports: [PluginsRuntimeModule, AddonsModule, JourneyDomainModule, DemoModule, MikroOrmModule.forFeature([JourneyEntries, Plugins])],
imports: [PluginsRuntimeModule, AddonsModule, JourneyDomainModule, DemoModule, MikroOrmModule.forFeature([JourneyEntries, Plugins, Days, Places])],
controllers: [
PlaceDetailsController,
PluginSearchController,
@@ -7,6 +7,12 @@ import { budgetFor } from '../plugin-host-state';
import { InjectRepository } from '@mikro-orm/nestjs';
import { PluginCapabilityAudit } from '../../../../db/entities/PluginCapabilityAudit.entity';
import type { PluginCapabilityAuditRepository } from '../../../../db/repositories/PluginCapabilityAudit.repository';
import { Users } from '../../../../db/entities/Users.entity';
import type { UsersRepository } from '../../../../db/repositories/Users.repository';
import { Trips } from '../../../../db/entities/Trips.entity';
import type { TripsRepository } from '../../../../db/repositories/Trips.repository';
import { PluginScheduledTasks } from '../../../../db/entities/PluginScheduledTasks.entity';
import type { PluginScheduledTasksRepository } from '../../../../db/repositories/PluginScheduledTasks.repository';
import { DatabaseService } from '../../../database/database.service';
import { RealtimeService } from '../../../realtime/realtime.service';
import { NotificationsService } from '../../../notifications/notifications.service';
@@ -46,6 +52,12 @@ export class HostSurfaceRpc {
private readonly oauth: PluginOAuthService,
private readonly guards: PluginGuards,
@InjectRepository(PluginCapabilityAudit) private readonly audit: PluginCapabilityAuditRepository,
// HR1 (Plan 3j Task 5) — the plugin-visible user row.
@InjectRepository(Users) private readonly users: UsersRepository,
// HR9 (Plan 3j Task 5) — the bilateral "do these two users share a trip" gate.
@InjectRepository(Trips) private readonly trips: TripsRepository,
// HR5–HR8 (Plan 3j Task 5) — a plugin's own scheduler.set/scheduler.cancel RPCs.
@InjectRepository(PluginScheduledTasks) private readonly scheduledTasks: PluginScheduledTasksRepository,
) {}
@PluginMethod('users.getById', { permission: 'db:read:users' })
@@ -57,7 +69,11 @@ export class HostSurfaceRpc {
if (id !== ctx.actingUserId && !(await this.sharesATrip(ctx.actingUserId, id))) {
throw new ForbiddenResource(`no access to user ${id}`);
}
return this.db.prepare('SELECT id, username, display_name, avatar FROM users WHERE id = ?').get(id);
// `?? undefined`: `findPublicIdentity` returns `null` on a miss (this
// repository's own convention); the legacy `better-sqlite3` `.get()`
// returned `undefined` — preserved so a missing row still serializes
// the same way over the wire to the plugin.
return (await this.users.findPublicIdentity(id)) ?? undefined;
}
@PluginMethod('ws.broadcastToTrip', { permission: 'ws:broadcast:trip' })
@@ -188,41 +204,32 @@ export class HostSurfaceRpc {
}
const json = JSON.stringify(params.payload ?? null);
if (json.length > SCHED_PAYLOAD_MAX) throw new BadParams(`scheduler payload too large (max ${SCHED_PAYLOAD_MAX} bytes)`);
const existing = this.db
.prepare('SELECT id FROM plugin_scheduled_tasks WHERE plugin_id = ? AND name = ?')
.get(ctx.pluginId, name) as { id: number } | undefined;
const existing = await this.scheduledTasks.existsForPluginAndName(ctx.pluginId, name); // HR5 — Plan 3j
if (!existing) {
const n = (this.db.prepare('SELECT COUNT(*) AS c FROM plugin_scheduled_tasks WHERE plugin_id = ?').get(ctx.pluginId) as { c: number }).c;
const n = await this.scheduledTasks.countForPlugin(ctx.pluginId); // HR6 — Plan 3j
if (n >= SCHED_MAX) throw new BadParams(`too many scheduled tasks (max ${SCHED_MAX})`);
}
// Upsert by (plugin, name): re-scheduling the same name replaces it.
this.db
.prepare(`INSERT INTO plugin_scheduled_tasks (plugin_id, name, due_at, payload, every_ms) VALUES (?, ?, ?, ?, ?)
ON CONFLICT (plugin_id, name) DO UPDATE SET due_at = excluded.due_at, payload = excluded.payload, every_ms = excluded.every_ms`)
.run(ctx.pluginId, name, Math.max(dueAt, Date.now()), json, everyMs ?? null);
await this.scheduledTasks.upsertTask({
// HR7 — Plan 3j
plugin_id: ctx.pluginId,
name,
due_at: Math.max(dueAt, Date.now()),
payload: json,
every_ms: everyMs ?? null,
});
return { scheduled: true };
}
@PluginMethod('scheduler.cancel', { permission: 'jobs:run' })
async schedulerCancel(params: Record<string, unknown>, ctx: PluginRpcContext): Promise<unknown> {
const r = this.db
.prepare('DELETE FROM plugin_scheduled_tasks WHERE plugin_id = ? AND name = ?')
.run(ctx.pluginId, str(params.name, 'name'));
return { cancelled: r.changes > 0 };
const cancelled = await this.scheduledTasks.deleteByPluginAndName(ctx.pluginId, str(params.name, 'name')); // HR8 — Plan 3j
return { cancelled };
}
/** Two users share a trip when both are owner-or-member of the same one. */
private async sharesATrip(actingUserId: number, targetUserId: number): Promise<boolean> {
return !!this.db
.prepare(
`SELECT 1 FROM trips t
LEFT JOIN trip_members m1 ON m1.trip_id = t.id AND m1.user_id = ?
LEFT JOIN trip_members m2 ON m2.trip_id = t.id AND m2.user_id = ?
WHERE (t.user_id = ? OR m1.user_id IS NOT NULL)
AND (t.user_id = ? OR m2.user_id IS NOT NULL)
LIMIT 1`,
)
.get(actingUserId, targetUserId, actingUserId, targetUserId);
return await this.trips.sharesTripWith(actingUserId, targetUserId); // HR9 — Plan 3j
}
private async requireLlm(userId: number) {
+50 -37
View File
@@ -1,9 +1,22 @@
import { InjectRepository } from '@mikro-orm/nestjs';
import { PluginController, PluginMethod } from '../rpc-kit/decorators';
import { PluginGuards } from '../plugin-guards.service';
import { BadParams, ForbiddenResource } from '../rpc-errors';
import { num, str } from '../rpc-params';
import type { PluginRpcContext } from '../rpc-kit/types';
import { DatabaseService } from '../../../database/database.service';
import { PluginEntityMetadata } from '../../../../db/entities/PluginEntityMetadata.entity';
import type { PluginEntityMetadataRepository } from '../../../../db/repositories/PluginEntityMetadata.repository';
import { Trips } from '../../../../db/entities/Trips.entity';
import type { TripsRepository } from '../../../../db/repositories/Trips.repository';
import { Places } from '../../../../db/entities/Places.entity';
import type { PlacesRepository } from '../../../../db/repositories/Places.repository';
import { Days } from '../../../../db/entities/Days.entity';
import type { DaysRepository } from '../../../../db/repositories/Days.repository';
import { Reservations } from '../../../../db/entities/Reservations.entity';
import type { ReservationsRepository } from '../../../../db/repositories/Reservations.repository';
import { DayAccommodations } from '../../../../db/entities/DayAccommodations.entity';
import type { DayAccommodationsRepository } from '../../../../db/repositories/DayAccommodations.repository';
/** Core entities a plugin may attach its own db:meta to. */
const META_ENTITY_TYPES: ReadonlySet<string> = new Set(['trip', 'place', 'day', 'reservation', 'accommodation']);
@@ -23,14 +36,6 @@ const EDIT_ACTION: Record<string, string> = {
accommodation: 'day_edit',
};
/** Each of these tables has a NOT NULL trip_id, so the gate resolves the owning trip. */
const ENTITY_TABLE: Record<string, string> = {
place: 'places',
day: 'days',
reservation: 'reservations',
accommodation: 'day_accommodations',
};
/**
* A plugin's OWN namespaced key/value store, attached to a core entity (#plugins).
*
@@ -46,17 +51,21 @@ export class MetaRpc {
constructor(
private readonly db: DatabaseService,
private readonly guards: PluginGuards,
@InjectRepository(PluginEntityMetadata) private readonly meta: PluginEntityMetadataRepository,
@InjectRepository(Trips) private readonly trips: TripsRepository,
@InjectRepository(Places) private readonly places: PlacesRepository,
@InjectRepository(Days) private readonly days: DaysRepository,
@InjectRepository(Reservations) private readonly reservations: ReservationsRepository,
@InjectRepository(DayAccommodations) private readonly dayAccommodations: DayAccommodationsRepository,
) {}
@PluginMethod('meta.get', { permission: 'db:meta' })
async get(params: Record<string, unknown>, ctx: PluginRpcContext): Promise<unknown> {
const { entityType, entityId } = await this.resolveEntity(params, ctx, false);
const row = this.db
.prepare('SELECT value FROM plugin_entity_metadata WHERE plugin_id=? AND entity_type=? AND entity_id=? AND key=?')
.get(ctx.pluginId, entityType, entityId, str(params.key, 'key')) as { value: string } | undefined;
if (!row) return null;
const value = await this.meta.findValue(ctx.pluginId, entityType, entityId, str(params.key, 'key')); // MR1 — Plan 3j
if (value === null) return null;
try {
return JSON.parse(row.value);
return JSON.parse(value);
} catch {
return null;
}
@@ -69,30 +78,19 @@ export class MetaRpc {
if (key.length > META_KEY_MAX) throw new BadParams(`metadata key too long (>${META_KEY_MAX} chars)`);
const json = JSON.stringify(params.value ?? null);
if (json.length > META_VALUE_MAX) throw new BadParams(`metadata value too large (>${META_VALUE_MAX} bytes)`);
const exists = this.db
.prepare('SELECT 1 FROM plugin_entity_metadata WHERE plugin_id=? AND entity_type=? AND entity_id=? AND key=?')
.get(ctx.pluginId, entityType, entityId, key);
const exists = (await this.meta.findValue(ctx.pluginId, entityType, entityId, key)) !== null; // MR2 — Plan 3j
if (!exists) {
const { n } = this.db
.prepare('SELECT COUNT(*) AS n FROM plugin_entity_metadata WHERE plugin_id=? AND entity_type=? AND entity_id=?')
.get(ctx.pluginId, entityType, entityId) as { n: number };
const n = await this.meta.countForEntity(ctx.pluginId, entityType, entityId); // MR3 — Plan 3j
if (n >= META_KEYS_MAX) throw new BadParams(`too many metadata keys on this ${entityType} (max ${META_KEYS_MAX})`);
}
this.db
.prepare(`INSERT INTO plugin_entity_metadata (plugin_id, entity_type, entity_id, key, value, updated_at)
VALUES (?, ?, ?, ?, ?, datetime('now'))
ON CONFLICT(plugin_id, entity_type, entity_id, key)
DO UPDATE SET value = excluded.value, updated_at = excluded.updated_at`)
.run(ctx.pluginId, entityType, entityId, key, json);
await this.meta.upsertValue(ctx.pluginId, entityType, entityId, key, json); // MR4 — Plan 3j
return { key, value: params.value ?? null };
}
@PluginMethod('meta.list', { permission: 'db:meta' })
async list(params: Record<string, unknown>, ctx: PluginRpcContext): Promise<unknown> {
const { entityType, entityId } = await this.resolveEntity(params, ctx, false);
const rows = this.db
.prepare('SELECT key, value FROM plugin_entity_metadata WHERE plugin_id=? AND entity_type=? AND entity_id=? ORDER BY key')
.all(ctx.pluginId, entityType, entityId) as Array<{ key: string; value: string }>;
const rows = await this.meta.listForEntity(ctx.pluginId, entityType, entityId); // MR5 — Plan 3j
const out: Record<string, unknown> = {};
for (const r of rows) {
try {
@@ -107,10 +105,8 @@ export class MetaRpc {
@PluginMethod('meta.delete', { permission: 'db:meta' })
async delete(params: Record<string, unknown>, ctx: PluginRpcContext): Promise<unknown> {
const { entityType, entityId } = await this.resolveEntity(params, ctx, true);
const res = this.db
.prepare('DELETE FROM plugin_entity_metadata WHERE plugin_id=? AND entity_type=? AND entity_id=? AND key=?')
.run(ctx.pluginId, entityType, entityId, str(params.key, 'key'));
return { deleted: res.changes > 0 };
const deleted = await this.meta.deleteValue(ctx.pluginId, entityType, entityId, str(params.key, 'key')); // MR6 — Plan 3j
return { deleted };
}
/**
@@ -139,12 +135,29 @@ export class MetaRpc {
return { entityType, entityId };
}
/**
* MR8/MR9 (Plan 3j Task 5) — R12's "one method per target table, no
* dynamic identifier dispatch" precedent (`PlacesRepository.findTripId`/
* `ReservationsRepository.findTripId`'s own docstrings): the legacy
* `` SELECT trip_id FROM ${table} WHERE id = ? `` string-interpolated a
* table name off a fixed 4-entry map (never request-controlled) — this
* dispatches over the SAME closed `entityType` union onto one typed read
* per table instead, so no identifier is ever interpolated into SQL here.
*/
private async entityTrip(entityType: string, entityId: number): Promise<number | undefined> {
if (entityType === 'trip') {
return (this.db.prepare('SELECT id FROM trips WHERE id = ?').get(entityId) as { id: number } | undefined)?.id;
switch (entityType) {
case 'trip':
return (await this.trips.existsById(entityId)) ? entityId : undefined; // MR8 — Plan 3j
case 'place':
return await this.places.findTripId(entityId); // MR9 — Plan 3j
case 'day':
return await this.days.findTripId(entityId); // MR9 — Plan 3j
case 'reservation':
return await this.reservations.findTripId(entityId); // MR9 — Plan 3j
case 'accommodation':
return await this.dayAccommodations.getTripId(entityId); // MR9 — Plan 3j
default:
return undefined;
}
// The table name comes from a fixed map, never from the request.
const table = ENTITY_TABLE[entityType];
return (this.db.prepare(`SELECT trip_id FROM ${table} WHERE id = ?`).get(entityId) as { trip_id: number } | undefined)?.trip_id;
}
}
@@ -1,9 +1,11 @@
import { Controller, Get, Param, Post, Query, Req, Res, UseGuards } from '@nestjs/common';
import type { Request, Response } from 'express';
import { InjectRepository } from '@mikro-orm/nestjs';
import { JwtAuthGuard } from '../../auth/jwt-auth.guard';
import { pluginsEnabled } from '../kill-switch';
import { PluginOAuthService } from './plugin-oauth.service';
import { DatabaseService } from '../../database/database.service';
import { Plugins } from '../../../db/entities/Plugins.entity';
import type { PluginsRepository } from '../../../db/repositories/Plugins.repository';
/**
* Host-brokered outbound OAuth endpoints (#plugins). All are gated by JwtAuthGuard —
@@ -17,11 +19,12 @@ import { DatabaseService } from '../../database/database.service';
export class PluginOAuthController {
constructor(
private readonly oauth: PluginOAuthService,
private readonly dbs: DatabaseService,
// POC1 (Plan 3j Task 5) — shared with plugin-user-settings.controller.ts's activeWithUserFields.
@InjectRepository(Plugins) private readonly pluginsRepo: PluginsRepository,
) {}
private async isActive(id: string): Promise<boolean> {
return !!this.dbs.connection.prepare("SELECT 1 FROM plugins WHERE id = ? AND status = 'active'").get(id);
return await this.pluginsRepo.existsActive(id);
}
@Get(':id/status')
@@ -1,10 +1,12 @@
import { Body, Controller, Get, HttpCode, HttpException, Param, Post, Req, UseGuards } from '@nestjs/common';
import type { Request } from 'express';
import { InjectRepository } from '@mikro-orm/nestjs';
import { JwtAuthGuard } from '../auth/jwt-auth.guard';
import { pluginsEnabled } from './kill-switch';
import { PluginsService, MissingRequiredSettingError } from './plugins.service';
import { PluginRuntimeService } from './plugin-runtime.service';
import { DatabaseService } from '../database/database.service';
import { Plugins } from '../../db/entities/Plugins.entity';
import type { PluginsRepository } from '../../db/repositories/Plugins.repository';
import { PluginUserSettingsUpdateDto } from './plugins.dto';
import type { PluginActionDescriptor, PluginActionResult } from '@trek/shared';
@@ -25,12 +27,12 @@ export class PluginUserSettingsController {
constructor(
private readonly plugins: PluginsService,
private readonly runtime: PluginRuntimeService,
private readonly dbs: DatabaseService,
// PUC1 (Plan 3j Task 5) — shared with oauth/plugin-oauth.controller.ts's isActive.
@InjectRepository(Plugins) private readonly pluginsRepo: PluginsRepository,
) {}
private async activeWithUserFields(id: string): Promise<boolean> {
const row = this.dbs.connection.prepare("SELECT 1 FROM plugins WHERE id = ? AND status = 'active'").get(id);
return !!row;
return await this.pluginsRepo.existsActive(id);
}
@Get(':id')
@@ -1,5 +1,7 @@
import { Controller, Get, UseGuards } from '@nestjs/common';
import { DatabaseService } from '../database/database.service';
import { InjectRepository } from '@mikro-orm/nestjs';
import { Plugins } from '../../db/entities/Plugins.entity';
import type { PluginsRepository } from '../../db/repositories/Plugins.repository';
import { JwtAuthGuard } from '../auth/jwt-auth.guard';
import { pluginsEnabled } from './kill-switch';
@@ -30,14 +32,14 @@ interface ActivePlugin {
@Controller('api/plugins')
@UseGuards(JwtAuthGuard)
export class PluginsFeedController {
constructor(private readonly dbs: DatabaseService) {}
constructor(@InjectRepository(Plugins) private readonly plugins: PluginsRepository) {}
@Get()
async list(): Promise<{ plugins: ActivePlugin[] }> {
if (!pluginsEnabled()) return { plugins: [] };
const rows = this.dbs.connection
.prepare("SELECT id, name, type, icon, capabilities, granted_permissions FROM plugins WHERE status = 'active' ORDER BY sort_order, name")
.all() as Array<Omit<ActivePlugin, 'slot' | 'tripPage'> & { capabilities: string; granted_permissions: string }>;
// PFC1 — Plan 3j: this row goes to the CLIENT, not a plugin process — stays
// exactly snake_case, R-facade's wrapper is never applied here.
const rows = await this.plugins.findActiveFeedRows();
const plugins = rows.map(({ capabilities, granted_permissions, ...p }) => {
const tripPage = p.type === 'trip-page' ? tripPageOf(capabilities) : undefined;
const routeProfiles = routeProfilesOf(capabilities, granted_permissions);
@@ -16,6 +16,12 @@ import { PluginMetaMigrations } from '../../db/entities/PluginMetaMigrations.ent
import { PluginCapabilityAudit } from '../../db/entities/PluginCapabilityAudit.entity';
import { Settings } from '../../db/entities/Settings.entity';
import { NotificationChannelPreferences } from '../../db/entities/NotificationChannelPreferences.entity';
import { Users } from '../../db/entities/Users.entity';
import { Trips } from '../../db/entities/Trips.entity';
import { Days } from '../../db/entities/Days.entity';
import { Places } from '../../db/entities/Places.entity';
import { Reservations } from '../../db/entities/Reservations.entity';
import { DayAccommodations } from '../../db/entities/DayAccommodations.entity';
import { PluginsService } from './plugins.service';
import { PluginUserSettingsService } from './plugin-user-settings.service';
import { PluginRuntimeService } from './plugin-runtime.service';
@@ -87,6 +93,14 @@ import { SchedulingModule } from '../scheduling/scheduling.module';
PluginSettingsFields, PluginActions, PluginUserConfig, PluginEntityMetadata,
PluginOauthTokens, PluginOauthState, PluginMetaMigrations, PluginCapabilityAudit,
Settings, NotificationChannelPreferences,
// Plan 3j Task 5 — HostSurfaceRpc's (Users, Trips, PluginScheduledTasks
// above) and MetaRpc's (PluginEntityMetadata above, Trips, Places, Days,
// Reservations, DayAccommodations) own `@InjectRepository` params; both
// classes are constructed in THIS module's `providers`, so — same
// reasoning every entry above already documents — it owns their
// registration too, even though `TripsModule`/`PlacesModule`/etc are
// already imported (none of them export `MikroOrmModule`).
Users, Trips, Days, Places, Reservations, DayAccommodations,
]),
// A leaf that hands the resource gates to the domain modules. It must not be this
// module: the domains would then have to import this one back and close a cycle.
+10 -1
View File
@@ -11,6 +11,7 @@ import { PluginOAuthModule } from './oauth/plugin-oauth.module';
import { PluginContributionsModule } from './contributions/plugin-contributions.module';
import { AppConfigModule } from '../app-config/app-config.module';
import { PluginCapabilityAudit } from '../../db/entities/PluginCapabilityAudit.entity';
import { Plugins } from '../../db/entities/Plugins.entity';
/**
* Plugin system (#plugins), composition root.
@@ -34,9 +35,17 @@ import { PluginCapabilityAudit } from '../../db/entities/PluginCapabilityAudit.e
* registers the same entity for ITS OWN providers but does not export the repository
* token, so this module (the one that constructs the controller) needs its own entry,
* same reasoning `PluginContributionsModule`'s own docstring gives for `JourneyEntries`.
* `Plugins` (Plan 3j Task 5, PFC1/PUC1) — `PluginsFeedController`'s own feed read and
* `PluginUserSettingsController`'s own active-plugin guard, same reasoning.
*/
@Module({
imports: [AppConfigModule, PluginsRuntimeModule, PluginOAuthModule, PluginContributionsModule, MikroOrmModule.forFeature([PluginCapabilityAudit])],
imports: [
AppConfigModule,
PluginsRuntimeModule,
PluginOAuthModule,
PluginContributionsModule,
MikroOrmModule.forFeature([PluginCapabilityAudit, Plugins]),
],
controllers: [
PluginsController,
PluginsFeedController,
+18 -5
View File
@@ -62,6 +62,7 @@ import { CollabRpc } from '../../src/nest/collab/collab.rpc';
import { AtlasRpc } from '../../src/nest/atlas/atlas.rpc';
import { VacayRpc } from '../../src/nest/vacay/vacay.rpc';
import { JournalRpc } from '../../src/nest/journey/journal.rpc';
import { DemoService } from '../../src/nest/common/demo.service';
import { CollectionsRpc } from '../../src/nest/collections/collections.rpc';
import { makeNotificationsService } from './notifications';
import { notificationsStub } from './notifications';
@@ -315,13 +316,25 @@ export async function createPluginRpcHostFactory(dbs: DatabaseService): Promise<
new VacayRpc(vacay, guards),
// The photo half needs storage plus the allowed-types setting and the EXIF
// backfill; none of the tests on this harness write bytes, so they are stubs.
// Plan 3g Task 3 — the constructor-ripple fix: `JournalRpc`'s demo-mode
// gate (JR1) now injects `UsersRepository.getEmail`, not `DatabaseService`.
new JournalRpc(journey, guards, generalStorage, { get: () => '*' } as never, { schedule: () => {} } as never, usersRepo),
// SV8 (Plan 3i, R-survivors) — JournalRpc's demo-mode gate (addEntryPhoto)
// now injects DemoService, the same shared primitive mcp-test-controllers.ts
// wires into the 4 *.mcp.ts survivor sites.
new JournalRpc(journey, guards, generalStorage, { get: () => '*' } as never, { schedule: () => {} } as never, new DemoService(new RuntimeEnvService(), (await sharedTestOrm(dbs.connection)).em)),
new CollectionsRpc(collections, guards),
new DbRpc(new PluginUserSettingsService(pluginOrm.repo(PluginSettingsFields), pluginOrm.repo(PluginUserConfig))),
new MetaRpc(dbs, guards),
new HostSurfaceRpc(dbs, realtime, notifications, llmConfig, oauth, guards, pluginAuditRepo),
// Plan 3j Task 5 — MetaRpc's own MR1–MR9 conversion: PluginEntityMetadata plus
// one typed trip-id read per entity table (R12's dispatch, no interpolation).
new MetaRpc(
dbs, guards, pluginOrm.repo(PluginEntityMetadata), await createTestTripsRepo(dbs.connection),
await createTestPlacesRepo(dbs.connection), await createTestDaysRepo(dbs.connection),
await createTestReservationsRepo(dbs.connection), await createTestDayAccommodationsRepo(dbs.connection),
),
// Plan 3j Task 5 — HostSurfaceRpc's own HR1/HR5–HR9 conversion: the plugin-visible
// user row, the bilateral trip-sharing gate, and its own scheduler.set/cancel.
new HostSurfaceRpc(
dbs, realtime, notifications, llmConfig, oauth, guards, pluginAuditRepo,
usersRepo, await createTestTripsRepo(dbs.connection), pluginOrm.repo(PluginScheduledTasks),
),
new PluginHooks(undefined as never),
]);
return new PluginRpcHostFactory(pluginAuditRepo, registry as unknown as PluginRpcRegistryService);
+6 -2
View File
@@ -81,8 +81,12 @@ export function allRpcControllers(): object[] {
new JournalRpc(anyService(), anyService(), anyService(), anyService(), anyService(), anyService()),
new CollectionsRpc(anyService(), anyService()),
new DbRpc(anyService()),
new MetaRpc(anyService(), anyService()),
new HostSurfaceRpc(anyService(), anyService(), anyService(), anyService(), anyService(), anyService(), anyService()),
// Plan 3j Task 5 — MetaRpc/HostSurfaceRpc's own repository conversions (MR#/HR#).
new MetaRpc(anyService(), anyService(), anyService(), anyService(), anyService(), anyService(), anyService(), anyService()),
new HostSurfaceRpc(
anyService(), anyService(), anyService(), anyService(), anyService(), anyService(), anyService(),
anyService(), anyService(), anyService(),
),
// Owns no wire method, but declares all 18 hook contracts, which requireTotalCoverage checks too.
new PluginHooks(anyService()),
];
@@ -36,14 +36,12 @@ const osrm = () =>
);
function setup(settings = {}) {
const hooks = { providersOf: vi.fn(() => ['scenic']), route: vi.fn() };
const db = {
connection: {
prepare: () => ({ get: () => ({ capabilities: JSON.stringify({ routeProfiles: [{ id: 'car' }] }) }) }),
},
};
// RRT1/RRT2 (Plan 3j Task 3, confirmed at Task 5's own HEAD) — `declaredProfiles`
// now takes `PluginsRepository`, not a raw connection.
const plugins = { findCapabilities: vi.fn(async () => JSON.stringify({ routeProfiles: [{ id: 'car' }] })) };
return {
hooks,
router: new RoadtripRouterService({ getUserSettings: () => settings } as never, hooks as never, db as never),
router: new RoadtripRouterService({ getUserSettings: () => settings } as never, hooks as never, plugins as never),
};
}
beforeEach(() => {
@@ -76,9 +76,8 @@ function build(addonOn: boolean) {
{ put: async () => undefined, delete: async () => undefined } as never,
{ get: () => '*' } as never,
{ schedule: () => undefined } as never,
// Plan 3g Task 3: JR1's demo-mode gate reads through `UsersRepository
// .getEmail`, not `DatabaseService.prepare`.
{ getEmail: async () => 'u@example.test' } as never),
// SV8 — Plan 3i: DemoService.isDemoUserId, the shared demo-gate primitive.
{ isDemoUserId: async () => false } as never),
new CollectionsRpc(spyService(calls, 'collections'), guards),
new CostsRpc(spyService(calls, 'budget'), db, { broadcast: vi.fn() } as never, guards, spyService(calls, 'membership')),
]);
@@ -21,6 +21,7 @@ vi.mock('../../../src/nest/plugins/kill-switch', () => ({ pluginsEnabled }));
import { DayScheduleController } from '../../../src/nest/plugins/contributions/day-schedule.controller';
import type { PluginHooks } from '../../../src/nest/plugins/plugin-hooks.service';
import type { DaysRepository } from '../../../src/db/repositories/Days.repository';
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const req = (id?: number) => ({ user: id === undefined ? undefined : { id } }) as any;
@@ -29,7 +30,9 @@ function controller(invoke: (id: string) => unknown, providers = ['p1']) {
providersOf: vi.fn(() => providers),
daySchedule: vi.fn(async (id: string) => invoke(id)),
} as unknown as PluginHooks;
return { c: new DayScheduleController(runtime, { canAccessTrip, connection: { prepare: () => ({ all: () => tripDays.value }) } } as unknown as DatabaseService), runtime };
// CT1 (Plan 3j Task 5) — the day-id-set read is now DaysRepository.listIdsByTrip.
const days = { listIdsByTrip: vi.fn(async () => tripDays.value.map((d) => d.id)) } as unknown as DaysRepository;
return { c: new DayScheduleController(runtime, { canAccessTrip } as unknown as DatabaseService, days), runtime };
}
const item = (over: Record<string, unknown> = {}) => ({ id: 's1', dayId: 10, label: 'Charging', ...over });
@@ -21,6 +21,7 @@ vi.mock('../../../src/nest/plugins/kill-switch', () => ({ pluginsEnabled }));
import { DayTintsController } from '../../../src/nest/plugins/contributions/day-tints.controller';
import type { PluginHooks } from '../../../src/nest/plugins/plugin-hooks.service';
import type { DaysRepository } from '../../../src/db/repositories/Days.repository';
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const req = (id?: number) => ({ user: id === undefined ? undefined : { id } }) as any;
@@ -29,7 +30,9 @@ function controller(invoke: (id: string) => unknown, providers = ['p1']) {
providersOf: vi.fn(() => providers),
dayTints: vi.fn(async (id: string) => invoke(id)),
} as unknown as PluginHooks;
return { c: new DayTintsController(runtime, { canAccessTrip, connection: { prepare: () => ({ all: () => tripDays.value }) } } as unknown as DatabaseService), runtime };
// CT2 (Plan 3j Task 5) — the day-id-set read is now DaysRepository.listIdsByTrip.
const days = { listIdsByTrip: vi.fn(async () => tripDays.value.map((d) => d.id)) } as unknown as DaysRepository;
return { c: new DayTintsController(runtime, { canAccessTrip } as unknown as DatabaseService, days), runtime };
}
const tint = (over: Record<string, unknown> = {}) => ({ dayId: 10, tone: 'success', ...over });
+129 -2
View File
@@ -74,6 +74,13 @@ import type { LlmConfigResolver } from '../../../src/nest/llm-parse/llm-config.r
import type { PluginOAuthService } from '../../../src/nest/plugins/oauth/plugin-oauth.service';
import type { RpcError, RpcResponse } from '../../../src/nest/plugins/protocol/envelope';
import type { PluginCapabilityAuditRepository } from '../../../src/db/repositories/PluginCapabilityAudit.repository';
import type { TripsRepository } from '../../../src/db/repositories/Trips.repository';
import type { PlacesRepository } from '../../../src/db/repositories/Places.repository';
import type { DaysRepository } from '../../../src/db/repositories/Days.repository';
import type { ReservationsRepository } from '../../../src/db/repositories/Reservations.repository';
import type { DayAccommodationsRepository } from '../../../src/db/repositories/DayAccommodations.repository';
import type { PluginEntityMetadataRepository } from '../../../src/db/repositories/PluginEntityMetadata.repository';
import type { PluginScheduledTasksRepository } from '../../../src/db/repositories/PluginScheduledTasks.repository';
// Typed from the real method rather than from the always-true body below, so a case that
// swaps in an implementation reading the action key (HOSTRPC-015) still type-checks.
@@ -130,6 +137,117 @@ const pluginAuditRepo = {
},
} as unknown as PluginCapabilityAuditRepository;
/**
* Plan 3j Task 5: `MetaRpc`/`HostSurfaceRpc`'s own repository conversions
* (MR#/HR#). Same reasoning as `pluginAuditRepo` above — this file's
* hand-trimmed `:memory:` schema (no top-level await here) means a real
* MikroORM repository isn't reachable, so every converted method is
* reproduced as real raw SQL against `mockDb`, wrapped in the repository's
* own method shape, rather than a canned stub — HOSTRPC-014/028/029/030
* read the real table state back out through `mockDb.prepare(...)`
* directly, so this HAS to be the real table.
*/
type RawDb = {
prepare(sql: string): { get(...a: unknown[]): unknown; all(...a: unknown[]): unknown[]; run(...a: unknown[]): { changes: number } };
};
const raw = mockDb as unknown as RawDb;
const tripsRepo = {
async sharesTripWith(userIdA: number, userIdB: number) {
return !!raw
.prepare(
`SELECT 1 FROM trips t
LEFT JOIN trip_members m1 ON m1.trip_id = t.id AND m1.user_id = ?
LEFT JOIN trip_members m2 ON m2.trip_id = t.id AND m2.user_id = ?
WHERE (t.user_id = ? OR m1.user_id IS NOT NULL)
AND (t.user_id = ? OR m2.user_id IS NOT NULL)
LIMIT 1`,
)
.get(userIdA, userIdB, userIdA, userIdB);
},
async existsById(id: number) {
return !!raw.prepare('SELECT id FROM trips WHERE id = ?').get(id);
},
} as unknown as TripsRepository;
const placesRepo = {
async findTripId(id: number) {
return (raw.prepare('SELECT trip_id FROM places WHERE id = ?').get(id) as { trip_id: number } | undefined)?.trip_id;
},
} as unknown as PlacesRepository;
const daysRepo = {
async findTripId(id: number) {
return (raw.prepare('SELECT trip_id FROM days WHERE id = ?').get(id) as { trip_id: number } | undefined)?.trip_id;
},
} as unknown as DaysRepository;
const reservationsRepo = {
async findTripId(id: number) {
return (raw.prepare('SELECT trip_id FROM reservations WHERE id = ?').get(id) as { trip_id: number } | undefined)?.trip_id;
},
} as unknown as ReservationsRepository;
const dayAccommodationsRepo = {
async getTripId(id: number) {
return (raw.prepare('SELECT trip_id FROM day_accommodations WHERE id = ?').get(id) as { trip_id: number } | undefined)?.trip_id;
},
} as unknown as DayAccommodationsRepository;
const metaRepo = {
async findValue(pluginId: string, entityType: string, entityId: number, key: string) {
const row = raw
.prepare('SELECT value FROM plugin_entity_metadata WHERE plugin_id=? AND entity_type=? AND entity_id=? AND key=?')
.get(pluginId, entityType, entityId, key) as { value: string } | undefined;
return row?.value ?? null;
},
async countForEntity(pluginId: string, entityType: string, entityId: number) {
return (
raw
.prepare('SELECT COUNT(*) AS n FROM plugin_entity_metadata WHERE plugin_id=? AND entity_type=? AND entity_id=?')
.get(pluginId, entityType, entityId) as { n: number }
).n;
},
async upsertValue(pluginId: string, entityType: string, entityId: number, key: string, value: string) {
raw
.prepare(
`INSERT INTO plugin_entity_metadata (plugin_id, entity_type, entity_id, key, value, updated_at)
VALUES (?, ?, ?, ?, ?, datetime('now'))
ON CONFLICT(plugin_id, entity_type, entity_id, key)
DO UPDATE SET value = excluded.value, updated_at = excluded.updated_at`,
)
.run(pluginId, entityType, entityId, key, value);
},
async listForEntity(pluginId: string, entityType: string, entityId: number) {
return raw
.prepare('SELECT key, value FROM plugin_entity_metadata WHERE plugin_id=? AND entity_type=? AND entity_id=? ORDER BY key')
.all(pluginId, entityType, entityId) as Array<{ key: string; value: string }>;
},
async deleteValue(pluginId: string, entityType: string, entityId: number, key: string) {
return raw.prepare('DELETE FROM plugin_entity_metadata WHERE plugin_id=? AND entity_type=? AND entity_id=? AND key=?').run(pluginId, entityType, entityId, key).changes > 0;
},
} as unknown as PluginEntityMetadataRepository;
const scheduledTasksRepo = {
async existsForPluginAndName(pluginId: string, name: string) {
return !!raw.prepare('SELECT id FROM plugin_scheduled_tasks WHERE plugin_id = ? AND name = ?').get(pluginId, name);
},
async countForPlugin(pluginId: string) {
return (raw.prepare('SELECT COUNT(*) AS c FROM plugin_scheduled_tasks WHERE plugin_id = ?').get(pluginId) as { c: number }).c;
},
async upsertTask(input: { plugin_id: string; name: string; due_at: number; payload: string; every_ms: number | null }) {
raw
.prepare(
`INSERT INTO plugin_scheduled_tasks (plugin_id, name, due_at, payload, every_ms) VALUES (?, ?, ?, ?, ?)
ON CONFLICT (plugin_id, name) DO UPDATE SET due_at = excluded.due_at, payload = excluded.payload, every_ms = excluded.every_ms`,
)
.run(input.plugin_id, input.name, input.due_at, input.payload, input.every_ms);
},
async deleteByPluginAndName(pluginId: string, name: string) {
return raw.prepare('DELETE FROM plugin_scheduled_tasks WHERE plugin_id = ? AND name = ?').run(pluginId, name).changes > 0;
},
} as unknown as PluginScheduledTasksRepository;
const dbs = new DatabaseService(mockDb);
// Plan 3c Task 0b: `canAccessTrip` is `TripsRepository.findAccessible` now,
// which needs a real EntityManager over the full migrated schema — this
@@ -152,12 +270,21 @@ vi.spyOn(dbs, 'canAccessTrip').mockImplementation(async (tripId, userId) =>
const seededRoles: Record<number, string> = { 5: 'trip_owner', 6: 'user', 9: 'user' };
const usersRepo = {
getRole: vi.fn(async (id: number) => seededRoles[id] ?? null),
// HR1 (Plan 3j Task 5) — same "real SQL against mockDb" reasoning as the
// repository stubs above; HOSTRPC-019 asserts the exact returned shape.
async findPublicIdentity(id: number) {
return (
(raw.prepare('SELECT id, username, display_name, avatar FROM users WHERE id = ?').get(id) as
| { id: number; username: string; display_name: string | null; avatar: string | null }
| undefined) ?? null
);
},
} as unknown as UsersRepository;
const guards = new PluginGuards(dbs, permissions, addons, usersRepo);
const registry = createTestPluginRegistry([
new DbRpc(userSettings),
new MetaRpc(dbs, guards),
new HostSurfaceRpc(dbs, new RealtimeService(), notifications, llmConfig, oauth, guards, pluginAuditRepo),
new MetaRpc(dbs, guards, metaRepo, tripsRepo, placesRepo, daysRepo, reservationsRepo, dayAccommodationsRepo),
new HostSurfaceRpc(dbs, new RealtimeService(), notifications, llmConfig, oauth, guards, pluginAuditRepo, usersRepo, tripsRepo, scheduledTasksRepo),
]);
const factory = new PluginRpcHostFactory(pluginAuditRepo, registry as unknown as PluginRpcRegistryService);
const stubRouter: PluginCallRouter = { callPlugin: async () => undefined, emitPluginEvent: async () => {} };
@@ -5,19 +5,8 @@
* to link and no provider asset. These cases pin what the handler refuses before
* it writes anything, and that a refused write leaves no object behind.
*/
import { describe, it, expect, vi, beforeEach } from 'vitest';
// vi.mock is hoisted above the imports, so the flag it reads has to be too.
// Only readEnv().demo is overridden: the module is shared, and replacing it
// wholesale takes defaultLanguage and the rest of the env with it.
const { demo } = vi.hoisted(() => ({ demo: { enabled: false } }));
vi.mock('../../../src/app-config', async (importOriginal) => {
const actual = await importOriginal<typeof import('../../../src/app-config')>();
return { ...actual, readEnv: () => ({ ...actual.readEnv(), demo }) };
});
import { describe, it, expect, vi } from 'vitest';
import { JournalRpc } from '../../../src/nest/journey/journal.rpc';
import { DEMO_EMAIL_PRIMARY } from '../../../src/nest/common/demo';
const ACTOR = { actingUserId: 7 } as never;
const PNG = Buffer.from('89504e470d0a1a0a', 'hex').toString('base64');
@@ -25,8 +14,10 @@ const PNG = Buffer.from('89504e470d0a1a0a', 'hex').toString('base64');
function build(overrides: {
addPhoto?: unknown;
allowed?: string;
demoEnabled?: boolean;
email?: string;
/** SV8 (Plan 3i, R-survivors) — JournalRpc now delegates the whole demo-mode
* check (env-enabled + email match) to `DemoService.isDemoUserId`, so the
* test double only needs to say whether that call refuses. */
demoBlocked?: boolean;
} = {}) {
// Typed parameters so the assertions below can index mock.calls.
const put = vi.fn(async (_category: string, _filename: string, _body?: unknown, _opts?: unknown) => undefined);
@@ -36,27 +27,20 @@ function build(overrides: {
? () => ({ id: 5, photo_id: 42 })
: () => overrides.addPhoto);
demo.enabled = overrides.demoEnabled ?? false;
const rpc = new JournalRpc(
{ addPhoto } as never,
{ requireAddon: vi.fn() } as never,
{ put, delete: del } as never,
{ get: () => overrides.allowed ?? '*' } as never,
{ schedule } as never,
// Plan 3g Task 3: JR1's demo-mode gate reads through `UsersRepository
// .getEmail`, not `DatabaseService.prepare`.
{ getEmail: async () => overrides.email ?? 'user@example.test' } as never,
// SV8 — Plan 3i: DemoService.isDemoUserId, the shared demo-gate primitive.
{ isDemoUserId: async () => overrides.demoBlocked ?? false } as never,
);
return { rpc, put, del, schedule, addPhoto };
}
const input = (over: Record<string, unknown> = {}) => ({ name: 'photo.jpg', content_base64: PNG, ...over });
beforeEach(() => {
demo.enabled = false;
});
describe('journal.addEntryPhoto', () => {
it('JPHOTO-001: stores the bytes under a name of its own, then links the photo', async () => {
const { rpc, put, schedule, addPhoto } = build();
@@ -129,7 +113,7 @@ describe('journal.addEntryPhoto', () => {
});
it('JPHOTO-007: refuses to write bytes for a demo user, exactly as the REST upload does', async () => {
const { rpc, put } = build({ demoEnabled: true, email: DEMO_EMAIL_PRIMARY });
const { rpc, put } = build({ demoBlocked: true });
await expect(rpc.addEntryPhoto({ entryId: 3, input: input() }, ACTOR)).rejects.toThrow(/disabled in demo mode/);
expect(put).not.toHaveBeenCalled();
@@ -14,6 +14,7 @@ vi.mock('../../../src/nest/plugins/kill-switch', () => ({ pluginsEnabled }));
import { PlaceDetailsController } from '../../../src/nest/plugins/contributions/place-details.controller';
import type { PluginHooks } from '../../../src/nest/plugins/plugin-hooks.service';
import type { PlacesRepository } from '../../../src/db/repositories/Places.repository';
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const req = (id?: number) => ({ user: id === undefined ? undefined : { id } }) as any;
@@ -23,7 +24,9 @@ function controller(over: Partial<PluginHooks> = {}) {
placeDetails: vi.fn(async (id: string) => (id === 'p2' ? [{ label: 'Rating', value: '4.5' }] : [{ label: 'Reviews', value: '12', url: 'https://x' }])),
...over,
} as unknown as PluginHooks;
return { c: new PlaceDetailsController(runtime, { canAccessTrip, connection: { prepare: () => ({ get: (placeId: number) => placeTrip(placeId) }) } } as unknown as DatabaseService), runtime };
// CT7 (Plan 3j Task 5) — the place's owning trip id is now Places.repository.ts#findTripId.
const places = { findTripId: vi.fn(async (placeId: number) => placeTrip(placeId)?.trip_id) } as unknown as PlacesRepository;
return { c: new PlaceDetailsController(runtime, { canAccessTrip } as unknown as DatabaseService, places), runtime };
}
describe('PlaceDetailsController', () => {
@@ -11,12 +11,10 @@ const { pluginsEnabled, getMock } = vi.hoisted(() => ({
getMock: vi.fn(() => ({ 1: 1 })), // plugin is active by default
}));
vi.mock('../../../src/nest/plugins/kill-switch', () => ({ pluginsEnabled }));
vi.mock('../../../src/db/database', () => ({ db: { prepare: () => ({ get: getMock }) } }));
import { db as dbConn } from '../../../src/db/database';
import { DatabaseService } from '../../../src/nest/database/database.service';
import { PluginOAuthController } from '../../../src/nest/plugins/oauth/plugin-oauth.controller';
import type { PluginOAuthService } from '../../../src/nest/plugins/oauth/plugin-oauth.service';
import type { PluginsRepository } from '../../../src/db/repositories/Plugins.repository';
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const req = (id?: number) => ({ user: id === undefined ? undefined : { id } }) as any;
@@ -32,7 +30,9 @@ function ctrl(over: Partial<PluginOAuthService> = {}) {
disconnect: vi.fn(async () => undefined),
...over,
} as unknown as PluginOAuthService;
return { c: new PluginOAuthController(svc, new DatabaseService(dbConn)), svc };
// POC1 (Plan 3j Task 5) — the active-plugin guard is now Plugins.repository.ts#existsActive.
const plugins = { existsActive: vi.fn(async () => !!getMock()) } as unknown as PluginsRepository;
return { c: new PluginOAuthController(svc, plugins), svc };
}
describe('PluginOAuthController', () => {
@@ -10,13 +10,11 @@ const { pluginsEnabled, getMock } = vi.hoisted(() => ({
getMock: vi.fn(() => ({ 1: 1 })), // active by default
}));
vi.mock('../../../src/nest/plugins/kill-switch', () => ({ pluginsEnabled }));
vi.mock('../../../src/db/database', () => ({ db: { prepare: () => ({ get: getMock }) } }));
import { db as dbConn } from '../../../src/db/database';
import { DatabaseService } from '../../../src/nest/database/database.service';
import { PluginUserSettingsController } from '../../../src/nest/plugins/plugin-user-settings.controller';
import type { PluginRuntimeService } from '../../../src/nest/plugins/plugin-runtime.service';
import type { PluginsService } from '../../../src/nest/plugins/plugins.service';
import type { PluginsRepository } from '../../../src/db/repositories/Plugins.repository';
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const req = (id?: number) => ({ user: id === undefined ? undefined : { id } }) as any;
@@ -28,7 +26,9 @@ function ctrl() {
} as unknown as PluginsService;
// The controller now also takes the runtime (for settings-page actions).
const runtime = { actionsOf: vi.fn(async () => []), invokeAction: vi.fn(async () => ({ ok: true })) } as unknown as PluginRuntimeService;
return { c: new PluginUserSettingsController(svc, runtime, new DatabaseService(dbConn)), svc, runtime };
// PUC1 (Plan 3j Task 5) — the active-plugin guard is now Plugins.repository.ts#existsActive.
const plugins = { existsActive: vi.fn(async () => !!getMock()) } as unknown as PluginsRepository;
return { c: new PluginUserSettingsController(svc, runtime, plugins), svc, runtime };
}
describe('PluginUserSettingsController', () => {
@@ -12,12 +12,10 @@ const { rows, pluginsEnabled } = vi.hoisted(() => ({
rows: { value: [] as Array<Record<string, unknown>> },
pluginsEnabled: vi.fn(() => true),
}));
vi.mock('../../../src/db/database', () => ({ db: { prepare: () => ({ all: () => rows.value }) } }));
import { db as dbConn } from '../../../src/db/database';
import { DatabaseService } from '../../../src/nest/database/database.service';
vi.mock('../../../src/nest/plugins/kill-switch', () => ({ pluginsEnabled }));
import { PluginsFeedController } from '../../../src/nest/plugins/plugins-feed.controller';
import type { PluginsRepository } from '../../../src/db/repositories/Plugins.repository';
const row = (over: Record<string, unknown> = {}) => ({
id: 'p1', name: 'P', type: 'integration', icon: null,
@@ -26,7 +24,9 @@ const row = (over: Record<string, unknown> = {}) => ({
});
describe('PluginsFeedController', () => {
const c = new PluginsFeedController(new DatabaseService(dbConn));
// PFC1 (Plan 3j Task 5) — the feed read is now Plugins.repository.ts#findActiveFeedRows.
const plugins = { findActiveFeedRows: vi.fn(async () => rows.value) } as unknown as PluginsRepository;
const c = new PluginsFeedController(plugins);
beforeEach(() => { pluginsEnabled.mockReturnValue(true); rows.value = []; });
it('returns an empty feed when the runtime is disabled', async () => {
@@ -67,6 +67,17 @@ async function makeService(): Promise<PluginsService> {
);
}
/**
* PFC1 (Plan 3j Task 5) — PluginsFeedController's own feed read, now
* Plugins.repository.ts#findActiveFeedRows, resolved over the SAME `testDb`
* handle every raw-SQL fixture line above writes to (same `sharedTestOrm`
* reasoning as `makeService` above).
*/
async function makeFeedController(): Promise<PluginsFeedController> {
const orm = await sharedTestOrm(testDb);
return new PluginsFeedController(orm.repo(Plugins));
}
beforeEach(() => {
testDb.exec('DELETE FROM plugins');
testDb.exec('DELETE FROM plugin_settings_fields');
@@ -247,7 +258,7 @@ describe('PluginsFeedController (client feed)', () => {
it('returns active plugins when enabled, nothing when disabled', async () => {
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(new DatabaseService(dbConn));
const feed = await makeFeedController();
process.env.TREK_PLUGINS_ENABLED = 'true';
const active = await feed.list();
@@ -261,7 +272,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 = await new PluginsFeedController(new DatabaseService(dbConn)).list();
const out = await (await makeFeedController()).list();
expect(out.plugins.find((p) => p.id === 'h')?.slot).toBe('hero');
expect(out.plugins.find((p) => p.id === 'b')?.slot).toBe('sidebar');
});
@@ -269,14 +280,14 @@ describe('PluginsFeedController (client feed)', () => {
it('exposes the day-detail slot (a day-panel widget must not fall back to the dashboard)', async () => {
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((await new PluginsFeedController(new DatabaseService(dbConn)).list()).plugins.find((p) => p.id === 'd')?.slot).toBe('day-detail');
expect((await (await makeFeedController()).list()).plugins.find((p) => p.id === 'd')?.slot).toBe('day-detail');
});
it('exposes settingsUi only when the capability is exactly true', async () => {
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 = await new PluginsFeedController(new DatabaseService(dbConn)).list();
const out = await (await makeFeedController()).list();
expect(out.plugins.find((p) => p.id === 'su')?.settingsUi).toBe(true);
expect(out.plugins.find((p) => p.id === 'no')?.settingsUi).toBeUndefined();
});
@@ -284,7 +295,7 @@ describe('PluginsFeedController (client feed)', () => {
it('exposes the reservation-detail slot (a booking-card widget must not fall back to the dashboard)', async () => {
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((await new PluginsFeedController(new DatabaseService(dbConn)).list()).plugins.find((p) => p.id === 'r')?.slot).toBe('reservation-detail');
expect((await (await makeFeedController()).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', async () => {
@@ -294,7 +305,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 = await new PluginsFeedController(new DatabaseService(dbConn)).list();
const out = await (await makeFeedController()).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();