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
`