refactor(stage-tamagotchi): consolidate electron plugin static assets
This commit is contained in:
@@ -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) {
|
||||
|
||||
-70
@@ -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',
|
||||
})
|
||||
})
|
||||
})
|
||||
-180
@@ -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()]
|
||||
}
|
||||
-100
@@ -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')
|
||||
})
|
||||
})
|
||||
-87
@@ -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
|
||||
}
|
||||
})
|
||||
}
|
||||
-64
@@ -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',
|
||||
},
|
||||
})
|
||||
})
|
||||
})
|
||||
-134
@@ -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()
|
||||
},
|
||||
}
|
||||
}
|
||||
-36
@@ -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
|
||||
}
|
||||
})
|
||||
}
|
||||
+431
@@ -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',
|
||||
},
|
||||
})
|
||||
})
|
||||
})
|
||||
+285
@@ -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()
|
||||
},
|
||||
}
|
||||
}
|
||||
+274
@@ -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,
|
||||
}
|
||||
|
||||
|
||||
+12
@@ -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,
|
||||
})
|
||||
},
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
+42
@@ -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')
|
||||
})
|
||||
})
|
||||
+88
-6
@@ -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),
|
||||
})
|
||||
|
||||
@@ -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',
|
||||
|
||||
Reference in New Issue
Block a user