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,