diff --git a/client/src/components/Plugins/PluginFrame.test.tsx b/client/src/components/Plugins/PluginFrame.test.tsx index fadb9efcc..c30aaf3ac 100644 --- a/client/src/components/Plugins/PluginFrame.test.tsx +++ b/client/src/components/Plugins/PluginFrame.test.tsx @@ -25,6 +25,7 @@ function fromFrame(frame: HTMLIFrameElement, data: unknown) { afterEach(() => { cleanup(); + sessionStorage.clear(); navigate.mockClear(); Object.values(toast).forEach((f) => f.mockClear()); invoke.mockClear(); @@ -317,4 +318,44 @@ describe('PluginFrame', () => { expect(geo.clearWatch).toHaveBeenCalledWith(7); }); }); + + it('FE-PLUGINS-FRAME-015: brokered plugin session state round-trips through host sessionStorage', () => { + 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); + + fromFrame(iframe, { type: 'trek:session:set', requestId: 's1', key: 'dismissed', value: { version: 1 } }); + fromFrame(iframe, { type: 'trek:session:get', requestId: 's2', key: 'dismissed' }); + + expect(posted.find((m) => m.requestId === 's1')).toMatchObject({ type: 'trek:response' }); + expect(posted.find((m) => m.requestId === 's2')).toMatchObject({ type: 'trek:response', data: { version: 1 } }); + // The raw key is host-owned: it includes the authenticated user + plugin id, + // not just the value name supplied by the untrusted frame. + expect(sessionStorage.key(0)).toContain('trek:plugin-session:7:demo:plugin:'); + expect(sessionStorage.key(0)).not.toBe('dismissed'); + }); + + it('FE-PLUGINS-FRAME-016: trip session state requires a trip and is partitioned from plugin scope', () => { + const noTrip = render(); + const noTripFrame = noTrip.container.querySelector('iframe')!; + const noTripPosted: Array> = []; + (noTripFrame.contentWindow as unknown as { postMessage: (m: unknown) => void }).postMessage = (m: unknown) => noTripPosted.push(m as Record); + + fromFrame(noTripFrame, { type: 'trek:session:get', requestId: 's1', key: 'filters', scope: 'trip' }); + expect(noTripPosted.find((m) => m.requestId === 's1')).toMatchObject({ type: 'trek:error', code: 'NO_TRIP_CONTEXT' }); + + noTrip.unmount(); + 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); + + fromFrame(iframe, { type: 'trek:session:set', requestId: 's2', key: 'filters', value: ['flight'], scope: 'trip' }); + fromFrame(iframe, { type: 'trek:session:get', requestId: 's3', key: 'filters' }); + fromFrame(iframe, { type: 'trek:session:get', requestId: 's4', key: 'filters', scope: 'trip' }); + + 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'] }); + }); }); diff --git a/client/src/components/Plugins/PluginFrame.tsx b/client/src/components/Plugins/PluginFrame.tsx index 4c38dd7b7..75f32357f 100644 --- a/client/src/components/Plugins/PluginFrame.tsx +++ b/client/src/components/Plugins/PluginFrame.tsx @@ -106,6 +106,8 @@ interface PluginFrameProps { path?: string } +type PluginSessionStorageScope = 'plugin' | 'trip' + type Inbound = | { type: 'trek:ready' } | { type: 'trek:context:request' } @@ -113,6 +115,10 @@ type Inbound = | { type: 'trek:notify'; level?: 'info' | 'success' | 'warning' | 'error'; message?: string; duration?: number } | { type: 'trek:resize'; height?: number } | { type: 'trek:invoke'; requestId: string; sub: string; method?: string; body?: unknown } + | { type: 'trek:session:get'; requestId: string; key: string; scope?: PluginSessionStorageScope } + | { type: 'trek:session:set'; requestId: string; key: string; value: unknown; scope?: PluginSessionStorageScope } + | { type: 'trek:session:remove'; requestId: string; key: string; scope?: PluginSessionStorageScope } + | { type: 'trek:session:clear'; requestId: string; scope?: PluginSessionStorageScope } | { type: 'trek:confirm'; requestId: string; title?: string; message?: string; confirmLabel?: string; cancelLabel?: string; danger?: boolean } | { type: 'trek:openExternal'; url?: string } | { type: 'trek:geolocation'; requestId: string; action?: 'get' | 'watch' | 'clear' } @@ -142,6 +148,38 @@ interface ConfirmRequest { danger: boolean } +/** + * Creates the prefix and full key for host-owned plugin session storage. + * + * trek:plugin-session:{userId}:{pluginId}:plugin:{key} + * trek:plugin-session:{userId}:{pluginId}:trip:{tripId}:{key} + * + * 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. + */ +function createPluginSessionStorageKeys( + userId: string | number | null, + pluginId: string, + scope: PluginSessionStorageScope, + 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 storageScopeSegment = encodedTripId === null ? scope : `${scope}:${encodedTripId}` + const storageKeyPrefix = `${namespace}${encodedUserId}:${encodedPluginId}:${storageScopeSegment}:` + const storageKey = encodedLogicalKey === undefined ? storageKeyPrefix : `${storageKeyPrefix}${encodedLogicalKey}` + return { + storageKeyPrefix, + storageKey, + } +} + export default function PluginFrame({ pluginId, tripId = null, placeId = null, dayId = null, reservationId = null, fill = false, className, title, path = 'index.html' }: PluginFrameProps) { const frameRef = useRef(null) // A sandboxed frame may navigate ITSELF (connect-src can't stop that), and its @@ -350,6 +388,65 @@ export default function PluginFrame({ pluginId, tripId = null, placeId = null, d ) break } + case 'trek:session:clear': { + const scope = msg.scope === 'trip' ? 'trip' : 'plugin' + + // tripId required if we are on a trip-scoped key. + if (scope === 'trip' && tripId == null) { + post({ type: 'trek:error', requestId: msg.requestId, code: 'NO_TRIP_CONTEXT', message: 'trip session storage requires a trip context' }) + break + } + + const { storageKeyPrefix } = 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)) + post({ type: 'trek:response', requestId: msg.requestId, data: undefined }) + } catch (error) { + const message = error instanceof Error ? error.message : 'session storage failed' + post({ type: 'trek:error', requestId: msg.requestId, code: 'SESSION_STORAGE_ERROR', message }) + } + break + } + case 'trek:session:get': + case 'trek:session:set': + case 'trek:session:remove': { + const scope = msg.scope === 'trip' ? 'trip' : 'plugin' + if (scope === 'trip' && tripId == null) { + 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) + try { + if (msg.type === 'trek:session:get') { + const storedValue = sessionStorage.getItem(storageKey) + post({ type: 'trek:response', requestId: msg.requestId, data: storedValue === null ? undefined : JSON.parse(storedValue) }) + break + } + + if (msg.type === 'trek:session:set') { + const serializedValue = JSON.stringify(msg.value) + if (serializedValue === undefined) { + post({ type: 'trek:error', requestId: msg.requestId, code: 'SESSION_INVALID_VALUE', message: 'session value must be JSON-serialisable' }) + break + } + sessionStorage.setItem(storageKey, serializedValue) + post({ type: 'trek:response', requestId: msg.requestId, data: undefined }) + break + } + + sessionStorage.removeItem(storageKey) + post({ type: 'trek:response', requestId: msg.requestId, data: undefined }) + } catch (error) { + const message = error instanceof Error ? error.message : 'session storage failed' + post({ type: 'trek:error', requestId: msg.requestId, code: 'SESSION_STORAGE_ERROR', message }) + } + break + } } } diff --git a/plugin-sdk/README.md b/plugin-sdk/README.md index da3d72b9c..d48c2f3db 100644 --- a/plugin-sdk/README.md +++ b/plugin-sdk/README.md @@ -76,6 +76,27 @@ const data = await trek.invoke('/status') // calls your own route, host-proxie trek.notify('success', 'Saved') ``` +### Keep UI state for this browser tab + +The opaque frame cannot use browser storage directly. The bridge provides +host-managed, JSON-only session state instead. It survives an iframe remount and +page reload in the same browser tab, but is not durable data. Do not use it to +store secrets. + +```js +// Default: this user + this plugin + this browser tab. +await trek.session.set('dismissed-onboarding', true) +const dismissed = await trek.session.get('dismissed-onboarding') + +// Explicitly partition state by the trip in view (fails without a trip context). +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. + The kit applies the theme, mirrors the appearance flags (reduced-motion, no-transparency) and auto-reports your height. It also upgrades any native `