mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-10-22 15:06:32 +00:00
fix(plugins): hold plugin SQL to a time budget between rows
A plugin's own database runs on the host's event loop, and the only bounds were a regex denylist, the 256 MB quota and a 100,000 row cap. A select whose every row is expensive, or a long scan under the cap, could hold the loop for as long as it liked. query() and tx() now get a wall-clock budget of 2 s, read between the rows a statement yields and between the statements of a batch. Past it the call throws, and a tx rolls back whole like any other failure inside it. The row cap and the budget are overridable per PluginDataDb for tests; the defaults are what production runs. better-sqlite3 has no interrupt or progress handler, so a statement that yields nothing until it is done, such as an aggregate over a cross join, still runs to its first row. Only moving plugin SQL off the host thread can bound that; the SDK README and the plugin wiki state the budget and this limit.
This commit is contained in:
@@ -238,7 +238,7 @@ for a legitimate plugin and only bite a runaway or abusive one — build against
|
||||
| `ctx.ai` | 200 calls/day per plugin (UTC midnight rollover); past it throws `"daily AI budget exhausted (resets at UTC midnight)"` |
|
||||
| `ctx.notify` | 100 calls/day per plugin (UTC midnight rollover); past it throws `"daily notification budget exhausted (resets at UTC midnight)"` |
|
||||
| RPC (every `ctx.*` call) | burst 60, sustained 20/s, 16 in-flight per plugin; a throttled call is refused with `HOST_ERROR: rate limit exceeded — slow down ctx.* calls` |
|
||||
| `ctx.db` (your own sqlite) | 256 MB per plugin |
|
||||
| `ctx.db` (your own sqlite) | 256 MB per plugin; a `query` or `tx` returns at most 100,000 rows (a whole `tx` batch shares one count) and has 2 s of wall-clock time, checked between the rows it reads and the statements of a batch; past either it throws (`query exceeded its 2000 ms time budget`) and a `tx` rolls back. `ATTACH`, `DETACH`, `VACUUM`, `PRAGMA`, `WITH RECURSIVE` and `load_extension` are refused. The budget cannot stop a single statement that produces no row until it is finished (an aggregate over a cross join of large tables), because SQLite runs it on TREK's main thread in one step: keep your queries indexed and bounded |
|
||||
| `ctx.meta` | 64 KB per value, 256 chars per key, 100 keys per (plugin, entity) |
|
||||
| Plugin process | 300 MB RSS ceiling; auto-disabled after 5 crashes in 5 minutes |
|
||||
| Event redelivery buffer | 200 events held per plugin, dropped unreplayed after 15 minutes |
|
||||
|
||||
@@ -72,7 +72,9 @@ export interface PluginContext {
|
||||
};
|
||||
/** Your OWN sqlite database (`db:own`) — a separate file the plugin never gets a
|
||||
* path or connection to directly. Quota: 256 MB per plugin (writes past it fail);
|
||||
* result sets are capped at 100,000 rows. See `tx()` below for the atomic-batch cap. */
|
||||
* result sets are capped at 100,000 rows, and a `query` or `tx` gets 2 s of wall-clock
|
||||
* time, checked between the rows it reads (past it, it throws and a `tx` rolls back).
|
||||
* See `tx()` below for the atomic-batch cap and README § Runtime limits. */
|
||||
db: {
|
||||
query<T = unknown>(sql: string, ...args: unknown[]): Promise<T[]>;
|
||||
exec(sql: string, ...args: unknown[]): Promise<{ changes: number }>;
|
||||
|
||||
+1
-1
@@ -48,7 +48,7 @@ Nest owns everything. Every domain is a DI module under `src/nest/<domain>/` (`c
|
||||
- **Realtime**: `/ws` is a Nest gateway in `src/nest/realtime/`. Inject `RealtimeService`; `src/websocket.ts` is a re-export stub for old call sites. Trip mutations forward `X-Socket-Id` so the originating client doesn't echo its own change.
|
||||
- **MCP**: `src/nest-mcp/` is the decorator/registry layer (`@McpController`/`@Tool`/`@Resource`/`@Prompt`; see its `README.md`); tools live in `<domain>.mcp.ts` files beside each module; the HTTP transport is `src/nest/mcp-transport/`; `src/mcp/` holds process-wide state only (scopes, session manager, boot policy). Every tool invocation writes an audit row through the registry's `onInvoke` seam. MCP tools are adapters over the same services and permission checks as REST — a change on one side lands on the other in the same change.
|
||||
- **Scheduling**: `src/nest/scheduling/` (`CronRegistrarService`) is the one way to schedule a cron. Jobs are `*.job.ts` providers in their owning domain. The registrar refuses to schedule under `NODE_ENV=test` (an integration test pins it). **Never use a `@Cron` decorator**: it bypasses that gate. Every tick first takes the job's row in `scheduler_leases` (`SchedulerLeasesRepository.acquire` in one `UnitOfWork` transaction, where a conditional UPDATE decides the race), renews it while it runs and keeps it for a short settle window afterwards, so two processes on one database never run the same tick; a job needs nothing of its own for this. Claims on individual rows (the reminder jobs' `claimReminder`) stay where they are: the lease decides who runs a tick, the claim what a tick may send.
|
||||
- **Plugins** (`src/nest/plugins/`): the child-process runtime (`supervisor/`, `runtime/plugin-host-entry.ts`, `protocol/envelope.ts`) is **intentionally not Nest** — it is the security boundary. `host/rpc-host.ts` registers an RPC method at spawn only if the plugin holds the unlocking permission (registration = authorization). Install-time gates live in `install/`; the manifest's `trek` version range fails closed, and `TREK_PLUGINS_IGNORE_TREK_RANGE` downgrades that to a warning. The protocol tables in `protocol/envelope.ts` (+ `src/plugin-event-sink.ts`) are **generated** into `plugin-sdk/` and `shared/` — run `gen:plugin-facts` after touching them and commit the output. What a plugin gets back is `protocol/output-contract.ts`: every wire method has an entry, and an entity read (`trips.getById`, `costs.listMine`, ...) is cut down to its published fields in `rpc-host.ts` on the way out, so a new column on `trips`, `places`, `reservations` and the other whole-row tables fails `tests/unit/plugins/output-contract.schema.test.ts` until it is listed as published or withheld (the SDK's `PLUGIN_ENTITY_FIELDS` is generated from it). `runtime/egress-policy.ts` is a hand-kept mirror guarded by a parity test in `plugin-sdk/`; change both sides together.
|
||||
- **Plugins** (`src/nest/plugins/`): the child-process runtime (`supervisor/`, `runtime/plugin-host-entry.ts`, `protocol/envelope.ts`) is **intentionally not Nest** — it is the security boundary. `host/rpc-host.ts` registers an RPC method at spawn only if the plugin holds the unlocking permission (registration = authorization). Install-time gates live in `install/`; the manifest's `trek` version range fails closed, and `TREK_PLUGINS_IGNORE_TREK_RANGE` downgrades that to a warning. The protocol tables in `protocol/envelope.ts` (+ `src/plugin-event-sink.ts`) are **generated** into `plugin-sdk/` and `shared/` — run `gen:plugin-facts` after touching them and commit the output. What a plugin gets back is `protocol/output-contract.ts`: every wire method has an entry, and an entity read (`trips.getById`, `costs.listMine`, ...) is cut down to its published fields in `rpc-host.ts` on the way out, so a new column on `trips`, `places`, `reservations` and the other whole-row tables fails `tests/unit/plugins/output-contract.schema.test.ts` until it is listed as published or withheld (the SDK's `PLUGIN_ENTITY_FIELDS` is generated from it). A plugin's own SQL (`host/plugin-data.service.ts`) is held to a row cap and a time budget checked between rows. `runtime/egress-policy.ts` is a hand-kept mirror guarded by a parity test in `plugin-sdk/`; change both sides together.
|
||||
- **Storage**: `src/nest/storage/` is the upload driver abstraction (local / S3 / mirror behind one interface, with an admin surface and a usage job). Domain services never touch the filesystem or the S3 SDK directly. Where the data lives (`data/`, `uploads/`, backups, logs, key files, the DB file) is resolved in one place, `src/app-config/data-paths.ts`; a provider that reads one injects `DataPathsService`, plain helper modules shared with code outside the container call `resolveDataPaths()` (the list is in `src/app-config/README.md`), and nothing anchors a data path on its own `__dirname` or on `process.cwd()`.
|
||||
- **DB** (`src/db/`): `better-sqlite3`, WAL, FK on. `database.ts` only holds the connection and applies pragmas. It does not build a schema, and importing it opens nothing (the first reader does). The connection's lifecycle belongs to the `DatabaseLifecycle` provider (`nest/database/database-lifecycle.service.ts`): `buildApp()` opens it through `open()` (which binds the ORM to later swaps and runs the schema bootstrap below), the shutdown closes it through `close()`, and a restore or the demo reset swaps the file only through the database backup port, `DATABASE_BACKUP` (`nest/database/database-backup.interface.ts`; the SQLite implementation is `nest/backup/sqlite-database-backup.ts`: `VACUUM INTO` snapshot, close, atomic file swap, `reopen()`). The port works on `DatabaseLifecycle.file`, the one resolved path (`TREK_DB_FILE` or `data/travel.db`), and a snapshot that fails fails the backup rather than archiving the live file. `MaintenanceRepository` is provided only by `MaintenanceModule`'s factory and injected everywhere else; no consumer constructs it. A provider that has to follow every reopen (the demo reset job saves the first demo baseline after a restore's re-bootstrap seeded the example trips) registers with `DatabaseLifecycle.onReopened()`, which runs after the reopen's schema bootstrap; a listener contains its own failures, since one that throws fails the reopen. MikroORM's migrations (`db/migrations/`) are the only schema source, **including for tests**: the hand-kept `schema.ts`/`migrations.ts`/`seeds.ts` builders and the parity test that guarded them are gone. Seeding is the MikroORM seeders in `db/seeders/`, applied by `db/orm.ts` from `buildApp()` before anything reads. An install the retired positional runner migrated (a `schema_version` row, no `mikro_orm_migrations` rows) is baselined by `db/legacy-baseline.ts`: the migrations whose `Legacy migration step N` docstring is ≤ its version are recorded as executed (never replayed) in the same transaction as the migrator run, while the baseline migration itself still runs first, as the legacy runner's `createTables()` did on every boot; anything it cannot map refuses the boot. Before a boot applies pending migrations to a file database that holds anything, `db/pre-migrate-snapshot.ts` writes a `VACUUM INTO` copy next to it (`pre-migrate-<schema>-<time>.db`, `<schema>` being `legacy-<schema_version>` or the last recorded migration's timestamp; the newest `TREK_DB_PRE_MIGRATE_SNAPSHOT_KEEP` stay) and the newer-database refusal names the matching copy; a copy that cannot be written refuses the boot unless `TREK_DB_PRE_MIGRATE_SNAPSHOT=false`. It is off under `NODE_ENV=test` unless a suite sets that variable to true, and a restore's re-bootstrap skips it (the archive is the copy). After the migrator, `db/email-case-index.ts` creates the case-insensitive unique index on `users.email` that its migration had to defer on an install holding two accounts whose emails differ only in case (flag `email_case_index_pending`). `db/orm-driver.ts` binds the ORM to that same connection instead of opening a second one — with `:memory:` under test, a second connection is a second, empty database. Kysely statements (`this.kysely<...>()`) type against `DB` from `db/kysely/db.ts`, narrowed with `Pick<DB, 'trips' | 'days'>`; those files are generated from the migrated schema by `scripts/generate-db-types.ts`, so a migration that adds or changes a column is followed by `npm run gen:db-types`, never by a hand-written table interface. Every read and write goes through a repository in `db/repositories/` (each extends the shared `TrekRepository` base in `db/repositories/_shared/`, which validates the request context before delegating to MikroORM) and, for multi-statement writes, `UnitOfWork.transactional(...)` (`nest/database/unit-of-work.ts`, provided by the global `OrmModule`) — there is no `DatabaseService`/`DatabaseModule` to inject any more. Entrypoints outside an HTTP request — an MCP tool call, a plugin RPC dispatch, a WebSocket message, a cron job, boot-time seeding, a backup restore — open one explicitly with `withRequestContext()` (`nest/database/request-context.ts`), since only the pathless `mikroOrmRequestContext` middleware `buildApp()` mounts (with `@mikro-orm/nestjs`'s own `registerRequestContext` turned off in `app.module.ts`, UPLOADS-P16) forks an `EntityManager` per HTTP request. `NODE_ENV=test` ⇒ isolated `:memory:` DB per vitest worker, opened from a snapshot `tests/global-setup.ts` migrates once per run; `TREK_DB_FILE` ⇒ explicit file DB (Playwright harness).
|
||||
|
||||
|
||||
@@ -13,7 +13,7 @@ import { pluginDataDir, pluginDbFile, pluginsDataRoot } from '../paths';
|
||||
*
|
||||
* A thin guard still rejects statements that would let a plugin escape its file
|
||||
* (ATTACH another db, VACUUM INTO elsewhere, PRAGMA fiddling) or DoS via
|
||||
* oversize SQL.
|
||||
* oversize SQL, and every read is held to a row cap and a time budget (below).
|
||||
*/
|
||||
|
||||
const MAX_SQL_LENGTH = 100_000;
|
||||
@@ -41,6 +41,25 @@ const MAX_ROWS = 100_000;
|
||||
// Cap statements per atomic batch so a single tx() can't monopolise the synchronous
|
||||
// host — generous for real write batches, far below anything abusive.
|
||||
const MAX_TX_OPS = 100;
|
||||
// Wall-clock budget for one query() or one whole tx() batch. better-sqlite3 has no
|
||||
// interrupt or progress handler, so the budget is checked between the rows a statement
|
||||
// yields (and between the statements of a batch): a scan that keeps producing rows, or
|
||||
// a select whose every row is expensive, stops at the first row past the budget
|
||||
// instead of holding the host loop until the row cap. A statement that yields nothing
|
||||
// until it is done (an aggregate over a cartesian product) still runs to its first
|
||||
// row; only moving plugin SQL off the host thread can bound that, and the plugin-sdk
|
||||
// README says so.
|
||||
const TIME_BUDGET_MS = 2_000;
|
||||
|
||||
/** The caps a PluginDataDb enforces. Overridable for tests; production uses the defaults. */
|
||||
export interface PluginDataLimits {
|
||||
maxRows: number;
|
||||
timeBudgetMs: number;
|
||||
/** The clock the time budget reads. */
|
||||
now: () => number;
|
||||
}
|
||||
|
||||
const DEFAULT_LIMITS: PluginDataLimits = { maxRows: MAX_ROWS, timeBudgetMs: TIME_BUDGET_MS, now: () => Date.now() };
|
||||
|
||||
// Every live per-plugin handle, so a backup can WAL-checkpoint them before archiving
|
||||
// (the host keeps these open, so their .db files would otherwise be copied with recent
|
||||
@@ -59,9 +78,11 @@ export function checkpointAllPluginDataDbs(): void {
|
||||
export class PluginDataDb {
|
||||
private db: Db;
|
||||
readonly pluginId: string;
|
||||
private readonly limits: PluginDataLimits;
|
||||
|
||||
constructor(pluginId: string) {
|
||||
constructor(pluginId: string, limits: Partial<PluginDataLimits> = {}) {
|
||||
this.pluginId = pluginId;
|
||||
this.limits = { ...DEFAULT_LIMITS, ...limits };
|
||||
fs.mkdirSync(pluginDataDir(pluginId), { recursive: true });
|
||||
this.db = openDatabase(pluginDbFile(pluginId));
|
||||
this.db.pragma('journal_mode = WAL');
|
||||
@@ -82,15 +103,28 @@ export class PluginDataDb {
|
||||
if (FORBIDDEN.test(sql)) throw new Error('statement type not allowed for plugin databases');
|
||||
}
|
||||
|
||||
/** Read query — returns rows up to MAX_ROWS. Single statement only. */
|
||||
/** A deadline for one call: throws once the call has run past the time budget. */
|
||||
private startBudget(what: string): () => void {
|
||||
const { now, timeBudgetMs } = this.limits;
|
||||
const startedAt = now();
|
||||
return () => {
|
||||
if (now() - startedAt > timeBudgetMs) throw new Error(`${what} exceeded its ${timeBudgetMs} ms time budget`);
|
||||
};
|
||||
}
|
||||
|
||||
/** Read query: returns rows up to the row cap, within the time budget. Single statement only. */
|
||||
query(sql: string, args: unknown[] = []): unknown[] {
|
||||
this.guard(sql);
|
||||
const { maxRows } = this.limits;
|
||||
const checkTime = this.startBudget('query');
|
||||
// iterate() pulls one row at a time, so a recursive CTE that would yield
|
||||
// unboundedly is halted at the cap instead of materializing via all().
|
||||
// unboundedly is halted at the cap instead of materializing via all(), and the
|
||||
// clock is read between rows.
|
||||
const rows: unknown[] = [];
|
||||
for (const row of this.db.prepare(sql).iterate(...(args as never[]))) {
|
||||
rows.push(row);
|
||||
if (rows.length > MAX_ROWS) throw new Error(`query returned more than ${MAX_ROWS} rows`);
|
||||
if (rows.length > maxRows) throw new Error(`query returned more than ${maxRows} rows`);
|
||||
checkTime();
|
||||
}
|
||||
return rows;
|
||||
}
|
||||
@@ -127,7 +161,11 @@ export class PluginDataDb {
|
||||
const head = String(op?.sql ?? '').replace(/^(?:\s|--[^\n]*\n?|\/\*[\s\S]*?\*\/)*/, '');
|
||||
if (TX_CONTROL.test(head)) throw new Error('transaction-control statements are not allowed inside tx()');
|
||||
}
|
||||
const { maxRows } = this.limits;
|
||||
let batchRows = 0; // one row budget for the WHOLE batch, not per statement
|
||||
// One time budget for the whole batch too. A throw inside the transaction rolls
|
||||
// every statement of the batch back, so running out of time is all-or-nothing.
|
||||
const checkTime = this.startBudget('tx');
|
||||
const run = this.db.transaction((batch: Array<{ sql: string; args?: unknown[] }>) => {
|
||||
const results: Array<{ changes?: number; rows?: unknown[] }> = [];
|
||||
for (const op of batch) {
|
||||
@@ -137,12 +175,14 @@ export class PluginDataDb {
|
||||
const rows: unknown[] = [];
|
||||
for (const row of stmt.iterate(...args)) {
|
||||
rows.push(row);
|
||||
if (++batchRows > MAX_ROWS) throw new Error(`tx returned more than ${MAX_ROWS} rows in total`);
|
||||
if (++batchRows > maxRows) throw new Error(`tx returned more than ${maxRows} rows in total`);
|
||||
checkTime();
|
||||
}
|
||||
results.push({ rows });
|
||||
} else {
|
||||
results.push({ changes: stmt.run(...args).changes });
|
||||
}
|
||||
checkTime();
|
||||
}
|
||||
return results;
|
||||
});
|
||||
|
||||
@@ -100,6 +100,54 @@ describe('PluginDataDb', () => {
|
||||
db.close();
|
||||
});
|
||||
|
||||
/** A clock that moves `step` ms each time it is read, so a budget runs out on cue. */
|
||||
const steppingClock = (step: number) => {
|
||||
let t = 0;
|
||||
return () => (t += step);
|
||||
};
|
||||
|
||||
it('stops a query at the first row past its time budget', () => {
|
||||
const db = new PluginDataDb('budget-query', { timeBudgetMs: 100, now: steppingClock(30) });
|
||||
db.exec('CREATE TABLE seq (n INTEGER)');
|
||||
db.exec('INSERT INTO seq (n) VALUES ' + Array.from({ length: 50 }, (_, i) => `(${i})`).join(','));
|
||||
// The start reads 30 and each row reads again (60, 90, 120, 150): at the fourth
|
||||
// row the call is 120 ms in, past the budget.
|
||||
expect(() => db.query('SELECT n FROM seq')).toThrow('query exceeded its 100 ms time budget');
|
||||
// A query that finishes inside the budget is untouched.
|
||||
expect(db.query('SELECT n FROM seq WHERE n < 2 ORDER BY n')).toEqual([{ n: 0 }, { n: 1 }]);
|
||||
db.close();
|
||||
});
|
||||
|
||||
it('holds a whole tx batch to one time budget and rolls it back when it runs out', () => {
|
||||
const db = new PluginDataDb('budget-tx', { timeBudgetMs: 80, now: steppingClock(30) });
|
||||
db.exec('CREATE TABLE acct (id INTEGER PRIMARY KEY, bal INTEGER)');
|
||||
db.exec('INSERT INTO acct (id, bal) VALUES (1, 100)');
|
||||
// The clock is read at the start and after each statement: 30 ms, 60 ms, then
|
||||
// 90 ms after the third write, past the budget. The two writes that did run are
|
||||
// rolled back with the rest.
|
||||
expect(() =>
|
||||
db.tx([
|
||||
{ sql: 'UPDATE acct SET bal = 1 WHERE id = 1' },
|
||||
{ sql: 'UPDATE acct SET bal = 2 WHERE id = 1' },
|
||||
{ sql: 'UPDATE acct SET bal = 3 WHERE id = 1' },
|
||||
]),
|
||||
).toThrow('tx exceeded its 80 ms time budget');
|
||||
expect(db.query('SELECT bal FROM acct WHERE id = 1')).toEqual([{ bal: 100 }]);
|
||||
// The rows a batch reads move the same clock: row, end of statement, row is 90 ms.
|
||||
expect(() => db.tx([{ sql: 'SELECT id FROM acct' }, { sql: 'SELECT bal FROM acct' }])).toThrow(/time budget/);
|
||||
db.close();
|
||||
});
|
||||
|
||||
it('applies an overridden row cap to query and tx alike', () => {
|
||||
const db = new PluginDataDb('budget-rows', { maxRows: 3 });
|
||||
db.exec('CREATE TABLE seq (n INTEGER)');
|
||||
db.exec('INSERT INTO seq (n) VALUES (1), (2), (3), (4)');
|
||||
expect(() => db.query('SELECT n FROM seq')).toThrow('query returned more than 3 rows');
|
||||
expect(() => db.tx([{ sql: 'SELECT n FROM seq' }])).toThrow('tx returned more than 3 rows in total');
|
||||
expect(db.query('SELECT n FROM seq WHERE n <= 3')).toHaveLength(3);
|
||||
db.close();
|
||||
});
|
||||
|
||||
it('removePluginData deletes the whole data dir', () => {
|
||||
const db = new PluginDataDb('temp');
|
||||
db.migrate('001', 'CREATE TABLE t (id INTEGER)');
|
||||
|
||||
@@ -269,7 +269,7 @@ exports the list per method as `PLUGIN_METHOD_RESULT`:
|
||||
|
||||
| Area | Methods | Requires |
|
||||
|---|---|---|
|
||||
| `ctx.db` | `query(sql, …args)` / `exec(sql, …args)` / `migrate(id, sql)` / `tx(ops)` against your **own** SQLite file. `tx([{sql, args?}, …])` runs up to 100 statements in one transaction (all commit or all roll back; reads see the batch's own earlier writes) → `{ results: [{changes?}\|{rows?}, …] }`. Your file is capped at **256 MB** (a write past it fails `SQLITE_FULL`, contained to your plugin) and a single result set at **100,000 rows** — page your reads instead of materialising a cartesian product | `db:own` |
|
||||
| `ctx.db` | `query(sql, …args)` / `exec(sql, …args)` / `migrate(id, sql)` / `tx(ops)` against your **own** SQLite file. `tx([{sql, args?}, …])` runs up to 100 statements in one transaction (all commit or all roll back; reads see the batch's own earlier writes) → `{ results: [{changes?}\|{rows?}, …] }`. Your file is capped at **256 MB** (a write past it fails `SQLITE_FULL`, contained to your plugin), a single result set at **100,000 rows** and a `query` or `tx` at **2 s** (see [Runtime limits](#runtime-limits)) — page your reads instead of materialising a cartesian product | `db:own` |
|
||||
| `ctx.trips` | `getById` / `getPlaces` / `getReservations` / `getDays` / `getAccommodations` / `listMine()` — enumerate every trip the acting user can access (membership-checked). `getDays` includes each day's `assignments` + `notes_items`; `getReservations` includes `endpoints` + `day_positions` | `db:read:trips` |
|
||||
| `ctx.trips.update(tripId, fields)` | update trip fields (title/dates/currency/reminder_days/…) | `db:write:trips` |
|
||||
| `ctx.trips.create(input)` | create a **new trip owned by the acting user** (importers) — `title` required, plus `description?`/`start_date?`/`end_date?`/`currency?`/`reminder_days?`/`day_count?`; without `currency` the trip takes the acting user's display currency, then the instance default, then EUR | `db:create:trips` (+ `trip_create`) |
|
||||
@@ -401,7 +401,7 @@ stores blobs in `ctx.db` runs into, so build against them.
|
||||
| Area | Limit |
|
||||
|---|---|
|
||||
| every `ctx.*` call | burst 60, sustained 20/s, 16 in-flight per plugin; a throttled call is refused with `HOST_ERROR: rate limit exceeded — slow down ctx.* calls`. Tunable with `TREK_PLUGIN_RPC_BURST` / `TREK_PLUGIN_RPC_PER_SEC` / `TREK_PLUGIN_RPC_INFLIGHT` (see [Environment-Variables](Environment-Variables#plugins)) |
|
||||
| `ctx.db` | 256 MB per plugin, one result set capped at 100,000 rows |
|
||||
| `ctx.db` | 256 MB per plugin; a `query` or a whole `tx` batch returns at most 100,000 rows and gets 2 s of wall-clock time, checked between the rows it reads and the statements of a batch (past it the call throws `query exceeded its 2000 ms time budget`, and a `tx` rolls back). A single statement that yields nothing until it is finished, such as an aggregate over a cross join of large tables, cannot be stopped midway, because SQLite runs it on TREK's main thread: keep your queries indexed and bounded |
|
||||
| event subscriptions | 200 events buffered per plugin while it restarts, dropped unreplayed after 15 minutes |
|
||||
| the plugin process | 300 MB RSS (`TREK_PLUGIN_MAX_RSS_MB`) — the child is killed past it; auto-disabled with status `error` after 5 crashes inside a 5-minute window |
|
||||
|
||||
|
||||
@@ -10,7 +10,7 @@ ungranted capability is physically unreachable**, not just disallowed. See
|
||||
|
||||
| Permission | Grants | Notes |
|
||||
|---|---|---|
|
||||
| `db:own` | Read/write the plugin's **own** SQLite file via `ctx.db` — `db.query`, `db.exec`, **and `db.migrate`** | A separate `plugin.db` per plugin under the plugin's data dir — never TREK's own `travel.db`. `db.migrate` runs a keyed, idempotent migration (schema/table creation, e.g. `CREATE TABLE`) once per id. `ATTACH`/`DETACH`/`VACUUM`/`PRAGMA` are refused. |
|
||||
| `db:own` | Read/write the plugin's **own** SQLite file via `ctx.db` — `db.query`, `db.exec`, **and `db.migrate`** | A separate `plugin.db` per plugin under the plugin's data dir — never TREK's own `travel.db`. `db.migrate` runs a keyed, idempotent migration (schema/table creation, e.g. `CREATE TABLE`) once per id. `ATTACH`/`DETACH`/`VACUUM`/`PRAGMA`/`WITH RECURSIVE` are refused, and a `query` or `tx` stops at 100,000 rows or 2 s. |
|
||||
| `db:read:trips` | Read-only trip data via `ctx.trips` (`getById`, `getPlaces`, `getReservations`, `getDays`, `getAccommodations`, `listMine`, `members`) | Every call is **membership-checked** against the acting user — a plugin can't read a trip that user can't see. `getDays` returns each day with its `assignments` + `notes_items`; `getReservations` rows carry `endpoints` + `day_positions` like the REST list. `members` returns the roster (id + display fields only). Each row carries the fields TREK publishes for it (`PLUGIN_ENTITY_FIELDS` in the SDK), never the trip's calendar `feed_token`, and a column added in a later release is not delivered until it is published. |
|
||||
| `db:read:users` | Read-only public profile via `ctx.users.getById` | Returns id, username, display name, avatar only — **never** password hashes, tokens, or secrets. |
|
||||
| `db:read:packing` | Read-only packing items of a trip via `ctx.packing.list(tripId)` | Membership-checked, and scoped to the acting user's visibility — a plugin never sees another member's private packing items. |
|
||||
|
||||
Reference in New Issue
Block a user