From 1ca972f3628cf7bd9c49a88742cc50722c229169 Mon Sep 17 00:00:00 2001 From: Neko Ayaka Date: Wed, 29 Apr 2026 00:42:33 +0800 Subject: [PATCH] refactor(stage-tamagotchi): consolidate electron plugin static assets --- .../services/airi/http-server/errors/index.ts | 10 +- .../extension-static-assets/index.test.ts | 70 --- .../http/extension-static-assets/index.ts | 180 -------- .../extension-static-assets/route.test.ts | 100 ---- .../http/extension-static-assets/route.ts | 87 ---- .../token-store.test.ts | 64 --- .../extension-static-assets/token-store.ts | 134 ------ .../http/extension-static-assets/types.ts | 36 -- .../services/airi/http-server/index.test.ts | 2 +- .../main/services/airi/http-server/index.ts | 4 +- .../http-server/static-assets/index.test.ts | 261 +++++++++++ .../airi/http-server/static-assets/index.ts | 221 +++++++++ .../http-server/static-assets/paths.test.ts | 92 ++++ .../airi/http-server/static-assets/paths.ts | 235 ++++++++++ .../http-server/static-assets/route.test.ts | 217 +++++++++ .../airi/http-server/static-assets/route.ts | 124 +++++ .../static-assets/session-store.test.ts | 431 ++++++++++++++++++ .../static-assets/session-store.ts | 285 ++++++++++++ .../airi/http-server/static-assets/types.ts | 85 ++++ .../services/airi/plugins/asset-mount.test.ts | 80 ---- .../main/services/airi/plugins/asset-mount.ts | 100 ---- .../services/airi/plugins/assets/index.ts | 118 ----- .../features/static-assets/index.test.ts | 274 +++++++++++ .../plugins/features/static-assets/index.ts | 273 +++++++++++ .../main/services/airi/plugins/host/debug.ts | 67 +-- .../main/services/airi/plugins/host/index.ts | 116 +++-- .../main/services/airi/plugins/index.test.ts | 103 ++++- .../src/main/services/airi/plugins/index.ts | 2 +- .../airi/plugins/kits/gamelet/index.ts | 126 +++++ .../plugins/kits/widget/asset-url.test.ts | 27 ++ .../airi/plugins/kits/widget/asset-url.ts | 96 ++-- .../src/main/services/airi/plugins/types.ts | 2 + .../src/main/services/airi/widgets/index.ts | 9 + .../main/services/airi/widgets/validation.ts | 20 + .../src/main/windows/widgets/index.ts | 18 + .../components/extension-ui-host.vue | 12 + .../use-iframe-message-port.test.ts | 42 ++ .../composables/use-iframe-message-port.ts | 94 +++- .../src/shared/eventa/index.ts | 1 + .../plugin-sdk-tamagotchi/src/index.test.ts | 50 +- .../plugin-sdk-tamagotchi/src/tools/index.ts | 94 +++- vitest.config.ts | 1 + 42 files changed, 3242 insertions(+), 1121 deletions(-) delete mode 100644 apps/stage-tamagotchi/src/main/services/airi/http-server/http/extension-static-assets/index.test.ts delete mode 100644 apps/stage-tamagotchi/src/main/services/airi/http-server/http/extension-static-assets/index.ts delete mode 100644 apps/stage-tamagotchi/src/main/services/airi/http-server/http/extension-static-assets/route.test.ts delete mode 100644 apps/stage-tamagotchi/src/main/services/airi/http-server/http/extension-static-assets/route.ts delete mode 100644 apps/stage-tamagotchi/src/main/services/airi/http-server/http/extension-static-assets/token-store.test.ts delete mode 100644 apps/stage-tamagotchi/src/main/services/airi/http-server/http/extension-static-assets/token-store.ts delete mode 100644 apps/stage-tamagotchi/src/main/services/airi/http-server/http/extension-static-assets/types.ts create mode 100644 apps/stage-tamagotchi/src/main/services/airi/http-server/static-assets/index.test.ts create mode 100644 apps/stage-tamagotchi/src/main/services/airi/http-server/static-assets/index.ts create mode 100644 apps/stage-tamagotchi/src/main/services/airi/http-server/static-assets/paths.test.ts create mode 100644 apps/stage-tamagotchi/src/main/services/airi/http-server/static-assets/paths.ts create mode 100644 apps/stage-tamagotchi/src/main/services/airi/http-server/static-assets/route.test.ts create mode 100644 apps/stage-tamagotchi/src/main/services/airi/http-server/static-assets/route.ts create mode 100644 apps/stage-tamagotchi/src/main/services/airi/http-server/static-assets/session-store.test.ts create mode 100644 apps/stage-tamagotchi/src/main/services/airi/http-server/static-assets/session-store.ts create mode 100644 apps/stage-tamagotchi/src/main/services/airi/http-server/static-assets/types.ts delete mode 100644 apps/stage-tamagotchi/src/main/services/airi/plugins/asset-mount.test.ts delete mode 100644 apps/stage-tamagotchi/src/main/services/airi/plugins/asset-mount.ts delete mode 100644 apps/stage-tamagotchi/src/main/services/airi/plugins/assets/index.ts create mode 100644 apps/stage-tamagotchi/src/main/services/airi/plugins/features/static-assets/index.test.ts create mode 100644 apps/stage-tamagotchi/src/main/services/airi/plugins/features/static-assets/index.ts create mode 100644 apps/stage-tamagotchi/src/main/services/airi/plugins/kits/widget/asset-url.test.ts create mode 100644 apps/stage-tamagotchi/src/renderer/widgets/extension-ui/composables/use-iframe-message-port.test.ts diff --git a/apps/stage-tamagotchi/src/main/services/airi/http-server/errors/index.ts b/apps/stage-tamagotchi/src/main/services/airi/http-server/errors/index.ts index fe541e3c5..2acfd24af 100644 --- a/apps/stage-tamagotchi/src/main/services/airi/http-server/errors/index.ts +++ b/apps/stage-tamagotchi/src/main/services/airi/http-server/errors/index.ts @@ -9,6 +9,10 @@ export interface HttpErrorInput { expose?: boolean } +export interface H3HttpErrorOptions { + headers?: HeadersInit +} + /** * Unified HTTP error shape for AIRI local HTTP server modules. * @@ -53,8 +57,10 @@ export class HttpError extends Error { * Returns: * - `HTTPError` preserving status while controlling client-visible message */ -export function toH3HttpError(error: HttpError) { - return HTTPError.status(error.status, error.expose ? error.message : defaultHttpMessage(error.status)) +export function toH3HttpError(error: HttpError, options: H3HttpErrorOptions = {}) { + return HTTPError.status(error.status, error.expose ? error.message : defaultHttpMessage(error.status), { + headers: options.headers, + }) } function defaultHttpMessage(status: number) { diff --git a/apps/stage-tamagotchi/src/main/services/airi/http-server/http/extension-static-assets/index.test.ts b/apps/stage-tamagotchi/src/main/services/airi/http-server/http/extension-static-assets/index.test.ts deleted file mode 100644 index f967e4b82..000000000 --- a/apps/stage-tamagotchi/src/main/services/airi/http-server/http/extension-static-assets/index.test.ts +++ /dev/null @@ -1,70 +0,0 @@ -import { mkdir, mkdtemp, writeFile } from 'node:fs/promises' -import { tmpdir } from 'node:os' -import { join } from 'node:path' - -import { afterEach, describe, expect, it } from 'vitest' - -import { createExtensionStaticAssetServer } from './index' -import { createExtensionAssetTokenStore } from './token-store' - -describe('createExtensionStaticAssetServer', () => { - const servers: Array> = [] - - afterEach(async () => { - while (servers.length > 0) { - const server = servers.pop() - if (!server) { - continue - } - await server.stop() - } - }) - - it('accepts pathPrefix relative to /ui route segment', async () => { - const rootDir = await mkdtemp(join(tmpdir(), 'airi-extension-static-assets-')) - await mkdir(join(rootDir, 'ui', 'assets'), { recursive: true }) - await writeFile(join(rootDir, 'ui', 'assets', 'app.js'), 'console.log("ok")\n') - - const extensionId = 'airi-plugin-game-chess' - const version = '1.0.0' - const tokenStore = createExtensionAssetTokenStore() - const validateInputs: string[] = [] - const server = createExtensionStaticAssetServer({ - getManifestEntryByName: () => new Map([ - [extensionId, { rootDir, version }], - ]), - tokenStore: { - ...tokenStore, - validate(token, input) { - validateInputs.push(input.assetPath) - return tokenStore.validate(token, input) - }, - }, - }) - servers.push(server) - await server.start() - - const token = server.issueToken({ - extensionId, - version, - sessionId: 'session-1', - pathPrefix: 'assets/', - ttlMs: 60_000, - }) - - const baseUrl = server.getBaseUrl() - expect(baseUrl).toBeTruthy() - - const response = await fetch(`${baseUrl}/_airi/extensions/${extensionId}/ui/assets/app.js?t=${token}`) - const responseBody = await response.text() - expect({ - status: response.status, - validateInputs, - responseBody, - }).toEqual({ - status: 200, - validateInputs: ['assets/app.js'], - responseBody: 'console.log("ok")\n', - }) - }) -}) diff --git a/apps/stage-tamagotchi/src/main/services/airi/http-server/http/extension-static-assets/index.ts b/apps/stage-tamagotchi/src/main/services/airi/http-server/http/extension-static-assets/index.ts deleted file mode 100644 index 70d1c63d1..000000000 --- a/apps/stage-tamagotchi/src/main/services/airi/http-server/http/extension-static-assets/index.ts +++ /dev/null @@ -1,180 +0,0 @@ -import type { ServerManager } from '../../server-manager/types' -import type { ExtensionAssetTokenStore } from './types' - -import { stat } from 'node:fs/promises' - -import { H3 } from 'h3' - -import { - normalizePluginAssetPath, - resolvePluginAssetFilePath, -} from '../../../plugins/asset-mount' -import { HttpError } from '../../errors' -import { createH3Server } from '../../server' -import { createExtensionStaticAssetRoute } from './route' -import { createExtensionAssetTokenStore } from './token-store' - -export interface ExtensionStaticAssetManifestEntry { - rootDir: string - version: string -} - -export interface ExtensionStaticAssetServer extends ServerManager { - getBaseUrl: () => string | undefined - issueToken: ExtensionAssetTokenStore['issue'] - revokeByExtensionId: ExtensionAssetTokenStore['revokeByExtensionId'] - revokeAll: ExtensionAssetTokenStore['revokeAll'] -} - -/** - * Creates the low-level extension static asset transport server. - * - * Use when: - * - Main process must serve plugin iframe assets via local loopback HTTP - * - Tokenized auth is required for all plugin asset requests - * - A higher-level plugin asset service needs an HTTP transport adapter - * - * Expects: - * - `getManifestEntryByName` returns up-to-date plugin root/version map - * - * Returns: - * - Lifecycle service with token issue/revoke APIs and local base URL getter - */ -export function createExtensionStaticAssetServer(options: { - getManifestEntryByName: () => Map - host?: string - tokenStore?: ExtensionAssetTokenStore - getType?: (ext: string) => string | undefined -}): ExtensionStaticAssetServer { - const host = options.host ?? '127.0.0.1' - const tokenStore = options.tokenStore ?? createExtensionAssetTokenStore() - const getType = options.getType ?? defaultExtensionAssetMimeTypeResolver - - const app = new H3() - const serverLifecycle = createH3Server({ app, host }) - - app.get('/_airi/extensions/**', createExtensionStaticAssetRoute({ - getType, - authorize: async ({ token, extensionId, assetPath }) => { - const entry = options.getManifestEntryByName().get(extensionId) - if (!entry) { - return { - ok: false, - error: new HttpError({ - status: 401, - code: 'EXTENSION_ASSET_EXTENSION_NOT_REGISTERED', - message: 'Unauthorized', - reason: 'extension manifest entry does not exist for requested extensionId', - }), - } - } - - return tokenStore.validate(token, { - extensionId, - version: entry.version, - assetPath, - }) - }, - resolveAsset: async ({ extensionId, assetPath }) => { - const entry = options.getManifestEntryByName().get(extensionId) - if (!entry) { - return { - ok: false, - error: new HttpError({ - status: 404, - code: 'EXTENSION_ASSET_EXTENSION_NOT_FOUND', - message: 'Not Found', - reason: 'extension manifest entry does not exist for requested extensionId', - }), - } - } - - const normalizedAssetPath = normalizePluginAssetPath(assetPath) - if (!normalizedAssetPath) { - return { - ok: false, - error: new HttpError({ - status: 400, - code: 'EXTENSION_ASSET_PATH_INVALID', - message: 'Bad Request', - reason: 'asset path could not be normalized', - }), - } - } - - const fullAssetPath = `ui/${normalizedAssetPath}` - const filePath = await resolvePluginAssetFilePath(entry.rootDir, fullAssetPath) - if (!filePath) { - return { - ok: false, - error: new HttpError({ - status: 400, - code: 'EXTENSION_ASSET_PATH_RESOLVE_FAILED', - message: 'Bad Request', - reason: 'resolved asset path is outside extension root', - }), - } - } - - try { - const fileStats = await stat(filePath) - if (!fileStats.isFile()) { - return { - ok: false, - error: new HttpError({ - status: 404, - code: 'EXTENSION_ASSET_NOT_FILE', - message: 'Not Found', - reason: 'resolved path exists but is not a file', - }), - } - } - - return { - ok: true, - filePath, - size: fileStats.size, - mtime: fileStats.mtimeMs, - } - } - catch { - return { - ok: false, - error: new HttpError({ - status: 404, - code: 'EXTENSION_ASSET_NOT_FOUND', - message: 'Not Found', - reason: 'resolved file does not exist', - }), - } - } - }, - })) - - return { - key: 'extension-static-assets', - async start() { - await serverLifecycle.start() - }, - async stop() { - await serverLifecycle.stop() - }, - getBaseUrl() { - return serverLifecycle.getAddress()?.baseUrl - }, - issueToken: tokenStore.issue, - revokeByExtensionId: tokenStore.revokeByExtensionId, - revokeAll: tokenStore.revokeAll, - } -} - -const extensionAssetMimeTypeOverrides: Record = { - '.wasm': 'application/wasm', - '.avif': 'image/avif', - '.heic': 'image/heic', - '.heif': 'image/heif', -} - -function defaultExtensionAssetMimeTypeResolver(ext: string) { - return extensionAssetMimeTypeOverrides[ext.toLowerCase()] -} diff --git a/apps/stage-tamagotchi/src/main/services/airi/http-server/http/extension-static-assets/route.test.ts b/apps/stage-tamagotchi/src/main/services/airi/http-server/http/extension-static-assets/route.test.ts deleted file mode 100644 index 860015e2e..000000000 --- a/apps/stage-tamagotchi/src/main/services/airi/http-server/http/extension-static-assets/route.test.ts +++ /dev/null @@ -1,100 +0,0 @@ -import { mkdtemp, rm, writeFile } from 'node:fs/promises' -import { createServer } from 'node:http' -import { tmpdir } from 'node:os' -import { join } from 'node:path' - -import { H3 } from 'h3' -import { toNodeHandler } from 'h3/node' -import { afterEach, describe, expect, it } from 'vitest' - -import { HttpError } from '../../errors' -import { createExtensionStaticAssetRoute } from './route' - -describe('createExtensionStaticAssetRoute', () => { - let server: ReturnType | undefined - const tempRoots: string[] = [] - - afterEach(async () => { - for (const root of tempRoots) { - await rm(root, { recursive: true, force: true }) - } - tempRoots.length = 0 - - await new Promise((resolve) => { - if (!server) { - resolve() - return - } - - server.close(() => resolve()) - server = undefined - }) - }) - - it('returns 401 when token is missing', async () => { - const app = new H3() - app.get('/_airi/extensions/:extensionId/ui/**assetPath', createExtensionStaticAssetRoute({ - authorize: async () => ({ - ok: false, - error: new HttpError({ - status: 401, - code: 'TOKEN_MISSING', - message: 'Unauthorized', - }), - }), - resolveAsset: async () => ({ - ok: false, - error: new HttpError({ - status: 404, - code: 'NOT_FOUND', - message: 'Not Found', - }), - }), - })) - - server = createServer(toNodeHandler(app)) - await new Promise(resolve => server!.listen(0, '127.0.0.1', () => resolve())) - const address = server.address() - const port = typeof address === 'object' && address ? address.port : 0 - - const response = await fetch(`http://127.0.0.1:${port}/_airi/extensions/a/ui/index.html`) - expect(response.status).toBe(401) - }) - - it('uses custom getType resolver and sets nosniff', async () => { - const root = await mkdtemp(join(tmpdir(), 'airi-extension-static-assets-')) - tempRoots.push(root) - - const wasmFilePath = join(root, 'module.wasm') - await writeFile(wasmFilePath, new Uint8Array([0, 97, 115, 109])) - - let authorizeCalled = false - const app = new H3() - app.get('/_airi/extensions/:extensionId/ui/:assetPath', createExtensionStaticAssetRoute({ - authorize: async () => { - authorizeCalled = true - return { ok: true } - }, - resolveAsset: async () => ({ - ok: true, - filePath: wasmFilePath, - size: 4, - mtime: Date.now(), - }), - getType: ext => ext === '.wasm' ? 'application/wasm' : undefined, - })) - - server = createServer(toNodeHandler(app)) - await new Promise(resolve => server!.listen(0, '127.0.0.1', () => resolve())) - const address = server.address() - const port = typeof address === 'object' && address ? address.port : 0 - - const response = await fetch(`http://127.0.0.1:${port}/_airi/extensions/a/ui/module.wasm?t=test-token`) - if (!authorizeCalled) { - throw new Error(`Expected authorize to be called. response=${response.status} body=${await response.text()}`) - } - expect(response.status).toBe(200) - expect(response.headers.get('content-type')).toBe('application/wasm') - expect(response.headers.get('x-content-type-options')).toBe('nosniff') - }) -}) diff --git a/apps/stage-tamagotchi/src/main/services/airi/http-server/http/extension-static-assets/route.ts b/apps/stage-tamagotchi/src/main/services/airi/http-server/http/extension-static-assets/route.ts deleted file mode 100644 index 17e1d927f..000000000 --- a/apps/stage-tamagotchi/src/main/services/airi/http-server/http/extension-static-assets/route.ts +++ /dev/null @@ -1,87 +0,0 @@ -import type { ExtensionStaticAssetRouteOptions } from './types' - -import { readFile } from 'node:fs/promises' - -import { eventHandler, getQuery, getRequestURL, serveStatic } from 'h3' - -import { - normalizePluginAssetPath, - parsePluginAssetRequestPath, -} from '../../../plugins/asset-mount' -import { HttpError, toH3HttpError } from '../../errors' - -/** - * Creates the secured extension static asset route handler. - * - * Use when: - * - Serving plugin iframe assets under `/_airi/extensions/:extensionId/ui/**assetPath` - * - * Expects: - * - Query token `t` to be present and valid - * - `resolveAsset` to map request params into a validated local file - * - * Returns: - * - H3 event handler that enforces token auth before static file response - */ -export function createExtensionStaticAssetRoute(options: ExtensionStaticAssetRouteOptions) { - return eventHandler(async (event) => { - try { - const requestPath = parsePluginAssetRequestPath(getRequestURL(event).pathname) - const extensionId = requestPath?.extensionId ?? '' - const assetPath = normalizePluginAssetPath(requestPath?.assetPath ?? '') - const queryToken = getQuery(event).t - const token = typeof queryToken === 'string' ? queryToken : '' - - if (!token || !extensionId || !assetPath) { - throw new HttpError({ - status: 401, - code: 'EXTENSION_ASSET_REQUEST_INVALID', - message: 'Unauthorized', - reason: 'required token, extensionId, or assetPath is missing', - }) - } - - const auth = await options.authorize({ token, extensionId, assetPath }) - if (!auth.ok) { - throw auth.error - } - - let resolved: Awaited> | undefined - const resolveOnce = async () => { - if (!resolved) { - resolved = await options.resolveAsset({ extensionId, assetPath }) - } - return resolved - } - - return await serveStatic(event, { - getType: options.getType, - getContents: async () => { - const item = await resolveOnce() - if (!item.ok) { - throw item.error - } - return await readFile(item.filePath) - }, - getMeta: async () => { - const item = await resolveOnce() - if (!item.ok) { - return undefined - } - - event.res.headers.set('X-Content-Type-Options', 'nosniff') - return { - size: item.size, - mtime: item.mtime, - } - }, - }) - } - catch (error) { - if (error instanceof HttpError) { - throw toH3HttpError(error) - } - throw error - } - }) -} diff --git a/apps/stage-tamagotchi/src/main/services/airi/http-server/http/extension-static-assets/token-store.test.ts b/apps/stage-tamagotchi/src/main/services/airi/http-server/http/extension-static-assets/token-store.test.ts deleted file mode 100644 index 56048011e..000000000 --- a/apps/stage-tamagotchi/src/main/services/airi/http-server/http/extension-static-assets/token-store.test.ts +++ /dev/null @@ -1,64 +0,0 @@ -import { describe, expect, it, vi } from 'vitest' - -import { createExtensionAssetTokenStore } from './token-store' - -describe('createExtensionAssetTokenStore', () => { - it('issues and validates short token', () => { - const now = vi.fn(() => 1000) - const store = createExtensionAssetTokenStore({ now }) - - const token = store.issue({ - extensionId: 'airi-plugin-game-chess', - version: '0.1.0', - sessionId: 'session-1', - pathPrefix: 'ui/', - ttlMs: 30_000, - }) - - expect(token.length).toBeLessThanOrEqual(24) - expect(store.validate(token, { - extensionId: 'airi-plugin-game-chess', - version: '0.1.0', - assetPath: 'ui/index.html', - }).ok).toBe(true) - }) - - it('rejects mismatched plugin and revoked tokens', () => { - const now = vi.fn(() => 1000) - const store = createExtensionAssetTokenStore({ now }) - - const token = store.issue({ - extensionId: 'airi-plugin-game-chess', - version: '0.1.0', - sessionId: 'session-1', - pathPrefix: 'ui/', - ttlMs: 30_000, - }) - - expect(store.validate(token, { - extensionId: 'other-plugin', - version: '0.1.0', - assetPath: 'ui/index.html', - })).toMatchObject({ - ok: false, - error: { - status: 401, - code: 'EXTENSION_ASSET_EXTENSION_MISMATCH', - }, - }) - - store.revokeByExtensionId('airi-plugin-game-chess') - - expect(store.validate(token, { - extensionId: 'airi-plugin-game-chess', - version: '0.1.0', - assetPath: 'ui/index.html', - })).toMatchObject({ - ok: false, - error: { - status: 401, - code: 'EXTENSION_ASSET_TOKEN_NOT_FOUND', - }, - }) - }) -}) diff --git a/apps/stage-tamagotchi/src/main/services/airi/http-server/http/extension-static-assets/token-store.ts b/apps/stage-tamagotchi/src/main/services/airi/http-server/http/extension-static-assets/token-store.ts deleted file mode 100644 index f543281a0..000000000 --- a/apps/stage-tamagotchi/src/main/services/airi/http-server/http/extension-static-assets/token-store.ts +++ /dev/null @@ -1,134 +0,0 @@ -import type { - ExtensionAssetTokenIssueInput, - ExtensionAssetTokenStore, - ExtensionAssetTokenValidateInput, - ExtensionAssetTokenValidationResult, -} from './types' - -import { randomBytes } from 'node:crypto' - -import { HttpError } from '../../errors' - -interface ExtensionAssetTokenRecord { - extensionId: string - version: string - sessionId: string - pathPrefix: string - exp: number -} - -function normalizePathPrefix(pathPrefix: string) { - const normalized = pathPrefix.trim().replaceAll('\\', '/') - if (!normalized) { - return '' - } - - return normalized -} - -function createOpaqueToken() { - return randomBytes(18).toString('base64url') -} - -/** - * Creates an in-memory opaque token store for extension static asset access. - * - * Use when: - * - Main process needs short-lived auth tokens for plugin iframe asset loading - * - * Expects: - * - Tokens are opaque and stored server-side only - * - Callers revoke by plugin id or revoke all on app shutdown - * - * Returns: - * - Issue/validate/revoke operations for extension asset tokens - */ -export function createExtensionAssetTokenStore(options: { now?: () => number } = {}): ExtensionAssetTokenStore { - const now = options.now ?? (() => Date.now()) - const records = new Map() - - const dropIfExpired = (token: string, record: ExtensionAssetTokenRecord) => { - if (record.exp > now()) { - return false - } - - records.delete(token) - return true - } - - const issue = (input: ExtensionAssetTokenIssueInput) => { - const token = createOpaqueToken() - records.set(token, { - extensionId: input.extensionId, - version: input.version, - sessionId: input.sessionId, - pathPrefix: normalizePathPrefix(input.pathPrefix), - exp: now() + input.ttlMs, - }) - return token - } - - const unauthorized = (code: string, reason: string) => { - return { - ok: false as const, - error: new HttpError({ - status: 401, - code, - message: 'Unauthorized', - reason, - }), - } - } - - const validate = (token: string, input: ExtensionAssetTokenValidateInput): ExtensionAssetTokenValidationResult => { - const record = records.get(token) - if (!record) { - return unauthorized('EXTENSION_ASSET_TOKEN_NOT_FOUND', 'token was not found in token store') - } - - if (dropIfExpired(token, record)) { - return unauthorized('EXTENSION_ASSET_TOKEN_EXPIRED', 'token has expired') - } - - if (record.extensionId !== input.extensionId) { - return unauthorized('EXTENSION_ASSET_EXTENSION_MISMATCH', 'token extensionId does not match request extensionId') - } - - if (typeof input.version === 'string' && record.version !== input.version) { - return unauthorized('EXTENSION_ASSET_VERSION_MISMATCH', 'token version does not match request version') - } - - const normalizedAssetPath = input.assetPath.trim().replaceAll('\\', '/') - if (!normalizedAssetPath) { - return unauthorized('EXTENSION_ASSET_PATH_EMPTY', 'asset path is empty') - } - - if (record.pathPrefix) { - const isDirectoryPrefix = record.pathPrefix.endsWith('/') - const isAllowed = isDirectoryPrefix - ? normalizedAssetPath.startsWith(record.pathPrefix) - : normalizedAssetPath === record.pathPrefix - - if (!isAllowed) { - return unauthorized('EXTENSION_ASSET_PATH_PREFIX_MISMATCH', 'asset path is outside allowed prefix') - } - } - - return { ok: true } - } - - return { - issue, - validate, - revokeByExtensionId(extensionId) { - for (const [token, record] of records.entries()) { - if (record.extensionId === extensionId) { - records.delete(token) - } - } - }, - revokeAll() { - records.clear() - }, - } -} diff --git a/apps/stage-tamagotchi/src/main/services/airi/http-server/http/extension-static-assets/types.ts b/apps/stage-tamagotchi/src/main/services/airi/http-server/http/extension-static-assets/types.ts deleted file mode 100644 index 288ec88bf..000000000 --- a/apps/stage-tamagotchi/src/main/services/airi/http-server/http/extension-static-assets/types.ts +++ /dev/null @@ -1,36 +0,0 @@ -import type { HttpError } from '../../errors' - -export interface ExtensionAssetTokenIssueInput { - extensionId: string - version: string - sessionId: string - pathPrefix: string - ttlMs: number -} - -export interface ExtensionAssetTokenValidateInput { - extensionId: string - version?: string - assetPath: string -} - -export type ExtensionAssetTokenValidationResult - = | { ok: true } - | { ok: false, error: HttpError } - -export interface ExtensionAssetTokenStore { - issue: (input: ExtensionAssetTokenIssueInput) => string - validate: (token: string, input: ExtensionAssetTokenValidateInput) => ExtensionAssetTokenValidationResult - revokeByExtensionId: (extensionId: string) => void - revokeAll: () => void -} - -export type ExtensionStaticAssetResolveResult - = | { ok: true, filePath: string, size: number, mtime: number } - | { ok: false, error: HttpError } - -export interface ExtensionStaticAssetRouteOptions { - authorize: (params: { token: string, extensionId: string, assetPath: string }) => Promise - resolveAsset: (params: { extensionId: string, assetPath: string }) => Promise - getType?: (ext: string) => string | undefined -} diff --git a/apps/stage-tamagotchi/src/main/services/airi/http-server/index.test.ts b/apps/stage-tamagotchi/src/main/services/airi/http-server/index.test.ts index 3aeb75d44..8d5c5fdb8 100644 --- a/apps/stage-tamagotchi/src/main/services/airi/http-server/index.test.ts +++ b/apps/stage-tamagotchi/src/main/services/airi/http-server/index.test.ts @@ -9,7 +9,7 @@ describe('setupBuiltInServer', () => { const service = setupBuiltInServer({ authServer: auth, - extensionStaticAssetServer: assets, + staticAssetServer: assets, }) await service.start() diff --git a/apps/stage-tamagotchi/src/main/services/airi/http-server/index.ts b/apps/stage-tamagotchi/src/main/services/airi/http-server/index.ts index 40b31cdac..e8b9e5178 100644 --- a/apps/stage-tamagotchi/src/main/services/airi/http-server/index.ts +++ b/apps/stage-tamagotchi/src/main/services/airi/http-server/index.ts @@ -21,12 +21,12 @@ export interface BuiltInServer { */ export function setupBuiltInServer(params: { authServer?: ServerManager - extensionStaticAssetServer?: ServerManager + staticAssetServer?: ServerManager servers?: ServerManager[] }): BuiltInServer { const servers = [ ...(params.authServer ? [params.authServer] : []), - ...(params.extensionStaticAssetServer ? [params.extensionStaticAssetServer] : []), + ...(params.staticAssetServer ? [params.staticAssetServer] : []), ...(params.servers ?? []), ] const manager = createHttpServerManager(servers) diff --git a/apps/stage-tamagotchi/src/main/services/airi/http-server/static-assets/index.test.ts b/apps/stage-tamagotchi/src/main/services/airi/http-server/static-assets/index.test.ts new file mode 100644 index 000000000..f53210dff --- /dev/null +++ b/apps/stage-tamagotchi/src/main/services/airi/http-server/static-assets/index.test.ts @@ -0,0 +1,261 @@ +import { mkdir, mkdtemp, rm, writeFile } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' + +import { afterEach, describe, expect, it } from 'vitest' + +import { createStaticAssetService } from './index' +import { createStaticAssetSessionStore } from './session-store' + +describe('createStaticAssetService', () => { + const servers: Array> = [] + const tempRoots: string[] = [] + + afterEach(async () => { + while (servers.length > 0) { + const server = servers.pop() + if (!server) { + continue + } + await server.stop() + } + + for (const root of tempRoots) { + await rm(root, { recursive: true, force: true }) + } + tempRoots.length = 0 + }) + + it('accepts pathPrefix relative to /ui route segment', async () => { + const rootDir = await mkdtemp(join(tmpdir(), 'airi-extension-static-assets-')) + tempRoots.push(rootDir) + await mkdir(join(rootDir, 'ui', 'assets'), { recursive: true }) + await writeFile(join(rootDir, 'ui', 'assets', 'app.js'), 'console.log("ok")\n') + + const extensionId = 'airi-plugin-game-chess' + const version = '1.0.0' + const sessionStore = createStaticAssetSessionStore() + const validateInputs: string[] = [] + const server = createStaticAssetService({ + getManifestEntryByName: () => new Map([ + [extensionId, { rootDir, version }], + ]), + sessionStore: { + ...sessionStore, + validateRequest(input) { + validateInputs.push(input.assetPath) + return sessionStore.validateRequest(input) + }, + }, + }) + servers.push(server) + await server.start() + + const session = server.createSession({ + extensionId, + version, + ownerSessionId: 'session-1', + pathPrefix: '', + ttlMs: 60_000, + }) + + const baseUrl = server.getBaseUrl() + expect(baseUrl).toBeTruthy() + + const response = await fetch(`${baseUrl}/_airi/extensions/${extensionId}/sessions/${session.assetSessionId}/ui/assets/app.js`, { + headers: { + cookie: `${session.cookieName}=${session.cookieValue}`, + }, + }) + const responseBody = await response.text() + expect({ + status: response.status, + validateInputs, + responseBody, + }).toEqual({ + status: 200, + validateInputs: ['assets/app.js'], + responseBody: 'console.log("ok")\n', + }) + }) + + it('serves HEAD requests with valid cookie auth refresh and empty body', async () => { + let refreshedSessionId: string | undefined + const { extensionId, server } = await createStartedAssetServer({ + onRefreshSession: (assetSessionId) => { + refreshedSessionId = assetSessionId + }, + }) + + const session = server.createSession({ + extensionId, + version: '1.0.0', + ownerSessionId: 'session-1', + pathPrefix: '', + ttlMs: 60_000, + }) + + const response = await fetch(`${server.getBaseUrl()}/_airi/extensions/${extensionId}/sessions/${session.assetSessionId}/ui/assets/app.js`, { + headers: { + cookie: `${session.cookieName}=${session.cookieValue}`, + }, + method: 'HEAD', + }) + + expect(response.status).toBe(200) + expect(refreshedSessionId).toBe(session.assetSessionId) + expect(response.headers.get('cache-control')).toBe('no-store') + expect(response.headers.get('referrer-policy')).toBe('no-referrer') + expect(response.headers.get('x-content-type-options')).toBe('nosniff') + expect(await response.text()).toBe('') + }) + + it('returns 405 with security headers for POST requests', async () => { + const { extensionId, server } = await createStartedAssetServer() + + const response = await fetch(`${server.getBaseUrl()}/_airi/extensions/${extensionId}/sessions/session-1/ui/assets/app.js`, { + method: 'POST', + }) + + expect(response.status).toBe(405) + expect(response.headers.get('cache-control')).toBe('no-store') + expect(response.headers.get('referrer-policy')).toBe('no-referrer') + expect(response.headers.get('x-content-type-options')).toBe('nosniff') + }) + + it('returns 401 with security headers when cookie is missing through the real server', async () => { + const { extensionId, server } = await createStartedAssetServer() + const session = server.createSession({ + extensionId, + version: '1.0.0', + ownerSessionId: 'session-1', + pathPrefix: '', + ttlMs: 60_000, + }) + + const response = await fetch(`${server.getBaseUrl()}/_airi/extensions/${extensionId}/sessions/${session.assetSessionId}/ui/assets/app.js`) + + expect(response.status).toBe(401) + expect(response.headers.get('cache-control')).toBe('no-store') + expect(response.headers.get('referrer-policy')).toBe('no-referrer') + expect(response.headers.get('x-content-type-options')).toBe('nosniff') + }) + + it('rejects a previously valid URL and cookie after revocation', async () => { + const { extensionId, server } = await createStartedAssetServer() + const session = server.createSession({ + extensionId, + version: '1.0.0', + ownerSessionId: 'session-1', + pathPrefix: '', + ttlMs: 60_000, + }) + const url = `${server.getBaseUrl()}/_airi/extensions/${extensionId}/sessions/${session.assetSessionId}/ui/assets/app.js` + const headers = { + cookie: `${session.cookieName}=${session.cookieValue}`, + } + + const validResponse = await fetch(url, { headers }) + await validResponse.arrayBuffer() + server.revokeSession(session.assetSessionId) + const revokedResponse = await fetch(url, { headers }) + + expect(validResponse.status).toBe(200) + expect(revokedResponse.status).toBe(401) + expect(revokedResponse.headers.get('cache-control')).toBe('no-store') + expect(revokedResponse.headers.get('referrer-policy')).toBe('no-referrer') + expect(revokedResponse.headers.get('x-content-type-options')).toBe('nosniff') + }) + + it('returns 404 for missing in-root assets with a valid session', async () => { + const { extensionId, server } = await createStartedAssetServer() + const session = server.createSession({ + extensionId, + version: '1.0.0', + ownerSessionId: 'session-1', + pathPrefix: '', + ttlMs: 60_000, + }) + + const response = await fetch(`${server.getBaseUrl()}/_airi/extensions/${extensionId}/sessions/${session.assetSessionId}/ui/assets/missing.js`, { + headers: { + cookie: `${session.cookieName}=${session.cookieValue}`, + }, + }) + + expect(response.status).toBe(404) + expect(response.headers.get('cache-control')).toBe('no-store') + expect(response.headers.get('referrer-policy')).toBe('no-referrer') + expect(response.headers.get('x-content-type-options')).toBe('nosniff') + }) + + it('uses the same manifest entry for auth and asset resolution within one request', async () => { + const firstRootDir = await mkdtemp(join(tmpdir(), 'airi-extension-static-assets-')) + const secondRootDir = await mkdtemp(join(tmpdir(), 'airi-extension-static-assets-')) + tempRoots.push(firstRootDir, secondRootDir) + await mkdir(join(firstRootDir, 'ui', 'assets'), { recursive: true }) + await mkdir(join(secondRootDir, 'ui', 'assets'), { recursive: true }) + await writeFile(join(firstRootDir, 'ui', 'assets', 'app.js'), 'console.log("first")\n') + await writeFile(join(secondRootDir, 'ui', 'assets', 'app.js'), 'console.log("second")\n') + + const extensionId = 'airi-plugin-game-chess' + const version = '1.0.0' + const manifestEntries = [ + new Map([[extensionId, { rootDir: firstRootDir, version }]]), + new Map([[extensionId, { rootDir: secondRootDir, version }]]), + ] + let manifestReadCount = 0 + const server = createStaticAssetService({ + getManifestEntryByName: () => manifestEntries[Math.min(manifestReadCount++, manifestEntries.length - 1)], + }) + servers.push(server) + await server.start() + + const session = server.createSession({ + extensionId, + version, + ownerSessionId: 'session-1', + pathPrefix: '', + ttlMs: 60_000, + }) + + const response = await fetch(`${server.getBaseUrl()}/_airi/extensions/${extensionId}/sessions/${session.assetSessionId}/ui/assets/app.js`, { + headers: { + cookie: `${session.cookieName}=${session.cookieValue}`, + }, + }) + + expect(response.status).toBe(200) + expect(await response.text()).toBe('console.log("first")\n') + expect(manifestReadCount).toBe(1) + }) + + async function createStartedAssetServer(options: { + onRefreshSession?: (assetSessionId: string) => void + } = {}) { + const rootDir = await mkdtemp(join(tmpdir(), 'airi-extension-static-assets-')) + tempRoots.push(rootDir) + await mkdir(join(rootDir, 'ui', 'assets'), { recursive: true }) + await writeFile(join(rootDir, 'ui', 'assets', 'app.js'), 'console.log("ok")\n') + + const extensionId = 'airi-plugin-game-chess' + const version = '1.0.0' + const sessionStore = createStaticAssetSessionStore() + const server = createStaticAssetService({ + getManifestEntryByName: () => new Map([ + [extensionId, { rootDir, version }], + ]), + sessionStore: { + ...sessionStore, + refreshSession(assetSessionId) { + options.onRefreshSession?.(assetSessionId) + return sessionStore.refreshSession(assetSessionId) + }, + }, + }) + servers.push(server) + await server.start() + + return { extensionId, rootDir, server, sessionStore, version } + } +}) diff --git a/apps/stage-tamagotchi/src/main/services/airi/http-server/static-assets/index.ts b/apps/stage-tamagotchi/src/main/services/airi/http-server/static-assets/index.ts new file mode 100644 index 000000000..4bf648950 --- /dev/null +++ b/apps/stage-tamagotchi/src/main/services/airi/http-server/static-assets/index.ts @@ -0,0 +1,221 @@ +import type { ServerManager } from '../server-manager/types' +import type { StaticAssetSessionStore } from './types' + +import { AsyncLocalStorage } from 'node:async_hooks' +import { realpath, stat } from 'node:fs/promises' +import { resolve } from 'node:path' + +import { H3 } from 'h3' + +import { HttpError } from '../errors' +import { createH3Server } from '../server' +import { + normalizeStaticAssetPath, + resolveStaticAssetFilePath, +} from './paths' +import { createStaticAssetRoute } from './route' +import { createStaticAssetSessionStore } from './session-store' + +export interface StaticAssetManifestEntry { + rootDir: string + version: string +} + +export interface StaticAssetService extends ServerManager { + getBaseUrl: () => string | undefined + createSession: StaticAssetSessionStore['createSession'] + revokeSession: StaticAssetSessionStore['revokeSession'] + revokeByOwnerSessionId: StaticAssetSessionStore['revokeByOwnerSessionId'] + revokeByExtensionId: StaticAssetSessionStore['revokeByExtensionId'] + revokeAll: StaticAssetSessionStore['revokeAll'] +} + +/** + * Creates the low-level extension static asset transport server. + * + * Use when: + * - Main process must serve plugin iframe assets via local loopback HTTP + * - Cookie-backed session auth is required for all plugin asset requests + * - A higher-level plugin asset service needs an HTTP transport adapter + * + * Expects: + * - `getManifestEntryByName` returns up-to-date plugin root/version map + * + * Returns: + * - Lifecycle service with session create/revoke APIs and local base URL getter + */ +export function createStaticAssetService(options: { + getManifestEntryByName: () => Map + host?: string + sessionStore?: StaticAssetSessionStore + getType?: (ext: string) => string | undefined +}): StaticAssetService { + const host = options.host ?? '127.0.0.1' + const sessionStore = options.sessionStore ?? createStaticAssetSessionStore() + const getType = options.getType ?? defaultStaticAssetMimeTypeResolver + + const app = new H3() + const serverLifecycle = createH3Server({ app, host }) + const manifestEntryRequestCache = new AsyncLocalStorage>() + const getManifestEntryForRequest = (extensionId: string) => { + const cache = manifestEntryRequestCache.getStore() + if (!cache) { + return options.getManifestEntryByName().get(extensionId) + } + + if (!cache.has(extensionId)) { + cache.set(extensionId, options.getManifestEntryByName().get(extensionId)) + } + + return cache.get(extensionId) + } + + const staticAssetRoute = createStaticAssetRoute({ + getType, + authorize: async ({ extensionId, assetSessionId, assetPath, cookieValue }) => { + const entry = getManifestEntryForRequest(extensionId) + if (!entry) { + return { + ok: false, + error: new HttpError({ + status: 401, + code: 'EXTENSION_ASSET_EXTENSION_NOT_REGISTERED', + message: 'Unauthorized', + reason: 'extension manifest entry does not exist for requested extensionId', + }), + } + } + + return sessionStore.validateRequest({ + extensionId, + version: entry.version, + assetSessionId, + assetPath, + cookieValue, + }) + }, + refreshSession: sessionStore.refreshSession, + resolveAsset: async ({ extensionId, assetPath }) => { + const entry = getManifestEntryForRequest(extensionId) + if (!entry) { + return { + ok: false, + error: new HttpError({ + status: 404, + code: 'EXTENSION_ASSET_EXTENSION_NOT_FOUND', + message: 'Not Found', + reason: 'extension manifest entry does not exist for requested extensionId', + }), + } + } + + const normalizedAssetPath = normalizeStaticAssetPath(assetPath) + if (!normalizedAssetPath) { + return { + ok: false, + error: new HttpError({ + status: 400, + code: 'EXTENSION_ASSET_PATH_INVALID', + message: 'Bad Request', + reason: 'asset path could not be normalized', + }), + } + } + + const fullAssetPath = `ui/${normalizedAssetPath}` + const resolvedRoot = await realpath(entry.rootDir) + const candidatePath = resolve(resolvedRoot, fullAssetPath) + const filePath = await resolveStaticAssetFilePath(entry.rootDir, fullAssetPath) + if (!filePath) { + try { + await stat(candidatePath) + } + catch { + return { + ok: false, + error: new HttpError({ + status: 404, + code: 'EXTENSION_ASSET_NOT_FOUND', + message: 'Not Found', + reason: 'resolved file does not exist', + }), + } + } + + return { + ok: false, + error: new HttpError({ + status: 400, + code: 'EXTENSION_ASSET_PATH_RESOLVE_FAILED', + message: 'Bad Request', + reason: 'resolved asset path is outside extension root', + }), + } + } + + try { + const fileStats = await stat(filePath) + if (!fileStats.isFile()) { + return { + ok: false, + error: new HttpError({ + status: 404, + code: 'EXTENSION_ASSET_NOT_FILE', + message: 'Not Found', + reason: 'resolved path exists but is not a file', + }), + } + } + + return { + ok: true, + filePath, + size: fileStats.size, + mtime: fileStats.mtimeMs, + } + } + catch { + return { + ok: false, + error: new HttpError({ + status: 404, + code: 'EXTENSION_ASSET_NOT_FOUND', + message: 'Not Found', + reason: 'resolved file does not exist', + }), + } + } + }, + }) + + app.use('/_airi/extensions/**', event => manifestEntryRequestCache.run(new Map(), () => staticAssetRoute(event))) + + return { + key: 'static-assets', + async start() { + await serverLifecycle.start() + }, + async stop() { + await serverLifecycle.stop() + }, + getBaseUrl() { + return serverLifecycle.getAddress()?.baseUrl + }, + createSession: sessionStore.createSession, + revokeSession: sessionStore.revokeSession, + revokeByOwnerSessionId: sessionStore.revokeByOwnerSessionId, + revokeByExtensionId: sessionStore.revokeByExtensionId, + revokeAll: sessionStore.revokeAll, + } +} + +const staticAssetMimeTypeOverrides: Record = { + '.wasm': 'application/wasm', + '.avif': 'image/avif', + '.heic': 'image/heic', + '.heif': 'image/heif', +} + +function defaultStaticAssetMimeTypeResolver(ext: string) { + return staticAssetMimeTypeOverrides[ext.toLowerCase()] +} diff --git a/apps/stage-tamagotchi/src/main/services/airi/http-server/static-assets/paths.test.ts b/apps/stage-tamagotchi/src/main/services/airi/http-server/static-assets/paths.test.ts new file mode 100644 index 000000000..cff0a8bee --- /dev/null +++ b/apps/stage-tamagotchi/src/main/services/airi/http-server/static-assets/paths.test.ts @@ -0,0 +1,92 @@ +import { mkdir, mkdtemp, rm, symlink, writeFile } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' + +import { afterEach, describe, expect, it } from 'vitest' + +import { + buildMountedStaticAssetPath, + normalizeStaticAssetPath, + parseStaticAssetRequestPath, + resolveStaticAssetFilePath, +} from './paths' + +describe('static asset paths', () => { + const tempRoots: string[] = [] + + afterEach(async () => { + for (const root of tempRoots) { + await rm(root, { recursive: true, force: true }) + } + tempRoots.length = 0 + }) + + it('normalizes valid asset paths and rejects traversal-like segments', () => { + expect(normalizeStaticAssetPath('dist/ui/index.html')).toBe('dist/ui/index.html') + expect(normalizeStaticAssetPath('./dist/ui/index.html')).toBeUndefined() + expect(normalizeStaticAssetPath('../secret.txt')).toBeUndefined() + expect(normalizeStaticAssetPath('dist/../ui/index.html')).toBeUndefined() + }) + + it('parses session-scoped mounted plugin request path and rejects malformed routes', () => { + expect(parseStaticAssetRequestPath('/_airi/extensions/airi-plugin-game-chess/sessions/asset-session-1/ui/dist/ui/index.html')).toEqual({ + extensionId: 'airi-plugin-game-chess', + assetSessionId: 'asset-session-1', + assetPath: 'dist/ui/index.html', + }) + expect(parseStaticAssetRequestPath('/_airi/extensions/airi-plugin-game-chess/ui/dist/ui/index.html')).toBeUndefined() + expect(parseStaticAssetRequestPath('/_airi/extensions/airi-plugin-game-chess/sessions/asset-session-1/ui/../../etc/passwd')).toBeUndefined() + expect(parseStaticAssetRequestPath('/_airi/extensions//sessions/asset-session-1/ui/index.html')).toBeUndefined() + expect(parseStaticAssetRequestPath('/_airi/extensions/airi-plugin-game-chess//sessions/asset-session-1/ui/index.html')).toBeUndefined() + expect(parseStaticAssetRequestPath('/_airi/extensions/airi-plugin-game-chess/sessions//ui/index.html')).toBeUndefined() + expect(parseStaticAssetRequestPath('/_airi/extensions/airi-plugin-game-chess/sessions/asset-session-1//ui/index.html')).toBeUndefined() + expect(parseStaticAssetRequestPath('/_airi/extensions/p/sessions/s/ui/safe%2F..%2Fsecret.txt')).toBeUndefined() + expect(parseStaticAssetRequestPath('/_airi/extensions/p%/sessions/s/ui/index.html')).toBeUndefined() + }) + + it('builds session-scoped mounted asset path with encoded segments', () => { + expect(buildMountedStaticAssetPath({ + extensionId: 'airi-plugin-game-chess', + assetSessionId: 'asset-session-1', + assetPath: 'dist/ui/index.html', + })).toBe('/_airi/extensions/airi-plugin-game-chess/sessions/asset-session-1/ui/dist/ui/index.html') + expect(buildMountedStaticAssetPath({ + extensionId: 'airi-plugin-game-chess', + assetSessionId: 'asset-session-1', + assetPath: 'dist/ui/file name.html', + })).toBe('/_airi/extensions/airi-plugin-game-chess/sessions/asset-session-1/ui/dist/ui/file%20name.html') + expect(buildMountedStaticAssetPath({ + extensionId: 'bad/id', + assetSessionId: 'asset-session-1', + assetPath: 'dist/ui/index.html', + })).toBeUndefined() + expect(buildMountedStaticAssetPath({ + extensionId: 'airi-plugin-game-chess', + assetSessionId: 'bad session', + assetPath: 'dist/ui/index.html', + })).toBeUndefined() + }) + + it('resolves only files inside plugin root', async () => { + const root = await mkdtemp(join(tmpdir(), 'airi-plugin-assets-')) + tempRoots.push(root) + + await mkdir(join(root, 'dist', 'ui'), { recursive: true }) + await writeFile(join(root, 'dist', 'ui', 'index.html'), '') + + await expect(resolveStaticAssetFilePath(root, 'dist/ui/index.html')).resolves.toContain('dist/ui/index.html') + await expect(resolveStaticAssetFilePath(root, '../outside.txt')).resolves.toBeUndefined() + }) + + it('rejects symlinked plugin asset files that resolve outside plugin root', async () => { + const root = await mkdtemp(join(tmpdir(), 'airi-plugin-assets-')) + const outsideRoot = await mkdtemp(join(tmpdir(), 'airi-plugin-assets-outside-')) + tempRoots.push(root, outsideRoot) + + const outsideFile = join(outsideRoot, 'secret.txt') + await writeFile(outsideFile, 'secret') + await symlink(outsideFile, join(root, 'link-name')) + + await expect(resolveStaticAssetFilePath(root, 'link-name')).resolves.toBeUndefined() + }) +}) diff --git a/apps/stage-tamagotchi/src/main/services/airi/http-server/static-assets/paths.ts b/apps/stage-tamagotchi/src/main/services/airi/http-server/static-assets/paths.ts new file mode 100644 index 000000000..6e2c4f97f --- /dev/null +++ b/apps/stage-tamagotchi/src/main/services/airi/http-server/static-assets/paths.ts @@ -0,0 +1,235 @@ +import { realpath } from 'node:fs/promises' +import { resolve, sep } from 'node:path' + +/** + * Parsed session-scoped plugin asset request route. + * + * @param extensionId Plugin extension identifier from the mounted route. + * @param assetSessionId Asset session identifier from the mounted route. + */ +export interface ParsedStaticAssetRequest { + /** Plugin extension identifier validated as one safe route segment. */ + extensionId: string + /** Asset session identifier validated as one safe route segment. */ + assetSessionId: string + /** Normalized plugin asset path relative to the mounted UI asset root. */ + assetPath: string +} + +const pathPrefix = '/_airi/extensions/' +const segmentPattern = /^[\w.+-]+$/ + +function decodePathSegment(segment: string): string | undefined { + try { + return decodeURIComponent(segment) + } + catch { + return undefined + } +} + +function isSafeRouteSegment(segment: string): boolean { + return !segment.includes('/') + && !segment.includes('\\') + && segmentPattern.test(segment) +} + +/** + * Normalizes plugin asset paths into safe forward-slash relative paths. + * + * Use when: + * - Accepting route asset paths before resolving files + * - Building mounted asset URLs from plugin-owned asset paths + * + * Expects: + * - Input may contain URL-encoded path segments + * - Decoded segments must not introduce separators or traversal segments + * + * Returns: + * - A slash-joined relative path, or `undefined` for empty, malformed, or traversal-like input + * + * Before: + * - "dist\\ui\\file%20name.html" + * - "safe%2F..%2Fsecret.txt" + * + * After: + * - "dist/ui/file name.html" + * - undefined + */ +export function normalizeStaticAssetPath(value: string): string | undefined { + const normalized = value.trim().replaceAll('\\', '/') + if (!normalized) { + return undefined + } + + const segments: string[] = [] + for (const rawSegment of normalized + .split('/') + ) { + const decodedSegment = decodePathSegment(rawSegment)?.trim() + if (decodedSegment == null) { + return undefined + } + + if (!decodedSegment) { + continue + } + + if (decodedSegment.includes('/') || decodedSegment.includes('\\')) { + return undefined + } + + segments.push(decodedSegment) + } + + if (segments.length === 0) { + return undefined + } + + if (segments.some(segment => segment === '.' || segment === '..')) { + return undefined + } + + return segments.join('/') +} + +/** + * Parses one session-scoped mounted plugin asset request path. + * + * Use when: + * - Handling `/_airi/extensions/:extensionId/sessions/:assetSessionId/ui/:assetPath` requests + * - Rejecting malformed plugin asset routes before file resolution + * + * Expects: + * - `pathname` is the URL pathname without query or hash + * - Route identity segments are safe single path segments + * + * Returns: + * - Parsed route fields with a normalized asset path, or `undefined` when the route is invalid + */ +export function parseStaticAssetRequestPath(pathname: string): ParsedStaticAssetRequest | undefined { + if (!pathname.startsWith(pathPrefix)) { + return undefined + } + + const rawRemainder = pathname.slice(pathPrefix.length) + if (!rawRemainder) { + return undefined + } + + const segments = rawRemainder.split('/') + if (segments.length < 5) { + return undefined + } + + if (segments.includes('')) { + return undefined + } + + const extensionId = decodePathSegment(segments[0] ?? '') + const sessionsSegment = decodePathSegment(segments[1] ?? '') + const assetSessionId = decodePathSegment(segments[2] ?? '') + const mountSegment = decodePathSegment(segments[3] ?? '') + const rawAssetPath = segments.slice(4).join('/') + + if ( + extensionId == null + || sessionsSegment == null + || assetSessionId == null + || mountSegment == null + || !isSafeRouteSegment(extensionId) + || sessionsSegment !== 'sessions' + || !isSafeRouteSegment(assetSessionId) + || mountSegment !== 'ui' + ) { + return undefined + } + + const assetPath = normalizeStaticAssetPath(rawAssetPath) + if (!assetPath) { + return undefined + } + + return { + extensionId, + assetSessionId, + assetPath, + } +} + +/** + * Resolves a normalized plugin asset path to a real file inside one plugin root. + * + * Use when: + * - Serving mounted plugin assets from disk + * - Preventing traversal and symlink escapes from the plugin asset root + * + * Expects: + * - `rootDir` exists and can be resolved with `realpath` + * - `assetPath` is route-relative user input and may still need normalization + * + * Returns: + * - The candidate file's real path when it exists inside `rootDir` + * - `undefined` when input is invalid, missing, or resolves outside `rootDir` + */ +export async function resolveStaticAssetFilePath(rootDir: string, assetPath: string) { + const normalizedAssetPath = normalizeStaticAssetPath(assetPath) + if (!normalizedAssetPath) { + return undefined + } + + const resolvedRoot = await realpath(rootDir) + const resolvedCandidate = resolve(resolvedRoot, normalizedAssetPath) + let realCandidate: string + try { + realCandidate = await realpath(resolvedCandidate) + } + catch { + return undefined + } + + const normalizedRootPrefix = `${resolvedRoot}${sep}` + if (realCandidate !== resolvedRoot && !realCandidate.startsWith(normalizedRootPrefix)) { + return undefined + } + + return realCandidate +} + +/** + * Builds a session-scoped mounted plugin asset route path. + * + * Use when: + * - Converting a validated plugin asset path into a mounted HTTP route + * - Emitting URLs for `/_airi/extensions/:extensionId/sessions/:assetSessionId/ui/:assetPath` + * + * Expects: + * - `extensionId` and `assetSessionId` are safe single route segments + * - `assetPath` is a plugin-relative asset path accepted by {@link normalizeStaticAssetPath} + * + * Returns: + * - Encoded mounted route path, or `undefined` when any input is unsafe + */ +export function buildMountedStaticAssetPath(input: { + extensionId: string + assetSessionId: string + assetPath: string +}) { + if (!isSafeRouteSegment(input.extensionId) || !isSafeRouteSegment(input.assetSessionId)) { + return undefined + } + + const normalizedAssetPath = normalizeStaticAssetPath(input.assetPath) + if (!normalizedAssetPath) { + return undefined + } + + const encodedExtensionId = encodeURIComponent(input.extensionId) + const encodedAssetSessionId = encodeURIComponent(input.assetSessionId) + const encodedAssetPath = normalizedAssetPath + .split('/') + .map(segment => encodeURIComponent(segment)) + .join('/') + + return `${pathPrefix}${encodedExtensionId}/sessions/${encodedAssetSessionId}/ui/${encodedAssetPath}` +} diff --git a/apps/stage-tamagotchi/src/main/services/airi/http-server/static-assets/route.test.ts b/apps/stage-tamagotchi/src/main/services/airi/http-server/static-assets/route.test.ts new file mode 100644 index 000000000..ea19b4f4b --- /dev/null +++ b/apps/stage-tamagotchi/src/main/services/airi/http-server/static-assets/route.test.ts @@ -0,0 +1,217 @@ +import { mkdtemp, rm, writeFile } from 'node:fs/promises' +import { createServer } from 'node:http' +import { tmpdir } from 'node:os' +import { join } from 'node:path' + +import { H3 } from 'h3' +import { toNodeHandler } from 'h3/node' +import { afterEach, describe, expect, it } from 'vitest' + +import { HttpError } from '../errors' +import { createStaticAssetRoute } from './route' +import { createStaticAssetSessionCookieName } from './session-store' + +describe('createStaticAssetRoute', () => { + let server: ReturnType | undefined + const tempRoots: string[] = [] + + afterEach(async () => { + for (const root of tempRoots) { + await rm(root, { recursive: true, force: true }) + } + tempRoots.length = 0 + + await new Promise((resolve) => { + if (!server) { + resolve() + return + } + + server.close(() => resolve()) + server = undefined + }) + }) + + it('returns 401 when cookie is missing', async () => { + const app = new H3() + app.get('/_airi/extensions/**', createStaticAssetRoute({ + authorize: async () => ({ + ok: false, + error: new HttpError({ + status: 401, + code: 'COOKIE_MISSING', + message: 'Unauthorized', + }), + }), + refreshSession: () => undefined, + resolveAsset: async () => ({ + ok: false, + error: new HttpError({ + status: 404, + code: 'NOT_FOUND', + message: 'Not Found', + }), + }), + })) + + server = createServer(toNodeHandler(app)) + await new Promise(resolve => server!.listen(0, '127.0.0.1', () => resolve())) + const address = server.address() + const port = typeof address === 'object' && address ? address.port : 0 + + const response = await fetch(`http://127.0.0.1:${port}/_airi/extensions/a/sessions/s1/ui/index.html`) + expect(response.status).toBe(401) + expect(response.headers.get('cache-control')).toBe('no-store') + expect(response.headers.get('referrer-policy')).toBe('no-referrer') + expect(response.headers.get('x-content-type-options')).toBe('nosniff') + }) + + it('returns 405 with security headers when method is not allowed', async () => { + const app = new H3() + app.use('/_airi/extensions/**', createStaticAssetRoute({ + authorize: async () => ({ + ok: false, + error: new HttpError({ + status: 401, + code: 'COOKIE_MISSING', + message: 'Unauthorized', + }), + }), + refreshSession: () => undefined, + resolveAsset: async () => ({ + ok: false, + error: new HttpError({ + status: 404, + code: 'NOT_FOUND', + message: 'Not Found', + }), + }), + })) + + server = createServer(toNodeHandler(app)) + await new Promise(resolve => server!.listen(0, '127.0.0.1', () => resolve())) + const address = server.address() + const port = typeof address === 'object' && address ? address.port : 0 + + const response = await fetch(`http://127.0.0.1:${port}/_airi/extensions/a/sessions/s1/ui/index.html`, { + method: 'POST', + }) + expect(response.status).toBe(405) + expect(response.headers.get('cache-control')).toBe('no-store') + expect(response.headers.get('referrer-policy')).toBe('no-referrer') + expect(response.headers.get('x-content-type-options')).toBe('nosniff') + }) + + it('uses custom getType resolver and sets nosniff', async () => { + const root = await mkdtemp(join(tmpdir(), 'airi-extension-static-assets-')) + tempRoots.push(root) + + const wasmFilePath = join(root, 'module.wasm') + await writeFile(wasmFilePath, new Uint8Array([0, 97, 115, 109])) + + let authorizeCalled = false + let authorizedCookieValue: string | undefined + let refreshedSessionId: string | undefined + const app = new H3() + app.get('/_airi/extensions/**', createStaticAssetRoute({ + authorize: async ({ cookieValue }) => { + authorizeCalled = true + authorizedCookieValue = cookieValue + return { + ok: true, + session: { + assetSessionId: 's1', + cookieName: createStaticAssetSessionCookieName('s1'), + cookieValue: 'test-token', + cookiePath: '/_airi/extensions/a/sessions/s1/ui', + expiresAt: Date.now() + 1000, + }, + } + }, + refreshSession: (assetSessionId) => { + refreshedSessionId = assetSessionId + return undefined + }, + resolveAsset: async () => ({ + ok: true, + filePath: wasmFilePath, + size: 4, + mtime: Date.now(), + }), + getType: ext => ext === '.wasm' ? 'application/wasm' : undefined, + })) + + server = createServer(toNodeHandler(app)) + await new Promise(resolve => server!.listen(0, '127.0.0.1', () => resolve())) + const address = server.address() + const port = typeof address === 'object' && address ? address.port : 0 + + const response = await fetch(`http://127.0.0.1:${port}/_airi/extensions/a/sessions/s1/ui/module.wasm`, { + headers: { cookie: `${createStaticAssetSessionCookieName('s1')}=test-token` }, + }) + if (!authorizeCalled) { + throw new Error(`Expected authorize to be called. response=${response.status} body=${await response.text()}`) + } + expect(response.status).toBe(200) + expect(authorizedCookieValue).toBe('test-token') + expect(refreshedSessionId).toBe('s1') + expect(response.headers.get('content-type')).toBe('application/wasm') + expect(response.headers.get('x-content-type-options')).toBe('nosniff') + }) + + it('serves HEAD requests with auth refresh and no response body', async () => { + const root = await mkdtemp(join(tmpdir(), 'airi-extension-static-assets-')) + tempRoots.push(root) + + const wasmFilePath = join(root, 'module.wasm') + await writeFile(wasmFilePath, new Uint8Array([0, 97, 115, 109])) + + let authorizeCalled = false + let refreshedSessionId: string | undefined + const app = new H3() + app.use('/_airi/extensions/**', createStaticAssetRoute({ + authorize: async ({ cookieValue }) => { + authorizeCalled = true + expect(cookieValue).toBe('test-token') + return { + ok: true, + session: { + assetSessionId: 's1', + cookieName: createStaticAssetSessionCookieName('s1'), + cookieValue: 'test-token', + cookiePath: '/_airi/extensions/a/sessions/s1/ui', + expiresAt: Date.now() + 1000, + }, + } + }, + refreshSession: (assetSessionId) => { + refreshedSessionId = assetSessionId + return undefined + }, + resolveAsset: async () => ({ + ok: true, + filePath: wasmFilePath, + size: 4, + mtime: Date.now(), + }), + getType: ext => ext === '.wasm' ? 'application/wasm' : undefined, + })) + + server = createServer(toNodeHandler(app)) + await new Promise(resolve => server!.listen(0, '127.0.0.1', () => resolve())) + const address = server.address() + const port = typeof address === 'object' && address ? address.port : 0 + + const response = await fetch(`http://127.0.0.1:${port}/_airi/extensions/a/sessions/s1/ui/module.wasm`, { + headers: { cookie: `${createStaticAssetSessionCookieName('s1')}=test-token` }, + method: 'HEAD', + }) + expect(response.status).toBe(200) + expect(authorizeCalled).toBe(true) + expect(refreshedSessionId).toBe('s1') + expect(response.headers.get('cache-control')).toBe('no-store') + expect(response.headers.get('referrer-policy')).toBe('no-referrer') + expect(response.headers.get('x-content-type-options')).toBe('nosniff') + expect(await response.text()).toBe('') + }) +}) diff --git a/apps/stage-tamagotchi/src/main/services/airi/http-server/static-assets/route.ts b/apps/stage-tamagotchi/src/main/services/airi/http-server/static-assets/route.ts new file mode 100644 index 000000000..c1cdbfa6c --- /dev/null +++ b/apps/stage-tamagotchi/src/main/services/airi/http-server/static-assets/route.ts @@ -0,0 +1,124 @@ +import type { StaticAssetResolveResult, StaticAssetSession, StaticAssetSessionValidationResult } from './types' + +import { readFile } from 'node:fs/promises' + +import { eventHandler, getCookie, getRequestURL, serveStatic } from 'h3' + +import { HttpError, toH3HttpError } from '../errors' +import { normalizeStaticAssetPath, parseStaticAssetRequestPath } from './paths' +import { createStaticAssetSessionCookieName } from './session-store' + +const staticAssetSecurityHeaders = { + 'Cache-Control': 'no-store', + 'Referrer-Policy': 'no-referrer', + 'X-Content-Type-Options': 'nosniff', +} + +export interface StaticAssetRouteOptions { + authorize: (params: { + extensionId: string + assetSessionId: string + assetPath: string + cookieValue: string | undefined + }) => Promise + refreshSession: (assetSessionId: string) => StaticAssetSession | undefined + resolveAsset: (params: { extensionId: string, assetPath: string }) => Promise + getType?: (ext: string) => string | undefined +} + +/** + * Creates the secured extension static asset route handler. + * + * Use when: + * - Serving plugin iframe assets under `/_airi/extensions/:extensionId/sessions/:assetSessionId/ui/**assetPath` + * + * Expects: + * - Cookie-backed asset session data to be present and valid + * - `resolveAsset` to map request params into a validated local file + * + * Returns: + * - H3 event handler that enforces cookie auth before static file response + */ +export function createStaticAssetRoute(options: StaticAssetRouteOptions) { + return eventHandler(async (event) => { + try { + Object.entries(staticAssetSecurityHeaders).forEach(([key, value]) => { + event.res.headers.set(key, value) + }) + + if (event.req.method !== 'GET' && event.req.method !== 'HEAD') { + throw new HttpError({ + status: 405, + code: 'EXTENSION_ASSET_METHOD_NOT_ALLOWED', + message: 'Method Not Allowed', + }) + } + + const requestPath = parseStaticAssetRequestPath(getRequestURL(event).pathname) + const extensionId = requestPath?.extensionId ?? '' + const assetSessionId = requestPath?.assetSessionId ?? '' + const assetPath = normalizeStaticAssetPath(requestPath?.assetPath ?? '') + + if (!extensionId || !assetSessionId || !assetPath) { + throw new HttpError({ + status: 401, + code: 'EXTENSION_ASSET_REQUEST_INVALID', + message: 'Unauthorized', + reason: 'required extensionId, assetSessionId, or assetPath is missing', + }) + } + + const cookieValue = getCookie(event, createStaticAssetSessionCookieName(assetSessionId)) + const auth = await options.authorize({ + extensionId, + assetSessionId, + assetPath, + cookieValue, + }) + if (!auth.ok) { + throw auth.error + } + + options.refreshSession(assetSessionId) + + let resolved: Awaited> | undefined + const resolveOnce = async () => { + if (!resolved) { + resolved = await options.resolveAsset({ extensionId, assetPath }) + } + return resolved + } + + return await serveStatic(event, { + getType: options.getType, + getContents: async () => { + const item = await resolveOnce() + if (!item.ok) { + throw item.error + } + return await readFile(item.filePath) + }, + getMeta: async () => { + const item = await resolveOnce() + if (!item.ok) { + throw item.error + } + + return { + size: item.size, + mtime: item.mtime, + } + }, + }) + } + catch (error) { + if (error instanceof HttpError) { + throw toH3HttpError(error, { + headers: staticAssetSecurityHeaders, + }) + } + + throw error + } + }) +} diff --git a/apps/stage-tamagotchi/src/main/services/airi/http-server/static-assets/session-store.test.ts b/apps/stage-tamagotchi/src/main/services/airi/http-server/static-assets/session-store.test.ts new file mode 100644 index 000000000..2231917b0 --- /dev/null +++ b/apps/stage-tamagotchi/src/main/services/airi/http-server/static-assets/session-store.test.ts @@ -0,0 +1,431 @@ +import type { StaticAssetSession } from './types' + +import { describe, expect, it, vi } from 'vitest' + +import { createStaticAssetSessionStore } from './session-store' + +function tryMutateCookieValue(session: StaticAssetSession, cookieValue: string) { + try { + Object.assign(session, { cookieValue }) + } + catch { + // Frozen snapshots reject mutation; the assertion that follows verifies store state. + } +} + +/** + * @example + * describe('createStaticAssetSessionStore', () => {}) + */ +describe('createStaticAssetSessionStore', () => { + /** + * @example + * it('creates and validates cookie-backed asset session', () => {}) + */ + it('creates and validates cookie-backed asset session', () => { + const now = vi.fn(() => 1000) + const store = createStaticAssetSessionStore({ now }) + const session = store.createSession({ + extensionId: 'airi-plugin-game-chess', + version: '0.1.0', + ownerSessionId: 'plugin-session-1', + pathPrefix: '', + ttlMs: 30_000, + }) + + expect(session.assetSessionId).toBeTruthy() + expect(session.cookieName).toContain(session.assetSessionId) + expect(session.cookieValue).toBeTruthy() + expect(store.validateRequest({ + extensionId: 'airi-plugin-game-chess', + version: '0.1.0', + assetSessionId: session.assetSessionId, + assetPath: 'assets/index.js', + cookieValue: session.cookieValue, + }).ok).toBe(true) + }) + + /** + * @example + * it('rejects mismatched extension and revoked sessions', () => {}) + */ + it('rejects mismatched extension and revoked sessions', () => { + const now = vi.fn(() => 1000) + const store = createStaticAssetSessionStore({ now }) + const session = store.createSession({ + extensionId: 'airi-plugin-game-chess', + version: '0.1.0', + ownerSessionId: 'plugin-session-1', + pathPrefix: '', + ttlMs: 30_000, + }) + + expect(store.validateRequest({ + extensionId: 'other-plugin', + version: '0.1.0', + assetSessionId: session.assetSessionId, + assetPath: 'index.html', + cookieValue: session.cookieValue, + })).toMatchObject({ + ok: false, + error: { + status: 401, + code: 'EXTENSION_ASSET_EXTENSION_MISMATCH', + }, + }) + + expect(store.revokeByOwnerSessionId('plugin-session-1')).toHaveLength(1) + expect(store.validateRequest({ + extensionId: 'airi-plugin-game-chess', + version: '0.1.0', + assetSessionId: session.assetSessionId, + assetPath: 'index.html', + cookieValue: session.cookieValue, + })).toMatchObject({ + ok: false, + error: { + status: 401, + code: 'EXTENSION_ASSET_SESSION_NOT_FOUND', + }, + }) + }) + + /** + * @example + * it('rejects invalid cookie, version, path, and expired requests', () => {}) + */ + it('rejects invalid cookie, version, path, and expired requests', () => { + const now = vi.fn(() => 1000) + const store = createStaticAssetSessionStore({ now }) + const session = store.createSession({ + extensionId: 'airi-plugin-game-chess', + version: '0.1.0', + ownerSessionId: 'plugin-session-1', + pathPrefix: 'assets/', + ttlMs: 30_000, + }) + + expect(store.validateRequest({ + extensionId: 'airi-plugin-game-chess', + version: '0.1.0', + assetSessionId: session.assetSessionId, + assetPath: 'assets/index.js', + cookieValue: undefined, + })).toMatchObject({ + ok: false, + error: { + status: 401, + code: 'EXTENSION_ASSET_COOKIE_MISSING', + }, + }) + + expect(store.validateRequest({ + extensionId: 'airi-plugin-game-chess', + version: '0.1.0', + assetSessionId: session.assetSessionId, + assetPath: 'assets/index.js', + cookieValue: 'wrong-cookie', + })).toMatchObject({ + ok: false, + error: { + status: 401, + code: 'EXTENSION_ASSET_COOKIE_MISMATCH', + }, + }) + + expect(store.validateRequest({ + extensionId: 'airi-plugin-game-chess', + version: '0.2.0', + assetSessionId: session.assetSessionId, + assetPath: 'assets/index.js', + cookieValue: session.cookieValue, + })).toMatchObject({ + ok: false, + error: { + status: 401, + code: 'EXTENSION_ASSET_VERSION_MISMATCH', + }, + }) + + expect(store.validateRequest({ + extensionId: 'airi-plugin-game-chess', + version: '0.1.0', + assetSessionId: session.assetSessionId, + assetPath: '', + cookieValue: session.cookieValue, + })).toMatchObject({ + ok: false, + error: { + status: 401, + code: 'EXTENSION_ASSET_PATH_EMPTY', + }, + }) + + expect(store.validateRequest({ + extensionId: 'airi-plugin-game-chess', + version: '0.1.0', + assetSessionId: session.assetSessionId, + assetPath: 'other/index.js', + cookieValue: session.cookieValue, + })).toMatchObject({ + ok: false, + error: { + status: 401, + code: 'EXTENSION_ASSET_PATH_PREFIX_MISMATCH', + }, + }) + + now.mockReturnValue(31_001) + + expect(store.validateRequest({ + extensionId: 'airi-plugin-game-chess', + version: '0.1.0', + assetSessionId: session.assetSessionId, + assetPath: 'assets/index.js', + cookieValue: session.cookieValue, + })).toMatchObject({ + ok: false, + error: { + status: 401, + code: 'EXTENSION_ASSET_SESSION_EXPIRED', + }, + }) + }) + + /** + * @example + * it('refreshes and revokes sessions by id, extension, and all records', () => {}) + */ + it('refreshes and revokes sessions by id, extension, and all records', () => { + const now = vi.fn(() => 1000) + const store = createStaticAssetSessionStore({ now }) + const firstSession = store.createSession({ + extensionId: 'airi-plugin-game-chess', + version: '0.1.0', + ownerSessionId: 'plugin-session-1', + pathPrefix: '', + ttlMs: 30_000, + }) + const secondSession = store.createSession({ + extensionId: 'airi-plugin-game-chess', + version: '0.1.0', + ownerSessionId: 'plugin-session-2', + pathPrefix: '', + ttlMs: 30_000, + }) + const thirdSession = store.createSession({ + extensionId: 'airi-plugin-game-go', + version: '0.1.0', + ownerSessionId: 'plugin-session-3', + pathPrefix: '', + ttlMs: 30_000, + }) + + now.mockReturnValue(2000) + + expect(store.refreshSession(firstSession.assetSessionId)).toMatchObject({ + assetSessionId: firstSession.assetSessionId, + expiresAt: 32_000, + }) + expect(store.revokeSession(firstSession.assetSessionId)).toMatchObject({ + assetSessionId: firstSession.assetSessionId, + }) + expect(store.revokeByExtensionId('airi-plugin-game-chess')).toEqual([secondSession]) + expect(store.revokeAll()).toEqual([thirdSession]) + }) + + /** + * @example + * it('returns immutable snapshots that cannot mutate internal session state', () => {}) + */ + it('returns immutable snapshots that cannot mutate internal session state', () => { + const now = vi.fn(() => 1000) + const store = createStaticAssetSessionStore({ now }) + const createdSession = store.createSession({ + extensionId: 'airi-plugin-game-chess', + version: '0.1.0', + ownerSessionId: 'plugin-session-1', + pathPrefix: '', + ttlMs: 30_000, + }) + const originalCookieValue = createdSession.cookieValue + + tryMutateCookieValue(createdSession, 'mutated-create-cookie') + + const firstValidation = store.validateRequest({ + extensionId: 'airi-plugin-game-chess', + version: '0.1.0', + assetSessionId: createdSession.assetSessionId, + assetPath: 'index.html', + cookieValue: originalCookieValue, + }) + expect(firstValidation.ok).toBe(true) + expect(Object.isFrozen(createdSession)).toBe(true) + if (!firstValidation.ok) { + throw firstValidation.error + } + + tryMutateCookieValue(firstValidation.session, 'mutated-validation-cookie') + + const secondValidation = store.validateRequest({ + extensionId: 'airi-plugin-game-chess', + version: '0.1.0', + assetSessionId: createdSession.assetSessionId, + assetPath: 'index.html', + cookieValue: originalCookieValue, + }) + expect(secondValidation.ok).toBe(true) + expect(Object.isFrozen(firstValidation.session)).toBe(true) + if (!secondValidation.ok) { + throw secondValidation.error + } + + now.mockReturnValue(2000) + const refreshedSession = store.refreshSession(createdSession.assetSessionId) + expect(refreshedSession).toBeTruthy() + if (!refreshedSession) { + throw new Error('Expected refreshed session') + } + + tryMutateCookieValue(refreshedSession, 'mutated-refresh-cookie') + + const thirdValidation = store.validateRequest({ + extensionId: 'airi-plugin-game-chess', + version: '0.1.0', + assetSessionId: createdSession.assetSessionId, + assetPath: 'index.html', + cookieValue: originalCookieValue, + }) + expect(thirdValidation.ok).toBe(true) + expect(Object.isFrozen(refreshedSession)).toBe(true) + + const revokedSessions = store.revokeByOwnerSessionId('plugin-session-1') + expect(revokedSessions).toHaveLength(1) + const revokedSession = revokedSessions[0] + expect(revokedSession).toBeTruthy() + if (!revokedSession) { + throw new Error('Expected revoked session') + } + + tryMutateCookieValue(revokedSession, 'mutated-revoked-cookie') + + expect(createdSession.cookieValue).toBe(originalCookieValue) + expect(Object.isFrozen(revokedSession)).toBe(true) + }) + + /** + * @example + * it('rejects invalid TTL values during session creation', () => {}) + */ + it('rejects invalid TTL values during session creation', () => { + const store = createStaticAssetSessionStore({ now: vi.fn(() => 1000) }) + const input = { + extensionId: 'airi-plugin-game-chess', + version: '0.1.0', + ownerSessionId: 'plugin-session-1', + pathPrefix: '', + } + + for (const ttlMs of [Number.NaN, Number.POSITIVE_INFINITY, 0, -1]) { + expect(() => store.createSession({ + ...input, + ttlMs, + })).toThrow(RangeError) + } + }) + + /** + * @example + * it('rejects traversal-like asset paths and prefixes at the store boundary', () => {}) + */ + it('rejects traversal-like asset paths and prefixes at the store boundary', () => { + const store = createStaticAssetSessionStore({ now: vi.fn(() => 1000) }) + const session = store.createSession({ + extensionId: 'airi-plugin-game-chess', + version: '0.1.0', + ownerSessionId: 'plugin-session-1', + pathPrefix: 'assets/', + ttlMs: 30_000, + }) + + for (const assetPath of [ + 'assets/../secret.js', + 'assets\\..\\secret.js', + 'assets%2Fsecret.js', + ]) { + expect(store.validateRequest({ + extensionId: 'airi-plugin-game-chess', + version: '0.1.0', + assetSessionId: session.assetSessionId, + assetPath, + cookieValue: session.cookieValue, + })).toMatchObject({ + ok: false, + error: { + status: 401, + code: 'EXTENSION_ASSET_PATH_PREFIX_MISMATCH', + }, + }) + } + + expect(() => store.createSession({ + extensionId: 'airi-plugin-game-chess', + version: '0.1.0', + ownerSessionId: 'plugin-session-1', + pathPrefix: '../', + ttlMs: 30_000, + })).toThrow(RangeError) + }) + + /** + * @example + * it('applies directory and exact-file prefix semantics', () => {}) + */ + it('applies directory and exact-file prefix semantics', () => { + const store = createStaticAssetSessionStore({ now: vi.fn(() => 1000) }) + const directorySession = store.createSession({ + extensionId: 'airi-plugin-game-chess', + version: '0.1.0', + ownerSessionId: 'plugin-session-1', + pathPrefix: 'assets/', + ttlMs: 30_000, + }) + const exactSession = store.createSession({ + extensionId: 'airi-plugin-game-chess', + version: '0.1.0', + ownerSessionId: 'plugin-session-2', + pathPrefix: 'assets', + ttlMs: 30_000, + }) + + expect(store.validateRequest({ + extensionId: 'airi-plugin-game-chess', + version: '0.1.0', + assetSessionId: directorySession.assetSessionId, + assetPath: 'assets/index.js', + cookieValue: directorySession.cookieValue, + }).ok).toBe(true) + + expect(store.validateRequest({ + extensionId: 'airi-plugin-game-chess', + version: '0.1.0', + assetSessionId: exactSession.assetSessionId, + assetPath: 'assets', + cookieValue: exactSession.cookieValue, + }).ok).toBe(true) + + expect(store.validateRequest({ + extensionId: 'airi-plugin-game-chess', + version: '0.1.0', + assetSessionId: exactSession.assetSessionId, + assetPath: 'assets/index.js', + cookieValue: exactSession.cookieValue, + })).toMatchObject({ + ok: false, + error: { + status: 401, + code: 'EXTENSION_ASSET_PATH_PREFIX_MISMATCH', + }, + }) + }) +}) diff --git a/apps/stage-tamagotchi/src/main/services/airi/http-server/static-assets/session-store.ts b/apps/stage-tamagotchi/src/main/services/airi/http-server/static-assets/session-store.ts new file mode 100644 index 000000000..a31cc2b95 --- /dev/null +++ b/apps/stage-tamagotchi/src/main/services/airi/http-server/static-assets/session-store.ts @@ -0,0 +1,285 @@ +import type { + StaticAssetSession, + StaticAssetSessionCreateInput, + StaticAssetSessionStore, + StaticAssetSessionValidateInput, + StaticAssetSessionValidationResult, +} from './types' + +import { Buffer } from 'node:buffer' +import { randomBytes, timingSafeEqual } from 'node:crypto' + +import { HttpError } from '../errors' +import { normalizeStaticAssetPath } from './paths' + +interface StaticAssetSessionRecord { + assetSessionId: string + extensionId: string + version: string + ownerSessionId: string + pathPrefix: string + ttlMs: number + cookieName: string + cookieValue: string + cookiePath: string + expiresAt: number +} + +/** + * Normalizes asset path prefixes used to constrain a session. + * + * Before: + * - " assets\\ " + * + * After: + * - "assets/" + */ +function normalizePathPrefix(pathPrefix: string) { + const normalizedInput = pathPrefix.trim().replaceAll('\\', '/') + if (!normalizedInput) { + return '' + } + + const isDirectoryPrefix = normalizedInput.endsWith('/') + const normalized = normalizeStaticAssetPath(normalizedInput) + if (!normalized) { + throw new RangeError('Extension asset session pathPrefix must be empty or a safe plugin asset path') + } + + return isDirectoryPrefix ? `${normalized}/` : normalized +} + +/** + * Normalizes requested asset paths before comparing them to session prefixes. + * + * Before: + * - " assets\\index.js " + * + * After: + * - "assets/index.js" + */ +function normalizeAssetPath(assetPath: string) { + return normalizeStaticAssetPath(assetPath.trim().replaceAll('\\', '/')) +} + +function createOpaqueToken() { + // Node's base64url alphabet is route/cookie friendly while staying opaque. + return randomBytes(18).toString('base64url') +} + +/** + * Creates the cookie name for a cookie-backed extension asset session. + * + * Use when: + * - Issuing extension asset session cookies + * - Reading extension asset session cookies from static asset route requests + * + * Expects: + * - `assetSessionId` is the opaque id returned by the session store + * + * Returns: + * - Stable cookie name shared by session creation and route validation + */ +export function createStaticAssetSessionCookieName(assetSessionId: string) { + return `airi_extension_asset_session_${assetSessionId}` +} + +function createCookiePath(extensionId: string, assetSessionId: string) { + return `/_airi/extensions/${encodeURIComponent(extensionId)}/sessions/${encodeURIComponent(assetSessionId)}/ui` +} + +function createSessionSnapshot(record: StaticAssetSessionRecord): StaticAssetSession { + return Object.freeze({ + assetSessionId: record.assetSessionId, + cookieName: record.cookieName, + cookieValue: record.cookieValue, + cookiePath: record.cookiePath, + expiresAt: record.expiresAt, + }) +} + +function cookieValuesMatch(expected: string, actual: string) { + const expectedBuffer = Buffer.from(expected, 'utf8') + const actualBuffer = Buffer.from(actual, 'utf8') + if (expectedBuffer.length !== actualBuffer.length) { + return false + } + + return timingSafeEqual(expectedBuffer, actualBuffer) +} + +function unauthorized(code: string, reason: string) { + return { + ok: false as const, + error: new HttpError({ + status: 401, + code, + message: 'Unauthorized', + reason, + }), + } +} + +/** + * Creates an in-memory cookie-backed session store for extension static assets. + * + * Use when: + * - Main process needs short-lived cookie auth for plugin iframe asset loading + * - Asset sessions must be revoked by asset id, owner plugin session, extension, or shutdown + * + * Expects: + * - Session ids and cookie values are opaque and stored server-side only + * - Callers set returned cookies on the returned cookie path + * + * Returns: + * - Create, validate, refresh, and revoke operations for extension asset sessions + */ +export function createStaticAssetSessionStore(options: { now?: () => number } = {}): StaticAssetSessionStore { + const now = options.now ?? (() => Date.now()) + const records = new Map() + + const dropIfExpired = (assetSessionId: string, record: StaticAssetSessionRecord) => { + if (record.expiresAt > now()) { + return false + } + + records.delete(assetSessionId) + return true + } + + const readActiveRecord = (assetSessionId: string, expiredCode: string): { ok: false, result: { ok: false, error: HttpError } } | { ok: true, record: StaticAssetSessionRecord } => { + const record = records.get(assetSessionId) + if (!record) { + return { + ok: false as const, + result: unauthorized('EXTENSION_ASSET_SESSION_NOT_FOUND', 'asset session was not found in session store'), + } + } + + if (dropIfExpired(assetSessionId, record)) { + return { + ok: false as const, + result: unauthorized(expiredCode, 'asset session has expired'), + } + } + + return { + ok: true as const, + record, + } + } + + const createSession = (input: StaticAssetSessionCreateInput) => { + if (!Number.isFinite(input.ttlMs) || input.ttlMs <= 0) { + throw new RangeError('Extension asset session ttlMs must be a finite positive number') + } + + const assetSessionId = createOpaqueToken() + const record: StaticAssetSessionRecord = { + assetSessionId, + extensionId: input.extensionId, + version: input.version, + ownerSessionId: input.ownerSessionId, + pathPrefix: normalizePathPrefix(input.pathPrefix), + ttlMs: input.ttlMs, + cookieName: createStaticAssetSessionCookieName(assetSessionId), + cookieValue: createOpaqueToken(), + cookiePath: createCookiePath(input.extensionId, assetSessionId), + expiresAt: now() + input.ttlMs, + } + + records.set(assetSessionId, record) + + return createSessionSnapshot(record) + } + + const validateRequest = (input: StaticAssetSessionValidateInput): StaticAssetSessionValidationResult => { + const active = readActiveRecord(input.assetSessionId, 'EXTENSION_ASSET_SESSION_EXPIRED') + if (!active.ok) { + return active.result + } + + const { record } = active + if (!input.cookieValue) { + return unauthorized('EXTENSION_ASSET_COOKIE_MISSING', 'asset session cookie is missing') + } + + if (!cookieValuesMatch(record.cookieValue, input.cookieValue)) { + return unauthorized('EXTENSION_ASSET_COOKIE_MISMATCH', 'asset session cookie does not match') + } + + if (record.extensionId !== input.extensionId) { + return unauthorized('EXTENSION_ASSET_EXTENSION_MISMATCH', 'asset session extensionId does not match request extensionId') + } + + if (record.version !== input.version) { + return unauthorized('EXTENSION_ASSET_VERSION_MISMATCH', 'asset session version does not match request version') + } + + const isEmptyAssetPath = !input.assetPath.trim() + if (isEmptyAssetPath) { + return unauthorized('EXTENSION_ASSET_PATH_EMPTY', 'asset path is empty') + } + + const normalizedAssetPath = normalizeAssetPath(input.assetPath) + if (!normalizedAssetPath) { + return unauthorized('EXTENSION_ASSET_PATH_PREFIX_MISMATCH', 'asset path is outside allowed prefix') + } + + if (record.pathPrefix) { + const isDirectoryPrefix = record.pathPrefix.endsWith('/') + const isAllowed = isDirectoryPrefix + ? normalizedAssetPath.startsWith(record.pathPrefix) + : normalizedAssetPath === record.pathPrefix + + if (!isAllowed) { + return unauthorized('EXTENSION_ASSET_PATH_PREFIX_MISMATCH', 'asset path is outside allowed prefix') + } + } + + return { ok: true, session: createSessionSnapshot(record) } + } + + const revokeWhere = (predicate: (record: StaticAssetSessionRecord) => boolean) => { + const revoked: StaticAssetSession[] = [] + for (const [assetSessionId, record] of records.entries()) { + if (predicate(record)) { + records.delete(assetSessionId) + revoked.push(createSessionSnapshot(record)) + } + } + return revoked + } + + return { + createSession, + validateRequest, + refreshSession(assetSessionId) { + const active = readActiveRecord(assetSessionId, 'EXTENSION_ASSET_SESSION_EXPIRED') + if (!active.ok) { + return undefined + } + + active.record.expiresAt = now() + active.record.ttlMs + return createSessionSnapshot(active.record) + }, + revokeSession(assetSessionId) { + const record = records.get(assetSessionId) + if (!record) { + return undefined + } + + records.delete(assetSessionId) + return createSessionSnapshot(record) + }, + revokeByOwnerSessionId(ownerSessionId) { + return revokeWhere(record => record.ownerSessionId === ownerSessionId) + }, + revokeByExtensionId(extensionId) { + return revokeWhere(record => record.extensionId === extensionId) + }, + revokeAll() { + return revokeWhere(() => true) + }, + } +} diff --git a/apps/stage-tamagotchi/src/main/services/airi/http-server/static-assets/types.ts b/apps/stage-tamagotchi/src/main/services/airi/http-server/static-assets/types.ts new file mode 100644 index 000000000..43df18669 --- /dev/null +++ b/apps/stage-tamagotchi/src/main/services/airi/http-server/static-assets/types.ts @@ -0,0 +1,85 @@ +import type { HttpError } from '../errors' + +/** + * Input required to create a cookie-backed static asset session. + */ +export interface StaticAssetSessionCreateInput { + /** Plugin extension id that owns the served static assets. */ + extensionId: string + /** Extension version expected by requests using this asset session. */ + version: string + /** Parent plugin session id used for owner-scoped revocation. */ + ownerSessionId: string + /** + * Required allowed asset path prefix for this session. + * + * Empty string allows all UI assets, a trailing slash means directory prefix, + * and no trailing slash means exact asset path. + */ + pathPrefix: string + /** Session lifetime in milliseconds from creation or refresh time. */ + ttlMs: number +} + +/** + * Cookie data returned after creating a static asset session. + */ +export interface StaticAssetSession { + /** Opaque server-side session id embedded in extension asset routes. */ + readonly assetSessionId: string + /** Cookie name callers set on the asset route path. */ + readonly cookieName: string + /** Opaque cookie value required to validate asset requests. */ + readonly cookieValue: string + /** Cookie path scope for browser requests. */ + readonly cookiePath: string + /** Unix timestamp in milliseconds when the session expires. */ + readonly expiresAt: number +} + +/** + * Request data required to validate a cookie-backed static asset session. + */ +export interface StaticAssetSessionValidateInput { + /** Plugin extension id from the requested route. */ + extensionId: string + /** Extension version from the requested route. */ + version: string + /** Opaque session id from the requested route. */ + assetSessionId: string + /** Static asset path being requested. */ + assetPath: string + /** Cookie value provided by the request, if any. */ + cookieValue: string | undefined +} + +/** + * Result of validating a cookie-backed static asset request. + */ +export type StaticAssetSessionValidationResult + = | { ok: true, session: StaticAssetSession } + | { ok: false, error: HttpError } + +/** + * In-memory store for cookie-backed extension static asset sessions. + */ +export interface StaticAssetSessionStore { + /** Creates a new cookie-backed static asset session. */ + createSession: (input: StaticAssetSessionCreateInput) => StaticAssetSession + /** Validates route and cookie data for a static asset request. */ + validateRequest: (input: StaticAssetSessionValidateInput) => StaticAssetSessionValidationResult + /** Extends an existing session using its original TTL. */ + refreshSession: (assetSessionId: string) => StaticAssetSession | undefined + /** Revokes one static asset session by id. */ + revokeSession: (assetSessionId: string) => StaticAssetSession | undefined + /** Revokes all static asset sessions owned by a plugin session. */ + revokeByOwnerSessionId: (ownerSessionId: string) => StaticAssetSession[] + /** Revokes all static asset sessions for one extension. */ + revokeByExtensionId: (extensionId: string) => StaticAssetSession[] + /** Revokes every static asset session. */ + revokeAll: () => StaticAssetSession[] +} + +export type StaticAssetResolveResult + = | { ok: true, filePath: string, size: number, mtime: number } + | { ok: false, error: HttpError } diff --git a/apps/stage-tamagotchi/src/main/services/airi/plugins/asset-mount.test.ts b/apps/stage-tamagotchi/src/main/services/airi/plugins/asset-mount.test.ts deleted file mode 100644 index 7c82c2578..000000000 --- a/apps/stage-tamagotchi/src/main/services/airi/plugins/asset-mount.test.ts +++ /dev/null @@ -1,80 +0,0 @@ -import { mkdir, mkdtemp, rm, writeFile } from 'node:fs/promises' -import { tmpdir } from 'node:os' -import { join } from 'node:path' - -import { afterEach, describe, expect, it } from 'vitest' - -import { - buildMountedPluginAssetPath, - normalizePluginAssetPath, - parsePluginAssetRequestPath, - resolvePluginAssetFilePath, -} from './asset-mount' -import { resolveWidgetAssetRoute } from './kits/widget' - -describe('asset-mount', () => { - const tempRoots: string[] = [] - - afterEach(async () => { - for (const root of tempRoots) { - await rm(root, { recursive: true, force: true }) - } - tempRoots.length = 0 - }) - - it('normalizes valid asset paths and rejects traversal-like segments', () => { - expect(normalizePluginAssetPath('dist/ui/index.html')).toBe('dist/ui/index.html') - expect(normalizePluginAssetPath('./dist/ui/index.html')).toBeUndefined() - expect(normalizePluginAssetPath('../secret.txt')).toBeUndefined() - expect(normalizePluginAssetPath('dist/../ui/index.html')).toBeUndefined() - }) - - it('parses mounted plugin request path and rejects malformed routes', () => { - expect(parsePluginAssetRequestPath('/_airi/extensions/airi-plugin-game-chess/ui/dist/ui/index.html')).toEqual({ - extensionId: 'airi-plugin-game-chess', - assetPath: 'dist/ui/index.html', - }) - expect(parsePluginAssetRequestPath('/_airi/extensions/airi-plugin-game-chess/ui/../../etc/passwd')).toBeUndefined() - expect(parsePluginAssetRequestPath('/_airi/extensions//ui/index.html')).toBeUndefined() - }) - - it('builds mounted asset path with encoded segments', () => { - expect(buildMountedPluginAssetPath({ - extensionId: 'airi-plugin-game-chess', - assetPath: 'dist/ui/index.html', - })).toBe('/_airi/extensions/airi-plugin-game-chess/ui/dist/ui/index.html') - }) - - it('resolves only files inside plugin root', async () => { - const root = await mkdtemp(join(tmpdir(), 'airi-plugin-assets-')) - tempRoots.push(root) - - await mkdir(join(root, 'dist', 'ui'), { recursive: true }) - await writeFile(join(root, 'dist', 'ui', 'index.html'), '') - - await expect(resolvePluginAssetFilePath(root, 'dist/ui/index.html')).resolves.toContain('dist/ui/index.html') - await expect(resolvePluginAssetFilePath(root, '../outside.txt')).resolves.toBeUndefined() - }) - - it('derives widget route asset path and token prefix with /ui semantics', () => { - expect(resolveWidgetAssetRoute('./ui/index.html')).toEqual({ - routeAssetPath: 'index.html', - tokenPathPrefix: 'index.html', - }) - - expect(resolveWidgetAssetRoute('ui/index.html')).toEqual({ - routeAssetPath: 'index.html', - tokenPathPrefix: 'index.html', - }) - - expect(resolveWidgetAssetRoute('ui/assets/index.html')).toEqual({ - routeAssetPath: 'assets/index.html', - tokenPathPrefix: 'assets/', - }) - - expect(resolveWidgetAssetRoute('assets/index.html')).toEqual({ - routeAssetPath: 'assets/index.html', - tokenPathPrefix: 'assets/', - }) - }) -}) diff --git a/apps/stage-tamagotchi/src/main/services/airi/plugins/asset-mount.ts b/apps/stage-tamagotchi/src/main/services/airi/plugins/asset-mount.ts deleted file mode 100644 index 3e3bd6ece..000000000 --- a/apps/stage-tamagotchi/src/main/services/airi/plugins/asset-mount.ts +++ /dev/null @@ -1,100 +0,0 @@ -import { realpath } from 'node:fs/promises' -import { resolve, sep } from 'node:path' - -export interface ParsedPluginAssetRequest { - extensionId: string - assetPath: string -} - -const pathPrefix = '/_airi/extensions/' -const segmentPattern = /^[\w.+-]+$/ - -export function normalizePluginAssetPath(value: string): string | undefined { - const normalized = value.trim().replaceAll('\\', '/') - if (!normalized) { - return undefined - } - - const segments = normalized - .split('/') - .map(segment => decodeURIComponent(segment).trim()) - .filter(Boolean) - - if (segments.length === 0) { - return undefined - } - - if (segments.some(segment => segment === '.' || segment === '..')) { - return undefined - } - - return segments.join('/') -} - -export function parsePluginAssetRequestPath(pathname: string): ParsedPluginAssetRequest | undefined { - if (!pathname.startsWith(pathPrefix)) { - return undefined - } - - const rawRemainder = pathname.slice(pathPrefix.length) - if (!rawRemainder) { - return undefined - } - - const segments = rawRemainder.split('/').filter(Boolean) - if (segments.length < 3) { - return undefined - } - - const extensionId = decodeURIComponent(segments[0] ?? '') - const mountSegment = decodeURIComponent(segments[1] ?? '') - const rawAssetPath = segments.slice(2).join('/') - - if (!segmentPattern.test(extensionId) || mountSegment !== 'ui') { - return undefined - } - - const assetPath = normalizePluginAssetPath(rawAssetPath) - if (!assetPath) { - return undefined - } - - return { - extensionId, - assetPath, - } -} - -export async function resolvePluginAssetFilePath(rootDir: string, assetPath: string) { - const normalizedAssetPath = normalizePluginAssetPath(assetPath) - if (!normalizedAssetPath) { - return undefined - } - - const resolvedRoot = await realpath(rootDir) - const resolvedCandidate = resolve(resolvedRoot, normalizedAssetPath) - const normalizedRootPrefix = `${resolvedRoot}${sep}` - if (resolvedCandidate !== resolvedRoot && !resolvedCandidate.startsWith(normalizedRootPrefix)) { - return undefined - } - - return resolvedCandidate -} - -export function buildMountedPluginAssetPath(input: { - extensionId: string - assetPath: string -}) { - const normalizedAssetPath = normalizePluginAssetPath(input.assetPath) - if (!normalizedAssetPath) { - return undefined - } - - const encodedExtensionId = encodeURIComponent(input.extensionId) - const encodedAssetPath = normalizedAssetPath - .split('/') - .map(segment => encodeURIComponent(segment)) - .join('/') - - return `${pathPrefix}${encodedExtensionId}/ui/${encodedAssetPath}` -} diff --git a/apps/stage-tamagotchi/src/main/services/airi/plugins/assets/index.ts b/apps/stage-tamagotchi/src/main/services/airi/plugins/assets/index.ts deleted file mode 100644 index 806e8e3c2..000000000 --- a/apps/stage-tamagotchi/src/main/services/airi/plugins/assets/index.ts +++ /dev/null @@ -1,118 +0,0 @@ -import type { ExtensionStaticAssetManifestEntry } from '../../http-server/http/extension-static-assets' -import type { ServerManager } from '../../http-server/server-manager/types' - -import { createExtensionStaticAssetServer } from '../../http-server/http/extension-static-assets' - -/** - * Describes one plugin asset access token issuance request. - * - * Use when: - * - A plugin-owned asset URL must be mounted behind the local loopback server - * - Snapshot builders need a transport-agnostic way to authorize one plugin asset route - * - * Expects: - * - `pluginId` matches a manifest entry registered in the asset host - * - `pathPrefix` is scoped to the mounted route prefix accepted by the token store - * - * Returns: - * - N/A - */ -export interface PluginAssetAccessTokenInput { - pluginId: string - version: string - sessionId: string - pathPrefix: string - ttlMs: number -} - -/** - * Describes the plugin asset methods needed while building renderer-facing snapshots. - * - * Use when: - * - Snapshot builders must request route-scoped asset tokens without depending on HTTP server internals - * - Host bootstrap wants to layer caching or policy on top of the raw asset transport - * - * Expects: - * - `routeAssetPath` identifies the mounted asset file being exposed in the snapshot - * - Implementations may use `routeAssetPath` for caching even if the transport ignores it - * - * Returns: - * - N/A - */ -export interface PluginAssetSnapshotService { - getBaseUrl: () => string | undefined - issueAccessToken: (input: { - pluginId: string - version: string - sessionId: string - routeAssetPath: string - pathPrefix: string - }) => string -} - -/** - * Defines the plugin-owned asset hosting service used by the plugin host. - * - * Use when: - * - Plugin snapshots need mounted asset URLs without depending on the H3 server shape - * - Host teardown must revoke plugin asset access independently from widget/gamelet logic - * - * Expects: - * - Implementations own the underlying transport and token lifecycle - * - * Returns: - * - A startable/stoppable asset-hosting service with generic plugin-facing methods - */ -export interface PluginAssetService extends ServerManager { - getBaseUrl: () => string | undefined - issueAccessToken: (input: PluginAssetAccessTokenInput) => string - revokeByPluginId: (pluginId: string) => void - revokeAll: () => void -} - -/** - * Creates the plugin asset host service backed by the extension static asset server. - * - * Use when: - * - The plugin host needs to expose mounted asset URLs to renderer snapshots - * - Asset token lifecycle should stay inside the plugin domain instead of the HTTP server layer - * - * Expects: - * - `getManifestEntryByName` returns the latest plugin root/version map - * - * Returns: - * - A plugin-facing asset host service with generic plugin asset methods - */ -export function createPluginAssetService(options: { - getManifestEntryByName: () => Map -}): PluginAssetService { - const server = createExtensionStaticAssetServer(options) - - return { - key: 'plugin-assets', - async start() { - await server.start() - }, - async stop() { - await server.stop() - }, - getBaseUrl() { - return server.getBaseUrl() - }, - issueAccessToken(input) { - return server.issueToken({ - extensionId: input.pluginId, - version: input.version, - sessionId: input.sessionId, - pathPrefix: input.pathPrefix, - ttlMs: input.ttlMs, - }) - }, - revokeByPluginId(pluginId) { - server.revokeByExtensionId(pluginId) - }, - revokeAll() { - server.revokeAll() - }, - } -} diff --git a/apps/stage-tamagotchi/src/main/services/airi/plugins/features/static-assets/index.test.ts b/apps/stage-tamagotchi/src/main/services/airi/plugins/features/static-assets/index.test.ts new file mode 100644 index 000000000..41b7ca50b --- /dev/null +++ b/apps/stage-tamagotchi/src/main/services/airi/plugins/features/static-assets/index.test.ts @@ -0,0 +1,274 @@ +import type { StaticAssetService } from '../../../http-server/static-assets' +import type { StaticAssetSession } from '../../../http-server/static-assets/types' +import type { PluginAssetCookie, PluginAssetCookieAdapter } from './index' + +import { beforeEach, describe, expect, it, vi } from 'vitest' + +import { createPluginAssetService } from './index' + +const mockState = vi.hoisted(() => ({ + createStaticAssetService: vi.fn(), +})) + +vi.mock('../../../http-server/static-assets', () => ({ + createStaticAssetService: mockState.createStaticAssetService, +})) + +function createSession(assetSessionId: string, extensionId = 'airi-plugin-game-chess'): StaticAssetSession { + return { + assetSessionId, + cookieName: `airi_extension_asset_session_${assetSessionId}`, + cookieValue: `cookie-value-${assetSessionId}`, + cookiePath: `/_airi/extensions/${extensionId}/sessions/${assetSessionId}/ui`, + expiresAt: 123_456, + } +} + +function createFakeServer(options: { + baseUrl?: string + createSessionResult?: StaticAssetSession + revokeByOwnerSessionIdResult?: StaticAssetSession[] + revokeByExtensionIdResult?: StaticAssetSession[] + revokeAllResult?: StaticAssetSession[] +} = {}) { + return { + key: 'static-assets', + start: vi.fn(async () => {}), + stop: vi.fn(async () => {}), + getBaseUrl: vi.fn(() => options.baseUrl), + createSession: vi.fn(() => options.createSessionResult ?? createSession('asset-session-1')), + revokeSession: vi.fn((assetSessionId: string) => createSession(assetSessionId)), + revokeByOwnerSessionId: vi.fn(() => options.revokeByOwnerSessionIdResult ?? []), + revokeByExtensionId: vi.fn(() => options.revokeByExtensionIdResult ?? []), + revokeAll: vi.fn(() => options.revokeAllResult ?? []), + } satisfies StaticAssetService +} + +function createFakeCookieAdapter() { + const setCookies: PluginAssetCookie[] = [] + const removedCookies: PluginAssetCookie[] = [] + + return { + adapter: { + setCookie: vi.fn(async (cookie) => { + setCookies.push(cookie) + }), + removeCookie: vi.fn(async (cookie) => { + removedCookies.push(cookie) + }), + } satisfies PluginAssetCookieAdapter, + removedCookies, + setCookies, + } +} + +describe('createPluginAssetService', () => { + beforeEach(() => { + mockState.createStaticAssetService.mockReset() + }) + + it('creates a cookie-backed asset session before returning the mounted URL', async () => { + const server = createFakeServer({ + baseUrl: 'http://127.0.0.1:48123', + createSessionResult: createSession('asset-session-1'), + }) + const { adapter, setCookies } = createFakeCookieAdapter() + mockState.createStaticAssetService.mockReturnValue(server) + + const service = createPluginAssetService({ + getManifestEntryByName: () => new Map(), + cookieAdapter: adapter, + }) + + const result = await service.createAssetSession({ + pluginId: 'airi-plugin-game-chess', + version: '1.0.0', + ownerSessionId: 'owner-session-1', + routeAssetPath: 'assets/app.js', + pathPrefix: 'assets/', + ttlMs: 60_000, + }) + + expect(server.createSession).toHaveBeenCalledWith({ + extensionId: 'airi-plugin-game-chess', + version: '1.0.0', + ownerSessionId: 'owner-session-1', + pathPrefix: 'assets/', + ttlMs: 60_000, + }) + expect(adapter.setCookie).toHaveBeenCalledOnce() + expect(setCookies).toEqual([ + { + name: 'airi_extension_asset_session_asset-session-1', + value: 'cookie-value-asset-session-1', + url: 'http://127.0.0.1:48123/_airi/extensions/airi-plugin-game-chess/sessions/asset-session-1/ui', + path: '/_airi/extensions/airi-plugin-game-chess/sessions/asset-session-1/ui', + expiresAt: 123_456, + }, + ]) + expect(result).toEqual({ + url: 'http://127.0.0.1:48123/_airi/extensions/airi-plugin-game-chess/sessions/asset-session-1/ui/assets/app.js', + assetSessionId: 'asset-session-1', + cookie: setCookies[0], + expiresAt: 123_456, + }) + }) + + it('revokes the server session when base URL is missing before setting a cookie', async () => { + const server = createFakeServer({ + baseUrl: undefined, + createSessionResult: createSession('asset-session-2'), + }) + const { adapter, setCookies } = createFakeCookieAdapter() + mockState.createStaticAssetService.mockReturnValue(server) + + const service = createPluginAssetService({ + getManifestEntryByName: () => new Map(), + cookieAdapter: adapter, + }) + + await expect(service.createAssetSession({ + pluginId: 'airi-plugin-game-chess', + version: '1.0.0', + ownerSessionId: 'owner-session-1', + routeAssetPath: 'assets/app.js', + pathPrefix: 'assets/', + ttlMs: 60_000, + })).rejects.toThrow('Plugin asset server base URL is unavailable') + + expect(server.revokeSession).toHaveBeenCalledWith('asset-session-2') + expect(adapter.setCookie).not.toHaveBeenCalled() + expect(setCookies).toEqual([]) + }) + + it('revokes the server session when route path or cookie setup fails', async () => { + const server = createFakeServer({ + baseUrl: 'http://127.0.0.1:48123', + createSessionResult: createSession('asset-session-3'), + }) + const { adapter } = createFakeCookieAdapter() + mockState.createStaticAssetService.mockReturnValue(server) + const service = createPluginAssetService({ + getManifestEntryByName: () => new Map(), + cookieAdapter: adapter, + }) + + await expect(service.createAssetSession({ + pluginId: 'airi-plugin-game-chess', + version: '1.0.0', + ownerSessionId: 'owner-session-1', + routeAssetPath: '../secret.txt', + pathPrefix: '', + ttlMs: 60_000, + })).rejects.toThrow('Plugin asset session routeAssetPath must be a safe plugin asset path') + + expect(server.revokeSession).toHaveBeenCalledWith('asset-session-3') + expect(adapter.setCookie).not.toHaveBeenCalled() + + server.createSession.mockReturnValue(createSession('asset-session-4')) + adapter.setCookie.mockRejectedValueOnce(new Error('cookie jar unavailable')) + + await expect(service.createAssetSession({ + pluginId: 'airi-plugin-game-chess', + version: '1.0.0', + ownerSessionId: 'owner-session-1', + routeAssetPath: 'assets/app.js', + pathPrefix: 'assets/', + ttlMs: 60_000, + })).rejects.toThrow('cookie jar unavailable') + + expect(server.revokeSession).toHaveBeenCalledWith('asset-session-4') + }) + + it('removes cookies returned by asset, owner, plugin, and global revocation', async () => { + const directSession = createSession('direct-asset-session') + const ownerSession = createSession('owner-asset-session') + const pluginSession = createSession('plugin-asset-session') + const allSession = createSession('all-asset-session') + const server = createFakeServer({ + baseUrl: 'http://127.0.0.1:48123', + revokeByOwnerSessionIdResult: [ownerSession], + revokeByExtensionIdResult: [pluginSession], + revokeAllResult: [allSession], + }) + server.revokeSession.mockReturnValue(directSession) + const { adapter, removedCookies } = createFakeCookieAdapter() + mockState.createStaticAssetService.mockReturnValue(server) + + const service = createPluginAssetService({ + getManifestEntryByName: () => new Map(), + cookieAdapter: adapter, + }) + + await service.revokeSession('direct-asset-session') + await service.revokeByOwnerSessionId('owner-session-1') + await service.revokeByPluginId('airi-plugin-game-chess') + await service.revokeAll() + + expect(server.revokeSession).toHaveBeenCalledWith('direct-asset-session') + expect(server.revokeByOwnerSessionId).toHaveBeenCalledWith('owner-session-1') + expect(server.revokeByExtensionId).toHaveBeenCalledWith('airi-plugin-game-chess') + expect(server.revokeAll).toHaveBeenCalledOnce() + expect(adapter.removeCookie).toHaveBeenCalledTimes(4) + expect(removedCookies).toEqual([ + { + name: 'airi_extension_asset_session_direct-asset-session', + value: 'cookie-value-direct-asset-session', + url: 'http://127.0.0.1:48123/_airi/extensions/airi-plugin-game-chess/sessions/direct-asset-session/ui', + path: '/_airi/extensions/airi-plugin-game-chess/sessions/direct-asset-session/ui', + expiresAt: 123_456, + }, + { + name: 'airi_extension_asset_session_owner-asset-session', + value: 'cookie-value-owner-asset-session', + url: 'http://127.0.0.1:48123/_airi/extensions/airi-plugin-game-chess/sessions/owner-asset-session/ui', + path: '/_airi/extensions/airi-plugin-game-chess/sessions/owner-asset-session/ui', + expiresAt: 123_456, + }, + { + name: 'airi_extension_asset_session_plugin-asset-session', + value: 'cookie-value-plugin-asset-session', + url: 'http://127.0.0.1:48123/_airi/extensions/airi-plugin-game-chess/sessions/plugin-asset-session/ui', + path: '/_airi/extensions/airi-plugin-game-chess/sessions/plugin-asset-session/ui', + expiresAt: 123_456, + }, + { + name: 'airi_extension_asset_session_all-asset-session', + value: 'cookie-value-all-asset-session', + url: 'http://127.0.0.1:48123/_airi/extensions/airi-plugin-game-chess/sessions/all-asset-session/ui', + path: '/_airi/extensions/airi-plugin-game-chess/sessions/all-asset-session/ui', + expiresAt: 123_456, + }, + ]) + }) + + it('revokes all sessions and removes cookies before stopping the server', async () => { + const allSession = createSession('stop-asset-session') + const server = createFakeServer({ + baseUrl: 'http://127.0.0.1:48123', + revokeAllResult: [allSession], + }) + const { adapter, removedCookies } = createFakeCookieAdapter() + mockState.createStaticAssetService.mockReturnValue(server) + + const service = createPluginAssetService({ + getManifestEntryByName: () => new Map(), + cookieAdapter: adapter, + }) + + await service.stop() + + expect(server.revokeAll).toHaveBeenCalledOnce() + expect(adapter.removeCookie).toHaveBeenCalledOnce() + expect(server.stop).toHaveBeenCalledOnce() + expect(removedCookies).toEqual([ + { + name: 'airi_extension_asset_session_stop-asset-session', + value: 'cookie-value-stop-asset-session', + url: 'http://127.0.0.1:48123/_airi/extensions/airi-plugin-game-chess/sessions/stop-asset-session/ui', + path: '/_airi/extensions/airi-plugin-game-chess/sessions/stop-asset-session/ui', + expiresAt: 123_456, + }, + ]) + }) +}) diff --git a/apps/stage-tamagotchi/src/main/services/airi/plugins/features/static-assets/index.ts b/apps/stage-tamagotchi/src/main/services/airi/plugins/features/static-assets/index.ts new file mode 100644 index 000000000..349c1a6e6 --- /dev/null +++ b/apps/stage-tamagotchi/src/main/services/airi/plugins/features/static-assets/index.ts @@ -0,0 +1,273 @@ +import type { ServerManager } from '../../../http-server/server-manager/types' +import type { StaticAssetManifestEntry } from '../../../http-server/static-assets' +import type { StaticAssetSession } from '../../../http-server/static-assets/types' + +import { createStaticAssetService } from '../../../http-server/static-assets' +import { buildMountedStaticAssetPath } from '../../../http-server/static-assets/paths' + +/** + * Describes one plugin asset session creation request. + * + * Use when: + * - A plugin-owned asset URL must be mounted behind the local loopback server with cookie auth + * - Snapshot builders need a transport-agnostic way to authorize one plugin asset route before iframe load + * + * Expects: + * - `pluginId` matches a manifest entry registered in the asset host + * - `routeAssetPath` identifies the iframe entry asset relative to the mounted `/ui` route + * - `pathPrefix` is scoped to the mounted route prefix accepted by the session store + * + * Returns: + * - N/A + */ +export interface PluginAssetSessionInput { + /** Plugin id that owns the static asset root. */ + pluginId: string + /** Plugin version expected by the server-side session validator. */ + version: string + /** Parent plugin session id used for owner-scoped revocation. */ + ownerSessionId: string + /** Asset path to mount in the returned renderer-facing URL. */ + routeAssetPath: string + /** Allowed asset path prefix enforced by the server-side session store. */ + pathPrefix: string + /** Session lifetime in milliseconds from creation or refresh time. */ + ttlMs: number +} + +/** + * Describes the cookie material Electron must apply before loading a plugin asset URL. + * + * Use when: + * - Main process bridges server-side asset sessions into Electron's cookie jar + * - Revocation needs enough cookie identity to remove previously issued asset cookies + * + * Expects: + * - `url` belongs to the local asset server origin + * - `path` matches the server-issued cookie path for the asset session route + * + * Returns: + * - N/A + */ +export interface PluginAssetCookie { + /** Cookie name generated for the asset session. */ + name: string + /** Opaque cookie value required by the static asset route. */ + value: string + /** Absolute URL on the local asset server used by Electron cookie APIs. */ + url: string + /** Route path scope generated by the asset session store. */ + path: string + /** Unix timestamp in milliseconds when the cookie-backed asset session expires. */ + expiresAt: number +} + +/** + * Applies and removes plugin asset cookies from the Electron host. + * + * Use when: + * - Asset sessions must exist in Electron's cookie jar before an iframe navigates to its URL + * - Asset session revocation must remove browser-visible cookie state + * + * Expects: + * - `setCookie` resolves only after Electron can send the cookie for matching asset URLs + * - `removeCookie` is idempotent for already-removed cookies + * + * Returns: + * - N/A + */ +export interface PluginAssetCookieAdapter { + setCookie: (cookie: PluginAssetCookie) => Promise + removeCookie: (cookie: PluginAssetCookie) => Promise +} + +/** + * Describes the plugin asset methods needed while building renderer-facing snapshots. + * + * Use when: + * - Snapshot builders must request route-scoped asset sessions without depending on HTTP server internals + * - Host bootstrap wants to layer caching or policy on top of the raw asset transport + * + * Expects: + * - `routeAssetPath` identifies the mounted asset file being exposed in the snapshot + * - Implementations set host cookies before returning renderer-facing URLs + * + * Returns: + * - A mounted asset URL and cookie-backed session metadata + */ +export interface PluginAssetSnapshotService { + getBaseUrl: () => string | undefined + createAssetSession: (input: Omit) => Promise +} + +/** + * Describes a plugin asset session prepared for renderer iframe navigation. + * + * Use when: + * - A plugin iframe needs a mounted static asset URL and pre-applied cookie state + * - Callers need the opaque session id for later targeted revocation + * + * Expects: + * - `cookie` was set through the host adapter before the value is returned + * + * Returns: + * - Renderer-facing URL plus server and cookie metadata + */ +export interface PluginAssetSession { + /** Absolute mounted asset URL safe to hand to a renderer iframe after cookie setup. */ + url: string + /** Opaque server-side asset session id embedded in mounted asset routes. */ + assetSessionId: string + /** Cookie data that was applied through the host adapter. */ + cookie: PluginAssetCookie + /** Unix timestamp in milliseconds when the cookie-backed asset session expires. */ + expiresAt: number +} + +/** + * Defines the plugin-owned asset hosting service used by the plugin host. + * + * Use when: + * - Plugin snapshots need mounted asset URLs without depending on the H3 server shape + * - Host teardown must revoke plugin asset access independently from widget/gamelet logic + * + * Expects: + * - Implementations own the underlying transport, cookie, and session lifecycle + * + * Returns: + * - A startable/stoppable asset-hosting service with generic plugin-facing methods + */ +export interface PluginAssetService extends ServerManager { + getBaseUrl: () => string | undefined + createAssetSession: (input: PluginAssetSessionInput) => Promise + revokeSession: (assetSessionId: string) => Promise + revokeByOwnerSessionId: (ownerSessionId: string) => Promise + revokeByPluginId: (pluginId: string) => Promise + revokeAll: () => Promise +} + +function createPluginAssetCookie(baseUrl: string, session: StaticAssetSession): PluginAssetCookie { + return { + name: session.cookieName, + value: session.cookieValue, + url: new URL(session.cookiePath, baseUrl).toString(), + path: session.cookiePath, + expiresAt: session.expiresAt, + } +} + +function requireBaseUrl(baseUrl: string | undefined) { + if (!baseUrl) { + throw new Error('Plugin asset server base URL is unavailable; start the asset server before creating asset sessions') + } + + return baseUrl +} + +async function removeCookies(cookieAdapter: PluginAssetCookieAdapter, baseUrl: string, sessions: readonly StaticAssetSession[]) { + await Promise.all(sessions.map(session => cookieAdapter.removeCookie(createPluginAssetCookie(baseUrl, session)))) +} + +/** + * Creates the plugin asset host service backed by the extension static asset server. + * + * Use when: + * - The plugin host needs to expose mounted asset URLs to renderer snapshots + * - Asset session lifecycle should stay inside the plugin domain instead of the HTTP server layer + * + * Expects: + * - `getManifestEntryByName` returns the latest plugin root/version map + * - `cookieAdapter` writes and removes cookies in the Electron host session used by plugin iframes + * + * Returns: + * - A plugin-facing asset host service with generic plugin asset methods + */ +export function createPluginAssetService(options: { + getManifestEntryByName: () => Map + cookieAdapter: PluginAssetCookieAdapter +}): PluginAssetService { + const server = createStaticAssetService({ getManifestEntryByName: options.getManifestEntryByName }) + let lastBaseUrl: string | undefined + + const readBaseUrl = () => { + const baseUrl = server.getBaseUrl() + lastBaseUrl = baseUrl ?? lastBaseUrl + return baseUrl + } + + const revokeSessions = async (sessions: readonly StaticAssetSession[]) => { + const baseUrl = readBaseUrl() ?? lastBaseUrl + if (!baseUrl) { + return + } + + await removeCookies(options.cookieAdapter, baseUrl, sessions) + } + + return { + key: 'plugin-assets', + async start() { + await server.start() + }, + async stop() { + await revokeSessions(server.revokeAll()) + await server.stop() + }, + getBaseUrl() { + return readBaseUrl() + }, + async createAssetSession(input) { + const session = server.createSession({ + extensionId: input.pluginId, + version: input.version, + ownerSessionId: input.ownerSessionId, + pathPrefix: input.pathPrefix, + ttlMs: input.ttlMs, + }) + + try { + const baseUrl = requireBaseUrl(readBaseUrl()) + const mountedPath = buildMountedStaticAssetPath({ + extensionId: input.pluginId, + assetSessionId: session.assetSessionId, + assetPath: input.routeAssetPath, + }) + + if (!mountedPath) { + throw new RangeError('Plugin asset session routeAssetPath must be a safe plugin asset path') + } + + const cookie = createPluginAssetCookie(baseUrl, session) + await options.cookieAdapter.setCookie(cookie) + + return { + url: new URL(mountedPath, baseUrl).toString(), + assetSessionId: session.assetSessionId, + cookie, + expiresAt: session.expiresAt, + } + } + catch (error) { + server.revokeSession(session.assetSessionId) + throw error + } + }, + async revokeSession(assetSessionId) { + const session = server.revokeSession(assetSessionId) + if (!session) { + return + } + + await revokeSessions([session]) + }, + async revokeByOwnerSessionId(ownerSessionId) { + await revokeSessions(server.revokeByOwnerSessionId(ownerSessionId)) + }, + async revokeByPluginId(pluginId) { + await revokeSessions(server.revokeByExtensionId(pluginId)) + }, + async revokeAll() { + await revokeSessions(server.revokeAll()) + }, + } +} diff --git a/apps/stage-tamagotchi/src/main/services/airi/plugins/host/debug.ts b/apps/stage-tamagotchi/src/main/services/airi/plugins/host/debug.ts index 21b30ce1b..abb3001e8 100644 --- a/apps/stage-tamagotchi/src/main/services/airi/plugins/host/debug.ts +++ b/apps/stage-tamagotchi/src/main/services/airi/plugins/host/debug.ts @@ -4,7 +4,7 @@ import type { PluginHostDebugSnapshot, PluginHostModuleSummary, } from '../../../../../shared/eventa/plugin/host' -import type { PluginAssetSnapshotService } from '../assets' +import type { PluginAssetSnapshotService } from '../features/static-assets' import type { ManifestEntry, PluginConfig } from '../types' import { rewriteWidgetModuleAssetUrl } from '../kits/widget' @@ -20,7 +20,7 @@ import { buildPluginRegistrySnapshot } from './registry' * Expects: * - `host` is the initialized plugin host instance * - `manifestEntryByName` contains entries for any plugin-owned modules being inspected - * - `pluginAssetService` owns plugin asset URL/token lifecycle when mounted asset URLs are needed + * - `pluginAssetService` owns plugin asset URL/session lifecycle when mounted asset URLs are needed * * Returns: * - A full debug snapshot with registry, sessions, kits, modules, and capabilities @@ -33,10 +33,38 @@ export function buildPluginHostDebugSnapshot(options: { loaded: Set manifestEntryByName: Map pluginAssetService?: PluginAssetSnapshotService -}): PluginHostDebugSnapshot { +}): Promise { const pluginAssetService = options.pluginAssetService + const modules = Promise.all(options.host + .listBindings() + .map(module => + rewriteWidgetModuleAssetUrl( + module as PluginHostModuleSummary, + options.manifestEntryByName, + { + pluginAssetBaseUrl: pluginAssetService?.getBaseUrl(), + ...(pluginAssetService + ? { + createAssetSession: ({ extensionId, version, sessionId, routeAssetPath, sessionPathPrefix }: { + extensionId: string + version: string + sessionId: string + routeAssetPath: string + sessionPathPrefix: string + }) => pluginAssetService.createAssetSession({ + pluginId: extensionId, + version, + ownerSessionId: sessionId, + routeAssetPath, + pathPrefix: sessionPathPrefix, + }), + } + : {}), + }, + ), + ) as Array>) - return { + return modules.then(resolvedModules => ({ registry: buildPluginRegistrySnapshot({ pluginsRoot: options.pluginsRoot, entries: options.entries, @@ -51,35 +79,8 @@ export function buildPluginHostDebugSnapshot(options: { moduleId: session.identity.id, })), kits: options.host.listKits(), - modules: options.host - .listBindings() - .map(module => - rewriteWidgetModuleAssetUrl( - module as PluginHostModuleSummary, - options.manifestEntryByName, - { - pluginAssetBaseUrl: pluginAssetService?.getBaseUrl(), - ...(pluginAssetService - ? { - issueAssetToken: ({ extensionId, version, sessionId, routeAssetPath, tokenPathPrefix }: { - extensionId: string - version: string - sessionId: string - routeAssetPath: string - tokenPathPrefix: string - }) => pluginAssetService.issueAccessToken({ - pluginId: extensionId, - version, - sessionId, - routeAssetPath, - pathPrefix: tokenPathPrefix, - }), - } - : {}), - }, - ), - ) as PluginHostDebugSnapshot['modules'], + modules: resolvedModules as PluginHostDebugSnapshot['modules'], capabilities: options.host.listCapabilities(), refreshedAt: Date.now(), - } + })) } diff --git a/apps/stage-tamagotchi/src/main/services/airi/plugins/host/index.ts b/apps/stage-tamagotchi/src/main/services/airi/plugins/host/index.ts index fb75c8027..02c10d652 100644 --- a/apps/stage-tamagotchi/src/main/services/airi/plugins/host/index.ts +++ b/apps/stage-tamagotchi/src/main/services/airi/plugins/host/index.ts @@ -2,7 +2,11 @@ import type { PluginHostDebugSnapshot, PluginRegistrySnapshot, } from '../../../../../shared/eventa/plugin/host' -import type { PluginAssetSnapshotService } from '../assets' +import type { + PluginAssetCookie, + PluginAssetSession, + PluginAssetSnapshotService, +} from '../features/static-assets' import type { PluginHostService, SetupPluginHostOptions, @@ -12,10 +16,10 @@ import { dirname, join } from 'node:path' import { useLogg } from '@guiiai/logg' import { PluginHost } from '@proj-airi/plugin-sdk/plugin-host' -import { app } from 'electron' +import { app, session as electronSession } from 'electron' -import { createPluginAssetService } from '../assets' import { createPluginAutoReloadFeature } from '../features/auto-reload' +import { createPluginAssetService } from '../features/static-assets' import { createBuiltInPluginKitRuntime } from '../kits' import { createPluginHostConfigStore } from './config' import { buildPluginHostDebugSnapshot } from './debug' @@ -26,7 +30,27 @@ import { resolvePluginRuntimeEntrypointPath, } from './registry' -const extensionAssetTokenTtlMs = 30 * 24 * 60 * 60 * 1000 +const extensionAssetSessionTtlMs = 30 * 24 * 60 * 60 * 1000 + +function createElectronPluginAssetCookieAdapter() { + return { + async setCookie(cookie: PluginAssetCookie) { + await electronSession.defaultSession.cookies.set({ + url: cookie.url, + name: cookie.name, + value: cookie.value, + path: cookie.path, + httpOnly: true, + sameSite: 'no_restriction', + secure: true, + expirationDate: Math.floor(cookie.expiresAt / 1000), + }) + }, + async removeCookie(cookie: PluginAssetCookie) { + await electronSession.defaultSession.cookies.remove(cookie.url, cookie.name) + }, + } +} /** * Internal plugin host bootstrap service used by the public `setupPluginHost(...)` facade. @@ -132,7 +156,7 @@ export interface PluginHostHostService extends PluginHostService { * Returns: * - The plugin registry snapshot after unload bookkeeping completes */ - unload: (name: string) => PluginRegistrySnapshot + unload: (name: string) => Promise /** * Builds the full plugin host debug snapshot. @@ -224,12 +248,30 @@ export async function setupPluginHostHostService( // Plugin feature: Static Assets serving const pluginAssetService = createPluginAssetService({ getManifestEntryByName: () => pluginRegistry.getManifestEntryByName(), + cookieAdapter: createElectronPluginAssetCookieAdapter(), }) await pluginAssetService.start() const loaded = new Set() const loadedSessionIds = new Map() - const moduleAssetTokenCache = new Map() + const moduleAssetSessionCache = new Map() + + const clearModuleAssetSessionCacheByPluginId = (pluginId: string) => { + for (const key of moduleAssetSessionCache.keys()) { + if (key.startsWith(`${pluginId}:`)) { + moduleAssetSessionCache.delete(key) + } + } + } + + const clearModuleAssetSessionCacheByOwnerSessionId = (ownerSessionId: string) => { + for (const key of moduleAssetSessionCache.keys()) { + const segments = key.split(':') + if (segments[2] === ownerSessionId) { + moduleAssetSessionCache.delete(key) + } + } + } const refreshManifests = async () => { await pluginRegistry.refresh() @@ -246,46 +288,47 @@ export async function setupPluginHostHostService( }) } - const issueModuleAssetToken = (input: { + const createModuleAssetSession = async (input: { pluginId: string version: string - sessionId: string + ownerSessionId: string routeAssetPath: string pathPrefix: string }) => { - const { pluginId, version, sessionId, routeAssetPath, pathPrefix } = input - const cacheKey = `${pluginId}:${version}:${sessionId}:${routeAssetPath}` - const cachedToken = moduleAssetTokenCache.get(cacheKey) - if (cachedToken) { - return cachedToken + const { pluginId, version, ownerSessionId, routeAssetPath, pathPrefix } = input + const cacheKey = `${pluginId}:${version}:${ownerSessionId}:${routeAssetPath}:${pathPrefix}` + const cachedSession = moduleAssetSessionCache.get(cacheKey) + if (cachedSession) { + return cachedSession } - const token = pluginAssetService.issueAccessToken({ + const session = await pluginAssetService.createAssetSession({ pluginId, version, - sessionId, + ownerSessionId, + routeAssetPath, pathPrefix, - ttlMs: extensionAssetTokenTtlMs, + ttlMs: extensionAssetSessionTtlMs, }) - moduleAssetTokenCache.set(cacheKey, token) - return token + moduleAssetSessionCache.set(cacheKey, session) + return session } const pluginAssetSnapshotService: PluginAssetSnapshotService = { getBaseUrl: pluginAssetService.getBaseUrl, - issueAccessToken: ({ pluginId, version, sessionId, routeAssetPath, pathPrefix }) => { - return issueModuleAssetToken({ + createAssetSession: ({ pluginId, version, ownerSessionId, routeAssetPath, pathPrefix }) => { + return createModuleAssetSession({ pluginId, version, - sessionId, + ownerSessionId, routeAssetPath, pathPrefix, }) }, } - const inspectSnapshot = (): PluginHostDebugSnapshot => { - return buildPluginHostDebugSnapshot({ + const inspectSnapshot = async (): Promise => { + return await buildPluginHostDebugSnapshot({ host, pluginsRoot, entries: pluginRegistry.listEntries(), @@ -316,7 +359,7 @@ export async function setupPluginHostHostService( log.log('plugin loaded', { plugin: name, sessionId: session.id }) } - const stopLoadedPluginByName = (name: string) => { + const stopLoadedPluginByName = async (name: string) => { const sessionId = loadedSessionIds.get(name) if (!sessionId) { loaded.delete(name) @@ -327,11 +370,8 @@ export async function setupPluginHostHostService( loadedSessionIds.delete(name) loaded.delete(name) - for (const key of moduleAssetTokenCache.keys()) { - if (key.startsWith(`${name}:`)) { - moduleAssetTokenCache.delete(key) - } - } + clearModuleAssetSessionCacheByOwnerSessionId(sessionId) + await pluginAssetService.revokeByOwnerSessionId(sessionId) log.log('plugin unloaded', { plugin: name, sessionId }) } @@ -354,15 +394,15 @@ export async function setupPluginHostHostService( isLoaded: name => loaded.has(name), resolveWatchPaths: resolveAutoReloadWatchPaths, reload: async (name) => { - stopLoadedPluginByName(name) + await stopLoadedPluginByName(name) await refreshManifests() await loadPluginByName(name, { cacheBustKey: `auto-reload-${Date.now()}` }) }, }) - const unloadPluginByName = (name: string) => { + const unloadPluginByName = async (name: string) => { autoReloadFeature.clearPlugin(name) - stopLoadedPluginByName(name) + await stopLoadedPluginByName(name) } const loadEnabledPlugins = async () => { @@ -409,7 +449,8 @@ export async function setupPluginHostHostService( } else { enabled.delete(payload.name) - pluginAssetService.revokeByPluginId(payload.name) + clearModuleAssetSessionCacheByPluginId(payload.name) + await pluginAssetService.revokeByPluginId(payload.name) } const entry = pluginRegistry.findManifestEntry(payload.name) @@ -458,15 +499,15 @@ export async function setupPluginHostHostService( autoReloadFeature.sync() return listSnapshot() }, - unload(name) { - unloadPluginByName(name) + async unload(name) { + await unloadPluginByName(name) autoReloadFeature.sync() return listSnapshot() }, async inspect() { await refreshManifests() autoReloadFeature.sync() - return inspectSnapshot() + return await inspectSnapshot() }, getAssetBaseUrl() { return pluginAssetService.getBaseUrl() ?? '' @@ -474,7 +515,8 @@ export async function setupPluginHostHostService( async dispose() { autoReloadFeature.dispose() - pluginAssetService.revokeAll() + moduleAssetSessionCache.clear() + await pluginAssetService.revokeAll() await pluginAssetService.stop() }, } diff --git a/apps/stage-tamagotchi/src/main/services/airi/plugins/index.test.ts b/apps/stage-tamagotchi/src/main/services/airi/plugins/index.test.ts index d79dfea59..48e7cfe05 100644 --- a/apps/stage-tamagotchi/src/main/services/airi/plugins/index.test.ts +++ b/apps/stage-tamagotchi/src/main/services/airi/plugins/index.test.ts @@ -44,6 +44,7 @@ import { pluginGameletApiConfigureEventName, pluginGameletApiIsOpenEventName, pluginGameletApiOpenEventName, + pluginGameletApiRequestEventName, } from './kits/gamelet' import { widgetPluginKitDescriptor } from './kits/widget' @@ -53,6 +54,14 @@ const appMock = vi.hoisted(() => ({ const protocolMock = vi.hoisted(() => ({ handle: vi.fn(), })) +const sessionMock = vi.hoisted(() => ({ + defaultSession: { + cookies: { + remove: vi.fn(async (_url: string, _name: string) => {}), + set: vi.fn(async (_details: { name: string, value: string }) => {}), + }, + }, +})) const contextState = vi.hoisted(() => ({ lastContext: undefined as ReturnType> | undefined, })) @@ -61,6 +70,7 @@ vi.mock('electron', () => ({ app: appMock, ipcMain: {}, protocol: protocolMock, + session: sessionMock, })) vi.mock('@moeru/eventa/adapters/electron/main', async () => { @@ -251,6 +261,7 @@ function createToolDrivenGameletManifest(entrypoint: string): ManifestV1 { { key: 'proj-airi:plugin-sdk:apis:client:tools:register', actions: ['invoke'] }, { key: pluginGameletApiOpenEventName, actions: ['invoke'] }, { key: pluginGameletApiConfigureEventName, actions: ['invoke'] }, + { key: pluginGameletApiRequestEventName, actions: ['invoke'] }, { key: pluginGameletApiCloseEventName, actions: ['invoke'] }, { key: pluginGameletApiIsOpenEventName, actions: ['invoke'] }, ], @@ -273,6 +284,18 @@ function createToolDrivenGameletManifest(entrypoint: string): ManifestV1 { function createWidgetsManagerDouble() { const widgetSnapshots = new Map() + const widgetEventListeners = new Set<(event: { id: string, event: Record }) => void>() + const publishWidgetEvent = vi.fn((id: string, event: Record) => { + for (const listener of widgetEventListeners) { + listener({ id, event }) + } + }) + const onWidgetEvent = vi.fn((listener: (event: { id: string, event: Record }) => void) => { + widgetEventListeners.add(listener) + return () => { + widgetEventListeners.delete(listener) + } + }) const openWindow = vi.fn(async (_params?: { id?: string }) => {}) const pushWidget = vi.fn(async (payload: WidgetsAddPayload) => { const snapshot: WidgetSnapshot = { @@ -300,6 +323,23 @@ function createWidgetsManagerDouble() { windowSize: payload.windowSize ?? existing.windowSize, ttlMs: payload.ttlMs ?? existing.ttlMs, }) + + const componentProps = payload.componentProps as Record | undefined + const command = componentProps?.payload && typeof componentProps.payload === 'object' && !Array.isArray(componentProps.payload) + ? (componentProps.payload as Record).command + : undefined + if (command && typeof command === 'object' && !Array.isArray(command) && typeof (command as Record).requestId === 'string') { + const requestId = (command as Record).requestId + queueMicrotask(() => { + publishWidgetEvent(payload.id, { + payload: { + requestId, + ready: true, + fen: 'fen-after-request', + }, + }) + }) + } }) const removeWidget = vi.fn(async (id: string) => { widgetSnapshots.delete(id) @@ -314,6 +354,8 @@ function createWidgetsManagerDouble() { updateWidget, removeWidget, getWidgetSnapshot, + publishWidgetEvent, + onWidgetEvent, }, } } @@ -339,6 +381,7 @@ function getGameletApis(session: { apis: Record }) { open: (id: string, params?: Record) => Promise configure: (id: string, patch: Record) => Promise close: (id: string) => Promise + request: (id: string, payload: Record, options?: { timeoutMs?: number }) => Promise> isOpen: (id: string) => Promise } } @@ -719,7 +762,7 @@ describe('setupPluginHost', () => { iframe: expect.objectContaining({ assetPath: 'ui/index.html', src: expect.stringMatching( - /^http:\/\/127\.0\.0\.1:\d+\/_airi\/extensions\/airi-plugin-game-chess\/ui\/index\.html\?t=[\w-]{10,}$/, + /^http:\/\/127\.0\.0\.1:\d+\/_airi\/extensions\/airi-plugin-game-chess\/sessions\/[\w-]{10,}\/ui\/index\.html$/, ), sandbox: 'allow-scripts allow-same-origin allow-forms allow-popups', }), @@ -865,10 +908,11 @@ describe('setupPluginHost', () => { ' async execute() {', ' await ctx.apis.gamelets.open(gameletId, { mode: \'new\', side: \'white\' })', ' await ctx.apis.gamelets.configure(gameletId, { opening: \'sicilian\', side: \'black\' })', + ' const state = await ctx.apis.gamelets.request(gameletId, { action: \'snapshot\' })', ' const wasOpen = await ctx.apis.gamelets.isOpen(gameletId)', ' await ctx.apis.gamelets.close(gameletId)', '', - ' return { ok: true, wasOpen }', + ' return { ok: true, wasOpen, state }', ' },', ' })', '}', @@ -884,7 +928,15 @@ describe('setupPluginHost', () => { ownerPluginId: session.identity.plugin.id, name: 'drive_gamelet', input: {}, - })).resolves.toEqual({ ok: true, wasOpen: true }) + })).resolves.toEqual({ + ok: true, + wasOpen: true, + state: { + requestId: expect.any(String), + ready: true, + fen: 'fen-after-request', + }, + }) expect(widgetsManager.pushWidget).toHaveBeenCalledWith(expect.objectContaining({ id: 'gamelet-under-test', @@ -908,6 +960,17 @@ describe('setupPluginHost', () => { }, }), })) + expect(widgetsManager.updateWidget).toHaveBeenCalledWith(expect.objectContaining({ + id: 'gamelet-under-test', + componentProps: expect.objectContaining({ + payload: expect.objectContaining({ + command: { + action: 'snapshot', + requestId: expect.any(String), + }, + }), + }), + })) expect(widgetsManager.removeWidget).toHaveBeenCalledWith('gamelet-under-test') expect(widgetSnapshots.get('gamelet-under-test')).toBeUndefined() expect(service.host.getBinding('gamelet-under-test')).toEqual(expect.objectContaining({ @@ -1086,6 +1149,8 @@ describe('setupPluginHost', () => { widgetSnapshots.delete(id) }), getWidgetSnapshot: vi.fn((id: string) => widgetSnapshots.get(id)), + publishWidgetEvent: vi.fn((_id: string, _event: Record) => {}), + onWidgetEvent: vi.fn((_listener: (event: { id: string, event: Record }) => void) => () => {}), } const service = await setupPluginHostService({ widgetsManager }) const pluginDir = join(pluginsDir, 'test-plugin-gamelets-stop-cleanup-reject') @@ -1264,7 +1329,7 @@ describe('setupPluginHost', () => { iframe: expect.objectContaining({ assetPath: './ui/index.html', src: expect.stringMatching( - /^http:\/\/127\.0\.0\.1:\d+\/_airi\/extensions\/test-plugin-widget-asset-url\/ui\/index\.html\?t=[\w-]{10,}$/, + /^http:\/\/127\.0\.0\.1:\d+\/_airi\/extensions\/test-plugin-widget-asset-url\/sessions\/[\w-]{10,}\/ui\/index\.html$/, ), sandbox: 'allow-scripts allow-same-origin allow-forms allow-popups', }), @@ -1278,19 +1343,33 @@ describe('setupPluginHost', () => { ?.iframe ?.src as string | undefined expect(iframeSource).toBeTruthy() + expect(iframeSource).not.toContain('?t=') + expect(sessionMock.defaultSession.cookies.set).toHaveBeenCalledOnce() - const iframeResponse = await fetch(iframeSource!) + const setCookie = sessionMock.defaultSession.cookies.set.mock.calls.at(0)?.[0] as { name: string, value: string } | undefined + if (!setCookie) { + throw new Error('Expected plugin asset cookie to be set before iframe URL is returned') + } + const cookieHeader = `${setCookie.name}=${setCookie.value}` + const iframeWithoutCookieResponse = await fetch(iframeSource!) + expect(iframeWithoutCookieResponse.status).toBe(401) + + const iframeResponse = await fetch(iframeSource!, { + headers: { + cookie: cookieHeader, + }, + }) expect(iframeResponse.status).toBe(200) expect(await iframeResponse.text()).toContain('widget') const iframeUrl = new URL(iframeSource!) - const siblingRootUrl = `${iframeUrl.origin}/_airi/extensions/test-plugin-widget-asset-url/ui/other.html?t=${iframeUrl.searchParams.get('t')}` - const siblingRootResponse = await fetch(siblingRootUrl) - expect(siblingRootResponse.status).toBe(401) - - const outsidePrefixUrl = `${iframeUrl.origin}/_airi/extensions/test-plugin-widget-asset-url/ui/private/secret.txt?t=${iframeUrl.searchParams.get('t')}` - const outsidePrefixResponse = await fetch(outsidePrefixUrl) - expect(outsidePrefixResponse.status).toBe(401) + const outsideSessionUrl = `${iframeUrl.origin}/_airi/extensions/test-plugin-widget-asset-url/ui/private/secret.txt` + const outsideSessionResponse = await fetch(outsideSessionUrl, { + headers: { + cookie: cookieHeader, + }, + }) + expect(outsideSessionResponse.status).toBe(401) }) it('mirrors degraded and withdrawn capability updates into the host snapshot', async () => { diff --git a/apps/stage-tamagotchi/src/main/services/airi/plugins/index.ts b/apps/stage-tamagotchi/src/main/services/airi/plugins/index.ts index 870dff346..066ec8b8d 100644 --- a/apps/stage-tamagotchi/src/main/services/airi/plugins/index.ts +++ b/apps/stage-tamagotchi/src/main/services/airi/plugins/index.ts @@ -80,7 +80,7 @@ export async function setupPluginHost(options: SetupPluginHostOptions): Promise< }) defineInvokeHandler(context, electronPluginUnload, async (payload) => { - return hostService.unload(payload.name) + return await hostService.unload(payload.name) }) defineInvokeHandler(context, electronPluginInspect, async () => { diff --git a/apps/stage-tamagotchi/src/main/services/airi/plugins/kits/gamelet/index.ts b/apps/stage-tamagotchi/src/main/services/airi/plugins/kits/gamelet/index.ts index d09abf291..f36de8467 100644 --- a/apps/stage-tamagotchi/src/main/services/airi/plugins/kits/gamelet/index.ts +++ b/apps/stage-tamagotchi/src/main/services/airi/plugins/kits/gamelet/index.ts @@ -48,6 +48,21 @@ export const pluginGameletApiOpenEventName = 'proj-airi:plugin-sdk:apis:client:g */ export const pluginGameletApiConfigureEventName = 'proj-airi:plugin-sdk:apis:client:gamelets:configure' +/** + * Identifies the stage-tamagotchi permission key used to request data from a host-backed gamelet surface. + * + * Use when: + * - Declaring or asserting permission for `session.apis.gamelets.request(...)` + * - Waiting for an iframe gamelet to process a host command and publish a response + * + * Expects: + * - The gamelet kit contribution and its tests share this stage-owned constant + * + * Returns: + * - The permission/event key string for request-response gamelet commands + */ +export const pluginGameletApiRequestEventName = 'proj-airi:plugin-sdk:apis:client:gamelets:request' + /** * Identifies the stage-tamagotchi permission key used to close a host-backed gamelet surface. * @@ -86,6 +101,35 @@ function toRecord(value: unknown): Record | undefined { return isPlainObject(value) ? cloneRecord(value as Record) : undefined } +/** + * Creates an opaque request id for correlating one iframe command response. + * + * Use when: + * - A plugin tool needs a one-shot reply from a gamelet iframe + * + * Expects: + * - Request ids only need to be unique within one live Electron process + * + * Returns: + * - A stable string safe to pass through JSON-like host data records + */ +function createGameletRequestId(): string { + const random = globalThis.crypto?.randomUUID?.() ?? Math.random().toString(36).slice(2, 12) + return `gamelet:${Date.now()}:${random}` +} + +function getEventPayload(event: Record): Record | undefined { + return toRecord(event.payload) +} + +function getPositiveTimeoutMs(timeoutMs: number | undefined): number { + if (typeof timeoutMs !== 'number' || !Number.isFinite(timeoutMs) || timeoutMs <= 0) { + return 15_000 + } + + return timeoutMs +} + /** * Declares the built-in gamelet kit exposed by `stage-tamagotchi`. * @@ -306,6 +350,88 @@ export function createGameletHostContribution(options: { windowSize, }) }, + async request(id: string, payload: HostDataRecord, requestOptions?: { timeoutMs?: number }) { + assertPermission({ + area: 'apis', + action: 'invoke', + key: pluginGameletApiRequestEventName, + }) + + const module = getOwnedGameletBindingOrThrow({ + host: requireHost(), + ownerPluginId: session.ownerPluginId, + ownerSessionId: session.sessionId, + moduleId: id, + }) + const existingSnapshot = options.widgetsManager.getWidgetSnapshot(id) + if (!existingSnapshot) { + throw new Error(`Gamelet widget \`${id}\` is not open.`) + } + + const requestId = createGameletRequestId() + const command = { + ...cloneRecord(payload), + requestId, + } + const timeoutMs = getPositiveTimeoutMs(requestOptions?.timeoutMs) + const responsePromise = new Promise((resolve, reject) => { + let isSettled = false + let dispose: (() => void) | undefined + const timer = setTimeout(() => { + if (isSettled) { + return + } + + isSettled = true + dispose?.() + reject(new Error(`Gamelet request \`${requestId}\` timed out for widget \`${id}\`.`)) + }, timeoutMs) + + dispose = options.widgetsManager.onWidgetEvent((event) => { + if (event.id !== id || isSettled) { + return + } + + const response = getEventPayload(event.event) + if (response?.requestId !== requestId) { + return + } + + isSettled = true + clearTimeout(timer) + dispose?.() + resolve(response as HostDataRecord) + }) + }) + + const existingComponentProps = toRecord(existingSnapshot.componentProps) + const existingPayload = toRecord(existingComponentProps?.payload) ?? getStoredGameletConfig(module.config) + const windowSize = getGameletWidgetWindowSize({ + moduleConfig: module.config, + existingSnapshot, + }) + + await options.widgetsManager.updateWidget({ + id, + componentProps: createGameletWidgetProps({ + moduleId: id, + title: getGameletTitle({ + moduleId: id, + moduleConfig: module.config, + existingComponentProps, + }), + payload: { + ...existingPayload, + command, + }, + windowSize, + existingComponentProps, + }), + windowSize, + }) + + return await responsePromise + }, async close(id: string) { assertPermission({ area: 'apis', diff --git a/apps/stage-tamagotchi/src/main/services/airi/plugins/kits/widget/asset-url.test.ts b/apps/stage-tamagotchi/src/main/services/airi/plugins/kits/widget/asset-url.test.ts new file mode 100644 index 000000000..97fb59ce2 --- /dev/null +++ b/apps/stage-tamagotchi/src/main/services/airi/plugins/kits/widget/asset-url.test.ts @@ -0,0 +1,27 @@ +import { describe, expect, it } from 'vitest' + +import { resolveWidgetAssetRoute } from './asset-url' + +describe('resolveWidgetAssetRoute', () => { + it('derives widget route asset path and session prefix with /ui semantics', () => { + expect(resolveWidgetAssetRoute('./ui/index.html')).toEqual({ + routeAssetPath: 'index.html', + sessionPathPrefix: '', + }) + + expect(resolveWidgetAssetRoute('ui/index.html')).toEqual({ + routeAssetPath: 'index.html', + sessionPathPrefix: '', + }) + + expect(resolveWidgetAssetRoute('ui/assets/index.html')).toEqual({ + routeAssetPath: 'assets/index.html', + sessionPathPrefix: 'assets/', + }) + + expect(resolveWidgetAssetRoute('assets/index.html')).toEqual({ + routeAssetPath: 'assets/index.html', + sessionPathPrefix: 'assets/', + }) + }) +}) diff --git a/apps/stage-tamagotchi/src/main/services/airi/plugins/kits/widget/asset-url.ts b/apps/stage-tamagotchi/src/main/services/airi/plugins/kits/widget/asset-url.ts index 3886262a3..8d5793d06 100644 --- a/apps/stage-tamagotchi/src/main/services/airi/plugins/kits/widget/asset-url.ts +++ b/apps/stage-tamagotchi/src/main/services/airi/plugins/kits/widget/asset-url.ts @@ -4,29 +4,27 @@ import type { ManifestEntry } from '../../types' import { isPlainObject } from 'es-toolkit' import { - buildMountedPluginAssetPath, - normalizePluginAssetPath, -} from '../../asset-mount' - -const trailingSlashesPattern = /\/+$/ + buildMountedStaticAssetPath, + normalizeStaticAssetPath, +} from '../../../http-server/static-assets/paths' /** * Describes one widget iframe asset as seen from the mounted `/ui` route. * * Use when: * - Converting plugin config asset paths into mounted extension asset URLs - * - Issuing tokens that must validate against route-relative asset paths + * - Creating sessions that must validate against route-relative asset paths * * Expects: - * - `routeAssetPath` is relative to `/_airi/extensions/:extensionId/ui/` - * - `tokenPathPrefix` is a directory prefix under that same route, or empty for root + * - `routeAssetPath` is relative to `/_airi/extensions/:extensionId/sessions/:assetSessionId/ui/` + * - `sessionPathPrefix` is a directory prefix under that same route, or empty for root * * Returns: * - N/A */ export interface WidgetAssetRoute { routeAssetPath: string - tokenPathPrefix: string + sessionPathPrefix: string } function normalizeWidgetAssetPath(assetPath: string): string | undefined { @@ -39,15 +37,7 @@ function normalizeWidgetAssetPath(assetPath: string): string | undefined { ? trimmed.slice(2) : trimmed - return normalizePluginAssetPath(withoutRelativePrefix) -} - -function withSearchParams(url: string, query: Record) { - const next = new URL(url) - for (const [key, value] of Object.entries(query)) { - next.searchParams.set(key, value) - } - return next.toString() + return normalizeStaticAssetPath(withoutRelativePrefix) } /** @@ -55,14 +45,14 @@ function withSearchParams(url: string, query: Record) { * * Use when: * - Building mounted widget iframe URLs - * - Issuing asset tokens that must validate against the `/ui` static asset route + * - Creating asset sessions that must validate against the `/ui` static asset route * - Keeping widget route semantics owned by the widget kit module * * Expects: * - `assetPath` points to a file-like path under plugin static assets * * Returns: - * - The route-relative asset path and the allowed token prefix for that route + * - The route-relative asset path and the allowed session prefix for that route */ export function resolveWidgetAssetRoute(assetPath: string): WidgetAssetRoute | undefined { const normalized = normalizeWidgetAssetPath(assetPath) @@ -81,13 +71,13 @@ export function resolveWidgetAssetRoute(assetPath: string): WidgetAssetRoute | u if (segments.length <= 1) { return { routeAssetPath, - tokenPathPrefix: routeAssetPath, + sessionPathPrefix: normalized.startsWith('ui/') ? '' : routeAssetPath, } } return { routeAssetPath, - tokenPathPrefix: `${segments.slice(0, -1).join('/')}/`, + sessionPathPrefix: `${segments.slice(0, -1).join('/')}/`, } } @@ -96,7 +86,7 @@ export function resolveWidgetAssetRoute(assetPath: string): WidgetAssetRoute | u * * Use when: * - Building plugin inspect snapshots with renderer-consumable widget iframe URLs - * - Issuing temporary asset tokens for widget-owned iframe assets + * - Creating temporary asset sessions for widget-owned iframe assets * * Expects: * - Module config may contain widget iframe `src` or `assetPath` fields @@ -111,15 +101,15 @@ export function rewriteWidgetModuleAssetUrl( manifestEntryByName: Map, options?: { pluginAssetBaseUrl?: string - issueAssetToken?: (input: { + createAssetSession?: (input: { extensionId: string version: string sessionId: string routeAssetPath: string - tokenPathPrefix: string - }) => string + sessionPathPrefix: string + }) => Promise<{ assetSessionId: string, url?: string }> }, -): PluginHostModuleSummary { +): Promise | PluginHostModuleSummary { const entry = manifestEntryByName.get(module.ownerPluginId) if (!entry) { return module @@ -151,39 +141,39 @@ export function rewriteWidgetModuleAssetUrl( return module } - const mountedPath = buildMountedPluginAssetPath({ - extensionId: entry.manifest.name, - assetPath: widgetAssetRoute.routeAssetPath, - }) - if (!mountedPath) { + if (!options?.pluginAssetBaseUrl || !options.createAssetSession) { return module } - const mountedAbsoluteUrl = options?.pluginAssetBaseUrl - ? new URL(mountedPath, `${options.pluginAssetBaseUrl.replace(trailingSlashesPattern, '')}/`).toString() - : mountedPath - const assetToken = options?.issueAssetToken?.({ - extensionId: entry.manifest.name, + return options.createAssetSession({ + extensionId: module.ownerPluginId, version: entry.version, sessionId: module.ownerSessionId, routeAssetPath: widgetAssetRoute.routeAssetPath, - tokenPathPrefix: widgetAssetRoute.tokenPathPrefix, - }) - const iframeSourceUrl = assetToken - ? withSearchParams(mountedAbsoluteUrl, { t: assetToken }) - : mountedAbsoluteUrl + sessionPathPrefix: widgetAssetRoute.sessionPathPrefix, + }).then((session) => { + const mountedPath = buildMountedStaticAssetPath({ + extensionId: module.ownerPluginId, + assetSessionId: session.assetSessionId, + assetPath: widgetAssetRoute.routeAssetPath, + }) + const iframeUrl = session.url ?? (mountedPath ? new URL(mountedPath, options.pluginAssetBaseUrl).toString() : '') + if (!iframeUrl) { + return module + } - return { - ...module, - config: { - ...config, - widget: { - ...widgetConfig, - iframe: { - ...iframeConfig, - src: iframeSourceUrl, + return { + ...module, + config: { + ...config, + widget: { + ...widgetConfig, + iframe: { + ...iframeConfig, + src: iframeUrl, + }, }, }, - }, - } + } + }) } diff --git a/apps/stage-tamagotchi/src/main/services/airi/plugins/types.ts b/apps/stage-tamagotchi/src/main/services/airi/plugins/types.ts index 596fe953f..01ed15a15 100644 --- a/apps/stage-tamagotchi/src/main/services/airi/plugins/types.ts +++ b/apps/stage-tamagotchi/src/main/services/airi/plugins/types.ts @@ -43,6 +43,8 @@ export interface PluginHostGameletWidgetsManager { updateWidget: (payload: WidgetsUpdatePayload) => Promise removeWidget: (id: string) => Promise getWidgetSnapshot: (id: string) => WidgetSnapshot | undefined + publishWidgetEvent: (id: string, event: Record) => void + onWidgetEvent: (listener: (event: { id: string, event: Record }) => void) => () => void } /** diff --git a/apps/stage-tamagotchi/src/main/services/airi/widgets/index.ts b/apps/stage-tamagotchi/src/main/services/airi/widgets/index.ts index bef0fa471..60a3c7816 100644 --- a/apps/stage-tamagotchi/src/main/services/airi/widgets/index.ts +++ b/apps/stage-tamagotchi/src/main/services/airi/widgets/index.ts @@ -10,6 +10,7 @@ import { widgetsClear, widgetsFetch, widgetsHideWindow, + widgetsIframePublish, widgetsOpenWindow, widgetsPrepareWindow, widgetsRemove, @@ -18,6 +19,7 @@ import { import { normalizeOptionalWidgetId, normalizeRequiredWidgetId, + validateWidgetIframeEvent, validateWidgetsAddPayload, validateWidgetsUpdatePayload, } from './validation' @@ -64,6 +66,7 @@ export function createWidgetsService(params: { context: ReturnType { if (!isFromWindow(options as InvokeOptions, params.window)) @@ -111,5 +114,11 @@ export function createWidgetsService(params: { context: ReturnType { + if (!isFromWindow(options as InvokeOptions, params.window)) + return undefined + const id = normalizeRequiredWidgetId(payload?.id, 'id is required to publish a widget iframe event.') + params.widgetsManager.publishWidgetEvent(id, validateWidgetIframeEvent(payload?.event)) + }, }) } diff --git a/apps/stage-tamagotchi/src/main/services/airi/widgets/validation.ts b/apps/stage-tamagotchi/src/main/services/airi/widgets/validation.ts index 9bc48ce79..32252cd01 100644 --- a/apps/stage-tamagotchi/src/main/services/airi/widgets/validation.ts +++ b/apps/stage-tamagotchi/src/main/services/airi/widgets/validation.ts @@ -149,3 +149,23 @@ export function normalizeRequiredWidgetId(id?: string, reason = 'id is required. export function normalizeOptionalWidgetId(id?: string): string | undefined { return normalizeWidgetId(id) } + +/** + * Validates iframe-published widget events at the Electron invoke boundary. + * + * Use when: + * - A renderer extension iframe publishes a structured event through its host widget shell + * + * Expects: + * - `event` is a plain JSON-like object + * + * Returns: + * - The event record safe to route through the widget manager + */ +export function validateWidgetIframeEvent(event: unknown): Record { + if (!isPlainObject(event)) { + throw new Error('iframe event must be a plain object.') + } + + return event as Record +} diff --git a/apps/stage-tamagotchi/src/main/windows/widgets/index.ts b/apps/stage-tamagotchi/src/main/windows/widgets/index.ts index 7d1dfcdc3..c0f640676 100644 --- a/apps/stage-tamagotchi/src/main/windows/widgets/index.ts +++ b/apps/stage-tamagotchi/src/main/windows/widgets/index.ts @@ -138,6 +138,8 @@ export interface WidgetsWindowManager { * - The current snapshot, or `undefined` when the widget is unknown */ getWidgetSnapshot: (id: string) => WidgetSnapshot | undefined + publishWidgetEvent: (id: string, event: Record) => void + onWidgetEvent: (listener: (event: { id: string, event: Record }) => void) => () => void /** * Reserves a widget id before content is pushed into the widgets window. * @@ -262,6 +264,7 @@ export function setupWidgetsWindowManager(params: { let eventaContext: ReturnType['context'] | undefined const widgetRecords = new Map() + const widgetEventListeners = new Set<(event: { id: string, event: Record }) => void>() const windowContexts = new Map() const rendererBase = baseUrl(resolve(getElectronMainDirname(), '..', 'renderer')) @@ -655,6 +658,19 @@ export function setupWidgetsWindowManager(params: { return toSnapshot(record) } + function publishWidgetEvent(id: string, event: Record) { + for (const listener of widgetEventListeners) { + listener({ id, event }) + } + } + + function onWidgetEvent(listener: (event: { id: string, event: Record }) => void) { + widgetEventListeners.add(listener) + return () => { + widgetEventListeners.delete(listener) + } + } + async function hideWindow(params?: { id?: string }) { const id = params?.id const context = id ? windowContexts.get(id) : undefined @@ -672,6 +688,8 @@ export function setupWidgetsWindowManager(params: { clearWidgets, hideWindow, getWidgetSnapshot, + publishWidgetEvent, + onWidgetEvent, prepareWidgetWindow, } diff --git a/apps/stage-tamagotchi/src/renderer/widgets/extension-ui/components/extension-ui-host.vue b/apps/stage-tamagotchi/src/renderer/widgets/extension-ui/components/extension-ui-host.vue index 8997c2a83..fe7baddca 100644 --- a/apps/stage-tamagotchi/src/renderer/widgets/extension-ui/components/extension-ui-host.vue +++ b/apps/stage-tamagotchi/src/renderer/widgets/extension-ui/components/extension-ui-host.vue @@ -7,6 +7,7 @@ import { useElectronEventaInvoke } from '@proj-airi/electron-vueuse' import { isPlainObject } from 'es-toolkit' import { computed, shallowRef } from 'vue' +import { widgetsIframePublish } from '../../../../shared/eventa' import { electronPluginGetAssetBaseUrl } from '../../../../shared/eventa/plugin/assets' import { electronPluginInspect } from '../../../../shared/eventa/plugin/host' import { useExtensionUIForModule } from '../composables/use-extension-ui-for-module' @@ -57,6 +58,7 @@ function omitControlFields(record: Record) { const inspectPluginHost = useElectronEventaInvoke(electronPluginInspect) const getPluginAssetBaseUrl = useElectronEventaInvoke(electronPluginGetAssetBaseUrl) +const publishWidgetIframeEvent = useElectronEventaInvoke(widgetsIframePublish) const model = computed>(() => ( isPlainObject(props.modelValue) ? props.modelValue as PluginModuleWidgetPayload & Record : {} as PluginModuleWidgetPayload & Record @@ -98,6 +100,16 @@ const { iframeLoadError, onIframeError, onIframeLoad } = useIframeMessagePort( moduleSnapshot: computed(() => moduleSnapshot.value as PluginHostModuleSummary | undefined), moduleConfig, propsPayload: resolvedWidgetProps, + onPublish: async (event) => { + if (!moduleId.value) { + return + } + + await publishWidgetIframeEvent({ + id: moduleId.value, + event, + }) + }, }, ) diff --git a/apps/stage-tamagotchi/src/renderer/widgets/extension-ui/composables/use-iframe-message-port.test.ts b/apps/stage-tamagotchi/src/renderer/widgets/extension-ui/composables/use-iframe-message-port.test.ts new file mode 100644 index 000000000..64edc9821 --- /dev/null +++ b/apps/stage-tamagotchi/src/renderer/widgets/extension-ui/composables/use-iframe-message-port.test.ts @@ -0,0 +1,42 @@ +import { describe, expect, it } from 'vitest' +import { reactive } from 'vue' + +import { toWidgetsIframePostMessageRecord } from './use-iframe-message-port' + +/** + * @example + * const payload = toWidgetsIframePostMessageRecord(reactive({ command: { requestId: 'req-1' } })) + * expect(() => structuredClone(payload)).not.toThrow() + */ +describe('toWidgetsIframePostMessageRecord', () => { + /** + * @example + * expect(structuredClone(toWidgetsIframePostMessageRecord(reactivePayload))).toMatchObject({ command: { requestId: 'req-1' } }) + */ + it('normalizes reactive nested payloads into structured-clone-safe records', () => { + const payload = reactive({ + command: { + requestId: 'req-1', + action: 'start', + }, + callback: () => 'not cloneable', + nested: { + createdAt: new Date('2026-04-28T00:00:00.000Z'), + }, + }) + + const normalized = toWidgetsIframePostMessageRecord(payload) + + expect(() => structuredClone(normalized)).not.toThrow() + expect(normalized).toMatchObject({ + command: { + requestId: 'req-1', + action: 'start', + }, + nested: { + createdAt: '2026-04-28T00:00:00.000Z', + }, + }) + expect(normalized).not.toHaveProperty('callback') + }) +}) diff --git a/apps/stage-tamagotchi/src/renderer/widgets/extension-ui/composables/use-iframe-message-port.ts b/apps/stage-tamagotchi/src/renderer/widgets/extension-ui/composables/use-iframe-message-port.ts index 2db2aca6e..0454d41e3 100644 --- a/apps/stage-tamagotchi/src/renderer/widgets/extension-ui/composables/use-iframe-message-port.ts +++ b/apps/stage-tamagotchi/src/renderer/widgets/extension-ui/composables/use-iframe-message-port.ts @@ -5,13 +5,74 @@ import type { ComputedRef } from 'vue' import type { PluginHostModuleSummary } from '../../../../shared/eventa/plugin/host' import { createContext } from '@moeru/eventa/adapters/window-message' +import { errorMessageFrom } from '@moeru/std' import { widgetsIframeChannel, widgetsIframeInitEvent, + widgetsIframePublishEvent, widgetsIframeReadyEvent, } from '@proj-airi/plugin-sdk-tamagotchi/widgets' import { unrefElement } from '@vueuse/core' -import { onBeforeUnmount, shallowRef, watch } from 'vue' +import { onBeforeUnmount, shallowRef, toRaw, watch } from 'vue' + +function toWidgetsIframePostMessageValue(value: unknown, seen = new WeakSet()): unknown { + if (value == null || typeof value === 'string' || typeof value === 'number' || typeof value === 'boolean') { + return value + } + + if (typeof value === 'bigint') { + return value + } + + if (typeof value === 'function' || typeof value === 'symbol') { + return undefined + } + + const raw = toRaw(value) + if (!raw || typeof raw !== 'object') { + return raw + } + + if (seen.has(raw)) { + return undefined + } + seen.add(raw) + + if (Array.isArray(raw)) { + const arrayValue = raw.map(item => toWidgetsIframePostMessageValue(item, seen)) + seen.delete(raw) + return arrayValue + } + + if (raw instanceof Date) { + seen.delete(raw) + return raw.toISOString() + } + + const recordValue = Object.fromEntries( + Object.entries(raw as Record) + .map(([key, entry]) => [key, toWidgetsIframePostMessageValue(entry, seen)]) + .filter(([, entry]) => entry !== undefined), + ) + seen.delete(raw) + return recordValue +} + +/** + * Normalizes extension iframe payload records into structured-clone-safe data. + * + * Before: + * - Vue reactive proxy records containing nested proxies or callback fields + * + * After: + * - Plain records that can be passed to `window.postMessage` + */ +export function toWidgetsIframePostMessageRecord(value: unknown): Record { + const normalized = toWidgetsIframePostMessageValue(value) + return normalized && typeof normalized === 'object' && !Array.isArray(normalized) + ? normalized as Record + : {} +} /** * Manages typed parent-to-iframe messaging for one extension UI iframe. @@ -37,6 +98,7 @@ export function useIframeMessagePort( moduleSnapshot: ComputedRef moduleConfig: ComputedRef> propsPayload: ComputedRef> + onPublish?: (event: Record) => void | Promise }, ) { const iframeLoadError = shallowRef() @@ -55,16 +117,28 @@ export function useIframeMessagePort( }) function createInitPayload(): WidgetsIframeInitPayload { + const module = options.moduleSnapshot.value return { - moduleId: options.moduleSnapshot.value?.moduleId, - module: options.moduleSnapshot.value as unknown as Record | undefined, - config: options.moduleConfig.value, - props: options.propsPayload.value, + moduleId: module?.moduleId, + module: module ? toWidgetsIframePostMessageRecord(module) : undefined, + config: toWidgetsIframePostMessageRecord(options.moduleConfig.value), + props: toWidgetsIframePostMessageRecord(options.propsPayload.value), } } function emitInitPayload() { - iframeRuntime.context.emit(widgetsIframeInitEvent, createInitPayload()) + try { + iframeRuntime.context.emit(widgetsIframeInitEvent, createInitPayload()) + } + catch (error) { + const message = errorMessageFrom(error) ?? 'Failed to send extension UI iframe init payload.' + iframeLoadError.value = message + console.error('[extension-ui] Failed to emit iframe init payload', { + error, + errorMessage: message, + moduleId: options.moduleId.value, + }) + } } function onIframeLoad() { @@ -80,6 +154,14 @@ export function useIframeMessagePort( emitInitPayload() }) + iframeRuntime.context.on(widgetsIframePublishEvent, (event) => { + if (!event.body || typeof event.body !== 'object' || Array.isArray(event.body)) { + return + } + + void options.onPublish?.(event.body as Record) + }) + watch(options.moduleId, () => { emitInitPayload() }, { immediate: true }) diff --git a/apps/stage-tamagotchi/src/shared/eventa/index.ts b/apps/stage-tamagotchi/src/shared/eventa/index.ts index 6850b9a34..a64a513aa 100644 --- a/apps/stage-tamagotchi/src/shared/eventa/index.ts +++ b/apps/stage-tamagotchi/src/shared/eventa/index.ts @@ -243,6 +243,7 @@ export const widgetsClear = defineInvokeEventa('eventa:invoke:electron:windows:w export const widgetsUpdate = defineInvokeEventa('eventa:invoke:electron:windows:widgets:update') export const widgetsFetch = defineInvokeEventa('eventa:invoke:electron:windows:widgets:fetch') export const widgetsPrepareWindow = defineInvokeEventa('eventa:invoke:electron:windows:widgets:prepare') +export const widgetsIframePublish = defineInvokeEventa }>('eventa:invoke:electron:windows:widgets:iframe-publish') export const electronWindowClose = defineInvokeEventa('eventa:invoke:electron:window:close') export type ElectronWindowLifecycleReason diff --git a/packages/plugin-sdk-tamagotchi/src/index.test.ts b/packages/plugin-sdk-tamagotchi/src/index.test.ts index 8eca3950d..defbd624a 100644 --- a/packages/plugin-sdk-tamagotchi/src/index.test.ts +++ b/packages/plugin-sdk-tamagotchi/src/index.test.ts @@ -26,6 +26,7 @@ describe('plugin-sdk-tamagotchi', () => { gamelets: { open: openGamelet, configure: configureGamelet, + request: vi.fn(async () => ({})), close: closeGamelet, isOpen: isGameletOpen, }, @@ -127,9 +128,10 @@ describe('plugin-sdk-tamagotchi', () => { type: 'object', properties: expect.objectContaining({ opening: expect.objectContaining({ - type: 'string', + type: ['string', 'null'], }), }), + required: ['opening'], }), }), })) @@ -159,6 +161,7 @@ describe('plugin-sdk-tamagotchi', () => { gamelets: { open: openGamelet, configure: configureGamelet, + request: vi.fn(async () => ({ ready: true })), close: closeGamelet, isOpen: isGameletOpen, }, @@ -181,6 +184,7 @@ describe('plugin-sdk-tamagotchi', () => { async execute(_input, context) { await context.gamelets.open('chess', { opening: 'sicilian' }) await context.gamelets.configure('chess', { side: 'black' }) + await context.gamelets.request('chess', { action: 'snapshot' }) await context.gamelets.close('chess') return { ok: true } @@ -195,11 +199,55 @@ describe('plugin-sdk-tamagotchi', () => { await expect(registration?.execute({})).resolves.toEqual({ ok: true }) expect(isGameletOpen).toHaveBeenCalledWith('chess') + expect(registration.availability).toBeTypeOf('function') expect(openGamelet).toHaveBeenCalledWith('chess', { opening: 'sicilian' }) expect(configureGamelet).toHaveBeenCalledWith('chess', { side: 'black' }) + expect(ctx.apis.gamelets.request).toHaveBeenCalledWith('chess', { action: 'snapshot' }) expect(closeGamelet).toHaveBeenCalledWith('chess') }) + /** + * @example + * expect(tool.parameters.required).toEqual(Object.keys(tool.parameters.properties)) + */ + it('serializes optional tool fields as required nullable properties for strict OpenAI-compatible schemas', async () => { + const registerTool = vi.fn() + const ctx: TamagotchiToolContext = { + apis: { + gamelets: { + open: vi.fn(), + configure: vi.fn(), + request: vi.fn(async () => ({})), + close: vi.fn(), + isOpen: vi.fn(() => true), + }, + tools: { + register: registerTool, + }, + }, + } + + await defineToolset(ctx, { + tools: [ + { + id: 'play_chess', + title: 'Play Chess', + description: 'Open chess.', + inputSchema: object({ + mode: string(), + opening: optional(string()), + }), + execute: async () => ({ ok: true }), + }, + ], + }) + + const parameters = registerTool.mock.calls[0]?.[0].tool.parameters + + expect(parameters.required).toEqual(['mode', 'opening']) + expect(parameters.properties.opening.type).toEqual(['string', 'null']) + }) + /** * @example * await expect(defineToolset({ apis: { tools: { register: registerTool } } } as never, options)).rejects.toThrow(/gamelet API/i) diff --git a/packages/plugin-sdk-tamagotchi/src/tools/index.ts b/packages/plugin-sdk-tamagotchi/src/tools/index.ts index cfe6b8a1b..1275de7ef 100644 --- a/packages/plugin-sdk-tamagotchi/src/tools/index.ts +++ b/packages/plugin-sdk-tamagotchi/src/tools/index.ts @@ -22,6 +22,7 @@ import { toJsonSchema } from 'xsschema' export interface ToolExecutionGameletApi { open: (id: string, params?: HostDataRecord) => Promise configure: (id: string, patch: HostDataRecord) => Promise + request: (id: string, payload: HostDataRecord, options?: { timeoutMs?: number }) => Promise close: (id: string) => Promise isOpen: (id: string) => Promise | boolean } @@ -129,6 +130,7 @@ function isToolExecutionGameletApi(value: unknown): value is ToolExecutionGamele return typeof candidate.open === 'function' && typeof candidate.configure === 'function' + && typeof candidate.request === 'function' && typeof candidate.close === 'function' && typeof candidate.isOpen === 'function' } @@ -208,6 +210,88 @@ function toHostDataRecord(value: object): HostDataRecord { return value as HostDataRecord } +function isJsonSchemaNode(value: JsonSchema | boolean | JsonSchema[] | undefined): value is JsonSchema { + return Boolean(value && !Array.isArray(value) && typeof value === 'object') +} + +function withNullableValue(schema: JsonSchema): JsonSchema { + const next: JsonSchema = { ...schema } + + if (Array.isArray(next.enum)) { + next.enum = next.enum.includes(null) ? next.enum : [...next.enum, null] + return next + } + + if (Array.isArray(next.type)) { + next.type = next.type.includes('null') ? next.type : [...next.type, 'null'] + return next + } + + if (typeof next.type === 'string') { + next.type = next.type === 'null' ? next.type : [next.type, 'null'] + return next + } + + next.anyOf = [...(next.anyOf ?? []), { type: 'null' }] + return next +} + +/** + * Normalizes plugin tool JSON Schema for strict OpenAI-compatible validators. + * + * Before: + * - `{ properties: { optionalName: { type: "string" } }, required: [] }` + * + * After: + * - `{ properties: { optionalName: { type: ["string", "null"] } }, required: ["optionalName"] }` + */ +function normalizeStrictToolParameterSchema(schema: JsonSchema): JsonSchema { + const next: JsonSchema = { ...schema } + + if (next.properties) { + const currentRequired = new Set(next.required ?? []) + const normalizedProperties = Object.fromEntries( + Object.entries(next.properties).map(([key, value]) => { + if (!isJsonSchemaNode(value)) { + return [key, value] + } + + const normalizedValue = normalizeStrictToolParameterSchema(value) + return [ + key, + currentRequired.has(key) + ? normalizedValue + : withNullableValue(normalizedValue), + ] + }), + ) + + next.properties = normalizedProperties + next.required = Object.keys(normalizedProperties) + } + + if (Array.isArray(next.items)) { + next.items = next.items.map(item => isJsonSchemaNode(item) ? normalizeStrictToolParameterSchema(item) : item) + } + else if (isJsonSchemaNode(next.items)) { + next.items = normalizeStrictToolParameterSchema(next.items) + } + + if (next.anyOf) { + next.anyOf = next.anyOf.map(value => isJsonSchemaNode(value) ? normalizeStrictToolParameterSchema(value) : value) + } + + if (next.oneOf) { + next.oneOf = next.oneOf.map(value => isJsonSchemaNode(value) ? normalizeStrictToolParameterSchema(value) : value) + } + + if (next.allOf) { + next.allOf = next.allOf.map(value => isJsonSchemaNode(value) ? normalizeStrictToolParameterSchema(value) : value) + } + + return next +} + /** * Normalizes tool parameter schemas into the host-safe record shape expected by plugin-sdk. * @@ -219,11 +303,11 @@ function toHostDataRecord(value: object): HostDataRecord { */ async function serializeToolParameters(inputSchema: unknown): Promise { if (isStandardSchema(inputSchema)) { - return toHostDataRecord(await toJsonSchema(inputSchema)) + return toHostDataRecord(normalizeStrictToolParameterSchema(await toJsonSchema(inputSchema))) } if (isJsonSchemaRecord(inputSchema)) { - return toHostDataRecord(structuredClone(inputSchema)) + return toHostDataRecord(normalizeStrictToolParameterSchema(structuredClone(inputSchema))) } throw new TypeError('Tool input schema must be a JSON Schema object or a Standard Schema instance.') @@ -248,6 +332,8 @@ export async function defineToolset( const executionContext = createToolExecutionContext(ctx) for (const definition of options.tools) { + const isAvailable = definition.isAvailable + await ctx.apis.tools.register({ tool: { id: definition.id, @@ -259,8 +345,8 @@ export async function defineToolset( }, parameters: await serializeToolParameters(definition.inputSchema), }, - availability: definition.isAvailable - ? () => definition.isAvailable?.(executionContext) + availability: isAvailable + ? () => isAvailable(executionContext) : undefined, execute: input => definition.execute(input, executionContext), }) diff --git a/vitest.config.ts b/vitest.config.ts index 3eb7a5b95..38f2f1989 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -8,6 +8,7 @@ export default defineConfig({ 'apps/stage-tamagotchi', 'packages/audio-pipelines-transcribe', 'packages/cap-vite', + 'packages/core-agent', 'packages/vishot-runner-browser', 'packages/plugin-sdk', 'packages/plugin-sdk-tamagotchi',