From 2ca16caf7ce689af306ebdd8edc15c4780cf2122 Mon Sep 17 00:00:00 2001 From: jubnl Date: Sat, 11 Jul 2026 20:25:55 +0200 Subject: [PATCH] feat(sdk): add missing methods in mock-host --- plugin-sdk/src/index.ts | 2 +- plugin-sdk/src/mock-host.ts | 102 ++++++++++++++++++++++++++++++++++-- plugin-sdk/test/sdk.test.ts | 98 ++++++++++++++++++++++++++++++++++ wiki/Plugin-Development.md | 30 +++++++++++ 4 files changed, 227 insertions(+), 5 deletions(-) diff --git a/plugin-sdk/src/index.ts b/plugin-sdk/src/index.ts index ac3f88e43..db1dbb951 100644 --- a/plugin-sdk/src/index.ts +++ b/plugin-sdk/src/index.ts @@ -614,7 +614,7 @@ export function definePlugin(def: PluginDefinition): PluginDefinition { return def; } -export { validateManifest, type PluginManifest, type ValidationResult } from './manifest.js'; +export { validateManifest, CHANNEL_EVENTS, type PluginManifest, type ValidationResult } from './manifest.js'; export { createMockHost, type MockHostOptions } from './mock-host.js'; // The design kit for page/widget UIs: inline these into your client/index.html // (or drop a `` marker and let `dev`/`pack` expand it) to get the diff --git a/plugin-sdk/src/mock-host.ts b/plugin-sdk/src/mock-host.ts index 9fa0722fa..739f61bc2 100644 --- a/plugin-sdk/src/mock-host.ts +++ b/plugin-sdk/src/mock-host.ts @@ -1,4 +1,5 @@ -import type { PluginContext, PluginDefinition, PluginRequest, PluginResponse, Trip, Place, Day, Reservation, PackingItem, TripFile, BudgetItem, User } from './index.js'; +import type { PluginContext, PluginDefinition, PluginRequest, PluginResponse, Trip, Place, Day, Reservation, PackingItem, TripFile, BudgetItem, User, NotificationMessage, PluginActionResult } from './index.js'; +import { CHANNEL_EVENTS } from './manifest.js'; /** * A mock PluginContext for unit-testing a plugin without a running TREK @@ -61,7 +62,17 @@ export interface MockHostOptions { * refuses it (and the SDK client swallows the rejection, so subscribers silently * never see it). Unset = record every emit, as before. */ declaredEmits?: string[]; - /** The acting user's per-user settings values for ctx.settings.get (unset key → undefined). */ + /** Action keys this plugin declares in its manifest `actions`. When set, driving an + * undeclared key throws — production refuses it before the child is ever woken. + * Unset = any key the plugin implements can be driven. */ + declaredActions?: string[]; + /** The events this plugin's notification channel accepts, i.e. the manifest's + * `capabilities.notificationChannel.events`. Defaults to the full CHANNEL_EVENTS set. + * A manifest can only NARROW that set, never widen it — so can this. */ + channelEvents?: string[]; + /** The acting user's per-user settings values for ctx.settings.get (unset key → undefined). + * Doubles as the default `config` handed to the notificationChannel hook. Keys must be + * ones the host would accept at install — `__proto__` & co are rejected here too. */ userSettings?: Record; /** The acting user's own (non-trip) data: tags, journals, collections, atlas, vacay. * `journals` also gates journal entry access — an unknown journey id is refused. */ @@ -107,6 +118,24 @@ export interface PluginDriver { exportUserData(userId: number): Promise; /** Invoke a provider hook, e.g. hook('tripCardProvider', 'getCards', [1, 2]). */ hook(name: string, fn: string, ...args: unknown[]): Promise; + /** Click one of the plugin's settings-page buttons ("Test connection"). USER-INITIATED, + * so the handler gets the acting-user ctx — ctx.settings.get() returns the host's + * `userSettings`. Returns the normalized result the user would actually see: a handler + * that returns nothing is `{ ok: true }`, and one that THROWS is `{ ok: false, message }` + * (the documented contract), never a rejected promise. */ + action(key: string): Promise<{ ok: boolean; message?: string }>; + /** Drive the `notificationChannel` hook the way the HOST does, which is the one thing a + * plain `hook()` call cannot express: USERLESS (a notification is host-initiated for an + * arbitrary recipient, so ctx.settings.get() returns undefined and trip reads are + * refused) with the recipient's decrypted settings handed in as `config` instead. That + * asymmetry is the whole security property of a channel plugin — test against it. */ + channel: { + /** `config` defaults to the host's `userSettings` fixture. An event the host would + * never dispatch to a plugin channel (an admin-scoped one, or one your manifest's + * `capabilities.notificationChannel.events` excludes) is refused rather than delivered. */ + send(msg: NotificationMessage, config?: Record): Promise; + test(config?: Record): Promise; + }; } export interface MockHost { @@ -164,6 +193,27 @@ function stripEmoji(s: string): string { return stripped.replace(/[^\S\r\n]{2,}/g, ' ').replace(/ +$/gm, '').trim(); } +// The host's settings-key rules (install/manifest.ts). A field the host would refuse to +// install must not be reachable in a test either: a key like `__proto__` or `constructor` +// used to resolve off Object.prototype and report as CONFIGURED for every user who had +// configured nothing — for a channel, enough to be dispatched to everyone with no +// credentials. Fixtures are key-checked and every config blob handed to a plugin is +// null-prototype, so the mock cannot reproduce the shape of that bug. +const SETTING_KEY_RE = /^[a-zA-Z][a-zA-Z0-9_.-]{0,63}$/; +const RESERVED_SETTING_KEYS = new Set(['constructor', 'prototype', '__proto__']); + +/** Copy onto a null-prototype object, refusing any key the host would not have installed. */ +function settingsBlob(src: Record | undefined, what: string): Record { + const out: Record = Object.create(null); + for (const [k, v] of Object.entries(src ?? {})) { + if (!SETTING_KEY_RE.test(k) || RESERVED_SETTING_KEYS.has(k)) { + throw new Error(`${what}: the host would reject the settings key "${k}"`); + } + out[k] = v; + } + return out; +} + // The REST string caps the host mirrors on the plugin write path (rpc-host.ts). const PLACE_STR_LIMITS: Record = { name: 200, description: 2000, address: 500, notes: 2000 }; const TRIP_STR_LIMITS: Record = { title: 200, description: 2000 }; @@ -175,6 +225,9 @@ export function createMockHost(opts: MockHostOptions = {}): MockHost { const broadcasts: MockHost['broadcasts'] = []; const emitted: MockHost['emitted'] = []; const notifications: MockHost['notifications'] = []; + // Validated once, up front: a fixture the host could never have installed should fail + // the test at construction, not silently at the first get(). + const userSettings = settingsBlob(opts.userSettings, 'userSettings'); const need = (perm: string, method: string) => { calls.push({ method, args: [] }); @@ -270,7 +323,10 @@ export function createMockHost(opts: MockHostOptions = {}): MockHost { async get(key) { calls.push({ method: 'settings.get', args: [key] }); if (actingUserId === undefined) return undefined; - return opts.userSettings?.[key]; + // A key the host would never have installed can hold no value — and, crucially, + // resolves to nothing rather than to something off Object.prototype. + if (!SETTING_KEY_RE.test(key) || RESERVED_SETTING_KEYS.has(key)) return undefined; + return userSettings[key]; }, }, db: { @@ -1356,7 +1412,45 @@ export function createMockHost(opts: MockHostOptions = {}): MockHost { const hooks = def.hooks as Record unknown> | undefined> | undefined; const impl = hooks?.[name]; if (!impl || typeof impl[fn] !== 'function') throw new Error(`no hook ${name}.${fn}`); - return impl[fn](...args, ctx) as never; + // Every provider hook is user-initiated and gets the acting-user ctx — except + // notificationChannel, which the host fires for an arbitrary recipient with no + // acting user at all. Handing it `ctx` here would let a channel plugin pass a test + // that reads ctx.settings.get() and then deliver to nobody in production. + return impl[fn](...args, name === 'notificationChannel' ? userlessCtx : ctx) as never; + }, + action: async (key) => { + if (opts.declaredActions && !opts.declaredActions.includes(key)) { + throw new Error(`RESOURCE_FORBIDDEN: plugin did not declare action "${key}"`); + } + const fn = def.actions?.[key]; + if (typeof fn !== 'function') throw new Error(`no action "${key}"`); + // Same shaping as the host: a plugin-supplied message is emoji-stripped and bounded + // before a user ever sees it, void means success, and a throw is a FAILED action + // ("your credentials don't work") rather than a fault to propagate. + const cap = (v: unknown) => stripEmoji(String(v)).slice(0, 200); + let raw: PluginActionResult | void; + try { + raw = await fn(ctx); + } catch (e) { + return { ok: false, message: cap(e instanceof Error ? e.message : 'Action failed') }; + } + return { ok: raw?.ok !== false, message: raw?.message === undefined ? undefined : cap(raw.message) }; + }, + channel: { + send: async (msg, config) => { + const impl = def.hooks?.notificationChannel; + if (!impl) throw new Error('plugin has no notificationChannel hook'); + const allowed = opts.channelEvents ?? CHANNEL_EVENTS; + if (!allowed.includes(msg.event)) { + throw new Error(`the host never dispatches "${msg.event}" to a plugin channel`); + } + await impl.send(msg, settingsBlob(config ?? opts.userSettings, 'channel config'), userlessCtx); + }, + test: async (config) => { + const impl = def.hooks?.notificationChannel; + if (typeof impl?.test !== 'function') throw new Error('plugin has no notificationChannel.test hook'); + await impl.test(settingsBlob(config ?? opts.userSettings, 'channel config'), userlessCtx); + }, }, }); diff --git a/plugin-sdk/test/sdk.test.ts b/plugin-sdk/test/sdk.test.ts index 809b1eeb9..802a60ffd 100644 --- a/plugin-sdk/test/sdk.test.ts +++ b/plugin-sdk/test/sdk.test.ts @@ -707,6 +707,104 @@ describe('mock-host inter-plugin (plugins.call + events.emit)', () => { ctx.events.emit('rate.updated', { pair: 'USD/EUR' }); expect(emitted).toEqual([{ name: 'rate.updated', payload: { pair: 'USD/EUR' } }]); }); + + it('drives a notificationChannel USERLESS, with the recipient config passed in', async () => { + const sent: unknown[] = []; + const def = definePlugin({ + hooks: { + notificationChannel: { + async send(msg, config, ctx) { + // The whole point of the userless dispatch: the credentials arrive as `config`, + // and the channel canNOT read anything as the recipient. + sent.push({ msg, token: config.token, settings: await ctx.settings.get('token') }); + }, + async test(config) { if (!config.token) throw new Error('no token'); }, + }, + }, + }); + const host = createMockHost({ + grants: ['hook:notification-channel', 'db:read:trips'], + actingUserId: 7, + userSettings: { token: 'abc' }, + trips: { 1: { members: [7] } }, + }); + const d = host.run(def); + + await d.channel.send({ event: 'trip_invite', title: 'Hi', body: 'Japan' }); + expect(sent).toEqual([{ + msg: { event: 'trip_invite', title: 'Hi', body: 'Japan' }, + token: 'abc', + // Userless: ctx.settings.get() resolves against the ACTING user, and there is none. + settings: undefined, + }]); + + await expect(d.channel.test({ token: 'x' })).resolves.toBeUndefined(); + await expect(d.channel.test({})).rejects.toThrow(/no token/); + }); + + it('refuses a channel event the host would never dispatch, and a manifest may narrow the set', async () => { + const def = definePlugin({ hooks: { notificationChannel: { async send() { /* delivered */ } } } }); + const d = createMockHost({ grants: ['hook:notification-channel'] }).run(def); + // Admin-scoped / in-app-only events are not in CHANNEL_EVENTS. + await expect(d.channel.send({ event: 'admin_alert', title: 'T', body: 'B' })).rejects.toThrow(/never dispatches/); + await expect(d.channel.send({ event: 'trip_invite', title: 'T', body: 'B' })).resolves.toBeUndefined(); + + const narrowed = createMockHost({ grants: ['hook:notification-channel'], channelEvents: ['todo_due'] }).run(def); + await expect(narrowed.channel.send({ event: 'trip_invite', title: 'T', body: 'B' })).rejects.toThrow(/never dispatches/); + await expect(narrowed.channel.send({ event: 'todo_due', title: 'T', body: 'B' })).resolves.toBeUndefined(); + + await expect(createMockHost({}).run(definePlugin({})).channel.send({ event: 'todo_due', title: 'T', body: 'B' })) + .rejects.toThrow(/no notificationChannel hook/); + }); + + it('a plugin with no notificationChannel.test is a clear error, not a silent pass', async () => { + const def = definePlugin({ hooks: { notificationChannel: { async send() {} } } }); + await expect(createMockHost({}).run(def).channel.test()).rejects.toThrow(/no notificationChannel\.test/); + }); + + it('runs a settings action as the CLICKING user and shapes the result like the host', async () => { + const def = definePlugin({ + actions: { + test: async (ctx) => ({ ok: true, message: `token=${await ctx.settings.get('token')} ✅` }), + quiet: () => {}, + boom: () => { throw new Error('credentials rejected'); }, + loud: () => ({ ok: false, message: 'x'.repeat(500) }), + }, + }); + const host = createMockHost({ actingUserId: 7, userSettings: { token: 'abc' } }); + const d = host.run(def); + + // User-initiated: ctx.settings.get() returns the clicker's own value. The message is + // emoji-stripped and bounded, exactly as the host does before showing it. + expect(await d.action('test')).toEqual({ ok: true, message: 'token=abc' }); + // A handler that returns nothing succeeded. + expect(await d.action('quiet')).toEqual({ ok: true, message: undefined }); + // Throwing is the documented "action failed" path, not a rejected promise. + expect(await d.action('boom')).toEqual({ ok: false, message: 'credentials rejected' }); + expect((await d.action('loud')).message).toHaveLength(200); + + await expect(d.action('nope')).rejects.toThrow(/no action/); + }); + + it('refuses an action key the manifest does not declare', async () => { + const def = definePlugin({ actions: { sync: () => ({ ok: true }) } }); + const d = createMockHost({ actingUserId: 7, declaredActions: ['test'] }).run(def); + await expect(d.action('sync')).rejects.toThrow(/RESOURCE_FORBIDDEN/); + }); + + it('never resolves a settings key off Object.prototype', async () => { + // The bug this models: `__proto__`/`constructor` used to resolve to a truthy object, + // reporting a REQUIRED field as configured for a user who had configured nothing. + const { ctx } = createMockHost({ actingUserId: 7, userSettings: { token: 'abc' } }); + expect(await ctx.settings.get('__proto__')).toBeUndefined(); + expect(await ctx.settings.get('constructor')).toBeUndefined(); + expect(await ctx.settings.get('token')).toBe('abc'); + // And a fixture the host would have refused at install fails the test up front. Note a + // `__proto__` OWN key only exists via JSON.parse (in a literal it sets the prototype) — + // which is exactly the shape a stored config blob arrives in. + expect(() => createMockHost({ userSettings: JSON.parse('{"__proto__":{"evil":1}}') })).toThrow(/settings key/); + expect(() => createMockHost({ userSettings: { '9lives': 1 } })).toThrow(/settings key/); + }); }); describe('validateManifest capabilities.provides/emits', () => { diff --git a/wiki/Plugin-Development.md b/wiki/Plugin-Development.md index cd4fa9c52..199be70f2 100644 --- a/wiki/Plugin-Development.md +++ b/wiki/Plugin-Development.md @@ -989,6 +989,36 @@ host.scheduled.get('daily') // timers the plugin A handler your plugin didn't declare throws a clear error (not a silent no-op), so a test catches a missing `scheduled`/`deleteUserData`/job before release. +#### Settings actions and notification channels + +These two have their own driver methods, because the host invokes them in ways a +plain `hook()` call can't express: + +```js +const host = createMockHost({ actingUserId: 7, userSettings: { token: 'abc' } }) +const app = host.run(myPlugin) + +// A settings-page button. USER-INITIATED, so ctx.settings.get() returns the clicker's +// own value. You get back the result the user would actually see: a handler that +// returns nothing is { ok: true }, and one that THROWS is { ok: false, message } — +// the documented contract, so this never rejects. +await app.action('test') // → { ok: true, message: 'Connected' } + +// A notification channel. USERLESS — the host fires it for an arbitrary recipient, so +// ctx.settings.get() returns undefined and trip reads are refused; the recipient's +// decrypted settings arrive as the `config` ARGUMENT instead (defaulting to the +// `userSettings` fixture). That asymmetry is the security property of a channel +// plugin — it is handed someone's push token without the right to read their trips. +await app.channel.send({ event: 'trip_invite', title: 'Hi', body: 'Japan' }) +await app.channel.test({ token: 'abc' }) +``` + +An event outside [`CHANNEL_EVENTS`](#notification-channels) — or outside your +manifest's `capabilities.notificationChannel.events`, which may only *narrow* that +set — is refused rather than delivered, so a test can't pass on a notification the +host would never route to you. Pass `declaredActions` / `channelEvents` to model +what your manifest declares. + ## Rules - **No native modules** (`.node`, `binding.gyp`, `prebuilds/`) — rejected at pack