mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-10-18 21:16:47 +00:00
feat(plugins): add quota-limited session storage API
This commit is contained in:
@@ -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(<PluginFrame pluginId="demo" />);
|
||||
const iframe = container.querySelector('iframe')!;
|
||||
const posted: Array<Record<string, unknown>> = [];
|
||||
(iframe.contentWindow as unknown as { postMessage: (m: unknown) => void }).postMessage = (m: unknown) =>
|
||||
posted.push(m as Record<string, unknown>);
|
||||
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' });
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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<string>) {
|
||||
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<PluginState>((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 })
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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;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 {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<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;
|
||||
|
||||
@@ -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<T = unknown>(key: string, options?: PluginSessionStorageOptions): Promise<T | undefined>;
|
||||
set(key: string, value: unknown, options?: PluginSessionStorageOptions): Promise<void>;
|
||||
remove(key: string, options?: PluginSessionStorageOptions): Promise<void>;
|
||||
clear(options?: PluginSessionStorageOptions): Promise<void>;
|
||||
}
|
||||
|
||||
// The design kit for page/widget UIs: inline these into your client/index.html
|
||||
// (or drop a `<!-- trek:ui -->` marker and let `dev`/`pack` expand it) to get the
|
||||
// native TREK look — glass, hover, buttons, inputs — plus a `window.trek` bridge.
|
||||
|
||||
@@ -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<string, unknown[]>;
|
||||
/** 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<string, string>();
|
||||
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
|
||||
|
||||
@@ -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 });
|
||||
|
||||
@@ -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) |
|
||||
|
||||
Reference in New Issue
Block a user