refactor(stage-tamagotchi): consolidate electron plugin static assets

This commit is contained in:
Neko Ayaka
2026-04-29 00:45:04 +08:00
parent 6f0b7e0b9b
commit 1ca972f362
42 changed files with 3242 additions and 1121 deletions
@@ -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) {
@@ -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<ReturnType<typeof createExtensionStaticAssetServer>> = []
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',
})
})
})
@@ -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<string, ExtensionStaticAssetManifestEntry>
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<string, string> = {
'.wasm': 'application/wasm',
'.avif': 'image/avif',
'.heic': 'image/heic',
'.heif': 'image/heif',
}
function defaultExtensionAssetMimeTypeResolver(ext: string) {
return extensionAssetMimeTypeOverrides[ext.toLowerCase()]
}
@@ -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<typeof createServer> | undefined
const tempRoots: string[] = []
afterEach(async () => {
for (const root of tempRoots) {
await rm(root, { recursive: true, force: true })
}
tempRoots.length = 0
await new Promise<void>((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<void>(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<void>(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')
})
})
@@ -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<ReturnType<ExtensionStaticAssetRouteOptions['resolveAsset']>> | 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
}
})
}
@@ -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',
},
})
})
})
@@ -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<string, ExtensionAssetTokenRecord>()
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()
},
}
}
@@ -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<ExtensionAssetTokenValidationResult>
resolveAsset: (params: { extensionId: string, assetPath: string }) => Promise<ExtensionStaticAssetResolveResult>
getType?: (ext: string) => string | undefined
}
@@ -9,7 +9,7 @@ describe('setupBuiltInServer', () => {
const service = setupBuiltInServer({
authServer: auth,
extensionStaticAssetServer: assets,
staticAssetServer: assets,
})
await service.start()
@@ -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)
@@ -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<ReturnType<typeof createStaticAssetService>> = []
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 }
}
})
@@ -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<string, StaticAssetManifestEntry>
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<Map<string, StaticAssetManifestEntry | undefined>>()
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<string, string> = {
'.wasm': 'application/wasm',
'.avif': 'image/avif',
'.heic': 'image/heic',
'.heif': 'image/heif',
}
function defaultStaticAssetMimeTypeResolver(ext: string) {
return staticAssetMimeTypeOverrides[ext.toLowerCase()]
}
@@ -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'), '<html></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()
})
})
@@ -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}`
}
@@ -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<typeof createServer> | undefined
const tempRoots: string[] = []
afterEach(async () => {
for (const root of tempRoots) {
await rm(root, { recursive: true, force: true })
}
tempRoots.length = 0
await new Promise<void>((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<void>(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<void>(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<void>(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<void>(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('')
})
})
@@ -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<StaticAssetSessionValidationResult>
refreshSession: (assetSessionId: string) => StaticAssetSession | undefined
resolveAsset: (params: { extensionId: string, assetPath: string }) => Promise<StaticAssetResolveResult>
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<ReturnType<StaticAssetRouteOptions['resolveAsset']>> | 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
}
})
}
@@ -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',
},
})
})
})
@@ -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<string, StaticAssetSessionRecord>()
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)
},
}
}
@@ -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 }
@@ -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'), '<html></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/',
})
})
})
@@ -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}`
}
@@ -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<string, ExtensionStaticAssetManifestEntry>
}): 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()
},
}
}
@@ -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,
},
])
})
})
@@ -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<void>
removeCookie: (cookie: PluginAssetCookie) => Promise<void>
}
/**
* 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<PluginAssetSessionInput, 'ttlMs'>) => Promise<PluginAssetSession>
}
/**
* 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<PluginAssetSession>
revokeSession: (assetSessionId: string) => Promise<void>
revokeByOwnerSessionId: (ownerSessionId: string) => Promise<void>
revokeByPluginId: (pluginId: string) => Promise<void>
revokeAll: () => Promise<void>
}
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<string, StaticAssetManifestEntry>
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())
},
}
}
@@ -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<string>
manifestEntryByName: Map<string, ManifestEntry>
pluginAssetService?: PluginAssetSnapshotService
}): PluginHostDebugSnapshot {
}): Promise<PluginHostDebugSnapshot> {
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<PluginHostModuleSummary | Promise<PluginHostModuleSummary>>)
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(),
}
}))
}
@@ -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<PluginRegistrySnapshot>
/**
* 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<string>()
const loadedSessionIds = new Map<string, string>()
const moduleAssetTokenCache = new Map<string, string>()
const moduleAssetSessionCache = new Map<string, PluginAssetSession>()
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<PluginHostDebugSnapshot> => {
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()
},
}
@@ -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<typeof createContext<any, any>> | 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<string, WidgetSnapshot>()
const widgetEventListeners = new Set<(event: { id: string, event: Record<string, unknown> }) => void>()
const publishWidgetEvent = vi.fn((id: string, event: Record<string, unknown>) => {
for (const listener of widgetEventListeners) {
listener({ id, event })
}
})
const onWidgetEvent = vi.fn((listener: (event: { id: string, event: Record<string, unknown> }) => 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<string, unknown> | undefined
const command = componentProps?.payload && typeof componentProps.payload === 'object' && !Array.isArray(componentProps.payload)
? (componentProps.payload as Record<string, unknown>).command
: undefined
if (command && typeof command === 'object' && !Array.isArray(command) && typeof (command as Record<string, unknown>).requestId === 'string') {
const requestId = (command as Record<string, unknown>).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<string, unknown> }) {
open: (id: string, params?: Record<string, unknown>) => Promise<void>
configure: (id: string, patch: Record<string, unknown>) => Promise<void>
close: (id: string) => Promise<void>
request: (id: string, payload: Record<string, unknown>, options?: { timeoutMs?: number }) => Promise<Record<string, unknown>>
isOpen: (id: string) => Promise<boolean>
}
}
@@ -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<string, unknown>) => {}),
onWidgetEvent: vi.fn((_listener: (event: { id: string, event: Record<string, unknown> }) => 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('<title>widget</title>')
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 () => {
@@ -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 () => {
@@ -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<string, unknown> | undefined {
return isPlainObject(value) ? cloneRecord(value as Record<string, unknown>) : 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<string, unknown>): Record<string, unknown> | 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<HostDataRecord>((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',
@@ -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/',
})
})
})
@@ -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<string, string>) {
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<string, string>) {
*
* 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<string, ManifestEntry>,
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> | 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,
},
},
},
},
}
}
})
}
@@ -43,6 +43,8 @@ export interface PluginHostGameletWidgetsManager {
updateWidget: (payload: WidgetsUpdatePayload) => Promise<void>
removeWidget: (id: string) => Promise<void>
getWidgetSnapshot: (id: string) => WidgetSnapshot | undefined
publishWidgetEvent: (id: string, event: Record<string, unknown>) => void
onWidgetEvent: (listener: (event: { id: string, event: Record<string, unknown> }) => void) => () => void
}
/**
@@ -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<typeof create
widgetsRemove,
widgetsClear,
widgetsFetch,
widgetsIframePublish,
}, {
widgetsPrepareWindow: async (payload, options) => {
if (!isFromWindow(options as InvokeOptions, params.window))
@@ -111,5 +114,11 @@ export function createWidgetsService(params: { context: ReturnType<typeof create
normalizeRequiredWidgetId(payload?.id, 'id is required to fetch a widget snapshot.'),
)
},
widgetsIframePublish: async (payload, options) => {
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))
},
})
}
@@ -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<string, unknown> {
if (!isPlainObject(event)) {
throw new Error('iframe event must be a plain object.')
}
return event as Record<string, unknown>
}
@@ -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<string, unknown>) => void
onWidgetEvent: (listener: (event: { id: string, event: Record<string, unknown> }) => 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<typeof createContext>['context'] | undefined
const widgetRecords = new Map<string, WidgetRecord>()
const widgetEventListeners = new Set<(event: { id: string, event: Record<string, unknown> }) => void>()
const windowContexts = new Map<string, WidgetWindowContext>()
const rendererBase = baseUrl(resolve(getElectronMainDirname(), '..', 'renderer'))
@@ -655,6 +658,19 @@ export function setupWidgetsWindowManager(params: {
return toSnapshot(record)
}
function publishWidgetEvent(id: string, event: Record<string, unknown>) {
for (const listener of widgetEventListeners) {
listener({ id, event })
}
}
function onWidgetEvent(listener: (event: { id: string, event: Record<string, unknown> }) => 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,
}
@@ -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<string, any>) {
const inspectPluginHost = useElectronEventaInvoke(electronPluginInspect)
const getPluginAssetBaseUrl = useElectronEventaInvoke(electronPluginGetAssetBaseUrl)
const publishWidgetIframeEvent = useElectronEventaInvoke(widgetsIframePublish)
const model = computed<PluginModuleWidgetPayload & Record<string, unknown>>(() => (
isPlainObject(props.modelValue) ? props.modelValue as PluginModuleWidgetPayload & Record<string, unknown> : {} as PluginModuleWidgetPayload & Record<string, unknown>
@@ -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,
})
},
},
)
@@ -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')
})
})
@@ -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<object>()): 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<string, unknown>)
.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<string, unknown> {
const normalized = toWidgetsIframePostMessageValue(value)
return normalized && typeof normalized === 'object' && !Array.isArray(normalized)
? normalized as Record<string, unknown>
: {}
}
/**
* Manages typed parent-to-iframe messaging for one extension UI iframe.
@@ -37,6 +98,7 @@ export function useIframeMessagePort(
moduleSnapshot: ComputedRef<PluginHostModuleSummary | undefined>
moduleConfig: ComputedRef<Record<string, unknown>>
propsPayload: ComputedRef<Record<string, unknown>>
onPublish?: (event: Record<string, unknown>) => void | Promise<void>
},
) {
const iframeLoadError = shallowRef<string>()
@@ -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<string, unknown> | 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<string, unknown>)
})
watch(options.moduleId, () => {
emitInitPayload()
}, { immediate: true })
@@ -243,6 +243,7 @@ export const widgetsClear = defineInvokeEventa('eventa:invoke:electron:windows:w
export const widgetsUpdate = defineInvokeEventa<void, WidgetsUpdatePayload>('eventa:invoke:electron:windows:widgets:update')
export const widgetsFetch = defineInvokeEventa<WidgetSnapshot | void, { id: string }>('eventa:invoke:electron:windows:widgets:fetch')
export const widgetsPrepareWindow = defineInvokeEventa<string | undefined, { id?: string }>('eventa:invoke:electron:windows:widgets:prepare')
export const widgetsIframePublish = defineInvokeEventa<void, { id: string, event: Record<string, unknown> }>('eventa:invoke:electron:windows:widgets:iframe-publish')
export const electronWindowClose = defineInvokeEventa<void>('eventa:invoke:electron:window:close')
export type ElectronWindowLifecycleReason
@@ -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)
@@ -22,6 +22,7 @@ import { toJsonSchema } from 'xsschema'
export interface ToolExecutionGameletApi {
open: (id: string, params?: HostDataRecord) => Promise<void>
configure: (id: string, patch: HostDataRecord) => Promise<void>
request: (id: string, payload: HostDataRecord, options?: { timeoutMs?: number }) => Promise<HostDataRecord>
close: (id: string) => Promise<void>
isOpen: (id: string) => Promise<boolean> | 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<HostDataRecord> {
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),
})
+1
View File
@@ -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',