feat(plugins): add quota-limited session storage API

This commit is contained in:
geracobo
2026-07-25 18:31:44 -06:00
committed by Maurice
parent cc8e4d480e
commit 2068133de2
10 changed files with 243 additions and 23 deletions
+2 -1
View File
@@ -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
+15 -1
View File
@@ -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;
+21
View File
@@ -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.
+50 -2
View File
@@ -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
+24
View File
@@ -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 });