mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-10-19 13:36:48 +00:00
741 lines
46 KiB
TypeScript
741 lines
46 KiB
TypeScript
/**
|
|
* trek-plugin dev — run your plugin locally with a real request loop and hot
|
|
* reload, without a full TREK. It loads server/index.js through the same
|
|
* definePlugin contract, injects a ctx that enforces your manifest's granted
|
|
* permissions (an ungranted call still throws PERMISSION_DENIED, so you catch
|
|
* missing grants), backs db:own with a real SQLite file when the runtime has
|
|
* node:sqlite, serves your routes over HTTP and your page/widget UI, and reloads
|
|
* on every save.
|
|
*
|
|
* Enforcement is NOT limited to ctx. TREK gates hooks, event subscriptions, jobs
|
|
* and outbound network BEFORE a plugin's ctx is ever reached, and it does so
|
|
* SILENTLY — an ungranted hook is simply never called, with no error and no log, so
|
|
* "my plugin does nothing" is all the author ever sees. Dev therefore refuses the
|
|
* same things, loudly: it warns at load about every entry point you implement but
|
|
* did not grant (see grantGaps), returns 403 from /__dev/fire for one, and installs
|
|
* the real egress guard so an undeclared host fails here rather than after install.
|
|
*
|
|
* Dependency-free: node:http + node:sqlite (when present) + built-ins.
|
|
*/
|
|
import fs from 'node:fs';
|
|
import path from 'node:path';
|
|
import http from 'node:http';
|
|
import { createRequire } from 'node:module';
|
|
import * as sdk from '../index.js';
|
|
import { injectTrekUi } from '../ui/kit.js';
|
|
import { readJsonFile } from './json.js';
|
|
import {
|
|
PermissionDenied, HOOK_PERMISSION, USER_DATA_PERMISSION, EVENTS_PERMISSION, JOBS_PERMISSION,
|
|
grantGaps, grantedHosts, type GrantGap,
|
|
} from '../permissions.js';
|
|
import { installEgressGuard } from '../egress-policy.js';
|
|
|
|
// Dev fixtures ARE mock-host options — the dev ctx delegates every non-db-own
|
|
// capability to a grant-enforcing mock host, so dev-fixtures.json can seed the full
|
|
// surface (trips, costs, packing, files, users, settings, weather, ai, …), exactly the
|
|
// shape a mock-host unit test uses. `actingUserId` binds the dev user (like the prod
|
|
// invocation user), defaulting to 1.
|
|
type Fixtures = NonNullable<Parameters<typeof sdk.createMockHost>[0]>;
|
|
interface PluginRouteLike { method: string; path: string; auth?: boolean; handler: (req: unknown, ctx: unknown) => Promise<{ status: number; headers?: Record<string, string>; body?: unknown }>; }
|
|
type Handler = (...a: unknown[]) => unknown;
|
|
interface PluginLike {
|
|
onLoad?: (ctx: unknown) => unknown;
|
|
routes?: PluginRouteLike[];
|
|
jobs?: Array<{ id: string; handler: Handler }>;
|
|
scheduled?: Handler;
|
|
events?: Array<{ on: string; handler: Handler }>;
|
|
deleteUserData?: Handler;
|
|
exportUserData?: Handler;
|
|
hooks?: Record<string, Record<string, Handler>>;
|
|
}
|
|
|
|
// /__dev/fire/<kind> → the permission TREK requires before it would ever fire that
|
|
// entry point. `hook` is resolved per hook name via HOOK_PERMISSION instead.
|
|
const FIRE_PERMISSION: Readonly<Record<string, string | undefined>> = {
|
|
job: JOBS_PERMISSION,
|
|
scheduled: JOBS_PERMISSION,
|
|
event: EVENTS_PERMISSION,
|
|
deleteUserData: USER_DATA_PERMISSION,
|
|
exportUserData: USER_DATA_PERMISSION,
|
|
};
|
|
|
|
/** Refuse an entry point the manifest never granted. An unknown kind/hook has no
|
|
* permission to check — it falls through to the handler's own 400/404. */
|
|
function needGrant(grants: ReadonlySet<string>, perm: string | undefined, what: string): void {
|
|
if (perm && !grants.has(perm)) {
|
|
throw new PermissionDenied(
|
|
`PERMISSION_DENIED: ${what} requires "${perm}" — add it to permissions in trek-plugin.json. ` +
|
|
`TREK would never fire this entry point (it skips ungranted plugins silently).`,
|
|
);
|
|
}
|
|
}
|
|
|
|
/** Warn about every entry point the plugin implements but cannot run. Printed at load
|
|
* and on every hot reload, because this failure is invisible in production. */
|
|
function printGaps(gaps: GrantGap[]): void {
|
|
for (const g of gaps) {
|
|
console.warn(` ⚠ ${g.entryPoint} is implemented but "${g.permission}" is NOT granted — ${g.consequence}`);
|
|
}
|
|
}
|
|
|
|
// The same SQL guards the real host's PluginDataDb applies (plugin-data.service.ts):
|
|
// forbidden statement types, an sql-length cap, a result-row cap, and — inside tx() —
|
|
// no transaction control plus a batch-size cap. Without them a statement production
|
|
// refuses would silently work in dev, and the author would debug a phantom.
|
|
const MAX_SQL_LENGTH = 100_000;
|
|
const FORBIDDEN_SQL = /\b(ATTACH|DETACH|VACUUM|PRAGMA|RECURSIVE|LOAD_EXTENSION)\b/i;
|
|
const TX_CONTROL = /^\s*(BEGIN|COMMIT|ROLLBACK|SAVEPOINT|RELEASE|END)\b/i;
|
|
const MAX_TX_OPS = 100;
|
|
const MAX_ROWS = 100_000;
|
|
|
|
function guardSql(sql: string): void {
|
|
if (typeof sql !== 'string') throw new Error('sql must be a string');
|
|
if (sql.length > MAX_SQL_LENGTH) throw new Error('sql too long');
|
|
if (FORBIDDEN_SQL.test(sql)) throw new Error('statement type not allowed for plugin databases');
|
|
}
|
|
|
|
/** Strip LEADING comments/whitespace so a COMMIT hidden behind a comment can't slip
|
|
* past the start-anchored TX_CONTROL check (mirrors the host's tx() guard). */
|
|
function stripLeadingComments(sql: string): string {
|
|
return String(sql ?? '').replace(/^(?:\s|--[^\n]*\n?|\/\*[\s\S]*?\*\/)*/, '');
|
|
}
|
|
|
|
// The credential-free inbound-header allowlist TREK's proxy forwards to auth:false
|
|
// routes (webhooks) — never Cookie/Authorization/session. Kept in lockstep with
|
|
// SAFE_INBOUND_HEADERS in plugins-proxy.controller.ts.
|
|
const SAFE_INBOUND_HEADERS = new Set([
|
|
'content-type', 'user-agent', 'x-request-id', 'x-idempotency-key',
|
|
'x-hub-signature', 'x-hub-signature-256', 'x-github-event', 'x-github-delivery',
|
|
'stripe-signature',
|
|
'svix-id', 'svix-timestamp', 'svix-signature',
|
|
'x-gitlab-event', 'x-gitlab-token',
|
|
'x-signature', 'x-signature-256', 'x-webhook-signature', 'x-event-type',
|
|
]);
|
|
|
|
function pickInboundHeaders(raw: http.IncomingHttpHeaders): Record<string, string> {
|
|
const out: Record<string, string> = {};
|
|
for (const [k, v] of Object.entries(raw)) {
|
|
if (!SAFE_INBOUND_HEADERS.has(k.toLowerCase())) continue;
|
|
out[k.toLowerCase()] = Array.isArray(v) ? v.join(', ') : String(v ?? '');
|
|
}
|
|
return out;
|
|
}
|
|
|
|
/**
|
|
* Accept both bind shapes the real host accepts: spread positional args AND a
|
|
* single array of them (better-sqlite3 binds an array; node:sqlite does not,
|
|
* so a plugin using the array form would fail only in dev).
|
|
*/
|
|
function flatBind(args: unknown[]): unknown[] {
|
|
return args.length === 1 && Array.isArray(args[0]) ? (args[0] as unknown[]) : args;
|
|
}
|
|
|
|
/** A dev db backed by node:sqlite if available, else an in-memory recorder that returns []. */
|
|
export function createDevDb(dbFile: string): { db: PluginContextDb; note: string; close: () => void } {
|
|
// node:sqlite ships on Node 22.5+. Probe for it SEPARATELY from opening the database:
|
|
// one try/catch around both meant an mkdir/permission failure silently degraded to the
|
|
// stub too, and the stub swallows every write while reporting success — a db:own plugin
|
|
// "works" in dev and persists nothing. Fail loudly on a real error; degrade only on old Node.
|
|
let DatabaseSync: (new (p: string) => SqliteDb) | undefined;
|
|
try {
|
|
({ DatabaseSync } = createRequire(import.meta.url)('node:sqlite') as { DatabaseSync: new (p: string) => SqliteDb });
|
|
} catch {
|
|
console.warn(
|
|
`\n ⚠ node:sqlite is unavailable on this Node (${process.version}) — ctx.db is a STUB.\n` +
|
|
` Every query returns [] and every write reports 0 changes: a db:own plugin will look\n` +
|
|
` like it works and persist NOTHING. Upgrade to Node 22.5+ to test db:own for real.\n`,
|
|
);
|
|
return {
|
|
note: `db:own → in-memory STUB (writes are discarded — upgrade to Node 22.5+; you are on ${process.version})`,
|
|
close: () => {},
|
|
db: {
|
|
async query(sql: string) { guardSql(sql); return []; },
|
|
async exec(sql: string) { guardSql(sql); return { changes: 0 }; },
|
|
async migrate(_id: string, sql: string) { guardSql(sql); return { applied: true }; },
|
|
},
|
|
};
|
|
}
|
|
|
|
{
|
|
fs.mkdirSync(path.dirname(dbFile), { recursive: true });
|
|
const sq = new DatabaseSync(dbFile);
|
|
const applied = new Set<string>();
|
|
return {
|
|
note: `db:own → real SQLite at ${dbFile}`,
|
|
close: () => { try { sq.close(); } catch { /* ignore */ } },
|
|
db: {
|
|
async query(sql: string, ...args: unknown[]) {
|
|
guardSql(sql);
|
|
const rows = sq.prepare(sql).all(...flatBind(args)) as unknown[];
|
|
if (rows.length > MAX_ROWS) throw new Error(`query returned more than ${MAX_ROWS} rows`);
|
|
return rows;
|
|
},
|
|
async exec(sql: string, ...args: unknown[]) {
|
|
guardSql(sql);
|
|
if (args.length) { const r = sq.prepare(sql).run(...flatBind(args)); return { changes: Number(r.changes ?? 0) }; }
|
|
sq.exec(sql); return { changes: 0 };
|
|
},
|
|
async migrate(id: string, sql: string) { guardSql(sql); if (applied.has(id)) return { applied: false }; sq.exec(sql); applied.add(id); return { applied: true }; },
|
|
},
|
|
};
|
|
}
|
|
}
|
|
|
|
interface SqliteDb { prepare(sql: string): { all(...a: unknown[]): unknown[]; run(...a: unknown[]): { changes?: number | bigint } }; exec(sql: string): void; close(): void; }
|
|
interface PluginContextDb { query(sql: string, ...a: unknown[]): Promise<unknown[]>; exec(sql: string, ...a: unknown[]): Promise<{ changes: number }>; migrate(id: string, sql: string): Promise<{ applied: boolean }>; }
|
|
|
|
function createDevContext(id: string, grants: Set<string>, fx: Fixtures, db: PluginContextDb, broadcasts: unknown[]) {
|
|
const need = (perm: string, method: string) => { if (!grants.has(perm)) throw new PermissionDenied(`PERMISSION_DENIED: ${method} requires ${perm}`); };
|
|
// Full parity: delegate EVERY capability to a grant-enforcing mock host (the same one
|
|
// used in unit tests), so ctx.costs/packing/files/notify/ai/settings/scheduler/meta/
|
|
// oauth/weather/rates/journal/… all work in dev instead of throwing TypeErrors — then
|
|
// override just the three surfaces dev does natively: db:own (real node:sqlite so your
|
|
// migrations/queries actually persist), ws broadcasts (captured for the dashboard),
|
|
// and logging (to the dev console). The mock enforces the SAME grants, so a missing
|
|
// permission fails identically here and in production.
|
|
// Default the dev acting user to 1 (like the prod invocation user) so the documented
|
|
// one-arg calls work on a fresh scaffold — without this, a fixtures file that omits
|
|
// actingUserId leaves the mock userless and every user-bound capability throws.
|
|
const mock = sdk.createMockHost({ ...fx, actingUserId: fx.actingUserId ?? 1, grants: [...grants] });
|
|
const isRead = (sql: string) => /^\s*(SELECT|WITH|VALUES)\b/i.test(sql) || /\bRETURNING\b/i.test(sql);
|
|
const devDb = {
|
|
query: (sql: string, ...a: unknown[]) => { need('db:own', 'db.query'); return db.query(sql, ...a); },
|
|
exec: (sql: string, ...a: unknown[]) => { need('db:own', 'db.exec'); return db.exec(sql, ...a); },
|
|
migrate: (mid: string, sql: string) => { need('db:own', 'db.migrate'); return db.migrate(mid, sql); },
|
|
// A functional (not strictly atomic) tx over the real dev db so read-modify-write
|
|
// batches work in dev; production runs it in one better-sqlite3 transaction.
|
|
tx: async (ops: Array<{ sql: string; args?: unknown[] }>) => {
|
|
need('db:own', 'db.tx');
|
|
// Validate the WHOLE batch up front, like the host's PluginDataDb.tx — a bad
|
|
// op must fail before anything ran, not leave earlier writes behind.
|
|
if (!Array.isArray(ops)) throw new Error('tx requires an array of { sql, args }');
|
|
if (ops.length > MAX_TX_OPS) throw new Error(`tx allows at most ${MAX_TX_OPS} statements`);
|
|
for (const op of ops) {
|
|
guardSql(op?.sql as string);
|
|
if (TX_CONTROL.test(stripLeadingComments(op?.sql as string))) throw new Error('transaction-control statements are not allowed inside tx()');
|
|
}
|
|
const results: Array<{ changes?: number; rows?: unknown[] }> = [];
|
|
for (const op of ops) {
|
|
if (isRead(op.sql)) results.push({ rows: await db.query(op.sql, ...(op.args ?? [])) as unknown[] });
|
|
else { const r = await db.exec(op.sql, ...(op.args ?? [])) as { changes?: number }; results.push({ changes: r?.changes ?? 0 }); }
|
|
}
|
|
return { results };
|
|
},
|
|
};
|
|
const wrap = (base: typeof mock.ctx, actingUserId: number | undefined) => ({
|
|
...base,
|
|
id,
|
|
db: devDb,
|
|
ws: {
|
|
// Same target gates as production: a trip room only for a member acting user,
|
|
// a user push only to the acting user themself.
|
|
broadcastToTrip: async (t: number, event: string, data: unknown) => {
|
|
need('ws:broadcast:trip', 'ws.broadcastToTrip');
|
|
if (actingUserId === undefined) throw new Error('RESOURCE_FORBIDDEN: broadcasts require an authenticated user context');
|
|
const trip = fx.trips?.[t];
|
|
if (!trip || !trip.members.includes(actingUserId)) throw new Error(`RESOURCE_FORBIDDEN: no access to trip ${t}`);
|
|
broadcasts.push({ kind: 'trip', target: t, event, data });
|
|
},
|
|
broadcastToUser: async (u: number, event: string, data: unknown) => {
|
|
need('ws:broadcast:user', 'ws.broadcastToUser');
|
|
if (actingUserId === undefined || u !== actingUserId) throw new Error('RESOURCE_FORBIDDEN: a plugin may only broadcast to the acting user');
|
|
broadcasts.push({ kind: 'user', target: u, event, data });
|
|
},
|
|
},
|
|
log: {
|
|
info: (m: string) => console.log(` [plugin] ${m}`),
|
|
warn: (m: string) => console.warn(` [plugin] ${m}`),
|
|
error: (m: string) => console.error(` [plugin] ${m}`),
|
|
},
|
|
});
|
|
// Routes (and hooks) run user-bound; jobs, scheduled tasks, event deliveries and the
|
|
// GDPR handlers run with NO acting user — exactly the split production makes, so a
|
|
// membership read from a job fails here the same way it would in TREK.
|
|
return {
|
|
ctx: wrap(mock.ctx, fx.actingUserId ?? 1),
|
|
userlessCtx: wrap(mock.userlessCtx, undefined),
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Serve `require('trek-plugin-sdk')` from THIS package, exactly like the TREK
|
|
* child process does at runtime — so `dev` works on a fresh scaffold with no
|
|
* npm install, and what loads here is what loads in production. That parity is
|
|
* the point: the injected surface is the SAME minimal frozen shim the prod
|
|
* child serves (definePlugin + PLUGIN_API_VERSION), and subpaths fail with the
|
|
* same pointed error. validateManifest/createMockHost stay available where they
|
|
* belong — in your tests, which load the installed package without this patch.
|
|
*/
|
|
let sdkInjected = false;
|
|
export function installSdkInjection(): void {
|
|
if (sdkInjected) return;
|
|
const nodeModule = createRequire(import.meta.url)('node:module') as {
|
|
_load: (request: string, parent: unknown, isMain: boolean) => unknown;
|
|
};
|
|
const realLoad = nodeModule._load;
|
|
if (typeof realLoad !== 'function') return;
|
|
sdkInjected = true;
|
|
const shim = Object.freeze({ definePlugin: sdk.definePlugin, PLUGIN_API_VERSION: sdk.PLUGIN_API_VERSION });
|
|
nodeModule._load = function (request: string, parent: unknown, isMain: boolean): unknown {
|
|
if (request === 'trek-plugin-sdk') return shim;
|
|
if (request.startsWith('trek-plugin-sdk/')) {
|
|
throw new Error(`${request} is a build/test-time module — only 'trek-plugin-sdk' itself is injected inside TREK`);
|
|
}
|
|
return realLoad.call(this, request, parent, isMain);
|
|
};
|
|
}
|
|
|
|
function loadFixtures(dir: string): Fixtures {
|
|
const p = path.join(dir, 'dev-fixtures.json');
|
|
if (fs.existsSync(p)) { try { return readJsonFile<Fixtures>(p); } catch { console.warn('warning: dev-fixtures.json is not valid JSON — ignoring'); } }
|
|
return {};
|
|
}
|
|
|
|
/** Watch a directory tree with per-dir fs.watch (works on every platform, unlike recursive). */
|
|
function watchTree(root: string, onChange: () => void): void {
|
|
const skip = new Set(['node_modules', '.git', '.trek-dev']);
|
|
const watch = (d: string) => {
|
|
try {
|
|
fs.watch(d, (_e, f) => { if (!f || !skip.has(f)) onChange(); });
|
|
for (const e of fs.readdirSync(d, { withFileTypes: true })) if (e.isDirectory() && !skip.has(e.name)) watch(path.join(d, e.name));
|
|
} catch { /* dir vanished */ }
|
|
};
|
|
if (fs.existsSync(root)) watch(root);
|
|
}
|
|
|
|
export async function runDev(dir: string, opts: { port?: number } = {}): Promise<void> {
|
|
const abs = path.resolve(dir);
|
|
const manifestPath = path.join(abs, 'trek-plugin.json');
|
|
if (!fs.existsSync(manifestPath)) throw new Error(`no trek-plugin.json in ${abs}`);
|
|
const manifest = readJsonFile<Record<string, unknown>>(manifestPath);
|
|
const id = String(manifest.id);
|
|
const grants = new Set(Array.isArray(manifest.permissions) ? (manifest.permissions as string[]) : []);
|
|
const fx = loadFixtures(abs);
|
|
const { db, note: dbNote, close: closeDb } = createDevDb(path.join(abs, '.trek-dev', 'db.sqlite'));
|
|
const broadcasts: unknown[] = [];
|
|
installSdkInjection();
|
|
const req = createRequire(path.join(abs, 'server', 'index.js'));
|
|
|
|
let plugin: PluginLike = {};
|
|
let gaps: GrantGap[] = [];
|
|
let version = 0;
|
|
const { ctx, userlessCtx } = createDevContext(id, grants, fx, db, broadcasts);
|
|
const port = opts.port ?? 4317;
|
|
|
|
// The plugin's outbound network, restricted to the hosts it was granted — with none,
|
|
// everything is blocked, exactly as in TREK. `operatorEgressHosts` in dev-fixtures.json
|
|
// stands in for the hosts an admin supplies at runtime to an `operatorEgress` plugin,
|
|
// which by definition cannot name them in its manifest.
|
|
const egressHosts = [...grantedHosts(grants), ...(fx.operatorEgressHosts ?? [])];
|
|
installEgressGuard(egressHosts);
|
|
|
|
// Block a browser-initiated cross-site request to the side-effectful endpoints:
|
|
// modern browsers stamp Sec-Fetch-Site (anything but same-origin is another site;
|
|
// 'none' is the user's own address bar and stays allowed), and every browser sends
|
|
// Origin on cross-origin fetches — refuse an Origin that isn't this dev server.
|
|
// curl/devtools-on-the-page pass both checks.
|
|
const isCrossSite = (request: http.IncomingMessage): boolean => {
|
|
const site = request.headers['sec-fetch-site'];
|
|
if (typeof site === 'string' && site !== 'same-origin' && site !== 'none') return true;
|
|
const origin = request.headers['origin'];
|
|
if (typeof origin === 'string' && origin !== `http://localhost:${port}` && origin !== `http://127.0.0.1:${port}`) return true;
|
|
return false;
|
|
};
|
|
|
|
const load = async () => {
|
|
const entry = path.join(abs, 'server', 'index.js');
|
|
if (!fs.existsSync(entry)) { console.error(' server/index.js is missing — build your plugin'); plugin = {}; return; }
|
|
for (const k of Object.keys(req.cache)) if (k.startsWith(abs) && !k.includes('node_modules')) delete req.cache[k];
|
|
try {
|
|
const mod = req(entry) as PluginLike & { default?: PluginLike };
|
|
plugin = mod.default ?? mod;
|
|
await plugin.onLoad?.(ctx);
|
|
version++;
|
|
console.log(` ↻ loaded ${plugin.routes?.length ?? 0} route(s)`);
|
|
// Recomputed on every reload: adding a hook (or removing its permission) mid-session
|
|
// must change the warning, not leave a stale one from boot. The initial load's gaps
|
|
// are printed by the startup banner; a reload prints its own (see reload()).
|
|
gaps = grantGaps(plugin, grants);
|
|
} catch (e) {
|
|
// A plugin whose load/onLoad throws would fail activation in TREK — don't
|
|
// keep serving its routes here, or dev would hide exactly that failure.
|
|
plugin = {};
|
|
gaps = [];
|
|
console.error(' ✗ plugin failed to load:', e instanceof Error ? e.message : e);
|
|
}
|
|
};
|
|
await load();
|
|
|
|
let timer: NodeJS.Timeout | null = null;
|
|
const reload = () => {
|
|
if (timer) clearTimeout(timer);
|
|
timer = setTimeout(() => { console.log(' change detected'); void load().then(() => printGaps(gaps)); }, 120);
|
|
};
|
|
watchTree(path.join(abs, 'server'), reload);
|
|
watchTree(path.join(abs, 'client'), reload);
|
|
|
|
const server = http.createServer((request, res) => { void handle(request, res); });
|
|
|
|
async function handle(request: http.IncomingMessage, res: http.ServerResponse): Promise<void> {
|
|
const url = new URL(request.url || '/', 'http://localhost');
|
|
const send = (status: number, body: string | Buffer, type = 'text/plain; charset=utf-8', headers: Record<string, string> = {}) => {
|
|
res.writeHead(status, { 'content-type': type, ...headers }); res.end(body);
|
|
};
|
|
|
|
if (url.pathname === '/__dev/version') return send(200, String(version));
|
|
|
|
// Fire a NON-route entry point against the dev ctx — the half the dev server used to
|
|
// be unable to reach. GET /__dev/fire/<kind>[/<name>][/<fn>], e.g.
|
|
// /__dev/fire/job/refresh · /__dev/fire/scheduled/daily · /__dev/fire/event/place:created
|
|
// /__dev/fire/hook/tripCardProvider/getCards · /__dev/fire/deleteUserData
|
|
// Query params become the payload/args; a JSON body is used verbatim when present.
|
|
if (url.pathname.startsWith('/__dev/fire/')) {
|
|
// These endpoints are side-effectful (fire a job, wipe user data, …). Even bound to
|
|
// loopback, a drive-by <img src="http://127.0.0.1:PORT/__dev/fire/deleteUserData?…">
|
|
// from an open browser tab could hit them — block a cross-site request.
|
|
if (isCrossSite(request)) return send(403, 'cross-site request refused');
|
|
const [kind, name, fn] = url.pathname.slice('/__dev/fire/'.length).split('/');
|
|
const bodyRaw = await readBody(request); // already JSON-parsed when sent as application/json
|
|
let payload: unknown = bodyRaw;
|
|
if (typeof bodyRaw === 'string') { try { payload = JSON.parse(bodyRaw); } catch { payload = bodyRaw; } }
|
|
if (payload === undefined) { const q: Record<string, string> = {}; url.searchParams.forEach((v, k) => { q[k] = v; }); if (Object.keys(q).length) payload = q; }
|
|
try {
|
|
// TREK gates these entry points on a permission BEFORE the plugin is reached, and
|
|
// an ungranted one is never invoked — silently. Refuse it here, loudly, or dev
|
|
// would keep green-lighting a plugin that does nothing at all once installed.
|
|
needGrant(grants, kind === 'hook' ? HOOK_PERMISSION[name] : FIRE_PERMISSION[kind], `${kind}${name ? `/${name}` : ''}`);
|
|
// Jobs, scheduled tasks, events and the GDPR handlers run with NO acting user in
|
|
// production — fire them against the userless ctx so a membership read fails here
|
|
// exactly like it would in TREK. Hooks stay user-bound.
|
|
let out: unknown;
|
|
if (kind === 'job') { const j = plugin.jobs?.find((x) => x.id === name); if (!j) return send(404, `no job "${name}"`); out = await j.handler(userlessCtx); }
|
|
else if (kind === 'scheduled') { if (!plugin.scheduled) return send(404, 'plugin has no scheduled handler'); out = await plugin.scheduled({ name, payload }, userlessCtx); }
|
|
else if (kind === 'event') { for (const s of plugin.events ?? []) if (s.on === name || s.on === '*') await s.handler({ event: name, tripId: 0, ...(payload as object) }, userlessCtx); out = { delivered: true }; }
|
|
else if (kind === 'deleteUserData') { if (!plugin.deleteUserData) return send(404, 'no deleteUserData handler'); out = await plugin.deleteUserData({ userId: Number((payload as { userId?: unknown })?.userId ?? name ?? 0) }, userlessCtx); }
|
|
else if (kind === 'exportUserData') { if (!plugin.exportUserData) return send(404, 'no exportUserData handler'); out = await plugin.exportUserData({ userId: Number((payload as { userId?: unknown })?.userId ?? name ?? 0) }, userlessCtx); }
|
|
else if (kind === 'hook') {
|
|
const impl = plugin.hooks?.[name];
|
|
if (!impl || typeof impl[fn] !== 'function') return send(404, `no hook ${name}.${fn}`);
|
|
if (name === 'notificationChannel') {
|
|
// A notification channel is NOT like the other hooks: the host fires it with no
|
|
// acting user (it delivers to an arbitrary recipient), and hands the recipient's
|
|
// decrypted settings in as a separate `config` argument —
|
|
// send(msg, config, ctx) · test(config, ctx)
|
|
// Firing it like a normal hook passed `ctx` where `config` belongs, so a channel
|
|
// plugin read its settings off the ctx object and "worked" in dev while being
|
|
// broken in production. Mirror the host (and createMockHost) exactly.
|
|
const config = (fx.userSettings ?? {}) as Record<string, unknown>;
|
|
const args = Array.isArray(payload) ? (payload as unknown[]) : payload != null ? [payload] : [];
|
|
out = fn === 'test'
|
|
? await impl[fn](config, userlessCtx)
|
|
: await impl[fn](args[0], (args[1] as Record<string, unknown>) ?? config, userlessCtx);
|
|
} else {
|
|
out = await impl[fn](...(Array.isArray(payload) ? payload : payload != null ? [payload] : []), ctx);
|
|
}
|
|
}
|
|
else return send(400, `unknown fire kind "${kind}"`);
|
|
return send(200, JSON.stringify(out ?? { ok: true }, null, 2), 'application/json; charset=utf-8');
|
|
} catch (e) {
|
|
const denied = e instanceof PermissionDenied;
|
|
return send(denied ? 403 : 500, e instanceof Error ? e.message : String(e));
|
|
}
|
|
}
|
|
|
|
if (url.pathname === '/') return send(200, dashboard(id, String(manifest.type), plugin.routes ?? [], dbNote, broadcasts.length, gaps), 'text/html; charset=utf-8');
|
|
|
|
// Faithful host preview: embeds /ui in a sandboxed opaque-origin iframe (exactly
|
|
// like TREK) and plays the host — posts trek:context (with a theme/accent toggle),
|
|
// proxies trek:invoke to your /api routes, and honours resize/notify/navigate. This
|
|
// is where the design kit actually renders themed.
|
|
if (url.pathname === '/preview' && String(manifest.type) !== 'integration') {
|
|
// Preview against a trip that actually EXISTS in the fixtures. This used to be
|
|
// hard-coded to 42 while the scaffold seeds trip 1, so the widget's first
|
|
// trek:invoke hit assertMember(42, …) and 500'd with RESOURCE_FORBIDDEN.
|
|
const previewTripId = Number(Object.keys(fx.trips ?? {})[0] ?? 1) || 1;
|
|
// ?chrome=0 strips the dev toolbar and centres the plugin — what `trek-plugin shot`
|
|
// renders into docs/screenshot.png. ?theme=dark picks the dark palette.
|
|
const chrome = url.searchParams.get('chrome') !== '0';
|
|
const theme = url.searchParams.get('theme') === 'dark' ? 'dark' : 'light';
|
|
return send(200, preview(id, String(manifest.type), previewTripId, { chrome, theme }), 'text/html; charset=utf-8');
|
|
}
|
|
|
|
// Static plugin UI at /ui (page/widget client bundle). The iframe loads
|
|
// `/ui/index.html` (not bare `/ui`) so relative asset URLs — `./assets/x.js`
|
|
// from a multi-file Vite/React build with `base: './'` — resolve to
|
|
// `/ui/assets/x.js`, exactly as the real host resolves them against
|
|
// `/plugin-frame/<id>/index.html`. A bare `/ui` document would resolve them
|
|
// against the origin root (`/assets/x.js`) and 404. Bare `/ui` and `/ui/` both
|
|
// still serve index.html for direct navigation.
|
|
if (url.pathname === '/ui' || url.pathname.startsWith('/ui/')) {
|
|
const relFile = url.pathname === '/ui' ? 'index.html' : (url.pathname.slice('/ui/'.length) || 'index.html');
|
|
const file = path.join(abs, 'client', relFile);
|
|
if (!file.startsWith(path.join(abs, 'client'))) return send(403, 'forbidden');
|
|
if (!fs.existsSync(file) || !fs.statSync(file).isFile()) return send(404, 'not found — build your client/ bundle');
|
|
const raw = fs.readFileSync(file);
|
|
const type = contentType(file);
|
|
// Only the HTML doc is rewritten (expand the `<!-- trek:ui -->` marker into the
|
|
// design kit, then inject live-reload) as a string; every other asset is sent as
|
|
// the raw Buffer — a UTF-8 round-trip corrupts binary files.
|
|
const body: string | Buffer = type.startsWith('text/html')
|
|
? injectTrekUi(String(raw)).replace('</body>', `${LIVE_RELOAD}</body>`)
|
|
: raw;
|
|
return send(200, body, type);
|
|
}
|
|
|
|
// Plugin API routes mounted under /api/<route path>
|
|
if (url.pathname.startsWith('/api/')) {
|
|
// Same drive-by guard as /__dev/fire: routes are side-effectful against the real
|
|
// dev db with no auth — an off-site page must not reach them on 127.0.0.1.
|
|
if (isCrossSite(request)) return send(403, 'cross-site request refused');
|
|
const pluginPath = url.pathname.slice('/api'.length); // "/api/hello" -> "/hello"
|
|
const route = (plugin.routes ?? []).find((r) => r.method.toUpperCase() === request.method && r.path === pluginPath);
|
|
if (!route) return send(404, `no ${request.method} route ${pluginPath}`);
|
|
const anon = url.searchParams.get('_anon') === '1';
|
|
if (route.auth !== false && anon) return send(401, 'this route requires auth (drop ?_anon=1 to send the dev user)');
|
|
// The dev user matches the ctx's acting user and is a regular account — the prod
|
|
// proxy never hands a plugin an admin session it didn't authenticate.
|
|
const user = anon ? null : { id: fx.actingUserId ?? 1, username: 'dev', isAdmin: false };
|
|
const body = await readBody(request);
|
|
const query: Record<string, unknown> = {}; url.searchParams.forEach((v, k) => { if (k !== '_anon') query[k] = v; });
|
|
// headers is always present on a PluginRequest; only auth:false routes (webhooks)
|
|
// receive the allowlisted, credential-free inbound subset — like the prod proxy.
|
|
const headers = route.auth === false ? pickInboundHeaders(request.headers) : {};
|
|
try {
|
|
const r = await route.handler({ method: route.method, path: route.path, query, body, headers, user }, ctx);
|
|
const out = typeof r.body === 'string' || Buffer.isBuffer(r.body) ? String(r.body) : JSON.stringify(r.body ?? null);
|
|
return send(r.status ?? 200, out, r.headers?.['content-type'] || 'application/json; charset=utf-8', r.headers ?? {});
|
|
} catch (e) {
|
|
const denied = e instanceof PermissionDenied;
|
|
return send(denied ? 403 : 500, (e instanceof Error ? e.message : String(e)));
|
|
}
|
|
}
|
|
return send(404, 'not found');
|
|
}
|
|
|
|
await new Promise<void>((resolve, reject) => {
|
|
server.once('error', (e: NodeJS.ErrnoException) =>
|
|
reject(e.code === 'EADDRINUSE' ? new Error(`port ${port} is already in use — pass --port <n> to pick another`) : e));
|
|
// Bind loopback only: the dev server serves the plugin's routes AND the side-effectful
|
|
// /__dev/fire endpoints against a real db with no auth — never expose that to the LAN.
|
|
server.listen(port, '127.0.0.1', resolve);
|
|
});
|
|
const routes = plugin.routes ?? [];
|
|
console.log(`\n trek-plugin dev — ${id} (${String(manifest.type)})`);
|
|
console.log(` ${dbNote}`);
|
|
console.log(` granted: ${[...grants].join(', ') || '(none)'}`);
|
|
console.log(` egress: ${egressHosts.join(', ') || '(none — all outbound is blocked, as in TREK)'}`);
|
|
printGaps(gaps);
|
|
console.log(`\n ▸ http://localhost:${port}/ dashboard`);
|
|
if (String(manifest.type) !== 'integration') {
|
|
console.log(` ▸ http://localhost:${port}/preview themed host preview`);
|
|
console.log(` ▸ http://localhost:${port}/ui your plugin UI (raw)`);
|
|
}
|
|
for (const r of routes) console.log(` ▸ ${r.method.padEnd(4)} http://localhost:${port}/api${r.path}${r.auth === false ? ' (public)' : ''}`);
|
|
console.log('\n editing server/ or client/ hot-reloads. Ctrl+C to stop.\n');
|
|
|
|
const shutdown = () => { closeDb(); server.close(); process.exit(0); };
|
|
process.on('SIGINT', shutdown);
|
|
process.on('SIGTERM', shutdown);
|
|
}
|
|
|
|
function readBody(req: http.IncomingMessage): Promise<unknown> {
|
|
return new Promise((resolve) => {
|
|
const chunks: Buffer[] = [];
|
|
req.on('data', (c) => chunks.push(c));
|
|
req.on('end', () => {
|
|
const raw = Buffer.concat(chunks).toString('utf8');
|
|
if (!raw) return resolve(undefined);
|
|
if ((req.headers['content-type'] || '').includes('application/json')) { try { return resolve(JSON.parse(raw)); } catch { /* raw */ } }
|
|
resolve(raw);
|
|
});
|
|
req.on('error', () => resolve(undefined));
|
|
});
|
|
}
|
|
|
|
function contentType(file: string): string {
|
|
const ext = path.extname(file).toLowerCase();
|
|
return ({ '.html': 'text/html; charset=utf-8', '.js': 'text/javascript; charset=utf-8', '.css': 'text/css; charset=utf-8', '.json': 'application/json; charset=utf-8', '.svg': 'image/svg+xml', '.png': 'image/png', '.jpg': 'image/jpeg' } as Record<string, string>)[ext] || 'application/octet-stream';
|
|
}
|
|
|
|
const LIVE_RELOAD = `<script>let __v;setInterval(async()=>{try{const v=await(await fetch('/__dev/version')).text();if(__v&&v!==__v)location.reload();__v=v;}catch(e){}},1000)</script>`;
|
|
|
|
function dashboard(id: string, type: string, routes: PluginRouteLike[], dbNote: string, bcasts: number, gaps: GrantGap[]): string {
|
|
const rows = routes.length
|
|
? routes.map((r) => `<tr><td><code>${r.method}</code></td><td><a href="/api${r.path}">/api${r.path}</a></td><td>${r.auth === false ? 'public' : 'auth'}</td></tr>`).join('')
|
|
: '<tr><td colspan="3" style="opacity:.6">no routes declared</td></tr>';
|
|
const ui = type !== 'integration'
|
|
? `<p><a href="/preview">▸ open the themed host preview (/preview)</a> · <a href="/ui">raw /ui</a></p>`
|
|
: '';
|
|
// The silent killer, made loud: in TREK these entry points are simply never invoked,
|
|
// with nothing logged anywhere. If any exist, they are the most important thing here.
|
|
const warn = gaps.length
|
|
? `<div class="warn"><strong>⚠ ${gaps.length} entry point(s) TREK would never run</strong><ul>${gaps
|
|
.map((g) => `<li><code>${g.entryPoint}</code> needs <code>${g.permission}</code> — ${g.consequence}</li>`)
|
|
.join('')}</ul><p>Add the permission(s) to <code>trek-plugin.json</code>. Until then this works in dev and does nothing once installed.</p></div>`
|
|
: '';
|
|
return `<!doctype html><meta charset="utf-8"><title>${id} · trek-plugin dev</title>
|
|
<style>body{font:15px/1.5 system-ui,sans-serif;max-width:720px;margin:3rem auto;padding:0 1rem;color:#111}
|
|
h1{font-size:1.4rem}code{background:#f3f4f6;padding:.1em .35em;border-radius:4px}
|
|
table{border-collapse:collapse;width:100%;margin:1rem 0}td{border-bottom:1px solid #eee;padding:.5rem .4rem;text-align:left}
|
|
.muted{color:#6b7280;font-size:.9rem}a{color:#4f46e5}
|
|
.warn{background:#fef2f2;border:1px solid #fecaca;border-radius:8px;padding:.75rem 1rem;margin:1rem 0}
|
|
.warn ul{margin:.5rem 0;padding-left:1.2rem}.warn p{margin:.5rem 0 0;font-size:.9rem}
|
|
@media(prefers-color-scheme:dark){body{background:#111827;color:#e5e7eb}code{background:#1f2937}td{border-color:#374151}
|
|
.warn{background:#3f1d1d;border-color:#7f1d1d}}</style>
|
|
<h1>${id} <span class="muted">· ${type}</span></h1>
|
|
<p class="muted">${dbNote} · ${bcasts} ws broadcast(s) captured</p>
|
|
${warn}
|
|
${ui}
|
|
<table><tr><th>Method</th><th>URL</th><th>Auth</th></tr>${rows}</table>
|
|
<p class="muted">Add <code>?_anon=1</code> to a route to hit it as an unauthenticated request. Edit <code>server/</code> or <code>client/</code> and this reloads.</p>
|
|
${LIVE_RELOAD}`;
|
|
}
|
|
|
|
/**
|
|
* A faithful host preview for a page/widget plugin. Loads /ui in a sandboxed,
|
|
* opaque-origin iframe (no allow-same-origin — exactly TREK's isolation) and plays
|
|
* the host over postMessage: it sends trek:context (with a theme/accent/appearance
|
|
* toggle), proxies trek:invoke to the dev server's /api routes with the dev user,
|
|
* and surfaces resize/notify/navigate. This is where the design kit renders themed.
|
|
*/
|
|
function preview(id: string, type: string, tripId: number, opts: { chrome?: boolean; theme?: 'light' | 'dark' } = {}): string {
|
|
const maxW = type === 'widget' ? '440px' : '1000px';
|
|
// `trek-plugin shot` renders this page into docs/screenshot.png — the image the plugin store
|
|
// shows. The theme picker and the "runs sandboxed at an opaque origin" note are DEV furniture:
|
|
// useful while you build, absurd on a store card. So shot mode strips them and centres the
|
|
// plugin in the frame, and the shot is of the plugin rather than of the tool that drew it.
|
|
const chrome = opts.chrome !== false;
|
|
const dark = opts.theme === 'dark';
|
|
return `<!doctype html><html lang="en"><head><meta charset="utf-8">
|
|
<meta name="viewport" content="width=device-width,initial-scale=1"><title>${id} · preview</title>
|
|
<style>
|
|
*{box-sizing:border-box}
|
|
${chrome ? '' : 'header,.hint{display:none!important}.stage{min-height:100vh;align-items:center;padding:0 20px}'}
|
|
body{margin:0;font:14px/1.5 system-ui,-apple-system,sans-serif;background:#f4f5f7;color:#111827}
|
|
body.dark{background:#0e0e11;color:#e5e7eb}
|
|
header{display:flex;gap:14px;align-items:center;flex-wrap:wrap;padding:12px 16px;border-bottom:1px solid #e5e7eb;position:sticky;top:0;background:inherit;z-index:2}
|
|
body.dark header{border-color:#27272a}
|
|
header strong{font-weight:600}.sp{flex:1}
|
|
label{display:inline-flex;align-items:center;gap:6px;font-size:12.5px;opacity:.85}
|
|
select,button{font:inherit;font-size:13px;padding:5px 10px;border-radius:8px;border:1px solid #d1d5db;background:#fff;color:inherit;cursor:pointer}
|
|
body.dark select,body.dark button{background:#1c1c21;border-color:#3f3f46;color:#e5e7eb}
|
|
.stage{padding:28px 20px;display:flex;justify-content:center}
|
|
.wrap{width:100%;max-width:${maxW}}
|
|
iframe{width:100%;border:0;background:transparent;min-height:120px;display:block}
|
|
.hint{font-size:12px;opacity:.55;text-align:center;padding:0 16px 20px}
|
|
.toast{position:fixed;bottom:18px;left:50%;transform:translateX(-50%);background:#111827;color:#fff;padding:9px 16px;border-radius:10px;font-size:13px;opacity:0;transition:opacity .2s;pointer-events:none;z-index:3}
|
|
.toast.on{opacity:.96}
|
|
</style></head>
|
|
<body>
|
|
<header>
|
|
<strong>${id}</strong><span style="opacity:.5">· ${type} preview</span>
|
|
<span class="sp"></span>
|
|
<label>Theme <select id="theme"><option value="light"${dark ? '' : ' selected'}>light</option><option value="dark"${dark ? ' selected' : ''}>dark</option></select></label>
|
|
<label>Accent <select id="accent"><option value="default">default</option><option value="indigo">indigo</option><option value="teal">teal</option><option value="rose">rose</option></select></label>
|
|
<label><input type="checkbox" id="rm"> reduce motion</label>
|
|
<label><input type="checkbox" id="nt"> no transparency</label>
|
|
<label><input type="checkbox" id="blur"> blur booking codes</label>
|
|
<label><input type="checkbox" id="trip" checked> trip context</label>
|
|
</header>
|
|
<div class="stage"><div class="wrap"><iframe id="f" src="/ui/index.html" sandbox="allow-scripts allow-forms" referrerpolicy="no-referrer" title="${id}"></iframe></div></div>
|
|
<p class="hint">The frame runs sandboxed at an opaque origin, exactly like TREK — <code>trek.invoke()</code> is proxied to your /api routes as the dev user.</p>
|
|
<div class="toast" id="toast"></div>
|
|
<script>
|
|
var f=document.getElementById('f');
|
|
var LA={"default":{"--accent":"#111827","--accent-text":"#fff","--accent-hover":"#1f2937","--accent-subtle":"#f1f5f9"},
|
|
indigo:{"--accent":"#4f46e5","--accent-text":"#fff","--accent-hover":"#4338ca","--accent-subtle":"#eef2ff"},
|
|
teal:{"--accent":"#0d9488","--accent-text":"#fff","--accent-hover":"#0f766e","--accent-subtle":"#f0fdfa"},
|
|
rose:{"--accent":"#e11d48","--accent-text":"#fff","--accent-hover":"#be123c","--accent-subtle":"#fff1f2"}};
|
|
var DA={"default":{"--accent":"#e4e4e7","--accent-text":"#09090b","--accent-hover":"#d4d4d8","--accent-subtle":"rgba(255,255,255,.08)"},
|
|
indigo:{"--accent":"#818cf8","--accent-text":"#09090b","--accent-hover":"#a5b4fc","--accent-subtle":"rgba(129,140,248,.18)"},
|
|
teal:{"--accent":"#2dd4bf","--accent-text":"#09090b","--accent-hover":"#5eead4","--accent-subtle":"rgba(45,212,191,.18)"},
|
|
rose:{"--accent":"#fb7185","--accent-text":"#09090b","--accent-hover":"#fda4af","--accent-subtle":"rgba(251,113,133,.18)"}};
|
|
function val(id){return document.getElementById(id);}
|
|
function ctx(){
|
|
var theme=val("theme").value, accent=val("accent").value;
|
|
document.body.classList.toggle("dark", theme==="dark");
|
|
var tokens={}; var src=theme==="dark"?DA[accent]:LA[accent]; for(var k in src){tokens[k]=src[k];}
|
|
return {type:"trek:context",theme:theme,locale:"en",hostOrigin:location.origin,
|
|
tripId: val("trip").checked?${tripId}:null, userId:"1",
|
|
user:{name:"Dev User",avatar:null,isAdmin:true},
|
|
appearance:{scheme:accent,density:"comfortable",reducedMotion:val("rm").checked,noTransparency:val("nt").checked},
|
|
formats:{locale:"en",currency:"EUR",timeFormat:"24h",distanceUnit:"metric",temperatureUnit:"celsius",timezone:Intl.DateTimeFormat().resolvedOptions().timeZone,blurBookingCodes:val("blur").checked},
|
|
tokens:tokens};
|
|
}
|
|
function postCtx(){ if(f.contentWindow) f.contentWindow.postMessage(ctx(),"*"); }
|
|
var tt; function toast(msg){var el=document.getElementById("toast");el.textContent=msg;el.classList.add("on");clearTimeout(tt);tt=setTimeout(function(){el.classList.remove("on");},2200);}
|
|
var SESSION_PREFIX="trek:plugin-session:1:"+encodeURIComponent(${JSON.stringify(id)})+":";
|
|
var SESSION_MAX_KEYS=32,SESSION_MAX_KEY_LENGTH=64,SESSION_MAX_VALUE_BYTES=1024;
|
|
function sessionFail(m,code,message){f.contentWindow.postMessage({type:"trek:error",requestId:m.requestId,code:code,message:message},"*");}
|
|
function sessionMessage(m){
|
|
try {
|
|
var trip=ctx().tripId, scope=m.scope==="trip"?"trip":"plugin";
|
|
if(scope==="trip"&&!trip){f.contentWindow.postMessage({type:"trek:error",requestId:m.requestId,code:"NO_TRIP_CONTEXT",message:"trip session storage requires a trip context"},"*");return;}
|
|
var prefix=SESSION_PREFIX+scope+(scope==="trip"?":"+encodeURIComponent(String(trip)):"")+":";
|
|
var data;
|
|
if(m.type==="trek:session:clear"){
|
|
var doomed=[];for(var i=0;i<sessionStorage.length;i++){var stored=sessionStorage.key(i);if(stored&&stored.indexOf(prefix)===0)doomed.push(stored);}doomed.forEach(function(stored){sessionStorage.removeItem(stored);});
|
|
} else {
|
|
if(typeof m.key!=="string"||!m.key||m.key.length>SESSION_MAX_KEY_LENGTH){sessionFail(m,"SESSION_INVALID_KEY","session key must be 1-"+SESSION_MAX_KEY_LENGTH+" characters");return;}
|
|
var key=prefix+encodeURIComponent(m.key);
|
|
if(m.type==="trek:session:get"){var raw=sessionStorage.getItem(key);data=raw===null?undefined:JSON.parse(raw);}
|
|
else if(m.type==="trek:session:remove"){sessionStorage.removeItem(key);}
|
|
else {
|
|
var json=JSON.stringify(m.value);
|
|
if(json===undefined){sessionFail(m,"SESSION_INVALID_VALUE","session value must be JSON-serialisable");return;}
|
|
var bytes=new TextEncoder().encode(json).length;
|
|
if(bytes>SESSION_MAX_VALUE_BYTES){sessionFail(m,"SESSION_VALUE_TOO_LARGE","session value exceeds "+SESSION_MAX_VALUE_BYTES+" bytes");return;}
|
|
var count=0,exists=false;
|
|
for(var j=0;j<sessionStorage.length;j++){var sk=sessionStorage.key(j);if(sk&&sk.indexOf(prefix)===0){count++;if(sk===key)exists=true;}}
|
|
if(!exists&&count>=SESSION_MAX_KEYS){sessionFail(m,"SESSION_KEY_LIMIT","plugin session storage allows at most "+SESSION_MAX_KEYS+" keys");return;}
|
|
sessionStorage.setItem(key,json);
|
|
}
|
|
}
|
|
f.contentWindow.postMessage({type:"trek:response",requestId:m.requestId,data:data},"*");
|
|
} catch(e) { sessionFail(m,"SESSION_STORAGE_ERROR",String(e&&e.message||e)); }
|
|
}
|
|
window.addEventListener("message", function(ev){
|
|
if(ev.source!==f.contentWindow) return;
|
|
var m=ev.data; if(!m||typeof m!=="object") return;
|
|
if(m.type==="trek:ready"||m.type==="trek:context:request"){ postCtx(); }
|
|
else if(m.type==="trek:resize"){ if(m.height>0) f.style.height=Math.min(m.height,2000)+"px"; }
|
|
else if(m.type==="trek:notify"){ toast((m.level?("["+m.level+"] "):"")+(m.message||"")); }
|
|
else if(m.type==="trek:navigate"){ toast("navigate \\u2192 "+m.to); }
|
|
else if(m.type==="trek:openExternal"){ toast("openExternal \\u2192 "+m.url); try{ var u=new URL(String(m.url||"")); if(u.protocol==="https:"||u.protocol==="http:") window.open(u.href,"_blank","noopener,noreferrer"); }catch(e){} }
|
|
else if(m.type==="trek:confirm"){
|
|
// Mirror the host contract so trek.confirm() resolves in the preview too.
|
|
var ok=window.confirm((m.title?m.title+"\\n\\n":"")+(m.message||""));
|
|
f.contentWindow.postMessage({type:"trek:confirm:result",requestId:m.requestId,confirmed:ok},"*");
|
|
}
|
|
else if(m.type==="trek:invoke"){
|
|
fetch("/api"+m.sub,{method:m.method||"GET",headers:{"content-type":"application/json"},body:m.body!=null?JSON.stringify(m.body):undefined})
|
|
.then(function(r){var ct=r.headers.get("content-type")||"";return (ct.indexOf("json")>=0?r.json():r.text());})
|
|
.then(function(data){ f.contentWindow.postMessage({type:"trek:response",requestId:m.requestId,data:data},"*"); })
|
|
.catch(function(e){ f.contentWindow.postMessage({type:"trek:error",requestId:m.requestId,code:"error",message:String(e&&e.message||e)},"*"); });
|
|
}
|
|
else if(m.type==="trek:geolocation"){
|
|
// Mirror the host's geolocation bridge with a fixed dev position, so
|
|
// trek.geolocation.* works in the preview without a permission prompt.
|
|
var pos={lat:52.5163,lng:13.3777,accuracy:12,heading:null,speed:null,timestamp:Date.now()};
|
|
var act=m.action||"get";
|
|
if(act==="clear"){ clearInterval(geoTick); geoTick=null; f.contentWindow.postMessage({type:"trek:geolocation:result",requestId:m.requestId,cleared:true},"*"); }
|
|
else if(act==="watch"){
|
|
f.contentWindow.postMessage({type:"trek:geolocation:result",requestId:m.requestId,watching:true},"*");
|
|
clearInterval(geoTick);
|
|
geoTick=setInterval(function(){ pos=Object.assign({},pos,{lng:pos.lng+0.0002,timestamp:Date.now()}); f.contentWindow.postMessage({type:"trek:geolocation:update",position:pos},"*"); },2000);
|
|
f.contentWindow.postMessage({type:"trek:geolocation:update",position:pos},"*");
|
|
}
|
|
else { f.contentWindow.postMessage({type:"trek:geolocation:result",requestId:m.requestId,position:pos},"*"); }
|
|
}
|
|
else if(m.type==="trek:session:get"||m.type==="trek:session:set"||m.type==="trek:session:remove"||m.type==="trek:session:clear"){
|
|
sessionMessage(m);
|
|
}
|
|
});
|
|
var geoTick=null;
|
|
["theme","accent","rm","nt","blur","trip"].forEach(function(id){ val(id).addEventListener("change",postCtx); });
|
|
f.addEventListener("load", function(){ f.style.height="120px"; postCtx(); });
|
|
var __v; setInterval(function(){ fetch("/__dev/version").then(function(r){return r.text();}).then(function(v){ if(__v&&v!==__v){ f.src=f.src; } __v=v; }).catch(function(){}); },1000);
|
|
</script>
|
|
</body></html>`;
|
|
}
|