From 2068133de2a70f5bfcf40f89151885823361db58 Mon Sep 17 00:00:00 2001 From: geracobo Date: Sat, 25 Jul 2026 18:31:44 -0600 Subject: [PATCH] feat(plugins): add quota-limited session storage API --- .../components/Plugins/PluginFrame.test.tsx | 25 +++++++ client/src/components/Plugins/PluginFrame.tsx | 72 ++++++++++++++----- client/src/store/pluginStore.test.ts | 25 +++++++ client/src/store/pluginStore.ts | 26 ++++++- plugin-sdk/README.md | 3 +- plugin-sdk/src/cli/dev.ts | 16 ++++- plugin-sdk/src/index.ts | 21 ++++++ plugin-sdk/src/mock-host.ts | 52 +++++++++++++- plugin-sdk/test/sdk.test.ts | 24 +++++++ wiki/Plugin-Development.md | 2 +- 10 files changed, 243 insertions(+), 23 deletions(-) diff --git a/client/src/components/Plugins/PluginFrame.test.tsx b/client/src/components/Plugins/PluginFrame.test.tsx index 87ed0b0b1..aed4d785f 100644 --- a/client/src/components/Plugins/PluginFrame.test.tsx +++ b/client/src/components/Plugins/PluginFrame.test.tsx @@ -358,4 +358,29 @@ describe('PluginFrame', () => { expect(posted.find((m) => m.requestId === 's3')).toMatchObject({ type: 'trek:response', data: undefined }); expect(posted.find((m) => m.requestId === 's4')).toMatchObject({ type: 'trek:response', data: ['flight'] }); }); + + it('FE-PLUGINS-FRAME-022: session storage enforces key, value and key-count limits', () => { + const { container } = render(); + const iframe = container.querySelector('iframe')!; + const posted: Array> = []; + (iframe.contentWindow as unknown as { postMessage: (m: unknown) => void }).postMessage = (m: unknown) => + posted.push(m as Record); + const set = (requestId: string, key: string, value: unknown) => { + fromFrame(iframe, { type: 'trek:session:set', requestId, key, value }); + return posted.find((message) => message.requestId === requestId); + }; + + expect(set('bad-key', 'x'.repeat(65), 1)).toMatchObject({ type: 'trek:error', code: 'SESSION_INVALID_KEY' }); + expect(set('big-value', 'large', 'x'.repeat(1024))).toMatchObject({ + type: 'trek:error', + code: 'SESSION_VALUE_TOO_LARGE', + }); + expect(set('at-limits', 'x'.repeat(64), 'x'.repeat(1022))).toMatchObject({ type: 'trek:response' }); + + sessionStorage.clear(); + for (let index = 0; index < 32; index += 1) { + expect(set(`key-${index}`, `key-${index}`, index)).toMatchObject({ type: 'trek:response' }); + } + expect(set('too-many', 'key-32', 32)).toMatchObject({ type: 'trek:error', code: 'SESSION_KEY_LIMIT' }); + }); }); diff --git a/client/src/components/Plugins/PluginFrame.tsx b/client/src/components/Plugins/PluginFrame.tsx index 75f32357f..8142c1554 100644 --- a/client/src/components/Plugins/PluginFrame.tsx +++ b/client/src/components/Plugins/PluginFrame.tsx @@ -148,12 +148,39 @@ interface ConfirmRequest { danger: boolean } +const PLUGIN_SESSION_NAMESPACE = 'trek:plugin-session:' +const PLUGIN_SESSION_MAX_KEY_LENGTH = 64 +const PLUGIN_SESSION_MAX_KEYS = 32 +const PLUGIN_SESSION_MAX_VALUE_BYTES = 1024 + +/** + * Returns all host-owned storage keys in one plugin scope (plugin-wide or a + * specific trip), excluding TREK state and every other plugin/scope. + */ +function getScopedSessionKeys(scopeKeyPrefix: string) { + const keys: string[] = [] + for (let i = 0; i < sessionStorage.length; i += 1) { + const key = sessionStorage.key(i) + if (key?.startsWith(scopeKeyPrefix)) keys.push(key) + } + return keys +} + /** * Creates the prefix and full key for host-owned plugin session storage. * + * Storage Keys: * trek:plugin-session:{userId}:{pluginId}:plugin:{key} * trek:plugin-session:{userId}:{pluginId}:trip:{tripId}:{key} * + * Scope Prefix: + * trek:plugin-session:{userId}:{pluginId}:plugin + * trek:plugin-session:{userId}:{pluginId}:trip:{tripId} + * + * Plugin Prefix: + * trek:plugin-session:{userId}:{pluginId} + * + * * Each dynamic segment is URI-encoded, so plugin-controlled values cannot alter * the key format. For clear, omit logicalKey; the returned full key is then * the same as the scoped prefix. @@ -165,17 +192,17 @@ function createPluginSessionStorageKeys( tripId: string | null, logicalKey?: string, ) { - const namespace = 'trek:plugin-session:' - const encodedUserId = encodeURIComponent(String(userId)) - const encodedPluginId = encodeURIComponent(pluginId) - const encodedTripId = scope === 'trip' ? encodeURIComponent(tripId!) : null - const encodedLogicalKey = logicalKey === undefined ? undefined : encodeURIComponent(logicalKey) + const pluginKeyPrefix = `${PLUGIN_SESSION_NAMESPACE}${encodeURIComponent(String(userId))}:${encodeURIComponent(pluginId)}:` - const storageScopeSegment = encodedTripId === null ? scope : `${scope}:${encodedTripId}` - const storageKeyPrefix = `${namespace}${encodedUserId}:${encodedPluginId}:${storageScopeSegment}:` - const storageKey = encodedLogicalKey === undefined ? storageKeyPrefix : `${storageKeyPrefix}${encodedLogicalKey}` + // Scope is either :plugin: or :trip:${tripId}: + const scopeSegment = scope === 'trip' ? `trip:${encodeURIComponent(tripId!)}` : 'plugin' + const scopeKeyPrefix = `${pluginKeyPrefix}${scopeSegment}:` + + const encodedLogicalKey = logicalKey === undefined ? undefined : encodeURIComponent(logicalKey) + const storageKey = encodedLogicalKey === undefined ? scopeKeyPrefix : `${scopeKeyPrefix}${encodedLogicalKey}` return { - storageKeyPrefix, + pluginKeyPrefix, + scopeKeyPrefix, storageKey, } } @@ -397,14 +424,9 @@ export default function PluginFrame({ pluginId, tripId = null, placeId = null, d break } - const { storageKeyPrefix } = createPluginSessionStorageKeys(userId, pluginId, scope, tripId) + const { scopeKeyPrefix } = createPluginSessionStorageKeys(userId, pluginId, scope, tripId) try { - const keysToRemove: string[] = [] - for (let i = 0; i < sessionStorage.length; i += 1) { - const storageKey = sessionStorage.key(i) - if (storageKey?.startsWith(storageKeyPrefix)) keysToRemove.push(storageKey) - } - keysToRemove.forEach((storageKey) => sessionStorage.removeItem(storageKey)) + getScopedSessionKeys(scopeKeyPrefix).forEach((storageKey) => sessionStorage.removeItem(storageKey)) post({ type: 'trek:response', requestId: msg.requestId, data: undefined }) } catch (error) { const message = error instanceof Error ? error.message : 'session storage failed' @@ -420,7 +442,11 @@ export default function PluginFrame({ pluginId, tripId = null, placeId = null, d post({ type: 'trek:error', requestId: msg.requestId, code: 'NO_TRIP_CONTEXT', message: 'trip session storage requires a trip context' }) break } - const { storageKey } = createPluginSessionStorageKeys(userId, pluginId, scope, tripId, msg.key) + if (typeof msg.key !== 'string' || msg.key.length === 0 || msg.key.length > PLUGIN_SESSION_MAX_KEY_LENGTH) { + post({ type: 'trek:error', requestId: msg.requestId, code: 'SESSION_INVALID_KEY', message: `session key must be 1-${PLUGIN_SESSION_MAX_KEY_LENGTH} characters` }) + break + } + const { scopeKeyPrefix, storageKey } = createPluginSessionStorageKeys(userId, pluginId, scope, tripId, msg.key) try { if (msg.type === 'trek:session:get') { const storedValue = sessionStorage.getItem(storageKey) @@ -434,6 +460,18 @@ export default function PluginFrame({ pluginId, tripId = null, placeId = null, d post({ type: 'trek:error', requestId: msg.requestId, code: 'SESSION_INVALID_VALUE', message: 'session value must be JSON-serialisable' }) break } + + // Validate length based on unicode byte sizes + const valueBytes = new TextEncoder().encode(serializedValue).byteLength + if (valueBytes > PLUGIN_SESSION_MAX_VALUE_BYTES) { + post({ type: 'trek:error', requestId: msg.requestId, code: 'SESSION_VALUE_TOO_LARGE', message: `session value exceeds ${PLUGIN_SESSION_MAX_VALUE_BYTES} bytes` }) + break + } + const keys = getScopedSessionKeys(scopeKeyPrefix) + if (!keys.includes(storageKey) && keys.length >= PLUGIN_SESSION_MAX_KEYS) { + post({ type: 'trek:error', requestId: msg.requestId, code: 'SESSION_KEY_LIMIT', message: `plugin session storage allows at most ${PLUGIN_SESSION_MAX_KEYS} keys` }) + break + } sessionStorage.setItem(storageKey, serializedValue) post({ type: 'trek:response', requestId: msg.requestId, data: undefined }) break diff --git a/client/src/store/pluginStore.test.ts b/client/src/store/pluginStore.test.ts index 5820e0a35..cb54f782b 100644 --- a/client/src/store/pluginStore.test.ts +++ b/client/src/store/pluginStore.test.ts @@ -7,6 +7,7 @@ const initial = usePluginStore.getState(); beforeEach(() => { usePluginStore.setState(initial, true); + sessionStorage.clear(); }); describe('pluginStore', () => { @@ -36,10 +37,12 @@ describe('pluginStore', () => { }); it('FE-STORE-PLUGIN-002: a failed fetch still marks the store loaded (no crash)', async () => { + sessionStorage.setItem('trek:plugin-session:7:active:plugin:filters', '["flight"]'); server.use(http.get('/api/plugins', () => HttpResponse.error())); await usePluginStore.getState().loadPlugins(); expect(usePluginStore.getState().loaded).toBe(true); expect(usePluginStore.getState().plugins).toEqual([]); + expect(sessionStorage.getItem('trek:plugin-session:7:active:plugin:filters')).toBe('["flight"]'); }); it('FE-STORE-PLUGIN-003: tolerates a missing plugins array', async () => { @@ -47,4 +50,26 @@ describe('pluginStore', () => { await usePluginStore.getState().loadPlugins(); expect(usePluginStore.getState().plugins).toEqual([]); }); + + it('FE-STORE-PLUGIN-004: clears session state for disabled plugins after a successful refresh', async () => { + sessionStorage.setItem('trek:plugin-session:7:active:plugin:filters', '["flight"]'); + sessionStorage.setItem('trek:plugin-session:7:disabled:plugin:filters', '["hotel"]'); + sessionStorage.setItem('trek:plugin-session:7:disabled:trip:42:view', '"table"'); + sessionStorage.setItem('trek_session', 'app-session'); + + server.use( + http.get('/api/plugins', () => + HttpResponse.json({ + plugins: [{ id: 'active', name: 'Active', type: 'widget', icon: null }], + }), + ), + ); + + await usePluginStore.getState().loadPlugins(); + + expect(sessionStorage.getItem('trek:plugin-session:7:active:plugin:filters')).toBe('["flight"]'); + expect(sessionStorage.getItem('trek:plugin-session:7:disabled:plugin:filters')).toBeNull(); + expect(sessionStorage.getItem('trek:plugin-session:7:disabled:trip:42:view')).toBeNull(); + expect(sessionStorage.getItem('trek_session')).toBe('app-session'); + }); }); diff --git a/client/src/store/pluginStore.ts b/client/src/store/pluginStore.ts index 892f59690..2d5d99603 100644 --- a/client/src/store/pluginStore.ts +++ b/client/src/store/pluginStore.ts @@ -1,6 +1,28 @@ import { create } from 'zustand' import { pluginsApi } from '../api/client' +const PLUGIN_SESSION_NAMESPACE = 'trek:plugin-session:' + +/** + * Purges state for plugins absent from a successful active-plugin response. + * A failed request never calls this, because plugin status is then unknown. + */ +function clearInactivePluginSessions(activePluginIds: Set) { + const keysToRemove: string[] = [] + for (let i = 0; i < sessionStorage.length; i += 1) { + const storageKey = sessionStorage.key(i) + if (!storageKey?.startsWith(PLUGIN_SESSION_NAMESPACE)) continue + const encodedPluginId = storageKey.slice(PLUGIN_SESSION_NAMESPACE.length).split(':')[1] + if (encodedPluginId === undefined) continue + try { + if (!activePluginIds.has(decodeURIComponent(encodedPluginId))) keysToRemove.push(storageKey) + } catch { + // Ignore malformed keys outside the host-owned format. + } + } + keysToRemove.forEach((storageKey) => sessionStorage.removeItem(storageKey)) +} + /** * Active plugins the client renders (#plugins, M3). Page plugins become nav * entries + a full-page iframe route; widget plugins mount on the dashboard. @@ -47,7 +69,9 @@ export const usePluginStore = create((set, get) => ({ loadPlugins: async () => { try { const data = await pluginsApi.active() - set({ plugins: (data.plugins as ActivePlugin[]) || [], loaded: true }) + const plugins = (data.plugins as ActivePlugin[]) || [] + clearInactivePluginSessions(new Set(plugins.map((plugin) => plugin.id))) + set({ plugins, loaded: true }) } catch { set({ loaded: true }) } diff --git a/plugin-sdk/README.md b/plugin-sdk/README.md index d48c2f3db..222cd163c 100644 --- a/plugin-sdk/README.md +++ b/plugin-sdk/README.md @@ -95,7 +95,8 @@ await trek.session.set('filters', ['flight', 'hotel'], { scope: 'trip' }) `get()` returns `undefined` for a missing key. `set`, `get`, `remove`, and `clear` accept `{ scope: 'plugin' | 'trip' }`; `plugin` is the default. Use your plugin's own logical key (for example `reservation:88:expanded`) for any finer -partitioning. +partitioning. Each plugin or individual trip scope is limited to 32 keys, +64 characters per key, and 1 KiB per JSON value. The kit applies the theme, mirrors the appearance flags (reduced-motion, no-transparency) and auto-reports your height. It also upgrades any native diff --git a/plugin-sdk/src/cli/dev.ts b/plugin-sdk/src/cli/dev.ts index ad585ce5c..5189696b6 100644 --- a/plugin-sdk/src/cli/dev.ts +++ b/plugin-sdk/src/cli/dev.ts @@ -665,7 +665,10 @@ function ctx(){ 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)):"")+":"; @@ -673,12 +676,23 @@ function sessionMessage(m){ if(m.type==="trek:session:clear"){ var doomed=[];for(var i=0;iSESSION_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 {sessionStorage.setItem(key,JSON.stringify(m.value));} + 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=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; diff --git a/plugin-sdk/src/index.ts b/plugin-sdk/src/index.ts index 0b77a15ec..635f8e4c1 100644 --- a/plugin-sdk/src/index.ts +++ b/plugin-sdk/src/index.ts @@ -729,6 +729,27 @@ export { PermissionDenied, HOOK_PERMISSION, USER_DATA_PERMISSION, EVENTS_PERMISSION, JOBS_PERMISSION, grantGaps, grantedHosts, type GrantGap, type PluginEntryPoints, } from './permissions.js'; + +/** Scope for host-managed, per-user session state in a sandboxed plugin UI. */ +export type PluginSessionStorageScope = 'plugin' | 'trip'; + +/** Limits enforced independently for the plugin scope and each trip scope. */ +export const PLUGIN_SESSION_MAX_KEYS = 32; +export const PLUGIN_SESSION_MAX_KEY_LENGTH = 64; +export const PLUGIN_SESSION_MAX_VALUE_BYTES = 1024; + +export interface PluginSessionStorageOptions { + scope?: PluginSessionStorageScope; +} + +/** The `window.trek.session` surface. Values must be JSON-serialisable. */ +export interface PluginSessionStorage { + get(key: string, options?: PluginSessionStorageOptions): Promise; + set(key: string, value: unknown, options?: PluginSessionStorageOptions): Promise; + remove(key: string, options?: PluginSessionStorageOptions): Promise; + clear(options?: PluginSessionStorageOptions): Promise; +} + // 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 // native TREK look — glass, hover, buttons, inputs — plus a `window.trek` bridge. diff --git a/plugin-sdk/src/mock-host.ts b/plugin-sdk/src/mock-host.ts index fcffd47ee..9a148372a 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, NotificationMessage, PluginActionResult } from './index.js'; +import { PLUGIN_SESSION_MAX_KEYS, PLUGIN_SESSION_MAX_KEY_LENGTH, PLUGIN_SESSION_MAX_VALUE_BYTES } from './index.js'; +import type { PluginContext, PluginDefinition, PluginRequest, PluginResponse, Trip, Place, Day, Reservation, PackingItem, TripFile, BudgetItem, User, NotificationMessage, PluginActionResult, PluginSessionStorage } from './index.js'; import { CHANNEL_EVENTS } from './manifest.js'; import { PermissionDenied, HOOK_PERMISSION, USER_DATA_PERMISSION, EVENTS_PERMISSION, JOBS_PERMISSION } from './permissions.js'; @@ -49,6 +50,8 @@ export interface MockHostOptions { queryResults?: Record; /** The host-bound acting user for costs.* (a job/onLoad has none → refused). */ actingUserId?: number; + /** Trip bound to the mock plugin UI's `session` helper. Required for scope: 'trip'. */ + sessionTripId?: number | string; /** Whether the Costs (budget) addon is enabled; gates all costs.* (default true). */ budgetAddonEnabled?: boolean; /** Same idea for the other gated subsystems (default true): journey gates @@ -157,6 +160,8 @@ export interface MockHost { * read or write refuses with RESOURCE_FORBIDDEN). Shares this host's fixtures, * grants and recorders. */ userlessCtx: PluginContext; + /** In-memory equivalent of `window.trek.session`, with production quota enforcement. */ + session: PluginSessionStorage; /** Everything the plugin did, for assertions. */ calls: { method: string; args: unknown[] }[]; logs: { level: string; msg: string }[]; @@ -236,6 +241,49 @@ export function createMockHost(opts: MockHostOptions = {}): MockHost { const broadcasts: MockHost['broadcasts'] = []; const emitted: MockHost['emitted'] = []; const notifications: MockHost['notifications'] = []; + const sessionValues = new Map(); + const sessionPrefix = (scope: 'plugin' | 'trip') => { + if (scope === 'trip' && opts.sessionTripId === undefined) { + throw new Error('NO_TRIP_CONTEXT: trip session storage requires a trip context'); + } + return scope === 'trip' ? `trip:${opts.sessionTripId}:` : 'plugin:'; + }; + const sessionKey = (key: string, scope: 'plugin' | 'trip') => { + if (!key || key.length > PLUGIN_SESSION_MAX_KEY_LENGTH) { + throw new Error(`SESSION_INVALID_KEY: session key must be 1-${PLUGIN_SESSION_MAX_KEY_LENGTH} characters`); + } + return `${sessionPrefix(scope)}${key}`; + }; + const session: PluginSessionStorage = { + async get(key, options) { + const raw = sessionValues.get(sessionKey(key, options?.scope === 'trip' ? 'trip' : 'plugin')); + return raw === undefined ? undefined : JSON.parse(raw); + }, + async set(key, value, options) { + const fullKey = sessionKey(key, options?.scope === 'trip' ? 'trip' : 'plugin'); + const serialized = JSON.stringify(value); + if (serialized === undefined) throw new Error('SESSION_INVALID_VALUE: session value must be JSON-serialisable'); + const valueBytes = Buffer.byteLength(serialized, 'utf8'); + if (valueBytes > PLUGIN_SESSION_MAX_VALUE_BYTES) { + throw new Error(`SESSION_VALUE_TOO_LARGE: session value exceeds ${PLUGIN_SESSION_MAX_VALUE_BYTES} bytes`); + } + const prefix = sessionPrefix(options?.scope === 'trip' ? 'trip' : 'plugin'); + const scopedKeyCount = [...sessionValues.keys()].filter((storedKey) => storedKey.startsWith(prefix)).length; + if (!sessionValues.has(fullKey) && scopedKeyCount >= PLUGIN_SESSION_MAX_KEYS) { + throw new Error(`SESSION_KEY_LIMIT: plugin session storage allows at most ${PLUGIN_SESSION_MAX_KEYS} keys`); + } + sessionValues.set(fullKey, serialized); + }, + async remove(key, options) { + sessionValues.delete(sessionKey(key, options?.scope === 'trip' ? 'trip' : 'plugin')); + }, + async clear(options) { + const prefix = sessionPrefix(options?.scope === 'trip' ? 'trip' : 'plugin'); + for (const key of sessionValues.keys()) { + if (key.startsWith(prefix)) sessionValues.delete(key); + } + }, + }; // 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'); @@ -1486,7 +1534,7 @@ export function createMockHost(opts: MockHostOptions = {}): MockHost { }, }); - return { ctx, userlessCtx, calls, logs, broadcasts, emitted, notifications, scheduled: scheduledTasks, run }; + return { ctx, userlessCtx, session, calls, logs, broadcasts, emitted, notifications, scheduled: scheduledTasks, run }; } // `trek-plugin-sdk/testing` resolves to this module, so re-export what a test needs to diff --git a/plugin-sdk/test/sdk.test.ts b/plugin-sdk/test/sdk.test.ts index aed4e71b6..9ceecdc29 100644 --- a/plugin-sdk/test/sdk.test.ts +++ b/plugin-sdk/test/sdk.test.ts @@ -131,6 +131,30 @@ describe('validateManifest', () => { }); describe('createMockHost', () => { + it('mocks plugin UI session storage with scope isolation and production limits', async () => { + const host = createMockHost({ sessionTripId: 42 }); + await host.session.set('filters', ['hotel']); + await host.session.set('filters', ['flight'], { scope: 'trip' }); + await expect(host.session.get('filters')).resolves.toEqual(['hotel']); + await expect(host.session.get('filters', { scope: 'trip' })).resolves.toEqual(['flight']); + + await expect(host.session.set('x'.repeat(65), true)).rejects.toThrow(/SESSION_INVALID_KEY/); + await expect(host.session.set('large', 'x'.repeat(1024))).rejects.toThrow(/SESSION_VALUE_TOO_LARGE/); + await expect(host.session.set('x'.repeat(64), 'x'.repeat(1022))).resolves.toBeUndefined(); + + await host.session.clear(); + await host.session.clear({ scope: 'trip' }); + for (let index = 0; index < 32; index += 1) await host.session.set(`key-${index}`, index); + await expect(host.session.set('key-32', 32)).rejects.toThrow(/SESSION_KEY_LIMIT/); + await expect(host.session.set('trip-key', true, { scope: 'trip' })).resolves.toBeUndefined(); + + }); + + it('requires a trip context for trip-scoped mock session storage', async () => { + const { session } = createMockHost(); + await expect(session.get('filters', { scope: 'trip' })).rejects.toThrow(/NO_TRIP_CONTEXT/); + }); + it('enforces the granted permission set', async () => { const { ctx } = createMockHost({ grants: ['db:own'] }); await expect(ctx.db.migrate('1', 'CREATE TABLE t (x)')).resolves.toEqual({ applied: true }); diff --git a/wiki/Plugin-Development.md b/wiki/Plugin-Development.md index e983f9b22..02c08a3fd 100644 --- a/wiki/Plugin-Development.md +++ b/wiki/Plugin-Development.md @@ -456,7 +456,7 @@ works. Add `data-trek-native` to a field to keep the browser default; `multiple` | `trek.onContext(cb)` | run `cb(context)` now (if already received) and on every update; returns an unsubscribe fn | | `trek.context` | the last context (or `null`) | | `trek.invoke(sub, { method, body })` | call your own route; returns a `Promise` (rejects with an `Error`, `.code` = HTTP status) | -| `trek.session.get(key, options?)`, `.set(key, value, options?)`, `.remove(key, options?)`, `.clear(options?)` | Host-managed JSON session state for this browser tab. `scope: 'plugin'` is the default; `scope: 'trip'` additionally partitions it by the trip in view and rejects with `NO_TRIP_CONTEXT` outside a trip. Missing `get()` values are `undefined`. Do not use it to store secrets. | +| `trek.session.get(key, options?)`, `.set(key, value, options?)`, `.remove(key, options?)`, `.clear(options?)` | Host-managed JSON session state for this browser tab. `scope: 'plugin'` is the default; `scope: 'trip'` additionally partitions it by the trip in view and rejects with `NO_TRIP_CONTEXT` outside a trip. Missing `get()` values are `undefined`. Each plugin or individual trip scope is limited to 32 keys, 64 characters/key, and 1 KiB/value. Do not use it to store secrets. | | `trek.notify(level, message, duration?)` | toast (`info`/`success`/`warning`/`error`); optional duration in ms (clamped 1.5–15s) | | `trek.confirm({ title?, message, confirmLabel?, cancelLabel?, danger? })` | host-rendered native confirm dialog; resolves `true`/`false` (one at a time — a second concurrent request resolves `false`) | | `trek.navigate(to)` | in-app navigation (relative paths only) |