style: lint
This commit is contained in:
@@ -40,13 +40,13 @@ export interface GameletKitService {
|
||||
|
||||
export function createGameletKit(options: { service: GameletKitService }) {
|
||||
return defineKit<GameletClient>({
|
||||
id: 'kit.gamelet',
|
||||
version: '1.0.0',
|
||||
createClient(runtime) {
|
||||
return {
|
||||
mount: input => options.service.mount(input, runtime),
|
||||
}
|
||||
},
|
||||
id: 'kit.gamelet',
|
||||
version: '1.0.0',
|
||||
})
|
||||
}
|
||||
```
|
||||
|
||||
@@ -119,14 +119,14 @@ Capability record baseline:
|
||||
```ts
|
||||
interface CapabilityRecord {
|
||||
capabilityId: string
|
||||
providerModuleId: string
|
||||
health?: 'degraded' | 'ok' | 'unknown'
|
||||
hostId: string
|
||||
instanceId?: string
|
||||
runtime: 'electron' | 'web' | 'pocket' | 'node'
|
||||
state: 'announced' | 'ready' | 'degraded' | 'withdrawn'
|
||||
version?: string
|
||||
health?: 'ok' | 'degraded' | 'unknown'
|
||||
metadata?: Record<string, unknown>
|
||||
providerModuleId: string
|
||||
runtime: 'electron' | 'node' | 'pocket' | 'web'
|
||||
state: 'announced' | 'degraded' | 'ready' | 'withdrawn'
|
||||
version?: string
|
||||
}
|
||||
```
|
||||
|
||||
|
||||
@@ -64,11 +64,11 @@ Define a small transport config type owned by the Plugin Host:
|
||||
|
||||
```ts
|
||||
export type PluginTransport
|
||||
= | { kind: 'in-memory' }
|
||||
| { kind: 'websocket', url: string, protocols?: string[] }
|
||||
| { kind: 'web-worker', worker: Worker }
|
||||
= | { kind: 'electron', target: 'main' | 'renderer', webContentsId?: number }
|
||||
| { kind: 'in-memory' }
|
||||
| { kind: 'node-worker', worker: import('node:worker_threads').Worker }
|
||||
| { kind: 'electron', target: 'main' | 'renderer', webContentsId?: number }
|
||||
| { kind: 'web-worker', worker: Worker }
|
||||
| { kind: 'websocket', protocols?: string[], url: string }
|
||||
```
|
||||
|
||||
`createPluginContext(transport)` creates and returns an Eventa context based on the transport adapter (in-memory, WebSocket, worker, electron).
|
||||
@@ -99,6 +99,10 @@ Context creation happens during host setup, before any plugin lifecycle method i
|
||||
Replace direct channel usage with context-bound factories:
|
||||
|
||||
```ts
|
||||
export function createApis(ctx: EventaContext) {
|
||||
return { providers: createProviders(ctx) }
|
||||
}
|
||||
|
||||
export function createProviders(ctx: EventaContext) {
|
||||
return {
|
||||
listProviders() {
|
||||
@@ -106,10 +110,6 @@ export function createProviders(ctx: EventaContext) {
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
export function createApis(ctx: EventaContext) {
|
||||
return { providers: createProviders(ctx) }
|
||||
}
|
||||
```
|
||||
|
||||
Plugins call `createApis(ctx)` provided by the host instead of importing global singletons.
|
||||
|
||||
@@ -10,20 +10,20 @@ import { createContext } from '@moeru/eventa'
|
||||
* Describes one extension-scoped Eventa channel context.
|
||||
*/
|
||||
export interface ExtensionChannelScope {
|
||||
/** Extension session identity associated with this scope. */
|
||||
identity: ExtensionIdentity
|
||||
/** Eventa context that carries scoped extension/module traffic. */
|
||||
context: EventContext<any, any>
|
||||
/** Extension session identity associated with this scope. */
|
||||
identity: ExtensionIdentity
|
||||
}
|
||||
|
||||
/**
|
||||
* Describes one module-scoped Eventa channel context.
|
||||
*/
|
||||
export interface ModuleChannelScope {
|
||||
/** Module identity associated with this scope. */
|
||||
identity: ExtensionModuleIdentity
|
||||
/** Eventa context shared with the owning extension scope. */
|
||||
context: EventContext<any, any>
|
||||
/** Module identity associated with this scope. */
|
||||
identity: ExtensionModuleIdentity
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -41,18 +41,18 @@ export interface ModuleChannelScope {
|
||||
* - Extension identity plus the Eventa context used by child module scopes
|
||||
*/
|
||||
export function createExtensionChannelScope(input: {
|
||||
context?: EventContext<any, any>
|
||||
extensionId: string
|
||||
sessionId?: string
|
||||
version?: string
|
||||
context?: EventContext<any, any>
|
||||
}): ExtensionChannelScope {
|
||||
return {
|
||||
context: input.context ?? createContext(),
|
||||
identity: {
|
||||
id: input.extensionId,
|
||||
sessionId: input.sessionId,
|
||||
version: input.version,
|
||||
},
|
||||
context: input.context ?? createContext(),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -71,14 +71,14 @@ export function createExtensionChannelScope(input: {
|
||||
*/
|
||||
export function createModuleChannelScope(
|
||||
extension: ExtensionChannelScope,
|
||||
input: { moduleId: string, labels?: Record<string, string> },
|
||||
input: { labels?: Record<string, string>, moduleId: string },
|
||||
): ModuleChannelScope {
|
||||
return {
|
||||
context: extension.context,
|
||||
identity: {
|
||||
id: input.moduleId,
|
||||
extension: extension.identity,
|
||||
id: input.moduleId,
|
||||
labels: input.labels,
|
||||
},
|
||||
context: extension.context,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,19 +1,19 @@
|
||||
import { createContext } from '@moeru/eventa/adapters/event-target'
|
||||
|
||||
/**
|
||||
* Creates a control-plane Eventa context backed by a local `EventTarget`.
|
||||
* Creates a data-plane Eventa context backed by a local `EventTarget`.
|
||||
*
|
||||
* Use when:
|
||||
* - A browser-like runtime wants an in-process host channel transport
|
||||
* - A browser-like runtime wants an in-process shared data channel transport
|
||||
*
|
||||
* Expects:
|
||||
* - `eventTarget` dispatches and listens for the Eventa adapter event format
|
||||
*
|
||||
* Returns:
|
||||
* - An Eventa context that can be assigned to the active host channel
|
||||
* - An Eventa context that can be assigned to the active data channel
|
||||
*/
|
||||
export function createEventTargetHostChannel(eventTarget: EventTarget) {
|
||||
// TODO: implement actual event target based host channel
|
||||
export function createEventTargetDataChannel(eventTarget: EventTarget) {
|
||||
// TODO: implement actual event target based data channel
|
||||
return createContext(eventTarget)
|
||||
}
|
||||
|
||||
@@ -34,18 +34,18 @@ export function createEventTargetExtensionTransport(eventTarget: EventTarget) {
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a data-plane Eventa context backed by a local `EventTarget`.
|
||||
* Creates a control-plane Eventa context backed by a local `EventTarget`.
|
||||
*
|
||||
* Use when:
|
||||
* - A browser-like runtime wants an in-process shared data channel transport
|
||||
* - A browser-like runtime wants an in-process host channel transport
|
||||
*
|
||||
* Expects:
|
||||
* - `eventTarget` dispatches and listens for the Eventa adapter event format
|
||||
*
|
||||
* Returns:
|
||||
* - An Eventa context that can be assigned to the active data channel
|
||||
* - An Eventa context that can be assigned to the active host channel
|
||||
*/
|
||||
export function createEventTargetDataChannel(eventTarget: EventTarget) {
|
||||
// TODO: implement actual event target based data channel
|
||||
export function createEventTargetHostChannel(eventTarget: EventTarget) {
|
||||
// TODO: implement actual event target based host channel
|
||||
return createContext(eventTarget)
|
||||
}
|
||||
|
||||
@@ -1,18 +1,18 @@
|
||||
import { createContext } from '@moeru/eventa/adapters/websocket/native'
|
||||
|
||||
/**
|
||||
* Creates a control-plane Eventa context backed by a native `WebSocket`.
|
||||
* Creates a data-plane Eventa context backed by a native `WebSocket`.
|
||||
*
|
||||
* Use when:
|
||||
* - A remote plugin talks to the host over a WebSocket transport
|
||||
* - A remote plugin needs a WebSocket-backed shared data channel
|
||||
*
|
||||
* Expects:
|
||||
* - `webSocket` is already connected and managed by the caller
|
||||
*
|
||||
* Returns:
|
||||
* - An Eventa context that can be assigned to the active host channel
|
||||
* - An Eventa context that can be assigned to the active data channel
|
||||
*/
|
||||
export function createWebSocketHostChannel(webSocket: WebSocket) {
|
||||
export function createWebSocketDataChannel(webSocket: WebSocket) {
|
||||
// TODO: make sure to setup proper event handling on the webSocket
|
||||
return createContext(webSocket)
|
||||
}
|
||||
@@ -34,18 +34,18 @@ export function createWebSocketExtensionTransport(webSocket: WebSocket) {
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a data-plane Eventa context backed by a native `WebSocket`.
|
||||
* Creates a control-plane Eventa context backed by a native `WebSocket`.
|
||||
*
|
||||
* Use when:
|
||||
* - A remote plugin needs a WebSocket-backed shared data channel
|
||||
* - A remote plugin talks to the host over a WebSocket transport
|
||||
*
|
||||
* Expects:
|
||||
* - `webSocket` is already connected and managed by the caller
|
||||
*
|
||||
* Returns:
|
||||
* - An Eventa context that can be assigned to the active data channel
|
||||
* - An Eventa context that can be assigned to the active host channel
|
||||
*/
|
||||
export function createWebSocketDataChannel(webSocket: WebSocket) {
|
||||
export function createWebSocketHostChannel(webSocket: WebSocket) {
|
||||
// TODO: make sure to setup proper event handling on the webSocket
|
||||
return createContext(webSocket)
|
||||
}
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
*/
|
||||
export interface Disposable {
|
||||
/** Releases the resource. */
|
||||
dispose: () => void | Promise<void>
|
||||
dispose: () => Promise<void> | void
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -7,27 +7,27 @@ import { createModule, defineExtension, DisposableStore } from './index'
|
||||
function createTestExtensionContext(register: ExtensionSetupContext['modules']['register']): ExtensionSetupContext {
|
||||
return {
|
||||
extension: { id: 'extension-test', sessionId: 'session-1', version: '1.0.0' },
|
||||
subscriptions: new DisposableStore(),
|
||||
kits: { use: vi.fn(), tryUse: vi.fn(), watch: vi.fn() },
|
||||
kits: { tryUse: vi.fn(), use: vi.fn(), watch: vi.fn() },
|
||||
modules: { register },
|
||||
subscriptions: new DisposableStore(),
|
||||
}
|
||||
}
|
||||
|
||||
function createTestModule(id: string, dispose = vi.fn(async () => {})): ExtensionModuleContext {
|
||||
return {
|
||||
dispose,
|
||||
id,
|
||||
identity: {
|
||||
id,
|
||||
extension: {
|
||||
id: 'extension-test',
|
||||
sessionId: 'session-1',
|
||||
version: '1.0.0',
|
||||
},
|
||||
id,
|
||||
},
|
||||
kits: { tryUse: vi.fn(), use: vi.fn(), watch: vi.fn() },
|
||||
permissions: {},
|
||||
kits: { use: vi.fn(), tryUse: vi.fn(), watch: vi.fn() },
|
||||
subscriptions: new DisposableStore(),
|
||||
dispose,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -36,8 +36,8 @@ describe('defineExtension', () => {
|
||||
const setup = vi.fn(async () => {})
|
||||
const extension = defineExtension({
|
||||
id: 'airi-extension-test',
|
||||
version: '1.0.0',
|
||||
setup,
|
||||
version: '1.0.0',
|
||||
})
|
||||
|
||||
expect(extension.id).toBe('airi-extension-test')
|
||||
@@ -47,18 +47,18 @@ describe('defineExtension', () => {
|
||||
await extension.setup({
|
||||
extension: {
|
||||
id: extension.id,
|
||||
version: extension.version,
|
||||
sessionId: 'session-1',
|
||||
version: extension.version,
|
||||
},
|
||||
subscriptions,
|
||||
kits: {
|
||||
use: vi.fn(),
|
||||
tryUse: vi.fn(),
|
||||
use: vi.fn(),
|
||||
watch: vi.fn(),
|
||||
},
|
||||
modules: {
|
||||
register: vi.fn(),
|
||||
},
|
||||
subscriptions,
|
||||
})
|
||||
|
||||
expect(setup).toHaveBeenCalledTimes(1)
|
||||
@@ -76,10 +76,10 @@ describe('createModule', () => {
|
||||
|
||||
expect(register).toHaveBeenCalledWith({ id: 'module-explicit' })
|
||||
expect(ref).toStrictEqual({
|
||||
dispose: expect.any(Function),
|
||||
id: 'module-explicit',
|
||||
kits: module.kits,
|
||||
subscriptions: module.subscriptions,
|
||||
dispose: expect.any(Function),
|
||||
})
|
||||
expect(ref).not.toHaveProperty('identity')
|
||||
expect(ref).not.toHaveProperty('permissions')
|
||||
|
||||
@@ -35,9 +35,9 @@ export async function createModule(
|
||||
ctx.subscriptions.add({ dispose })
|
||||
|
||||
return {
|
||||
dispose,
|
||||
id: module.id,
|
||||
kits: module.kits,
|
||||
subscriptions: module.subscriptions,
|
||||
dispose,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -9,31 +9,26 @@ import type { KitAvailability, KitRef, KitUseResult } from '../kit'
|
||||
import type { Disposable, DisposableStore } from './disposable'
|
||||
|
||||
/**
|
||||
* Describes an optional advanced lifecycle/attribution scope inside an extension session.
|
||||
* Public extension authoring contract returned by `defineExtension`.
|
||||
*/
|
||||
export interface RegisterExtensionModuleInput {
|
||||
/** Stable module id within the current extension session. */
|
||||
export interface Extension {
|
||||
/** Stable extension id from the manifest/package. */
|
||||
id: string
|
||||
/**
|
||||
* Runtime permissions this module actually intends to use.
|
||||
*
|
||||
* The host intersects these requests with the extension manifest grant, so a
|
||||
* module can never widen access beyond the package/session-level ceiling.
|
||||
*/
|
||||
permissions?: ModulePermissionDeclaration
|
||||
/** Optional labels used for routing, policy, and inspection. */
|
||||
labels?: Record<string, string>
|
||||
/** Runs extension initialization. */
|
||||
setup: (ctx: ExtensionSetupContext) => Promise<void> | void
|
||||
/** Optional extension package version. */
|
||||
version?: string
|
||||
}
|
||||
|
||||
/**
|
||||
* Minimal kit client registry exposed to extension setup and optional module scopes.
|
||||
*/
|
||||
export interface ExtensionKitRegistry {
|
||||
use: <TClient>(kit: KitRef<TClient>) => Promise<TClient>
|
||||
tryUse: <TClient>(kit: KitRef<TClient>) => Promise<KitUseResult<TClient>>
|
||||
use: <TClient>(kit: KitRef<TClient>) => Promise<TClient>
|
||||
watch: <TClient>(
|
||||
kit: KitRef<TClient>,
|
||||
callback: (availability: KitAvailability<TClient>) => void | Promise<void>,
|
||||
callback: (availability: KitAvailability<TClient>) => Promise<void> | void,
|
||||
) => Disposable
|
||||
}
|
||||
|
||||
@@ -41,32 +36,32 @@ export interface ExtensionKitRegistry {
|
||||
* Runtime context returned from module registration.
|
||||
*/
|
||||
export interface ExtensionModuleContext {
|
||||
/** Disposes module-owned resources. */
|
||||
dispose: () => Promise<void>
|
||||
/** Stable module id within the current extension session. */
|
||||
id: string
|
||||
/** Protocol identity for this module. */
|
||||
identity: ExtensionModuleIdentity
|
||||
/** Effective grant after applying the extension-level permission ceiling. */
|
||||
permissions: ModulePermissionGrant
|
||||
/** Module-scoped kit access for attribution and optional lifecycle cleanup. */
|
||||
kits: ExtensionKitRegistry
|
||||
/** Effective grant after applying the extension-level permission ceiling. */
|
||||
permissions: ModulePermissionGrant
|
||||
/** Cleanup callbacks owned by this module. */
|
||||
subscriptions: DisposableStore
|
||||
/** Disposes module-owned resources. */
|
||||
dispose: () => Promise<void>
|
||||
}
|
||||
|
||||
/**
|
||||
* Narrow module reference exposed to extension authors.
|
||||
*/
|
||||
export interface ExtensionModuleRef {
|
||||
/** Disposes module-owned resources. */
|
||||
dispose: () => Promise<void>
|
||||
/** Stable module id within the current extension session. */
|
||||
id: string
|
||||
/** Module-scoped kit access for attribution and optional lifecycle cleanup. */
|
||||
kits: ExtensionKitRegistry
|
||||
/** Cleanup callbacks owned by this module. */
|
||||
subscriptions: DisposableStore
|
||||
/** Disposes module-owned resources. */
|
||||
dispose: () => Promise<void>
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -85,22 +80,27 @@ export interface ExtensionModuleRegistry {
|
||||
export interface ExtensionSetupContext {
|
||||
/** Current extension session identity. */
|
||||
extension: ExtensionIdentity
|
||||
/** Extension-session cleanup callbacks. */
|
||||
subscriptions: DisposableStore
|
||||
/** Extension-scoped kit access for the common authoring path. */
|
||||
kits: ExtensionKitRegistry
|
||||
/** Optional advanced lifecycle/attribution scopes. */
|
||||
modules: ExtensionModuleRegistry
|
||||
/** Extension-session cleanup callbacks. */
|
||||
subscriptions: DisposableStore
|
||||
}
|
||||
|
||||
/**
|
||||
* Public extension authoring contract returned by `defineExtension`.
|
||||
* Describes an optional advanced lifecycle/attribution scope inside an extension session.
|
||||
*/
|
||||
export interface Extension {
|
||||
/** Stable extension id from the manifest/package. */
|
||||
export interface RegisterExtensionModuleInput {
|
||||
/** Stable module id within the current extension session. */
|
||||
id: string
|
||||
/** Optional extension package version. */
|
||||
version?: string
|
||||
/** Runs extension initialization. */
|
||||
setup: (ctx: ExtensionSetupContext) => Promise<void> | void
|
||||
/** Optional labels used for routing, policy, and inspection. */
|
||||
labels?: Record<string, string>
|
||||
/**
|
||||
* Runtime permissions this module actually intends to use.
|
||||
*
|
||||
* The host intersects these requests with the extension manifest grant, so a
|
||||
* module can never widen access beyond the package/session-level ceiling.
|
||||
*/
|
||||
permissions?: ModulePermissionDeclaration
|
||||
}
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
export class KitUnavailableError extends Error {
|
||||
constructor(
|
||||
readonly kitId: string,
|
||||
readonly reason: 'missing-kit' | 'permission-denied' | 'incompatible-version' | 'not-ready',
|
||||
readonly reason: 'incompatible-version' | 'missing-kit' | 'not-ready' | 'permission-denied',
|
||||
) {
|
||||
super(`Kit \`${kitId}\` is unavailable: ${reason}.`)
|
||||
this.name = 'KitUnavailableError'
|
||||
|
||||
@@ -6,30 +6,30 @@ import { defineKit, kitUseFailure } from './index'
|
||||
describe('defineKit', () => {
|
||||
it('defines a typed kit reference with expose policy metadata', () => {
|
||||
const kit = defineKit({
|
||||
id: 'kit.test',
|
||||
version: '1.0.0',
|
||||
allowedExposePolicies: ['local-only', 'remote-observable'],
|
||||
defaultExposePolicy: 'local-only',
|
||||
createClient: runtime => ({
|
||||
identity: `${runtime.extensionId}:${runtime.moduleId}`,
|
||||
}),
|
||||
defaultExposePolicy: 'local-only',
|
||||
id: 'kit.test',
|
||||
version: '1.0.0',
|
||||
})
|
||||
|
||||
expect(kit.id).toBe('kit.test')
|
||||
expect(kit.defaultExposePolicy).toBe('local-only')
|
||||
expect(kit.createClient({
|
||||
extensionId: 'extension-a',
|
||||
sessionId: 'session-a',
|
||||
moduleId: 'module-a',
|
||||
sessionId: 'session-a',
|
||||
subscriptions: new DisposableStore(),
|
||||
}).identity).toBe('extension-a:module-a')
|
||||
})
|
||||
|
||||
it('creates typed kit use failures', () => {
|
||||
const kit = defineKit({
|
||||
createClient: () => ({}),
|
||||
id: 'kit.missing',
|
||||
version: '1.0.0',
|
||||
createClient: () => ({}),
|
||||
})
|
||||
|
||||
const result = kitUseFailure(kit, 'missing-kit')
|
||||
|
||||
@@ -2,7 +2,11 @@ import type { Disposable, DisposableStore } from '../extension/disposable'
|
||||
|
||||
import { KitUnavailableError } from './errors'
|
||||
|
||||
export type ExposePolicy = 'local-only' | 'remote-observable' | 'remote-callable'
|
||||
export type ExposePolicy = 'local-only' | 'remote-callable' | 'remote-observable'
|
||||
|
||||
export type KitAvailability<TClient>
|
||||
= | { available: false, error: Error, kit: KitRef<TClient>, reason: KitUnavailableReason }
|
||||
| { available: true, client: TClient, kit: KitRef<TClient> }
|
||||
|
||||
/**
|
||||
* Host-provided runtime values used to create a scope-aware kit client.
|
||||
@@ -10,10 +14,10 @@ export type ExposePolicy = 'local-only' | 'remote-observable' | 'remote-callable
|
||||
export interface KitClientRuntime {
|
||||
/** Stable extension id. */
|
||||
extensionId: string
|
||||
/** Host-assigned extension session id. */
|
||||
sessionId: string
|
||||
/** Stable module id when the kit client is created for an explicit module scope. */
|
||||
moduleId?: string
|
||||
/** Host-assigned extension session id. */
|
||||
sessionId: string
|
||||
/** Cleanup store for the current extension or module scope. */
|
||||
subscriptions: DisposableStore
|
||||
}
|
||||
@@ -29,27 +33,23 @@ export interface KitClientRuntime {
|
||||
* @param TClient Kit client type returned to extension authors.
|
||||
*/
|
||||
export interface KitRef<TClient> {
|
||||
/** Exposure policies this kit can support across host/peer boundaries. */
|
||||
allowedExposePolicies?: ExposePolicy[]
|
||||
/** Creates a scope-aware client for this kit. */
|
||||
createClient: (runtime: KitClientRuntime) => TClient
|
||||
/** Default exposure policy when module/host policy does not override it. */
|
||||
defaultExposePolicy?: ExposePolicy
|
||||
/** Stable kit id. */
|
||||
id: string
|
||||
/** Kit API version used for compatibility checks. */
|
||||
version: string
|
||||
/** Exposure policies this kit can support across host/peer boundaries. */
|
||||
allowedExposePolicies?: ExposePolicy[]
|
||||
/** Default exposure policy when module/host policy does not override it. */
|
||||
defaultExposePolicy?: ExposePolicy
|
||||
/** Creates a scope-aware client for this kit. */
|
||||
createClient: (runtime: KitClientRuntime) => TClient
|
||||
}
|
||||
|
||||
export type KitUnavailableReason = 'missing-kit' | 'permission-denied' | 'incompatible-version' | 'not-ready'
|
||||
export type KitUnavailableReason = 'incompatible-version' | 'missing-kit' | 'not-ready' | 'permission-denied'
|
||||
|
||||
export type KitUseResult<TClient>
|
||||
= | { ok: true, client: TClient }
|
||||
| { ok: false, reason: KitUnavailableReason, error: Error }
|
||||
|
||||
export type KitAvailability<TClient>
|
||||
= | { available: true, kit: KitRef<TClient>, client: TClient }
|
||||
| { available: false, kit: KitRef<TClient>, reason: KitUnavailableReason, error: Error }
|
||||
= | { client: TClient, ok: true }
|
||||
| { error: Error, ok: false, reason: KitUnavailableReason }
|
||||
|
||||
/**
|
||||
* Defines a kit reference.
|
||||
@@ -87,9 +87,9 @@ export function kitUseFailure<TClient>(
|
||||
reason: KitUnavailableReason,
|
||||
): Extract<KitUseResult<TClient>, { ok: false }> {
|
||||
return {
|
||||
error: new KitUnavailableError(kit.id, reason),
|
||||
ok: false,
|
||||
reason,
|
||||
error: new KitUnavailableError(kit.id, reason),
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -13,12 +13,12 @@ describe('extension manifest schema', () => {
|
||||
it('accepts extension.airi.json v1 manifests', () => {
|
||||
const result = safeParse(extensionManifestV1Schema, {
|
||||
apiVersion: 'v1',
|
||||
kind: 'manifest.extension.airi.moeru.ai' as const,
|
||||
id: 'airi-extension-test',
|
||||
permissions: {},
|
||||
entrypoints: {
|
||||
electron: './extension.mjs',
|
||||
},
|
||||
id: 'airi-extension-test',
|
||||
kind: 'manifest.extension.airi.moeru.ai' as const,
|
||||
permissions: {},
|
||||
})
|
||||
|
||||
expect(result.success).toBe(true)
|
||||
@@ -27,12 +27,12 @@ describe('extension manifest schema', () => {
|
||||
it('rejects legacy extension manifests', () => {
|
||||
const result = safeParse(extensionManifestV1Schema, {
|
||||
apiVersion: 'v1',
|
||||
kind: 'manifest.plugin.airi.moeru.ai',
|
||||
name: 'airi-plugin-test',
|
||||
permissions: {},
|
||||
entrypoints: {
|
||||
electron: './plugin.mjs',
|
||||
},
|
||||
kind: 'manifest.plugin.airi.moeru.ai',
|
||||
name: 'airi-plugin-test',
|
||||
permissions: {},
|
||||
})
|
||||
|
||||
expect(result.success).toBe(false)
|
||||
@@ -53,10 +53,10 @@ describe('for ExtensionHost', () => {
|
||||
const session = await host.startExtension(extension, {
|
||||
manifest: {
|
||||
apiVersion: 'v1',
|
||||
kind: 'manifest.extension.airi.moeru.ai' as const,
|
||||
id: 'airi-extension-test',
|
||||
permissions: {},
|
||||
entrypoints: {},
|
||||
id: 'airi-extension-test',
|
||||
kind: 'manifest.extension.airi.moeru.ai' as const,
|
||||
permissions: {},
|
||||
},
|
||||
})
|
||||
|
||||
@@ -74,10 +74,10 @@ describe('for ExtensionHost', () => {
|
||||
await expect(host.startExtension(extension, {
|
||||
manifest: {
|
||||
apiVersion: 'v1',
|
||||
kind: 'manifest.extension.airi.moeru.ai' as const,
|
||||
id: 'airi-extension-manifest-id',
|
||||
permissions: {},
|
||||
entrypoints: {},
|
||||
id: 'airi-extension-manifest-id',
|
||||
kind: 'manifest.extension.airi.moeru.ai' as const,
|
||||
permissions: {},
|
||||
},
|
||||
})).rejects.toThrow(
|
||||
'Extension entrypoint id `airi-extension-entrypoint-id` must match manifest id `airi-extension-manifest-id`.',
|
||||
@@ -109,10 +109,10 @@ describe('for ExtensionHost', () => {
|
||||
await expect(host.startExtension(extension, {
|
||||
manifest: {
|
||||
apiVersion: 'v1',
|
||||
kind: 'manifest.extension.airi.moeru.ai' as const,
|
||||
id: 'airi-extension-failing',
|
||||
permissions: {},
|
||||
entrypoints: {},
|
||||
id: 'airi-extension-failing',
|
||||
kind: 'manifest.extension.airi.moeru.ai' as const,
|
||||
permissions: {},
|
||||
},
|
||||
})).rejects.toThrow('setup failed')
|
||||
|
||||
@@ -123,24 +123,24 @@ describe('for ExtensionHost', () => {
|
||||
it('cleans up extension kit resources registered before setup failure', async () => {
|
||||
const host = new ExtensionHost()
|
||||
const kit = defineKit({
|
||||
id: 'kit.cleanup-failure',
|
||||
version: '1.0.0',
|
||||
createClient: runtime => ({
|
||||
bind() {
|
||||
return host.bindExtensionKitModule(runtime.sessionId, {
|
||||
moduleId: 'cleanup-failure-gamelet',
|
||||
config: {},
|
||||
kitId: 'kit.cleanup-failure',
|
||||
kitModuleType: 'gamelet',
|
||||
config: {},
|
||||
moduleId: 'cleanup-failure-gamelet',
|
||||
})
|
||||
},
|
||||
}),
|
||||
id: 'kit.cleanup-failure',
|
||||
version: '1.0.0',
|
||||
})
|
||||
host.registerKit({
|
||||
kitId: 'kit.cleanup-failure',
|
||||
version: '1.0.0',
|
||||
runtimes: ['electron'],
|
||||
capabilities: [],
|
||||
kitId: 'kit.cleanup-failure',
|
||||
runtimes: ['electron'],
|
||||
version: '1.0.0',
|
||||
})
|
||||
host.registerKitApi(kit)
|
||||
const extension = defineExtension({
|
||||
@@ -155,17 +155,17 @@ describe('for ExtensionHost', () => {
|
||||
await expect(host.startExtension(extension, {
|
||||
manifest: {
|
||||
apiVersion: 'v1',
|
||||
kind: 'manifest.extension.airi.moeru.ai' as const,
|
||||
entrypoints: {},
|
||||
id: 'airi-extension-cleanup-failure',
|
||||
kind: 'manifest.extension.airi.moeru.ai' as const,
|
||||
permissions: {
|
||||
apis: [
|
||||
{ key: 'kit.cleanup-failure', actions: ['invoke'] },
|
||||
{ actions: ['invoke'], key: 'kit.cleanup-failure' },
|
||||
],
|
||||
resources: [
|
||||
{ key: 'proj-airi:plugin-sdk:resources:kits:kit.cleanup-failure:bindings', actions: ['write'] },
|
||||
{ actions: ['write'], key: 'proj-airi:plugin-sdk:resources:kits:kit.cleanup-failure:bindings' },
|
||||
],
|
||||
},
|
||||
entrypoints: {},
|
||||
},
|
||||
})).rejects.toThrow('setup failed after resource registration')
|
||||
|
||||
@@ -179,32 +179,32 @@ describe('for ExtensionHost', () => {
|
||||
it('cleans up module-scoped kit resources when the module is disposed', async () => {
|
||||
const host = new ExtensionHost()
|
||||
const kit = defineKit({
|
||||
id: 'kit.module-dispose',
|
||||
version: '1.0.0',
|
||||
createClient: runtime => ({
|
||||
bind() {
|
||||
return host.bindExtensionKitModule(runtime.sessionId, {
|
||||
moduleId: 'module-dispose-gamelet',
|
||||
config: {},
|
||||
kitId: 'kit.module-dispose',
|
||||
kitModuleType: 'gamelet',
|
||||
config: {},
|
||||
moduleId: 'module-dispose-gamelet',
|
||||
}, runtime.moduleId)
|
||||
},
|
||||
}),
|
||||
id: 'kit.module-dispose',
|
||||
version: '1.0.0',
|
||||
})
|
||||
host.registerKit({
|
||||
kitId: 'kit.module-dispose',
|
||||
version: '1.0.0',
|
||||
runtimes: ['electron'],
|
||||
capabilities: [],
|
||||
kitId: 'kit.module-dispose',
|
||||
runtimes: ['electron'],
|
||||
version: '1.0.0',
|
||||
})
|
||||
host.registerKitApi(kit)
|
||||
const permissions: ModulePermissionDeclaration = {
|
||||
apis: [
|
||||
{ key: 'kit.module-dispose', actions: ['invoke'] },
|
||||
{ actions: ['invoke'], key: 'kit.module-dispose' },
|
||||
],
|
||||
resources: [
|
||||
{ key: 'proj-airi:plugin-sdk:resources:kits:kit.module-dispose:bindings', actions: ['write'] },
|
||||
{ actions: ['write'], key: 'proj-airi:plugin-sdk:resources:kits:kit.module-dispose:bindings' },
|
||||
],
|
||||
}
|
||||
const extension = defineExtension({
|
||||
@@ -224,10 +224,10 @@ describe('for ExtensionHost', () => {
|
||||
const session = await host.startExtension(extension, {
|
||||
manifest: {
|
||||
apiVersion: 'v1',
|
||||
kind: 'manifest.extension.airi.moeru.ai' as const,
|
||||
id: 'airi-extension-module-dispose',
|
||||
permissions,
|
||||
entrypoints: {},
|
||||
id: 'airi-extension-module-dispose',
|
||||
kind: 'manifest.extension.airi.moeru.ai' as const,
|
||||
permissions,
|
||||
},
|
||||
})
|
||||
|
||||
@@ -239,11 +239,11 @@ describe('for ExtensionHost', () => {
|
||||
it('lets extension setup use granted kits without registering a module', async () => {
|
||||
const host = new ExtensionHost()
|
||||
const kit = defineKit({
|
||||
id: 'kit.extension-direct',
|
||||
version: '1.0.0',
|
||||
createClient: runtime => ({
|
||||
ping: () => `${runtime.extensionId}:${runtime.sessionId}:${runtime.moduleId ?? 'root'}`,
|
||||
}),
|
||||
id: 'kit.extension-direct',
|
||||
version: '1.0.0',
|
||||
})
|
||||
host.registerKitApi(kit)
|
||||
|
||||
@@ -259,12 +259,12 @@ describe('for ExtensionHost', () => {
|
||||
await host.startExtension(extension, {
|
||||
manifest: {
|
||||
apiVersion: 'v1',
|
||||
kind: 'manifest.extension.airi.moeru.ai' as const,
|
||||
id: 'airi-extension-direct-kit',
|
||||
permissions: {
|
||||
apis: [{ key: 'kit.extension-direct', actions: ['invoke'] }],
|
||||
},
|
||||
entrypoints: {},
|
||||
id: 'airi-extension-direct-kit',
|
||||
kind: 'manifest.extension.airi.moeru.ai' as const,
|
||||
permissions: {
|
||||
apis: [{ actions: ['invoke'], key: 'kit.extension-direct' }],
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
@@ -276,9 +276,9 @@ describe('for ExtensionHost', () => {
|
||||
it('denies extension-scoped kit use when the extension grant does not allow the kit', async () => {
|
||||
const host = new ExtensionHost()
|
||||
const kit = defineKit({
|
||||
createClient: () => ({ ping: () => 'pong' }),
|
||||
id: 'kit.extension-denied',
|
||||
version: '1.0.0',
|
||||
createClient: () => ({ ping: () => 'pong' }),
|
||||
})
|
||||
host.registerKitApi(kit)
|
||||
|
||||
@@ -297,12 +297,12 @@ describe('for ExtensionHost', () => {
|
||||
await host.startExtension(extension, {
|
||||
manifest: {
|
||||
apiVersion: 'v1',
|
||||
kind: 'manifest.extension.airi.moeru.ai' as const,
|
||||
id: 'airi-extension-direct-kit-denied',
|
||||
permissions: {
|
||||
apis: [{ key: 'kit.other', actions: ['invoke'] }],
|
||||
},
|
||||
entrypoints: {},
|
||||
id: 'airi-extension-direct-kit-denied',
|
||||
kind: 'manifest.extension.airi.moeru.ai' as const,
|
||||
permissions: {
|
||||
apis: [{ actions: ['invoke'], key: 'kit.other' }],
|
||||
},
|
||||
},
|
||||
})
|
||||
})
|
||||
@@ -310,13 +310,13 @@ describe('for ExtensionHost', () => {
|
||||
it('denies extension-scoped kit use when host permission resolver narrows the manifest grant', async () => {
|
||||
const host = new ExtensionHost({
|
||||
permissionResolver: () => ({
|
||||
apis: [{ key: 'kit.other', actions: ['invoke'] }],
|
||||
apis: [{ actions: ['invoke'], key: 'kit.other' }],
|
||||
}),
|
||||
})
|
||||
const kit = defineKit({
|
||||
createClient: () => ({ ping: () => 'pong' }),
|
||||
id: 'kit.extension-resolver-denied',
|
||||
version: '1.0.0',
|
||||
createClient: () => ({ ping: () => 'pong' }),
|
||||
})
|
||||
host.registerKitApi(kit)
|
||||
|
||||
@@ -335,12 +335,12 @@ describe('for ExtensionHost', () => {
|
||||
await host.startExtension(extension, {
|
||||
manifest: {
|
||||
apiVersion: 'v1',
|
||||
kind: 'manifest.extension.airi.moeru.ai' as const,
|
||||
id: 'airi-extension-direct-kit-resolver-denied',
|
||||
permissions: {
|
||||
apis: [{ key: 'kit.extension-resolver-denied', actions: ['invoke'] }],
|
||||
},
|
||||
entrypoints: {},
|
||||
id: 'airi-extension-direct-kit-resolver-denied',
|
||||
kind: 'manifest.extension.airi.moeru.ai' as const,
|
||||
permissions: {
|
||||
apis: [{ actions: ['invoke'], key: 'kit.extension-resolver-denied' }],
|
||||
},
|
||||
},
|
||||
})
|
||||
})
|
||||
@@ -350,26 +350,26 @@ describe('for ExtensionHost', () => {
|
||||
const host = new ExtensionHost({
|
||||
permissionResolver: () => ({
|
||||
apis: [{
|
||||
key: grantRequestedKit ? 'kit.extension-persisted-revoked' : 'kit.other',
|
||||
actions: ['invoke'],
|
||||
key: grantRequestedKit ? 'kit.extension-persisted-revoked' : 'kit.other',
|
||||
}],
|
||||
}),
|
||||
})
|
||||
const kit = defineKit({
|
||||
createClient: () => ({ ping: () => 'pong' }),
|
||||
id: 'kit.extension-persisted-revoked',
|
||||
version: '1.0.0',
|
||||
createClient: () => ({ ping: () => 'pong' }),
|
||||
})
|
||||
host.registerKitApi(kit)
|
||||
|
||||
const manifest = {
|
||||
apiVersion: 'v1',
|
||||
kind: 'manifest.extension.airi.moeru.ai' as const,
|
||||
id: 'airi-extension-direct-kit-persisted-revoked',
|
||||
permissions: {
|
||||
apis: [{ key: 'kit.extension-persisted-revoked', actions: ['invoke'] }],
|
||||
},
|
||||
entrypoints: {},
|
||||
id: 'airi-extension-direct-kit-persisted-revoked',
|
||||
kind: 'manifest.extension.airi.moeru.ai' as const,
|
||||
permissions: {
|
||||
apis: [{ actions: ['invoke'], key: 'kit.extension-persisted-revoked' }],
|
||||
},
|
||||
} satisfies ExtensionManifestV1
|
||||
|
||||
const grantedExtension = defineExtension({
|
||||
@@ -401,9 +401,9 @@ describe('for ExtensionHost', () => {
|
||||
it('lets module-scoped kit use inherit the extension grant when module permissions are omitted', async () => {
|
||||
const host = new ExtensionHost()
|
||||
const kit = defineKit({
|
||||
createClient: () => ({ ping: () => 'pong' }),
|
||||
id: 'kit.module-inherited-grant',
|
||||
version: '1.0.0',
|
||||
createClient: () => ({ ping: () => 'pong' }),
|
||||
})
|
||||
host.registerKitApi(kit)
|
||||
|
||||
@@ -424,12 +424,12 @@ describe('for ExtensionHost', () => {
|
||||
await host.startExtension(extension, {
|
||||
manifest: {
|
||||
apiVersion: 'v1',
|
||||
kind: 'manifest.extension.airi.moeru.ai' as const,
|
||||
id: 'airi-extension-module-inherited-grant',
|
||||
permissions: {
|
||||
apis: [{ key: 'kit.module-inherited-grant', actions: ['invoke'] }],
|
||||
},
|
||||
entrypoints: {},
|
||||
id: 'airi-extension-module-inherited-grant',
|
||||
kind: 'manifest.extension.airi.moeru.ai' as const,
|
||||
permissions: {
|
||||
apis: [{ actions: ['invoke'], key: 'kit.module-inherited-grant' }],
|
||||
},
|
||||
},
|
||||
})
|
||||
})
|
||||
@@ -437,13 +437,13 @@ describe('for ExtensionHost', () => {
|
||||
it('denies module-scoped kit use when host permission resolver narrows the extension grant', async () => {
|
||||
const host = new ExtensionHost({
|
||||
permissionResolver: () => ({
|
||||
apis: [{ key: 'kit.other', actions: ['invoke'] }],
|
||||
apis: [{ actions: ['invoke'], key: 'kit.other' }],
|
||||
}),
|
||||
})
|
||||
const kit = defineKit({
|
||||
createClient: () => ({ ping: () => 'pong' }),
|
||||
id: 'kit.module-resolver-denied',
|
||||
version: '1.0.0',
|
||||
createClient: () => ({ ping: () => 'pong' }),
|
||||
})
|
||||
host.registerKitApi(kit)
|
||||
|
||||
@@ -453,7 +453,7 @@ describe('for ExtensionHost', () => {
|
||||
const module = await ctx.modules.register({
|
||||
id: 'module-a',
|
||||
permissions: {
|
||||
apis: [{ key: 'kit.module-resolver-denied', actions: ['invoke'] }],
|
||||
apis: [{ actions: ['invoke'], key: 'kit.module-resolver-denied' }],
|
||||
},
|
||||
})
|
||||
const result = await module.kits.tryUse(kit)
|
||||
@@ -468,12 +468,12 @@ describe('for ExtensionHost', () => {
|
||||
await host.startExtension(extension, {
|
||||
manifest: {
|
||||
apiVersion: 'v1',
|
||||
kind: 'manifest.extension.airi.moeru.ai' as const,
|
||||
id: 'airi-extension-module-kit-resolver-denied',
|
||||
permissions: {
|
||||
apis: [{ key: 'kit.module-resolver-denied', actions: ['invoke'] }],
|
||||
},
|
||||
entrypoints: {},
|
||||
id: 'airi-extension-module-kit-resolver-denied',
|
||||
kind: 'manifest.extension.airi.moeru.ai' as const,
|
||||
permissions: {
|
||||
apis: [{ actions: ['invoke'], key: 'kit.module-resolver-denied' }],
|
||||
},
|
||||
},
|
||||
})
|
||||
})
|
||||
@@ -481,9 +481,9 @@ describe('for ExtensionHost', () => {
|
||||
it('lets extension setup watch kit availability without registering a module', async () => {
|
||||
const host = new ExtensionHost()
|
||||
const kit = defineKit({
|
||||
createClient: () => ({ ping: () => 'pong' }),
|
||||
id: 'kit.extension-watch',
|
||||
version: '1.0.0',
|
||||
createClient: () => ({ ping: () => 'pong' }),
|
||||
})
|
||||
|
||||
const observed: boolean[] = []
|
||||
@@ -499,12 +499,12 @@ describe('for ExtensionHost', () => {
|
||||
await host.startExtension(extension, {
|
||||
manifest: {
|
||||
apiVersion: 'v1',
|
||||
kind: 'manifest.extension.airi.moeru.ai' as const,
|
||||
id: 'airi-extension-direct-kit-watch',
|
||||
permissions: {
|
||||
apis: [{ key: 'kit.extension-watch', actions: ['invoke'] }],
|
||||
},
|
||||
entrypoints: {},
|
||||
id: 'airi-extension-direct-kit-watch',
|
||||
kind: 'manifest.extension.airi.moeru.ai' as const,
|
||||
permissions: {
|
||||
apis: [{ actions: ['invoke'], key: 'kit.extension-watch' }],
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
@@ -516,9 +516,9 @@ describe('for ExtensionHost', () => {
|
||||
it('disposes extension-scoped kit availability watchers with the extension session', async () => {
|
||||
const host = new ExtensionHost()
|
||||
const kit = defineKit({
|
||||
createClient: () => ({ ping: () => 'pong' }),
|
||||
id: 'kit.extension-watch-dispose',
|
||||
version: '1.0.0',
|
||||
createClient: () => ({ ping: () => 'pong' }),
|
||||
})
|
||||
|
||||
const observed: boolean[] = []
|
||||
@@ -534,12 +534,12 @@ describe('for ExtensionHost', () => {
|
||||
const session = await host.startExtension(extension, {
|
||||
manifest: {
|
||||
apiVersion: 'v1',
|
||||
kind: 'manifest.extension.airi.moeru.ai' as const,
|
||||
id: 'airi-extension-direct-kit-watch-dispose',
|
||||
permissions: {
|
||||
apis: [{ key: 'kit.extension-watch-dispose', actions: ['invoke'] }],
|
||||
},
|
||||
entrypoints: {},
|
||||
id: 'airi-extension-direct-kit-watch-dispose',
|
||||
kind: 'manifest.extension.airi.moeru.ai' as const,
|
||||
permissions: {
|
||||
apis: [{ actions: ['invoke'], key: 'kit.extension-watch-dispose' }],
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
@@ -552,9 +552,9 @@ describe('for ExtensionHost', () => {
|
||||
it('supports required, optional, and watched kit availability', async () => {
|
||||
const host = new ExtensionHost()
|
||||
const kit = defineKit({
|
||||
createClient: () => ({ ping: () => 'pong' }),
|
||||
id: 'kit.test',
|
||||
version: '1.0.0',
|
||||
createClient: () => ({ ping: () => 'pong' }),
|
||||
})
|
||||
host.registerKitApi(kit)
|
||||
|
||||
@@ -565,7 +565,7 @@ describe('for ExtensionHost', () => {
|
||||
const module = await ctx.modules.register({
|
||||
id: 'module-a',
|
||||
permissions: {
|
||||
apis: [{ key: 'kit.test', actions: ['invoke'] }],
|
||||
apis: [{ actions: ['invoke'], key: 'kit.test' }],
|
||||
},
|
||||
})
|
||||
const client = await module.kits.use(kit)
|
||||
@@ -583,12 +583,12 @@ describe('for ExtensionHost', () => {
|
||||
await host.startExtension(extension, {
|
||||
manifest: {
|
||||
apiVersion: 'v1',
|
||||
kind: 'manifest.extension.airi.moeru.ai' as const,
|
||||
id: 'airi-extension-kit-test',
|
||||
permissions: {
|
||||
apis: [{ key: 'kit.*', actions: ['invoke'] }],
|
||||
},
|
||||
entrypoints: {},
|
||||
id: 'airi-extension-kit-test',
|
||||
kind: 'manifest.extension.airi.moeru.ai' as const,
|
||||
permissions: {
|
||||
apis: [{ actions: ['invoke'], key: 'kit.*' }],
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
@@ -598,9 +598,9 @@ describe('for ExtensionHost', () => {
|
||||
it('disposes module-scoped kit availability watchers with the module scope', async () => {
|
||||
const host = new ExtensionHost()
|
||||
const kit = defineKit({
|
||||
createClient: () => ({ ping: () => 'pong' }),
|
||||
id: 'kit.module-watch-dispose',
|
||||
version: '1.0.0',
|
||||
createClient: () => ({ ping: () => 'pong' }),
|
||||
})
|
||||
|
||||
const observed: boolean[] = []
|
||||
@@ -611,7 +611,7 @@ describe('for ExtensionHost', () => {
|
||||
const module = await ctx.modules.register({
|
||||
id: 'module-a',
|
||||
permissions: {
|
||||
apis: [{ key: 'kit.module-watch-dispose', actions: ['invoke'] }],
|
||||
apis: [{ actions: ['invoke'], key: 'kit.module-watch-dispose' }],
|
||||
},
|
||||
})
|
||||
disposeModule = module.dispose
|
||||
@@ -624,12 +624,12 @@ describe('for ExtensionHost', () => {
|
||||
await host.startExtension(extension, {
|
||||
manifest: {
|
||||
apiVersion: 'v1',
|
||||
kind: 'manifest.extension.airi.moeru.ai' as const,
|
||||
id: 'airi-extension-module-kit-watch-dispose',
|
||||
permissions: {
|
||||
apis: [{ key: 'kit.module-watch-dispose', actions: ['invoke'] }],
|
||||
},
|
||||
entrypoints: {},
|
||||
id: 'airi-extension-module-kit-watch-dispose',
|
||||
kind: 'manifest.extension.airi.moeru.ai' as const,
|
||||
permissions: {
|
||||
apis: [{ actions: ['invoke'], key: 'kit.module-watch-dispose' }],
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
@@ -664,10 +664,10 @@ describe('for ExtensionHost', () => {
|
||||
const session = await host.startExtension(extension, {
|
||||
manifest: {
|
||||
apiVersion: 'v1',
|
||||
kind: 'manifest.extension.airi.moeru.ai' as const,
|
||||
id: 'airi-extension-duplicate-module',
|
||||
permissions: {},
|
||||
entrypoints: {},
|
||||
id: 'airi-extension-duplicate-module',
|
||||
kind: 'manifest.extension.airi.moeru.ai' as const,
|
||||
permissions: {},
|
||||
},
|
||||
})
|
||||
|
||||
@@ -697,10 +697,10 @@ describe('for ExtensionHost', () => {
|
||||
const session = await host.startExtension(extension, {
|
||||
manifest: {
|
||||
apiVersion: 'v1',
|
||||
kind: 'manifest.extension.airi.moeru.ai' as const,
|
||||
id: 'airi-extension-async-stop-cleanup',
|
||||
permissions: {},
|
||||
entrypoints: {},
|
||||
id: 'airi-extension-async-stop-cleanup',
|
||||
kind: 'manifest.extension.airi.moeru.ai' as const,
|
||||
permissions: {},
|
||||
},
|
||||
})
|
||||
|
||||
@@ -716,9 +716,9 @@ describe('for ExtensionHost', () => {
|
||||
it('denies kit use when module permissions exceed the extension grant ceiling', async () => {
|
||||
const host = new ExtensionHost()
|
||||
const kit = defineKit({
|
||||
createClient: () => ({ ping: () => 'pong' }),
|
||||
id: 'kit.denied',
|
||||
version: '1.0.0',
|
||||
createClient: () => ({ ping: () => 'pong' }),
|
||||
})
|
||||
host.registerKitApi(kit)
|
||||
|
||||
@@ -728,7 +728,7 @@ describe('for ExtensionHost', () => {
|
||||
const module = await ctx.modules.register({
|
||||
id: 'module-a',
|
||||
permissions: {
|
||||
apis: [{ key: 'kit.denied', actions: ['invoke'] }],
|
||||
apis: [{ actions: ['invoke'], key: 'kit.denied' }],
|
||||
},
|
||||
})
|
||||
const result = await module.kits.tryUse(kit)
|
||||
@@ -743,12 +743,12 @@ describe('for ExtensionHost', () => {
|
||||
await host.startExtension(extension, {
|
||||
manifest: {
|
||||
apiVersion: 'v1',
|
||||
kind: 'manifest.extension.airi.moeru.ai' as const,
|
||||
id: 'airi-extension-kit-denied',
|
||||
permissions: {
|
||||
apis: [{ key: 'kit.other', actions: ['invoke'] }],
|
||||
},
|
||||
entrypoints: {},
|
||||
id: 'airi-extension-kit-denied',
|
||||
kind: 'manifest.extension.airi.moeru.ai' as const,
|
||||
permissions: {
|
||||
apis: [{ actions: ['invoke'], key: 'kit.other' }],
|
||||
},
|
||||
},
|
||||
})
|
||||
})
|
||||
@@ -757,14 +757,14 @@ describe('for ExtensionHost', () => {
|
||||
describe('for FileSystemLoader', () => {
|
||||
const testPermissions: ModulePermissionDeclaration = {
|
||||
apis: [
|
||||
{ key: 'proj-airi:plugin-sdk:apis:protocol:capabilities:wait', actions: ['invoke'] },
|
||||
{ key: 'proj-airi:plugin-sdk:apis:protocol:resources:providers:list-providers', actions: ['invoke'] },
|
||||
],
|
||||
resources: [
|
||||
{ key: 'proj-airi:plugin-sdk:apis:protocol:resources:providers:list-providers', actions: ['read'] },
|
||||
{ actions: ['invoke'], key: 'proj-airi:plugin-sdk:apis:protocol:capabilities:wait' },
|
||||
{ actions: ['invoke'], key: 'proj-airi:plugin-sdk:apis:protocol:resources:providers:list-providers' },
|
||||
],
|
||||
capabilities: [
|
||||
{ key: 'proj-airi:plugin-sdk:apis:protocol:resources:providers:list-providers', actions: ['wait'] },
|
||||
{ actions: ['wait'], key: 'proj-airi:plugin-sdk:apis:protocol:resources:providers:list-providers' },
|
||||
],
|
||||
resources: [
|
||||
{ actions: ['read'], key: 'proj-airi:plugin-sdk:apis:protocol:resources:providers:list-providers' },
|
||||
],
|
||||
}
|
||||
|
||||
@@ -777,12 +777,12 @@ describe('for FileSystemLoader', () => {
|
||||
|
||||
await host.start({
|
||||
apiVersion: 'v1',
|
||||
kind: 'manifest.extension.airi.moeru.ai' as const,
|
||||
id: 'test-define-extension-entrypoint',
|
||||
permissions: {},
|
||||
entrypoints: {
|
||||
electron: join(import.meta.dirname, 'testdata', 'test-define-extension-entrypoint.ts'),
|
||||
},
|
||||
id: 'test-define-extension-entrypoint',
|
||||
kind: 'manifest.extension.airi.moeru.ai' as const,
|
||||
permissions: {},
|
||||
}, { cwd: '', runtime: 'electron' })
|
||||
|
||||
expect(host.listModules().map(module => module.id)).toEqual(['defined-extension-module'])
|
||||
@@ -800,12 +800,12 @@ describe('for FileSystemLoader', () => {
|
||||
|
||||
const session = await host.start({
|
||||
apiVersion: 'v1',
|
||||
kind: 'manifest.extension.airi.moeru.ai' as const,
|
||||
id: 'test-stoppable-extension-entrypoint',
|
||||
permissions: {},
|
||||
entrypoints: {
|
||||
electron: entrypointPath,
|
||||
},
|
||||
id: 'test-stoppable-extension-entrypoint',
|
||||
kind: 'manifest.extension.airi.moeru.ai' as const,
|
||||
permissions: {},
|
||||
}, { cwd: '', runtime: 'electron' })
|
||||
|
||||
expect(host.listModules().map(module => module.id)).toEqual(['stoppable-extension-module'])
|
||||
@@ -830,12 +830,12 @@ describe('for FileSystemLoader', () => {
|
||||
|
||||
const session = await host.start({
|
||||
apiVersion: 'v1',
|
||||
kind: 'manifest.extension.airi.moeru.ai' as const,
|
||||
id: 'test-stoppable-extension-entrypoint',
|
||||
permissions: {},
|
||||
entrypoints: {
|
||||
electron: entrypointPath,
|
||||
},
|
||||
id: 'test-stoppable-extension-entrypoint',
|
||||
kind: 'manifest.extension.airi.moeru.ai' as const,
|
||||
permissions: {},
|
||||
}, { cwd: '', runtime: 'electron' })
|
||||
|
||||
const reloaded = await host.reload(session.id)
|
||||
@@ -850,12 +850,12 @@ describe('for FileSystemLoader', () => {
|
||||
|
||||
const extension = await host.loadExtensionFor({
|
||||
apiVersion: 'v1',
|
||||
kind: 'manifest.extension.airi.moeru.ai' as const,
|
||||
id: 'test-extension',
|
||||
permissions: testPermissions,
|
||||
entrypoints: {
|
||||
node: join(import.meta.dirname, 'testdata', 'test-define-extension-entrypoint.ts'),
|
||||
},
|
||||
id: 'test-extension',
|
||||
kind: 'manifest.extension.airi.moeru.ai' as const,
|
||||
permissions: testPermissions,
|
||||
}, { cwd: '', runtime: 'node' })
|
||||
|
||||
expect(extension).toBeDefined()
|
||||
@@ -868,12 +868,12 @@ describe('for FileSystemLoader', () => {
|
||||
|
||||
await expect(host.loadExtensionFor({
|
||||
apiVersion: 'v1',
|
||||
kind: 'manifest.extension.airi.moeru.ai' as const,
|
||||
id: 'test-extension',
|
||||
permissions: testPermissions,
|
||||
entrypoints: {
|
||||
electron: join(import.meta.dirname, 'testdata', 'test-invalid-extension-entrypoint.ts'),
|
||||
},
|
||||
id: 'test-extension',
|
||||
kind: 'manifest.extension.airi.moeru.ai' as const,
|
||||
permissions: testPermissions,
|
||||
}, { cwd: '', runtime: 'electron' })).rejects.toThrow('Failed to resolve extension module. The entrypoint must export defineExtension(...).')
|
||||
})
|
||||
|
||||
@@ -881,17 +881,17 @@ describe('for FileSystemLoader', () => {
|
||||
const host = new FileSystemLoader()
|
||||
const baseManifest = {
|
||||
apiVersion: 'v1' as const,
|
||||
kind: 'manifest.extension.airi.moeru.ai' as const,
|
||||
id: 'test-extension',
|
||||
kind: 'manifest.extension.airi.moeru.ai' as const,
|
||||
permissions: testPermissions,
|
||||
}
|
||||
|
||||
const runtimeEntryManifest = {
|
||||
...baseManifest,
|
||||
entrypoints: {
|
||||
node: './node-entry.ts',
|
||||
default: './default-entry.ts',
|
||||
electron: './electron-entry.ts',
|
||||
node: './node-entry.ts',
|
||||
},
|
||||
}
|
||||
const defaultFallbackManifest = {
|
||||
@@ -929,12 +929,12 @@ describe('for FileSystemLoader', () => {
|
||||
|
||||
expect(host.resolveEntrypointFor({
|
||||
apiVersion: 'v1',
|
||||
kind: 'manifest.extension.airi.moeru.ai' as const,
|
||||
id: 'test-extension',
|
||||
permissions: testPermissions,
|
||||
entrypoints: {
|
||||
node: '/opt/extensions/entry.ts',
|
||||
},
|
||||
id: 'test-extension',
|
||||
kind: 'manifest.extension.airi.moeru.ai' as const,
|
||||
permissions: testPermissions,
|
||||
}, {
|
||||
cwd: '/tmp/extension',
|
||||
runtime: 'node',
|
||||
@@ -946,10 +946,10 @@ describe('for FileSystemLoader', () => {
|
||||
|
||||
expect(() => host.resolveEntrypointFor({
|
||||
apiVersion: 'v1',
|
||||
kind: 'manifest.extension.airi.moeru.ai' as const,
|
||||
id: 'test-extension',
|
||||
permissions: testPermissions,
|
||||
entrypoints: {},
|
||||
id: 'test-extension',
|
||||
kind: 'manifest.extension.airi.moeru.ai' as const,
|
||||
permissions: testPermissions,
|
||||
}, { runtime: 'node' })).toThrow('Extension entrypoint is required for runtime `node`.')
|
||||
})
|
||||
})
|
||||
@@ -960,12 +960,12 @@ describe('for migrated extension testdata', () => {
|
||||
|
||||
const session = await host.start({
|
||||
apiVersion: 'v1',
|
||||
kind: 'manifest.extension.airi.moeru.ai' as const,
|
||||
id: 'test-plugin',
|
||||
permissions: {},
|
||||
entrypoints: {
|
||||
electron: join(import.meta.dirname, 'testdata', 'test-normal-plugin.ts'),
|
||||
},
|
||||
id: 'test-plugin',
|
||||
kind: 'manifest.extension.airi.moeru.ai' as const,
|
||||
permissions: {},
|
||||
}, { cwd: '', runtime: 'electron' })
|
||||
|
||||
expect(session.phase).toBe('ready')
|
||||
@@ -977,12 +977,12 @@ describe('for migrated extension testdata', () => {
|
||||
|
||||
await expect(host.start({
|
||||
apiVersion: 'v1',
|
||||
kind: 'manifest.extension.airi.moeru.ai' as const,
|
||||
id: 'test-plugin-no-connect',
|
||||
permissions: {},
|
||||
entrypoints: {
|
||||
electron: join(import.meta.dirname, 'testdata', 'test-no-connect-plugin.ts'),
|
||||
},
|
||||
id: 'test-plugin-no-connect',
|
||||
kind: 'manifest.extension.airi.moeru.ai' as const,
|
||||
permissions: {},
|
||||
}, { cwd: '', runtime: 'electron' })).rejects.toThrow(
|
||||
'Plugin initialization aborted by plugin: test-plugin-no-connect',
|
||||
)
|
||||
@@ -995,14 +995,14 @@ describe('for migrated extension testdata', () => {
|
||||
|
||||
const session = await host.start({
|
||||
apiVersion: 'v1',
|
||||
kind: 'manifest.extension.airi.moeru.ai' as const,
|
||||
id: 'test-plugin-injected-host-apis',
|
||||
permissions: {
|
||||
apis: [{ key: testWidgetKit.id, actions: ['invoke'] }],
|
||||
},
|
||||
entrypoints: {
|
||||
electron: join(import.meta.dirname, 'testdata', 'test-injected-host-apis-plugin.ts'),
|
||||
},
|
||||
id: 'test-plugin-injected-host-apis',
|
||||
kind: 'manifest.extension.airi.moeru.ai' as const,
|
||||
permissions: {
|
||||
apis: [{ actions: ['invoke'], key: testWidgetKit.id }],
|
||||
},
|
||||
}, { cwd: '', runtime: 'electron' })
|
||||
|
||||
expect(session.phase).toBe('ready')
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -4,30 +4,6 @@ import type { ExtensionLoadOptions, ExtensionManifestV1 } from '../../../shared/
|
||||
import { isAbsolute, join } from 'node:path'
|
||||
import { cwd } from 'node:process'
|
||||
|
||||
function isExtensionDefinition(value: unknown): value is Extension {
|
||||
return typeof value === 'object'
|
||||
&& value !== null
|
||||
&& 'id' in value
|
||||
&& typeof (value as { id?: unknown }).id === 'string'
|
||||
&& 'setup' in value
|
||||
&& typeof (value as { setup?: unknown }).setup === 'function'
|
||||
}
|
||||
|
||||
function coerceExtensionFromModule(moduleValue: unknown): Extension {
|
||||
if (isExtensionDefinition(moduleValue)) {
|
||||
return moduleValue
|
||||
}
|
||||
|
||||
if (typeof moduleValue === 'object' && moduleValue !== null) {
|
||||
const defaultExport = (moduleValue as { default?: unknown }).default
|
||||
if (isExtensionDefinition(defaultExport)) {
|
||||
return defaultExport
|
||||
}
|
||||
}
|
||||
|
||||
throw new Error('Failed to resolve extension module. The entrypoint must export defineExtension(...).')
|
||||
}
|
||||
|
||||
/**
|
||||
* Loads extension entrypoints from the local filesystem for the current runtime.
|
||||
*
|
||||
@@ -42,6 +18,12 @@ function coerceExtensionFromModule(moduleValue: unknown): Extension {
|
||||
* - Filesystem-backed helpers for resolving and loading extension entrypoints
|
||||
*/
|
||||
export class FileSystemLoader {
|
||||
async loadExtensionFor(manifest: ExtensionManifestV1, options?: ExtensionLoadOptions) {
|
||||
const entrypoint = this.resolveEntrypointFor(manifest, options)
|
||||
const extensionModule = await import(entrypoint)
|
||||
return coerceExtensionFromModule(extensionModule)
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve a manifest entrypoint for the requested runtime.
|
||||
*
|
||||
@@ -68,10 +50,28 @@ export class FileSystemLoader {
|
||||
|
||||
return isAbsolute(entrypoint) ? entrypoint : join(root, entrypoint)
|
||||
}
|
||||
}
|
||||
|
||||
async loadExtensionFor(manifest: ExtensionManifestV1, options?: ExtensionLoadOptions) {
|
||||
const entrypoint = this.resolveEntrypointFor(manifest, options)
|
||||
const extensionModule = await import(entrypoint)
|
||||
return coerceExtensionFromModule(extensionModule)
|
||||
function coerceExtensionFromModule(moduleValue: unknown): Extension {
|
||||
if (isExtensionDefinition(moduleValue)) {
|
||||
return moduleValue
|
||||
}
|
||||
|
||||
if (typeof moduleValue === 'object' && moduleValue !== null) {
|
||||
const defaultExport = (moduleValue as { default?: unknown }).default
|
||||
if (isExtensionDefinition(defaultExport)) {
|
||||
return defaultExport
|
||||
}
|
||||
}
|
||||
|
||||
throw new Error('Failed to resolve extension module. The entrypoint must export defineExtension(...).')
|
||||
}
|
||||
|
||||
function isExtensionDefinition(value: unknown): value is Extension {
|
||||
return typeof value === 'object'
|
||||
&& value !== null
|
||||
&& 'id' in value
|
||||
&& typeof (value as { id?: unknown }).id === 'string'
|
||||
&& 'setup' in value
|
||||
&& typeof (value as { setup?: unknown }).setup === 'function'
|
||||
}
|
||||
|
||||
@@ -14,8 +14,8 @@ describe('dependencyService', () => {
|
||||
const announced = service.announce('cap:dynamic', { source: 'announce' })
|
||||
expect(announced).toMatchObject({
|
||||
key: 'cap:dynamic',
|
||||
state: 'announced',
|
||||
metadata: { source: 'announce' },
|
||||
state: 'announced',
|
||||
})
|
||||
expect(service.isReady('cap:dynamic')).toBe(false)
|
||||
expect(service.list()).toEqual([
|
||||
@@ -30,8 +30,8 @@ describe('dependencyService', () => {
|
||||
const degraded = service.markDegraded('cap:dynamic')
|
||||
expect(degraded).toMatchObject({
|
||||
key: 'cap:dynamic',
|
||||
state: 'degraded',
|
||||
metadata: { source: 'announce' },
|
||||
state: 'degraded',
|
||||
})
|
||||
expect(service.isReady('cap:dynamic')).toBe(false)
|
||||
expect(service.list()).toEqual([
|
||||
@@ -46,15 +46,15 @@ describe('dependencyService', () => {
|
||||
const withdrawn = service.withdraw('cap:dynamic', { reason: 'disabled' })
|
||||
expect(withdrawn).toMatchObject({
|
||||
key: 'cap:dynamic',
|
||||
state: 'withdrawn',
|
||||
metadata: { reason: 'disabled' },
|
||||
state: 'withdrawn',
|
||||
})
|
||||
expect(service.isReady('cap:dynamic')).toBe(false)
|
||||
expect(service.list()).toEqual([
|
||||
expect.objectContaining({
|
||||
key: 'cap:dynamic',
|
||||
state: 'withdrawn',
|
||||
metadata: { reason: 'disabled' },
|
||||
state: 'withdrawn',
|
||||
}),
|
||||
])
|
||||
}
|
||||
@@ -62,15 +62,15 @@ describe('dependencyService', () => {
|
||||
const ready = service.markReady('cap:dynamic')
|
||||
expect(ready).toMatchObject({
|
||||
key: 'cap:dynamic',
|
||||
state: 'ready',
|
||||
metadata: { reason: 'disabled' },
|
||||
state: 'ready',
|
||||
})
|
||||
expect(service.isReady('cap:dynamic')).toBe(true)
|
||||
expect(service.list()).toEqual([
|
||||
expect.objectContaining({
|
||||
key: 'cap:dynamic',
|
||||
state: 'ready',
|
||||
metadata: { reason: 'disabled' },
|
||||
state: 'ready',
|
||||
}),
|
||||
])
|
||||
})
|
||||
|
||||
@@ -21,8 +21,29 @@ export class DependencyService {
|
||||
const current = this.capabilities.get(key)
|
||||
const descriptor: CapabilityDescriptor = {
|
||||
key,
|
||||
state: 'announced',
|
||||
metadata: metadata ?? current?.metadata,
|
||||
state: 'announced',
|
||||
updatedAt: Date.now(),
|
||||
}
|
||||
|
||||
this.capabilities.set(key, descriptor)
|
||||
return descriptor
|
||||
}
|
||||
|
||||
isReady(key: string) {
|
||||
return this.capabilities.get(key)?.state === 'ready'
|
||||
}
|
||||
|
||||
list() {
|
||||
return [...this.capabilities.values()]
|
||||
}
|
||||
|
||||
markDegraded(key: string, metadata?: Record<string, unknown>) {
|
||||
const current = this.capabilities.get(key)
|
||||
const descriptor: CapabilityDescriptor = {
|
||||
key,
|
||||
metadata: metadata ?? current?.metadata,
|
||||
state: 'degraded',
|
||||
updatedAt: Date.now(),
|
||||
}
|
||||
|
||||
@@ -34,8 +55,8 @@ export class DependencyService {
|
||||
const current = this.capabilities.get(key)
|
||||
const descriptor: CapabilityDescriptor = {
|
||||
key,
|
||||
state: 'ready',
|
||||
metadata: metadata ?? current?.metadata,
|
||||
state: 'ready',
|
||||
updatedAt: Date.now(),
|
||||
}
|
||||
|
||||
@@ -51,44 +72,6 @@ export class DependencyService {
|
||||
return descriptor
|
||||
}
|
||||
|
||||
markDegraded(key: string, metadata?: Record<string, unknown>) {
|
||||
const current = this.capabilities.get(key)
|
||||
const descriptor: CapabilityDescriptor = {
|
||||
key,
|
||||
state: 'degraded',
|
||||
metadata: metadata ?? current?.metadata,
|
||||
updatedAt: Date.now(),
|
||||
}
|
||||
|
||||
this.capabilities.set(key, descriptor)
|
||||
return descriptor
|
||||
}
|
||||
|
||||
withdraw(key: string, metadata?: Record<string, unknown>) {
|
||||
const current = this.capabilities.get(key)
|
||||
const descriptor: CapabilityDescriptor = {
|
||||
key,
|
||||
state: 'withdrawn',
|
||||
metadata: metadata ?? current?.metadata,
|
||||
updatedAt: Date.now(),
|
||||
}
|
||||
|
||||
this.capabilities.set(key, descriptor)
|
||||
return descriptor
|
||||
}
|
||||
|
||||
list() {
|
||||
return [...this.capabilities.values()]
|
||||
}
|
||||
|
||||
isReady(key: string) {
|
||||
return this.capabilities.get(key)?.state === 'ready'
|
||||
}
|
||||
|
||||
async waitForMany(keys: string[], timeoutMs: number = 15000) {
|
||||
await Promise.all(keys.map(async key => await this.waitFor(key, timeoutMs)))
|
||||
}
|
||||
|
||||
async waitFor(key: string, timeoutMs: number = 15000) {
|
||||
const existing = this.capabilities.get(key)
|
||||
if (existing?.state === 'ready') {
|
||||
@@ -118,4 +101,21 @@ export class DependencyService {
|
||||
}, timeoutMs)
|
||||
})
|
||||
}
|
||||
|
||||
async waitForMany(keys: string[], timeoutMs: number = 15000) {
|
||||
await Promise.all(keys.map(async key => await this.waitFor(key, timeoutMs)))
|
||||
}
|
||||
|
||||
withdraw(key: string, metadata?: Record<string, unknown>) {
|
||||
const current = this.capabilities.get(key)
|
||||
const descriptor: CapabilityDescriptor = {
|
||||
key,
|
||||
metadata: metadata ?? current?.metadata,
|
||||
state: 'withdrawn',
|
||||
updatedAt: Date.now(),
|
||||
}
|
||||
|
||||
this.capabilities.set(key, descriptor)
|
||||
return descriptor
|
||||
}
|
||||
}
|
||||
|
||||
+13
-13
@@ -14,15 +14,25 @@ import { nanoid } from 'nanoid/non-secure'
|
||||
* - An in-memory session registry with identity generation helpers
|
||||
*/
|
||||
export class ExtensionSessionService<TSession extends { id: string }> {
|
||||
private readonly sessions = new Map<string, TSession>()
|
||||
private sessionCounter = 0
|
||||
private readonly sessions = new Map<string, TSession>()
|
||||
|
||||
get(sessionId: string) {
|
||||
return this.sessions.get(sessionId)
|
||||
}
|
||||
|
||||
list() {
|
||||
return [...this.sessions.values()]
|
||||
}
|
||||
|
||||
get(sessionId: string) {
|
||||
return this.sessions.get(sessionId)
|
||||
nextSessionIdentity() {
|
||||
const index = this.sessionCounter
|
||||
this.sessionCounter += 1
|
||||
|
||||
return {
|
||||
index,
|
||||
sessionId: `extension-session-${nanoid()}`,
|
||||
}
|
||||
}
|
||||
|
||||
register(session: TSession) {
|
||||
@@ -39,14 +49,4 @@ export class ExtensionSessionService<TSession extends { id: string }> {
|
||||
this.sessions.delete(session.id)
|
||||
return session
|
||||
}
|
||||
|
||||
nextSessionIdentity() {
|
||||
const index = this.sessionCounter
|
||||
this.sessionCounter += 1
|
||||
|
||||
return {
|
||||
index,
|
||||
sessionId: `extension-session-${nanoid()}`,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+44
-44
@@ -7,12 +7,12 @@ describe('kitApiBindingRegistryService', () => {
|
||||
const service = new KitApiBindingRegistryService()
|
||||
|
||||
const binding = service.bind({
|
||||
moduleId: 'chess-gamelet',
|
||||
ownerSessionId: 'session-1',
|
||||
ownerExtensionId: 'airi-extension-chess',
|
||||
config: { title: 'Chess' },
|
||||
kitId: 'kit.gamelet',
|
||||
kitModuleType: 'gamelet',
|
||||
config: { title: 'Chess' },
|
||||
moduleId: 'chess-gamelet',
|
||||
ownerExtensionId: 'airi-extension-chess',
|
||||
ownerSessionId: 'session-1',
|
||||
runtime: 'electron',
|
||||
})
|
||||
|
||||
@@ -24,12 +24,12 @@ describe('kitApiBindingRegistryService', () => {
|
||||
const service = new KitApiBindingRegistryService()
|
||||
|
||||
service.bind({
|
||||
moduleId: 'm1',
|
||||
ownerSessionId: 'session-a',
|
||||
ownerExtensionId: 'plugin-a',
|
||||
config: {},
|
||||
kitId: 'kit.widget',
|
||||
kitModuleType: 'panel',
|
||||
config: {},
|
||||
moduleId: 'm1',
|
||||
ownerExtensionId: 'plugin-a',
|
||||
ownerSessionId: 'session-a',
|
||||
runtime: 'electron',
|
||||
})
|
||||
|
||||
@@ -40,12 +40,12 @@ describe('kitApiBindingRegistryService', () => {
|
||||
const service = new KitApiBindingRegistryService()
|
||||
|
||||
const announced = service.bind({
|
||||
moduleId: 'm2',
|
||||
ownerSessionId: 'session-a',
|
||||
ownerExtensionId: 'plugin-a',
|
||||
config: { mountPoint: 'widgets' },
|
||||
kitId: 'kit.widget',
|
||||
kitModuleType: 'panel',
|
||||
config: { mountPoint: 'widgets' },
|
||||
moduleId: 'm2',
|
||||
ownerExtensionId: 'plugin-a',
|
||||
ownerSessionId: 'session-a',
|
||||
runtime: 'web',
|
||||
})
|
||||
|
||||
@@ -65,12 +65,12 @@ describe('kitApiBindingRegistryService', () => {
|
||||
const service = new KitApiBindingRegistryService()
|
||||
|
||||
service.bind({
|
||||
moduleId: 'm3',
|
||||
ownerSessionId: 'session-a',
|
||||
ownerExtensionId: 'plugin-a',
|
||||
config: {},
|
||||
kitId: 'kit.widget',
|
||||
kitModuleType: 'panel',
|
||||
config: {},
|
||||
moduleId: 'm3',
|
||||
ownerExtensionId: 'plugin-a',
|
||||
ownerSessionId: 'session-a',
|
||||
runtime: 'electron',
|
||||
})
|
||||
|
||||
@@ -83,23 +83,23 @@ describe('kitApiBindingRegistryService', () => {
|
||||
const service = new KitApiBindingRegistryService()
|
||||
|
||||
service.bind({
|
||||
moduleId: 'm4',
|
||||
ownerSessionId: 'session-a',
|
||||
ownerExtensionId: 'plugin-a',
|
||||
config: {},
|
||||
kitId: 'kit.widget',
|
||||
kitModuleType: 'panel',
|
||||
config: {},
|
||||
moduleId: 'm4',
|
||||
ownerExtensionId: 'plugin-a',
|
||||
ownerSessionId: 'session-a',
|
||||
runtime: 'electron',
|
||||
})
|
||||
|
||||
expect(() =>
|
||||
service.bind({
|
||||
moduleId: 'm4',
|
||||
ownerSessionId: 'session-b',
|
||||
ownerExtensionId: 'plugin-b',
|
||||
config: {},
|
||||
kitId: 'kit.widget',
|
||||
kitModuleType: 'panel',
|
||||
config: {},
|
||||
moduleId: 'm4',
|
||||
ownerExtensionId: 'plugin-b',
|
||||
ownerSessionId: 'session-b',
|
||||
runtime: 'electron',
|
||||
}),
|
||||
).toThrowError(/module id collision/i)
|
||||
@@ -109,22 +109,22 @@ describe('kitApiBindingRegistryService', () => {
|
||||
const service = new KitApiBindingRegistryService()
|
||||
|
||||
const original = service.bind({
|
||||
moduleId: 'm5',
|
||||
ownerSessionId: 'session-a',
|
||||
ownerExtensionId: 'plugin-a',
|
||||
config: { mountPoint: 'widgets' },
|
||||
kitId: 'kit.widget',
|
||||
kitModuleType: 'panel',
|
||||
config: { mountPoint: 'widgets' },
|
||||
moduleId: 'm5',
|
||||
ownerExtensionId: 'plugin-a',
|
||||
ownerSessionId: 'session-a',
|
||||
runtime: 'electron',
|
||||
})
|
||||
|
||||
const duplicate = service.bind({
|
||||
moduleId: 'm5',
|
||||
ownerSessionId: 'session-a',
|
||||
ownerExtensionId: 'plugin-a',
|
||||
config: { mountPoint: 'mutated', width: 480 },
|
||||
kitId: 'kit.widget',
|
||||
kitModuleType: 'dialog',
|
||||
config: { mountPoint: 'mutated', width: 480 },
|
||||
moduleId: 'm5',
|
||||
ownerExtensionId: 'plugin-a',
|
||||
ownerSessionId: 'session-a',
|
||||
runtime: 'web',
|
||||
})
|
||||
|
||||
@@ -138,23 +138,23 @@ describe('kitApiBindingRegistryService', () => {
|
||||
const service = new KitApiBindingRegistryService()
|
||||
|
||||
service.bind({
|
||||
moduleId: 'm6',
|
||||
ownerSessionId: 'session-a',
|
||||
ownerExtensionId: 'plugin-a',
|
||||
config: {},
|
||||
kitId: 'kit.widget',
|
||||
kitModuleType: 'panel',
|
||||
config: {},
|
||||
moduleId: 'm6',
|
||||
ownerExtensionId: 'plugin-a',
|
||||
ownerSessionId: 'session-a',
|
||||
runtime: 'electron',
|
||||
})
|
||||
|
||||
expect(() =>
|
||||
service.bind({
|
||||
moduleId: 'm6',
|
||||
ownerSessionId: 'session-a',
|
||||
ownerExtensionId: 'plugin-b',
|
||||
config: {},
|
||||
kitId: 'kit.widget',
|
||||
kitModuleType: 'panel',
|
||||
config: {},
|
||||
moduleId: 'm6',
|
||||
ownerExtensionId: 'plugin-b',
|
||||
ownerSessionId: 'session-a',
|
||||
runtime: 'electron',
|
||||
}),
|
||||
).toThrowError(/module id collision/i)
|
||||
@@ -164,12 +164,12 @@ describe('kitApiBindingRegistryService', () => {
|
||||
const service = new KitApiBindingRegistryService()
|
||||
|
||||
service.bind({
|
||||
moduleId: 'm7',
|
||||
ownerSessionId: 'session-a',
|
||||
ownerExtensionId: 'plugin-a',
|
||||
config: {},
|
||||
kitId: 'kit.widget',
|
||||
kitModuleType: 'panel',
|
||||
config: {},
|
||||
moduleId: 'm7',
|
||||
ownerExtensionId: 'plugin-a',
|
||||
ownerSessionId: 'session-a',
|
||||
runtime: 'electron',
|
||||
})
|
||||
|
||||
|
||||
+143
-143
@@ -18,32 +18,13 @@ import type { HostDataRecord, PluginRuntime } from '../../../shared/types'
|
||||
* - A serializable payload that {@link KitApiBindingRegistryService.bind} stores as canonical binding state
|
||||
*/
|
||||
export interface BindingInput<C extends HostDataRecord = HostDataRecord> {
|
||||
moduleId: string
|
||||
ownerSessionId: string
|
||||
ownerExtensionId: string
|
||||
config: C
|
||||
kitId: string
|
||||
kitModuleType: string
|
||||
moduleId: string
|
||||
ownerExtensionId: string
|
||||
ownerSessionId: string
|
||||
runtime: PluginRuntime
|
||||
config: C
|
||||
}
|
||||
|
||||
/**
|
||||
* Describes an incremental change to a binding record.
|
||||
*
|
||||
* Use when:
|
||||
* - A kit-specific API needs to change binding lifecycle state
|
||||
* - A plugin updates binding configuration after initial registration
|
||||
*
|
||||
* Expects:
|
||||
* - `state` follows the host lifecycle rules for the current record
|
||||
* - `config` only contains fields that should be shallow-merged into the current config
|
||||
*
|
||||
* Returns:
|
||||
* - A partial mutation applied by {@link KitApiBindingRegistryService.update} or {@link KitApiBindingRegistryService.transition}
|
||||
*/
|
||||
export interface BindingUpdatePatch<C extends HostDataRecord = HostDataRecord> {
|
||||
state?: BindingState
|
||||
config?: Partial<C>
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -61,41 +42,36 @@ export interface BindingUpdatePatch<C extends HostDataRecord = HostDataRecord> {
|
||||
* - A compact identity tuple used in collision and ownership checks
|
||||
*/
|
||||
export interface BindingOwnerIdentity {
|
||||
ownerSessionId: string
|
||||
ownerExtensionId: string
|
||||
ownerSessionId: string
|
||||
}
|
||||
|
||||
/**
|
||||
* Describes an incremental change to a binding record.
|
||||
*
|
||||
* Use when:
|
||||
* - A kit-specific API needs to change binding lifecycle state
|
||||
* - A plugin updates binding configuration after initial registration
|
||||
*
|
||||
* Expects:
|
||||
* - `state` follows the host lifecycle rules for the current record
|
||||
* - `config` only contains fields that should be shallow-merged into the current config
|
||||
*
|
||||
* Returns:
|
||||
* - A partial mutation applied by {@link KitApiBindingRegistryService.update} or {@link KitApiBindingRegistryService.transition}
|
||||
*/
|
||||
export interface BindingUpdatePatch<C extends HostDataRecord = HostDataRecord> {
|
||||
config?: Partial<C>
|
||||
state?: BindingState
|
||||
}
|
||||
|
||||
const allowedBindingTransitions: Record<BindingState, readonly BindingState[]> = {
|
||||
announced: ['active', 'degraded', 'withdrawn'],
|
||||
active: ['degraded', 'withdrawn', 'active'],
|
||||
announced: ['active', 'degraded', 'withdrawn'],
|
||||
degraded: ['active', 'withdrawn', 'degraded'],
|
||||
withdrawn: ['withdrawn'],
|
||||
}
|
||||
|
||||
function createOwnershipError(
|
||||
moduleId: string,
|
||||
expected: BindingOwnerIdentity,
|
||||
actual: BindingOwnerIdentity,
|
||||
) {
|
||||
return new Error(
|
||||
`Ownership violation for module \`${moduleId}\`: owned by \`${expected.ownerSessionId}/${expected.ownerExtensionId}\`, not \`${actual.ownerSessionId}/${actual.ownerExtensionId}\`.`,
|
||||
)
|
||||
}
|
||||
|
||||
function createModuleCollisionError(
|
||||
moduleId: string,
|
||||
expected: BindingOwnerIdentity,
|
||||
actual: BindingOwnerIdentity,
|
||||
) {
|
||||
return new Error(
|
||||
`Module id collision for \`${moduleId}\`: owned by \`${expected.ownerSessionId}/${expected.ownerExtensionId}\`, not \`${actual.ownerSessionId}/${actual.ownerExtensionId}\`.`,
|
||||
)
|
||||
}
|
||||
|
||||
function createInvalidTransitionError(moduleId: string, from: BindingState, to: BindingState) {
|
||||
return new Error(`Invalid binding lifecycle transition for \`${moduleId}\`: \`${from}\` -> \`${to}\`.`)
|
||||
}
|
||||
|
||||
/**
|
||||
* Stores the host's canonical binding records for dynamically contributed kit instances.
|
||||
*
|
||||
@@ -173,6 +149,22 @@ function createInvalidTransitionError(moduleId: string, from: BindingState, to:
|
||||
export class KitApiBindingRegistryService<C extends HostDataRecord = HostDataRecord> {
|
||||
private readonly bindings = new Map<string, BindingRecord<C>>()
|
||||
|
||||
/**
|
||||
* Transitions a bound instance into the `active` lifecycle state.
|
||||
*
|
||||
* Use when:
|
||||
* - The host or a higher-level kit API has completed setup for a bound instance
|
||||
*
|
||||
* Expects:
|
||||
* - The binding exists and is owned by the caller
|
||||
*
|
||||
* Returns:
|
||||
* - The updated active binding record
|
||||
*/
|
||||
activate(ownerSessionId: string, ownerExtensionId: string, moduleId: string) {
|
||||
return this.transition({ ownerExtensionId, ownerSessionId }, moduleId, 'active')
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates or reuses one binding record for a extension-owned runtime instance.
|
||||
*
|
||||
@@ -197,12 +189,12 @@ export class KitApiBindingRegistryService<C extends HostDataRecord = HostDataRec
|
||||
throw createModuleCollisionError(
|
||||
input.moduleId,
|
||||
{
|
||||
ownerSessionId: current.ownerSessionId,
|
||||
ownerExtensionId: current.ownerExtensionId,
|
||||
ownerSessionId: current.ownerSessionId,
|
||||
},
|
||||
{
|
||||
ownerSessionId: input.ownerSessionId,
|
||||
ownerExtensionId: input.ownerExtensionId,
|
||||
ownerSessionId: input.ownerSessionId,
|
||||
},
|
||||
)
|
||||
}
|
||||
@@ -211,22 +203,38 @@ export class KitApiBindingRegistryService<C extends HostDataRecord = HostDataRec
|
||||
}
|
||||
|
||||
const record: BindingRecord<C> = {
|
||||
moduleId: input.moduleId,
|
||||
ownerSessionId: input.ownerSessionId,
|
||||
ownerExtensionId: input.ownerExtensionId,
|
||||
config: input.config,
|
||||
kitId: input.kitId,
|
||||
kitModuleType: input.kitModuleType,
|
||||
state: 'announced',
|
||||
runtime: input.runtime,
|
||||
moduleId: input.moduleId,
|
||||
ownerExtensionId: input.ownerExtensionId,
|
||||
ownerSessionId: input.ownerSessionId,
|
||||
revision: 1,
|
||||
runtime: input.runtime,
|
||||
state: 'announced',
|
||||
updatedAt: Date.now(),
|
||||
config: input.config,
|
||||
}
|
||||
|
||||
this.bindings.set(record.moduleId, record)
|
||||
return record
|
||||
}
|
||||
|
||||
/**
|
||||
* Transitions a bound instance into the `degraded` lifecycle state.
|
||||
*
|
||||
* Use when:
|
||||
* - A previously healthy binding loses a dependency or adapter guarantee
|
||||
*
|
||||
* Expects:
|
||||
* - The binding exists and is owned by the caller
|
||||
*
|
||||
* Returns:
|
||||
* - The updated degraded binding record
|
||||
*/
|
||||
degrade(ownerSessionId: string, ownerExtensionId: string, moduleId: string) {
|
||||
return this.transition({ ownerExtensionId, ownerSessionId }, moduleId, 'degraded')
|
||||
}
|
||||
|
||||
/**
|
||||
* Looks up one binding record by its runtime instance id.
|
||||
*
|
||||
@@ -276,20 +284,20 @@ export class KitApiBindingRegistryService<C extends HostDataRecord = HostDataRec
|
||||
}
|
||||
|
||||
/**
|
||||
* Lists bindings owned by one extension session.
|
||||
* Lists bindings attached to one kit family.
|
||||
*
|
||||
* Use when:
|
||||
* - Stopping or reloading a session
|
||||
* - Inspecting one plugin's currently active contributions
|
||||
* - A kit adapter needs to enumerate all currently bound instances
|
||||
* - Debug tooling needs to inspect one kit's contribution footprint
|
||||
*
|
||||
* Expects:
|
||||
* - `ownerSessionId` is the session-scoped owner id stored in each binding
|
||||
* - `kitId` matches the `kitId` stored on each binding record
|
||||
*
|
||||
* Returns:
|
||||
* - All binding records whose owner session matches the input
|
||||
* - All binding records attached to the requested kit
|
||||
*/
|
||||
listByOwner(ownerSessionId: string) {
|
||||
return this.list().filter(binding => binding.ownerSessionId === ownerSessionId)
|
||||
listByKit(kitId: string) {
|
||||
return this.list().filter(binding => binding.kitId === kitId)
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -313,86 +321,20 @@ export class KitApiBindingRegistryService<C extends HostDataRecord = HostDataRec
|
||||
}
|
||||
|
||||
/**
|
||||
* Lists bindings attached to one kit family.
|
||||
* Lists bindings owned by one extension session.
|
||||
*
|
||||
* Use when:
|
||||
* - A kit adapter needs to enumerate all currently bound instances
|
||||
* - Debug tooling needs to inspect one kit's contribution footprint
|
||||
* - Stopping or reloading a session
|
||||
* - Inspecting one plugin's currently active contributions
|
||||
*
|
||||
* Expects:
|
||||
* - `kitId` matches the `kitId` stored on each binding record
|
||||
* - `ownerSessionId` is the session-scoped owner id stored in each binding
|
||||
*
|
||||
* Returns:
|
||||
* - All binding records attached to the requested kit
|
||||
* - All binding records whose owner session matches the input
|
||||
*/
|
||||
listByKit(kitId: string) {
|
||||
return this.list().filter(binding => binding.kitId === kitId)
|
||||
}
|
||||
|
||||
/**
|
||||
* Applies a shallow config and/or state update to an existing binding.
|
||||
*
|
||||
* Use when:
|
||||
* - A kit helper wants to mutate config after the initial bind
|
||||
* - A caller wants transition semantics and config merge in one operation
|
||||
*
|
||||
* Expects:
|
||||
* - The caller owns the binding
|
||||
* - Any requested `state` is valid from the current lifecycle state
|
||||
*
|
||||
* Returns:
|
||||
* - The updated binding record with incremented revision and timestamp
|
||||
*/
|
||||
update(ownerSessionId: string, ownerExtensionId: string, moduleId: string, patch: BindingUpdatePatch<C>) {
|
||||
return this.transition({ ownerSessionId, ownerExtensionId }, moduleId, patch.state, patch)
|
||||
}
|
||||
|
||||
/**
|
||||
* Transitions a bound instance into the `active` lifecycle state.
|
||||
*
|
||||
* Use when:
|
||||
* - The host or a higher-level kit API has completed setup for a bound instance
|
||||
*
|
||||
* Expects:
|
||||
* - The binding exists and is owned by the caller
|
||||
*
|
||||
* Returns:
|
||||
* - The updated active binding record
|
||||
*/
|
||||
activate(ownerSessionId: string, ownerExtensionId: string, moduleId: string) {
|
||||
return this.transition({ ownerSessionId, ownerExtensionId }, moduleId, 'active')
|
||||
}
|
||||
|
||||
/**
|
||||
* Transitions a bound instance into the `degraded` lifecycle state.
|
||||
*
|
||||
* Use when:
|
||||
* - A previously healthy binding loses a dependency or adapter guarantee
|
||||
*
|
||||
* Expects:
|
||||
* - The binding exists and is owned by the caller
|
||||
*
|
||||
* Returns:
|
||||
* - The updated degraded binding record
|
||||
*/
|
||||
degrade(ownerSessionId: string, ownerExtensionId: string, moduleId: string) {
|
||||
return this.transition({ ownerSessionId, ownerExtensionId }, moduleId, 'degraded')
|
||||
}
|
||||
|
||||
/**
|
||||
* Transitions a bound instance into the `withdrawn` lifecycle state.
|
||||
*
|
||||
* Use when:
|
||||
* - A plugin wants the host to stop treating a binding as live before eventual removal
|
||||
*
|
||||
* Expects:
|
||||
* - The binding exists and is owned by the caller
|
||||
*
|
||||
* Returns:
|
||||
* - The updated withdrawn binding record
|
||||
*/
|
||||
withdraw(ownerSessionId: string, ownerExtensionId: string, moduleId: string) {
|
||||
return this.transition({ ownerSessionId, ownerExtensionId }, moduleId, 'withdrawn')
|
||||
listByOwner(ownerSessionId: string) {
|
||||
return this.list().filter(binding => binding.ownerSessionId === ownerSessionId)
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -426,8 +368,8 @@ export class KitApiBindingRegistryService<C extends HostDataRecord = HostDataRec
|
||||
throw createOwnershipError(
|
||||
moduleId,
|
||||
{
|
||||
ownerSessionId: current.ownerSessionId,
|
||||
ownerExtensionId: current.ownerExtensionId,
|
||||
ownerSessionId: current.ownerSessionId,
|
||||
},
|
||||
owner,
|
||||
)
|
||||
@@ -440,10 +382,10 @@ export class KitApiBindingRegistryService<C extends HostDataRecord = HostDataRec
|
||||
|
||||
const next: BindingRecord<C> = {
|
||||
...current,
|
||||
state: nextState,
|
||||
revision: current.revision + 1,
|
||||
updatedAt: Date.now(),
|
||||
config: patch.config ? ({ ...current.config, ...patch.config } as C) : current.config,
|
||||
revision: current.revision + 1,
|
||||
state: nextState,
|
||||
updatedAt: Date.now(),
|
||||
}
|
||||
|
||||
this.bindings.set(moduleId, next)
|
||||
@@ -477,12 +419,12 @@ export class KitApiBindingRegistryService<C extends HostDataRecord = HostDataRec
|
||||
throw createOwnershipError(
|
||||
moduleId,
|
||||
{
|
||||
ownerSessionId: current.ownerSessionId,
|
||||
ownerExtensionId: current.ownerExtensionId,
|
||||
ownerSessionId: current.ownerSessionId,
|
||||
},
|
||||
{
|
||||
ownerSessionId,
|
||||
ownerExtensionId,
|
||||
ownerSessionId,
|
||||
},
|
||||
)
|
||||
}
|
||||
@@ -490,4 +432,62 @@ export class KitApiBindingRegistryService<C extends HostDataRecord = HostDataRec
|
||||
this.bindings.delete(moduleId)
|
||||
return current
|
||||
}
|
||||
|
||||
/**
|
||||
* Applies a shallow config and/or state update to an existing binding.
|
||||
*
|
||||
* Use when:
|
||||
* - A kit helper wants to mutate config after the initial bind
|
||||
* - A caller wants transition semantics and config merge in one operation
|
||||
*
|
||||
* Expects:
|
||||
* - The caller owns the binding
|
||||
* - Any requested `state` is valid from the current lifecycle state
|
||||
*
|
||||
* Returns:
|
||||
* - The updated binding record with incremented revision and timestamp
|
||||
*/
|
||||
update(ownerSessionId: string, ownerExtensionId: string, moduleId: string, patch: BindingUpdatePatch<C>) {
|
||||
return this.transition({ ownerExtensionId, ownerSessionId }, moduleId, patch.state, patch)
|
||||
}
|
||||
|
||||
/**
|
||||
* Transitions a bound instance into the `withdrawn` lifecycle state.
|
||||
*
|
||||
* Use when:
|
||||
* - A plugin wants the host to stop treating a binding as live before eventual removal
|
||||
*
|
||||
* Expects:
|
||||
* - The binding exists and is owned by the caller
|
||||
*
|
||||
* Returns:
|
||||
* - The updated withdrawn binding record
|
||||
*/
|
||||
withdraw(ownerSessionId: string, ownerExtensionId: string, moduleId: string) {
|
||||
return this.transition({ ownerExtensionId, ownerSessionId }, moduleId, 'withdrawn')
|
||||
}
|
||||
}
|
||||
|
||||
function createInvalidTransitionError(moduleId: string, from: BindingState, to: BindingState) {
|
||||
return new Error(`Invalid binding lifecycle transition for \`${moduleId}\`: \`${from}\` -> \`${to}\`.`)
|
||||
}
|
||||
|
||||
function createModuleCollisionError(
|
||||
moduleId: string,
|
||||
expected: BindingOwnerIdentity,
|
||||
actual: BindingOwnerIdentity,
|
||||
) {
|
||||
return new Error(
|
||||
`Module id collision for \`${moduleId}\`: owned by \`${expected.ownerSessionId}/${expected.ownerExtensionId}\`, not \`${actual.ownerSessionId}/${actual.ownerExtensionId}\`.`,
|
||||
)
|
||||
}
|
||||
|
||||
function createOwnershipError(
|
||||
moduleId: string,
|
||||
expected: BindingOwnerIdentity,
|
||||
actual: BindingOwnerIdentity,
|
||||
) {
|
||||
return new Error(
|
||||
`Ownership violation for module \`${moduleId}\`: owned by \`${expected.ownerSessionId}/${expected.ownerExtensionId}\`, not \`${actual.ownerSessionId}/${actual.ownerExtensionId}\`.`,
|
||||
)
|
||||
}
|
||||
|
||||
@@ -7,18 +7,18 @@ describe('kitRegistryService', () => {
|
||||
const service = new KitRegistryService()
|
||||
|
||||
const widgetKit = service.register({
|
||||
kitId: 'kit.widget',
|
||||
version: '1.0.0',
|
||||
capabilities: [
|
||||
{ key: 'kit.widget.module', actions: ['announce', 'activate'] },
|
||||
{ actions: ['announce', 'activate'], key: 'kit.widget.module' },
|
||||
],
|
||||
kitId: 'kit.widget',
|
||||
runtimes: ['electron', 'web'],
|
||||
version: '1.0.0',
|
||||
})
|
||||
service.register({
|
||||
capabilities: [{ actions: ['publish'], key: 'kit.system.channel' }],
|
||||
kitId: 'kit.system',
|
||||
version: '1.0.0',
|
||||
capabilities: [{ key: 'kit.system.channel', actions: ['publish'] }],
|
||||
runtimes: ['node'],
|
||||
version: '1.0.0',
|
||||
})
|
||||
|
||||
expect(widgetKit.kitId).toBe('kit.widget')
|
||||
@@ -31,18 +31,18 @@ describe('kitRegistryService', () => {
|
||||
const service = new KitRegistryService()
|
||||
|
||||
service.register({
|
||||
capabilities: [{ actions: ['announce'], key: 'kit.widget.module' }],
|
||||
kitId: 'kit.widget',
|
||||
version: '1.0.0',
|
||||
capabilities: [{ key: 'kit.widget.module', actions: ['announce'] }],
|
||||
runtimes: ['electron'],
|
||||
version: '1.0.0',
|
||||
})
|
||||
|
||||
expect(() =>
|
||||
service.register({
|
||||
capabilities: [{ actions: ['announce', 'activate'], key: 'kit.widget.module' }],
|
||||
kitId: 'kit.widget',
|
||||
version: '1.0.1',
|
||||
capabilities: [{ key: 'kit.widget.module', actions: ['announce', 'activate'] }],
|
||||
runtimes: ['electron', 'web'],
|
||||
version: '1.0.1',
|
||||
}),
|
||||
).toThrowError(/duplicate kit registration/i)
|
||||
})
|
||||
@@ -51,23 +51,23 @@ describe('kitRegistryService', () => {
|
||||
const service = new KitRegistryService()
|
||||
|
||||
const original = service.register({
|
||||
kitId: 'kit.widget',
|
||||
version: '1.0.0',
|
||||
capabilities: [
|
||||
{ key: 'kit.widget.module', actions: ['announce', 'activate'] },
|
||||
{ key: 'kit.widget.panel', actions: ['withdraw'] },
|
||||
{ actions: ['announce', 'activate'], key: 'kit.widget.module' },
|
||||
{ actions: ['withdraw'], key: 'kit.widget.panel' },
|
||||
],
|
||||
kitId: 'kit.widget',
|
||||
runtimes: ['electron', 'web'],
|
||||
version: '1.0.0',
|
||||
})
|
||||
|
||||
const duplicate = service.register({
|
||||
kitId: 'kit.widget',
|
||||
version: '1.0.0',
|
||||
capabilities: [
|
||||
{ key: 'kit.widget.panel', actions: ['withdraw'] },
|
||||
{ key: 'kit.widget.module', actions: ['activate', 'announce'] },
|
||||
{ actions: ['withdraw'], key: 'kit.widget.panel' },
|
||||
{ actions: ['activate', 'announce'], key: 'kit.widget.module' },
|
||||
],
|
||||
kitId: 'kit.widget',
|
||||
runtimes: ['web', 'electron'],
|
||||
version: '1.0.0',
|
||||
})
|
||||
|
||||
expect(duplicate).toBe(original)
|
||||
|
||||
@@ -1,28 +1,6 @@
|
||||
import type { KitDescriptor } from '../../../shared/kits'
|
||||
import type { PluginRuntime } from '../../../shared/types'
|
||||
|
||||
function normalizeKitDescriptor(kit: KitDescriptor) {
|
||||
return {
|
||||
kitId: kit.kitId,
|
||||
version: kit.version,
|
||||
runtimes: [...new Set(kit.runtimes)].sort(),
|
||||
capabilities: kit.capabilities
|
||||
.map(capability => ({
|
||||
key: capability.key,
|
||||
actions: [...new Set(capability.actions)].sort(),
|
||||
}))
|
||||
.sort((left, right) => left.key.localeCompare(right.key)),
|
||||
}
|
||||
}
|
||||
|
||||
function isSemanticallyEqualKitDescriptor(left: KitDescriptor, right: KitDescriptor) {
|
||||
return JSON.stringify(normalizeKitDescriptor(left)) === JSON.stringify(normalizeKitDescriptor(right))
|
||||
}
|
||||
|
||||
function createKitCollisionError(kitId: string) {
|
||||
return new Error(`Duplicate kit registration for \`${kitId}\` conflicts with an existing descriptor.`)
|
||||
}
|
||||
|
||||
/**
|
||||
* Stores host-registered kit descriptors and exposes runtime-filtered lookups.
|
||||
*
|
||||
@@ -39,6 +17,22 @@ function createKitCollisionError(kitId: string) {
|
||||
export class KitRegistryService<TKit extends KitDescriptor = KitDescriptor> {
|
||||
private readonly kits = new Map<string, TKit>()
|
||||
|
||||
get(kitId: string) {
|
||||
return this.kits.get(kitId)
|
||||
}
|
||||
|
||||
has(kitId: string) {
|
||||
return this.kits.has(kitId)
|
||||
}
|
||||
|
||||
list() {
|
||||
return [...this.kits.values()]
|
||||
}
|
||||
|
||||
listByRuntime(runtime: PluginRuntime) {
|
||||
return this.list().filter(kit => kit.runtimes.includes(runtime))
|
||||
}
|
||||
|
||||
register(kit: TKit) {
|
||||
const current = this.kits.get(kit.kitId)
|
||||
if (!current) {
|
||||
@@ -53,14 +47,6 @@ export class KitRegistryService<TKit extends KitDescriptor = KitDescriptor> {
|
||||
return current
|
||||
}
|
||||
|
||||
get(kitId: string) {
|
||||
return this.kits.get(kitId)
|
||||
}
|
||||
|
||||
has(kitId: string) {
|
||||
return this.kits.has(kitId)
|
||||
}
|
||||
|
||||
remove(kitId: string) {
|
||||
const kit = this.kits.get(kitId)
|
||||
if (!kit) {
|
||||
@@ -70,12 +56,26 @@ export class KitRegistryService<TKit extends KitDescriptor = KitDescriptor> {
|
||||
this.kits.delete(kitId)
|
||||
return kit
|
||||
}
|
||||
}
|
||||
|
||||
list() {
|
||||
return [...this.kits.values()]
|
||||
}
|
||||
function createKitCollisionError(kitId: string) {
|
||||
return new Error(`Duplicate kit registration for \`${kitId}\` conflicts with an existing descriptor.`)
|
||||
}
|
||||
|
||||
listByRuntime(runtime: PluginRuntime) {
|
||||
return this.list().filter(kit => kit.runtimes.includes(runtime))
|
||||
function isSemanticallyEqualKitDescriptor(left: KitDescriptor, right: KitDescriptor) {
|
||||
return JSON.stringify(normalizeKitDescriptor(left)) === JSON.stringify(normalizeKitDescriptor(right))
|
||||
}
|
||||
|
||||
function normalizeKitDescriptor(kit: KitDescriptor) {
|
||||
return {
|
||||
capabilities: kit.capabilities
|
||||
.map(capability => ({
|
||||
actions: [...new Set(capability.actions)].sort(),
|
||||
key: capability.key,
|
||||
}))
|
||||
.sort((left, right) => left.key.localeCompare(right.key)),
|
||||
kitId: kit.kitId,
|
||||
runtimes: [...new Set(kit.runtimes)].sort(),
|
||||
version: kit.version,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -9,15 +9,15 @@ describe('permissionService', () => {
|
||||
const service = new PermissionService()
|
||||
const requested: ModulePermissionDeclaration = {
|
||||
apis: [
|
||||
{ key: 'plugin.api.users', actions: ['invoke', 'emit'], reason: 'requested-reason' },
|
||||
{ actions: ['invoke', 'emit'], key: 'plugin.api.users', reason: 'requested-reason' },
|
||||
],
|
||||
}
|
||||
|
||||
const snapshot = service.initialize('plugin-a', requested, {
|
||||
grant: {
|
||||
apis: [
|
||||
{ key: 'plugin.api.*', actions: ['invoke'] },
|
||||
{ key: 'plugin.api.audit', actions: ['emit'] },
|
||||
{ actions: ['invoke'], key: 'plugin.api.*' },
|
||||
{ actions: ['emit'], key: 'plugin.api.audit' },
|
||||
],
|
||||
},
|
||||
})
|
||||
@@ -25,8 +25,8 @@ describe('permissionService', () => {
|
||||
expect(snapshot.requested.resources).toEqual([])
|
||||
expect(snapshot.granted.apis).toEqual([
|
||||
{
|
||||
key: 'plugin.api.users',
|
||||
actions: ['invoke'],
|
||||
key: 'plugin.api.users',
|
||||
reason: 'requested-reason',
|
||||
},
|
||||
])
|
||||
@@ -37,8 +37,8 @@ describe('permissionService', () => {
|
||||
const requested: ModulePermissionDeclaration = {
|
||||
resources: [
|
||||
{
|
||||
key: 'plugin.resource.settings',
|
||||
actions: ['read', 'write'],
|
||||
key: 'plugin.resource.settings',
|
||||
label: 'Settings',
|
||||
metadata: { source: 'manifest' },
|
||||
},
|
||||
@@ -46,18 +46,18 @@ describe('permissionService', () => {
|
||||
}
|
||||
|
||||
const initialized = service.initialize('plugin-b', requested, {
|
||||
grant: {},
|
||||
persisted: {
|
||||
resources: [
|
||||
{ key: 'plugin.resource.settings', actions: ['read'] },
|
||||
{ actions: ['read'], key: 'plugin.resource.settings' },
|
||||
],
|
||||
},
|
||||
grant: {},
|
||||
})
|
||||
|
||||
expect(initialized.granted.resources).toEqual([
|
||||
{
|
||||
key: 'plugin.resource.settings',
|
||||
actions: ['read'],
|
||||
key: 'plugin.resource.settings',
|
||||
label: 'Settings',
|
||||
metadata: { source: 'manifest' },
|
||||
},
|
||||
@@ -65,14 +65,14 @@ describe('permissionService', () => {
|
||||
|
||||
const updated = service.grant('plugin-b', {
|
||||
resources: [
|
||||
{ key: 'plugin.resource.settings', actions: ['write'] },
|
||||
{ actions: ['write'], key: 'plugin.resource.settings' },
|
||||
],
|
||||
})
|
||||
|
||||
expect(updated.granted.resources).toEqual([
|
||||
{
|
||||
key: 'plugin.resource.settings',
|
||||
actions: ['read', 'write'],
|
||||
key: 'plugin.resource.settings',
|
||||
label: 'Settings',
|
||||
metadata: { source: 'manifest' },
|
||||
},
|
||||
@@ -91,8 +91,8 @@ describe('permissionService', () => {
|
||||
const declared = service.declare('plugin-runtime', {
|
||||
apis: [
|
||||
{
|
||||
key: 'plugin.api.runtime',
|
||||
actions: ['invoke'],
|
||||
key: 'plugin.api.runtime',
|
||||
reason: 'Late-bound runtime capability',
|
||||
},
|
||||
],
|
||||
@@ -100,8 +100,8 @@ describe('permissionService', () => {
|
||||
|
||||
expect(declared.requested.apis).toEqual([
|
||||
{
|
||||
key: 'plugin.api.runtime',
|
||||
actions: ['invoke'],
|
||||
key: 'plugin.api.runtime',
|
||||
reason: 'Late-bound runtime capability',
|
||||
},
|
||||
])
|
||||
@@ -110,16 +110,16 @@ describe('permissionService', () => {
|
||||
const granted = service.grant('plugin-runtime', {
|
||||
apis: [
|
||||
{
|
||||
key: 'plugin.api.runtime',
|
||||
actions: ['invoke'],
|
||||
key: 'plugin.api.runtime',
|
||||
},
|
||||
],
|
||||
})
|
||||
|
||||
expect(granted.granted.apis).toEqual([
|
||||
{
|
||||
key: 'plugin.api.runtime',
|
||||
actions: ['invoke'],
|
||||
key: 'plugin.api.runtime',
|
||||
reason: 'Late-bound runtime capability',
|
||||
},
|
||||
])
|
||||
@@ -131,8 +131,8 @@ describe('permissionService', () => {
|
||||
const requested: ModulePermissionDeclaration = {
|
||||
resources: [
|
||||
{
|
||||
key: 'plugin.resource.*',
|
||||
actions: ['read'],
|
||||
key: 'plugin.resource.*',
|
||||
reason: 'Read plugin resources',
|
||||
},
|
||||
],
|
||||
@@ -146,7 +146,7 @@ describe('permissionService', () => {
|
||||
const snapshot = service.initialize('plugin-c', requested, {
|
||||
grant: {
|
||||
resources: [
|
||||
{ key: 'plugin.resource.settings', actions: ['read'] },
|
||||
{ actions: ['read'], key: 'plugin.resource.settings' },
|
||||
],
|
||||
},
|
||||
})
|
||||
@@ -156,8 +156,8 @@ describe('permissionService', () => {
|
||||
// of silently widening it back to the plugin's original wildcard request.
|
||||
expect(snapshot.granted.resources).toEqual([
|
||||
{
|
||||
key: 'plugin.resource.settings',
|
||||
actions: ['read'],
|
||||
key: 'plugin.resource.settings',
|
||||
reason: 'Read plugin resources',
|
||||
},
|
||||
])
|
||||
@@ -169,7 +169,7 @@ describe('permissionService', () => {
|
||||
const service = new PermissionService()
|
||||
const requested: ModulePermissionDeclaration = {
|
||||
apis: [
|
||||
{ key: 'plugin.api.*', actions: ['invoke', 'emit'], reason: 'Use selected APIs' },
|
||||
{ actions: ['invoke', 'emit'], key: 'plugin.api.*', reason: 'Use selected APIs' },
|
||||
],
|
||||
}
|
||||
|
||||
@@ -181,8 +181,8 @@ describe('permissionService', () => {
|
||||
const snapshot = service.initialize('plugin-d', requested, {
|
||||
grant: {
|
||||
apis: [
|
||||
{ key: 'plugin.api.users', actions: ['invoke'] },
|
||||
{ key: 'plugin.api.audit', actions: ['emit'] },
|
||||
{ actions: ['invoke'], key: 'plugin.api.users' },
|
||||
{ actions: ['emit'], key: 'plugin.api.audit' },
|
||||
],
|
||||
},
|
||||
})
|
||||
@@ -193,13 +193,13 @@ describe('permissionService', () => {
|
||||
// `plugin.api.audit`, and nothing else is implied.
|
||||
expect(snapshot.granted.apis).toEqual([
|
||||
{
|
||||
key: 'plugin.api.users',
|
||||
actions: ['invoke'],
|
||||
key: 'plugin.api.users',
|
||||
reason: 'Use selected APIs',
|
||||
},
|
||||
{
|
||||
key: 'plugin.api.audit',
|
||||
actions: ['emit'],
|
||||
key: 'plugin.api.audit',
|
||||
reason: 'Use selected APIs',
|
||||
},
|
||||
])
|
||||
@@ -212,19 +212,19 @@ describe('permissionService', () => {
|
||||
it('caps module grants by the extension permission ceiling', () => {
|
||||
const service = new PermissionService()
|
||||
const extension = service.initialize('extension-session', {
|
||||
apis: [{ key: 'kit.tools.register', actions: ['invoke'] }],
|
||||
apis: [{ actions: ['invoke'], key: 'kit.tools.register' }],
|
||||
})
|
||||
const module = service.initialize('module-session', {
|
||||
apis: [
|
||||
{ key: 'kit.tools.register', actions: ['invoke'] },
|
||||
{ key: 'kit.gamelet.open', actions: ['invoke'] },
|
||||
{ actions: ['invoke'], key: 'kit.tools.register' },
|
||||
{ actions: ['invoke'], key: 'kit.gamelet.open' },
|
||||
],
|
||||
})
|
||||
|
||||
const effective = service.intersectGrant(extension.granted, module.requested)
|
||||
|
||||
expect(effective.apis).toEqual([
|
||||
{ key: 'kit.tools.register', actions: ['invoke'] },
|
||||
{ actions: ['invoke'], key: 'kit.tools.register' },
|
||||
])
|
||||
})
|
||||
})
|
||||
|
||||
@@ -4,31 +4,138 @@ import type {
|
||||
ModulePermissionGrant,
|
||||
} from '@proj-airi/plugin-protocol/types'
|
||||
|
||||
interface PermissionScope<Action extends string = string> {
|
||||
actions: Action[]
|
||||
key: string
|
||||
}
|
||||
|
||||
interface PermissionSnapshot {
|
||||
requested: ModulePermissionDeclaration
|
||||
granted: ModulePermissionGrant
|
||||
requested: ModulePermissionDeclaration
|
||||
revision: number
|
||||
}
|
||||
|
||||
interface PermissionScope<Action extends string = string> {
|
||||
key: string
|
||||
actions: Action[]
|
||||
}
|
||||
/**
|
||||
* Tracks requested and granted permissions for extension sessions.
|
||||
*
|
||||
* Use when:
|
||||
* - The host needs to initialize permission state for a session
|
||||
* - Runtime-declared permissions must be merged with persisted or host-granted scopes
|
||||
* - Callers need to check whether one action is allowed for one scope
|
||||
*
|
||||
* Expects:
|
||||
* - Permission declarations use the protocol key and action model
|
||||
*
|
||||
* Returns:
|
||||
* - An in-memory permission store with initialize, merge, and query helpers
|
||||
*/
|
||||
export class PermissionService {
|
||||
private readonly store = new Map<string, PermissionSnapshot>()
|
||||
|
||||
function hasAction<Action extends string>(actions: Action[], action: string): action is Action {
|
||||
return actions.includes(action as Action)
|
||||
}
|
||||
declare(extensionId: string, requestedDeclaration: ModulePermissionDeclaration) {
|
||||
const existing = this.store.get(extensionId)
|
||||
if (!existing) {
|
||||
throw new Error(`Cannot declare permissions for unknown plugin "${extensionId}".`)
|
||||
}
|
||||
|
||||
function matchKey(pattern: string, target: string) {
|
||||
if (pattern === '*') {
|
||||
return true
|
||||
const requested = normalizeDeclaration(requestedDeclaration)
|
||||
const snapshot: PermissionSnapshot = {
|
||||
granted: existing.granted,
|
||||
requested: mergePermissionDeclarations(existing.requested, requested),
|
||||
revision: existing.revision + 1,
|
||||
}
|
||||
|
||||
this.store.set(extensionId, snapshot)
|
||||
return snapshot
|
||||
}
|
||||
|
||||
if (pattern.endsWith('*')) {
|
||||
return target.startsWith(pattern.slice(0, -1))
|
||||
get(extensionId: string) {
|
||||
return this.store.get(extensionId)
|
||||
}
|
||||
|
||||
return pattern === target
|
||||
grant(extensionId: string, grant: ModulePermissionGrant) {
|
||||
const existing = this.store.get(extensionId)
|
||||
if (!existing) {
|
||||
throw new Error(`Cannot grant permissions to unknown plugin "${extensionId}".`)
|
||||
}
|
||||
|
||||
const mergedGranted = mergePermissions(existing.granted, grant)
|
||||
const snapshot: PermissionSnapshot = {
|
||||
granted: intersectPermissions(existing.requested, mergedGranted),
|
||||
requested: existing.requested,
|
||||
revision: existing.revision + 1,
|
||||
}
|
||||
this.store.set(extensionId, snapshot)
|
||||
return snapshot
|
||||
}
|
||||
|
||||
grantAllows(grant: ModulePermissionGrant, area: ModulePermissionArea, action: string, key: string) {
|
||||
const scopes = grant[area] ?? []
|
||||
return scopes.some(scope =>
|
||||
matchKey(scope.key, key)
|
||||
&& hasAction(scope.actions, action),
|
||||
)
|
||||
}
|
||||
|
||||
initialize(
|
||||
extensionId: string,
|
||||
requestedDeclaration: ModulePermissionDeclaration,
|
||||
options?: {
|
||||
grant?: ModulePermissionGrant
|
||||
persisted?: ModulePermissionGrant
|
||||
},
|
||||
) {
|
||||
const requested = normalizeDeclaration(requestedDeclaration)
|
||||
const persisted = options?.persisted ?? {}
|
||||
const explicitGrant = options?.grant ?? requested
|
||||
const mergedGrant = mergePermissions(persisted, explicitGrant)
|
||||
const granted = intersectPermissions(requested, mergedGrant)
|
||||
const previousRevision = this.store.get(extensionId)?.revision ?? 0
|
||||
const snapshot: PermissionSnapshot = {
|
||||
granted,
|
||||
requested,
|
||||
revision: previousRevision + 1,
|
||||
}
|
||||
|
||||
this.store.set(extensionId, snapshot)
|
||||
return snapshot
|
||||
}
|
||||
|
||||
/**
|
||||
* Computes the effective permission boundary for one module.
|
||||
*
|
||||
* Use when:
|
||||
* - Extension permissions define the install/session-level ceiling
|
||||
* - Module permissions describe actual runtime usage
|
||||
*
|
||||
* Expects:
|
||||
* - `extensionGrant` is the already granted extension-level ceiling
|
||||
* - `moduleRequest` is the module-level requested usage
|
||||
*
|
||||
* Returns:
|
||||
* - The intersection that stays within both extension and module boundaries
|
||||
*/
|
||||
intersectGrant(
|
||||
extensionGrant: ModulePermissionGrant,
|
||||
moduleRequest: ModulePermissionDeclaration,
|
||||
): ModulePermissionGrant {
|
||||
// Extension grants are the package/session ceiling; module requests are
|
||||
// actual runtime usage. Effective access must stay inside both boundaries.
|
||||
return intersectPermissions(normalizeDeclaration(moduleRequest), normalizeDeclaration(extensionGrant))
|
||||
}
|
||||
|
||||
isAllowed(extensionId: string, area: ModulePermissionArea, action: string, key: string) {
|
||||
const snapshot = this.store.get(extensionId)
|
||||
if (!snapshot) {
|
||||
return false
|
||||
}
|
||||
|
||||
const scopes = snapshot.granted[area] ?? []
|
||||
return scopes.some(scope =>
|
||||
matchKey(scope.key, key)
|
||||
&& hasAction(scope.actions, action),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
function getIntersectionKey(left: string, right: string) {
|
||||
@@ -43,13 +150,20 @@ function getIntersectionKey(left: string, right: string) {
|
||||
return undefined
|
||||
}
|
||||
|
||||
function normalizeDeclaration(declaration?: ModulePermissionDeclaration | null): ModulePermissionDeclaration {
|
||||
function hasAction<Action extends string>(actions: Action[], action: string): action is Action {
|
||||
return actions.includes(action as Action)
|
||||
}
|
||||
|
||||
function intersectPermissions(
|
||||
requested: ModulePermissionDeclaration,
|
||||
grant: ModulePermissionGrant,
|
||||
): ModulePermissionGrant {
|
||||
return {
|
||||
apis: declaration?.apis ?? [],
|
||||
resources: declaration?.resources ?? [],
|
||||
capabilities: declaration?.capabilities ?? [],
|
||||
processors: declaration?.processors ?? [],
|
||||
pipelines: declaration?.pipelines ?? [],
|
||||
apis: intersectPermissionScopes(requested.apis, grant.apis),
|
||||
capabilities: intersectPermissionScopes(requested.capabilities, grant.capabilities),
|
||||
pipelines: intersectPermissionScopes(requested.pipelines, grant.pipelines),
|
||||
processors: intersectPermissionScopes(requested.processors, grant.processors),
|
||||
resources: intersectPermissionScopes(requested.resources, grant.resources),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -149,8 +263,8 @@ function intersectPermissionScopes<T extends PermissionScope>(
|
||||
// }
|
||||
...requestedSpec,
|
||||
...existing,
|
||||
key: intersectionKey,
|
||||
actions: [...mergedActions],
|
||||
key: intersectionKey,
|
||||
} as T)
|
||||
}
|
||||
}
|
||||
@@ -158,16 +272,38 @@ function intersectPermissionScopes<T extends PermissionScope>(
|
||||
return [...result.values()]
|
||||
}
|
||||
|
||||
function intersectPermissions(
|
||||
requested: ModulePermissionDeclaration,
|
||||
grant: ModulePermissionGrant,
|
||||
): ModulePermissionGrant {
|
||||
function matchKey(pattern: string, target: string) {
|
||||
if (pattern === '*') {
|
||||
return true
|
||||
}
|
||||
|
||||
if (pattern.endsWith('*')) {
|
||||
return target.startsWith(pattern.slice(0, -1))
|
||||
}
|
||||
|
||||
return pattern === target
|
||||
}
|
||||
|
||||
function mergePermissionDeclarations(
|
||||
current: ModulePermissionDeclaration,
|
||||
incoming: ModulePermissionDeclaration,
|
||||
): ModulePermissionDeclaration {
|
||||
return {
|
||||
apis: intersectPermissionScopes(requested.apis, grant.apis),
|
||||
resources: intersectPermissionScopes(requested.resources, grant.resources),
|
||||
capabilities: intersectPermissionScopes(requested.capabilities, grant.capabilities),
|
||||
processors: intersectPermissionScopes(requested.processors, grant.processors),
|
||||
pipelines: intersectPermissionScopes(requested.pipelines, grant.pipelines),
|
||||
apis: mergePermissionScopes(current.apis, incoming.apis),
|
||||
capabilities: mergePermissionScopes(current.capabilities, incoming.capabilities),
|
||||
pipelines: mergePermissionScopes(current.pipelines, incoming.pipelines),
|
||||
processors: mergePermissionScopes(current.processors, incoming.processors),
|
||||
resources: mergePermissionScopes(current.resources, incoming.resources),
|
||||
}
|
||||
}
|
||||
|
||||
function mergePermissions(current: ModulePermissionGrant, incoming: ModulePermissionGrant): ModulePermissionGrant {
|
||||
return {
|
||||
apis: mergePermissionScopes(current.apis, incoming.apis),
|
||||
capabilities: mergePermissionScopes(current.capabilities, incoming.capabilities),
|
||||
pipelines: mergePermissionScopes(current.pipelines, incoming.pipelines),
|
||||
processors: mergePermissionScopes(current.processors, incoming.processors),
|
||||
resources: mergePermissionScopes(current.resources, incoming.resources),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -229,148 +365,12 @@ function mergePermissionScopes<T extends PermissionScope>(
|
||||
return [...map.values()]
|
||||
}
|
||||
|
||||
function mergePermissions(current: ModulePermissionGrant, incoming: ModulePermissionGrant): ModulePermissionGrant {
|
||||
function normalizeDeclaration(declaration?: ModulePermissionDeclaration | null): ModulePermissionDeclaration {
|
||||
return {
|
||||
apis: mergePermissionScopes(current.apis, incoming.apis),
|
||||
resources: mergePermissionScopes(current.resources, incoming.resources),
|
||||
capabilities: mergePermissionScopes(current.capabilities, incoming.capabilities),
|
||||
processors: mergePermissionScopes(current.processors, incoming.processors),
|
||||
pipelines: mergePermissionScopes(current.pipelines, incoming.pipelines),
|
||||
}
|
||||
}
|
||||
|
||||
function mergePermissionDeclarations(
|
||||
current: ModulePermissionDeclaration,
|
||||
incoming: ModulePermissionDeclaration,
|
||||
): ModulePermissionDeclaration {
|
||||
return {
|
||||
apis: mergePermissionScopes(current.apis, incoming.apis),
|
||||
resources: mergePermissionScopes(current.resources, incoming.resources),
|
||||
capabilities: mergePermissionScopes(current.capabilities, incoming.capabilities),
|
||||
processors: mergePermissionScopes(current.processors, incoming.processors),
|
||||
pipelines: mergePermissionScopes(current.pipelines, incoming.pipelines),
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Tracks requested and granted permissions for extension sessions.
|
||||
*
|
||||
* Use when:
|
||||
* - The host needs to initialize permission state for a session
|
||||
* - Runtime-declared permissions must be merged with persisted or host-granted scopes
|
||||
* - Callers need to check whether one action is allowed for one scope
|
||||
*
|
||||
* Expects:
|
||||
* - Permission declarations use the protocol key and action model
|
||||
*
|
||||
* Returns:
|
||||
* - An in-memory permission store with initialize, merge, and query helpers
|
||||
*/
|
||||
export class PermissionService {
|
||||
private readonly store = new Map<string, PermissionSnapshot>()
|
||||
|
||||
/**
|
||||
* Computes the effective permission boundary for one module.
|
||||
*
|
||||
* Use when:
|
||||
* - Extension permissions define the install/session-level ceiling
|
||||
* - Module permissions describe actual runtime usage
|
||||
*
|
||||
* Expects:
|
||||
* - `extensionGrant` is the already granted extension-level ceiling
|
||||
* - `moduleRequest` is the module-level requested usage
|
||||
*
|
||||
* Returns:
|
||||
* - The intersection that stays within both extension and module boundaries
|
||||
*/
|
||||
intersectGrant(
|
||||
extensionGrant: ModulePermissionGrant,
|
||||
moduleRequest: ModulePermissionDeclaration,
|
||||
): ModulePermissionGrant {
|
||||
// Extension grants are the package/session ceiling; module requests are
|
||||
// actual runtime usage. Effective access must stay inside both boundaries.
|
||||
return intersectPermissions(normalizeDeclaration(moduleRequest), normalizeDeclaration(extensionGrant))
|
||||
}
|
||||
|
||||
initialize(
|
||||
extensionId: string,
|
||||
requestedDeclaration: ModulePermissionDeclaration,
|
||||
options?: {
|
||||
grant?: ModulePermissionGrant
|
||||
persisted?: ModulePermissionGrant
|
||||
},
|
||||
) {
|
||||
const requested = normalizeDeclaration(requestedDeclaration)
|
||||
const persisted = options?.persisted ?? {}
|
||||
const explicitGrant = options?.grant ?? requested
|
||||
const mergedGrant = mergePermissions(persisted, explicitGrant)
|
||||
const granted = intersectPermissions(requested, mergedGrant)
|
||||
const previousRevision = this.store.get(extensionId)?.revision ?? 0
|
||||
const snapshot: PermissionSnapshot = {
|
||||
requested,
|
||||
granted,
|
||||
revision: previousRevision + 1,
|
||||
}
|
||||
|
||||
this.store.set(extensionId, snapshot)
|
||||
return snapshot
|
||||
}
|
||||
|
||||
declare(extensionId: string, requestedDeclaration: ModulePermissionDeclaration) {
|
||||
const existing = this.store.get(extensionId)
|
||||
if (!existing) {
|
||||
throw new Error(`Cannot declare permissions for unknown plugin "${extensionId}".`)
|
||||
}
|
||||
|
||||
const requested = normalizeDeclaration(requestedDeclaration)
|
||||
const snapshot: PermissionSnapshot = {
|
||||
requested: mergePermissionDeclarations(existing.requested, requested),
|
||||
granted: existing.granted,
|
||||
revision: existing.revision + 1,
|
||||
}
|
||||
|
||||
this.store.set(extensionId, snapshot)
|
||||
return snapshot
|
||||
}
|
||||
|
||||
grant(extensionId: string, grant: ModulePermissionGrant) {
|
||||
const existing = this.store.get(extensionId)
|
||||
if (!existing) {
|
||||
throw new Error(`Cannot grant permissions to unknown plugin "${extensionId}".`)
|
||||
}
|
||||
|
||||
const mergedGranted = mergePermissions(existing.granted, grant)
|
||||
const snapshot: PermissionSnapshot = {
|
||||
requested: existing.requested,
|
||||
granted: intersectPermissions(existing.requested, mergedGranted),
|
||||
revision: existing.revision + 1,
|
||||
}
|
||||
this.store.set(extensionId, snapshot)
|
||||
return snapshot
|
||||
}
|
||||
|
||||
get(extensionId: string) {
|
||||
return this.store.get(extensionId)
|
||||
}
|
||||
|
||||
isAllowed(extensionId: string, area: ModulePermissionArea, action: string, key: string) {
|
||||
const snapshot = this.store.get(extensionId)
|
||||
if (!snapshot) {
|
||||
return false
|
||||
}
|
||||
|
||||
const scopes = snapshot.granted[area] ?? []
|
||||
return scopes.some(scope =>
|
||||
matchKey(scope.key, key)
|
||||
&& hasAction(scope.actions, action),
|
||||
)
|
||||
}
|
||||
|
||||
grantAllows(grant: ModulePermissionGrant, area: ModulePermissionArea, action: string, key: string) {
|
||||
const scopes = grant[area] ?? []
|
||||
return scopes.some(scope =>
|
||||
matchKey(scope.key, key)
|
||||
&& hasAction(scope.actions, action),
|
||||
)
|
||||
apis: declaration?.apis ?? [],
|
||||
capabilities: declaration?.capabilities ?? [],
|
||||
pipelines: declaration?.pipelines ?? [],
|
||||
processors: declaration?.processors ?? [],
|
||||
resources: declaration?.resources ?? [],
|
||||
}
|
||||
}
|
||||
|
||||
@@ -35,54 +35,6 @@ export class ResourceService {
|
||||
private readonly resolvers = new Map<string, ResourceResolver<unknown>>()
|
||||
private readonly values = new Map<string, unknown>()
|
||||
|
||||
/**
|
||||
* Registers a lazy resource provider for `key`.
|
||||
*
|
||||
* The resolver is called every time `get(key)` is executed, and its result
|
||||
* takes precedence over any value previously stored with `setValue(key, ...)`.
|
||||
*/
|
||||
setResolver<T>(key: string, resolver: ResourceResolver<T>) {
|
||||
this.resolvers.set(key, resolver as ResourceResolver<unknown>)
|
||||
}
|
||||
|
||||
/**
|
||||
* Removes the lazy resolver for `key`.
|
||||
*
|
||||
* If a value still exists for the same key, subsequent `get(key)` calls fall
|
||||
* back to that stored value.
|
||||
*/
|
||||
removeResolver(key: string) {
|
||||
this.resolvers.delete(key)
|
||||
}
|
||||
|
||||
/**
|
||||
* Stores an eager value for `key`.
|
||||
*
|
||||
* Use this when the resource is already available and does not need to be
|
||||
* computed on demand. This value is only returned when no resolver is
|
||||
* registered for the same key.
|
||||
*/
|
||||
setValue<T>(key: string, value: T) {
|
||||
this.values.set(key, value)
|
||||
}
|
||||
|
||||
/**
|
||||
* Removes the stored value for `key`.
|
||||
*
|
||||
* If a resolver still exists for the same key, `get(key)` continues to
|
||||
* resolve through that resolver.
|
||||
*/
|
||||
removeValue(key: string) {
|
||||
this.values.delete(key)
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns whether any resolver or stored value is registered for `key`.
|
||||
*/
|
||||
has(key: string) {
|
||||
return this.resolvers.has(key) || this.values.has(key)
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolves a resource by key.
|
||||
*
|
||||
@@ -102,4 +54,52 @@ export class ResourceService {
|
||||
|
||||
return fallback
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns whether any resolver or stored value is registered for `key`.
|
||||
*/
|
||||
has(key: string) {
|
||||
return this.resolvers.has(key) || this.values.has(key)
|
||||
}
|
||||
|
||||
/**
|
||||
* Removes the lazy resolver for `key`.
|
||||
*
|
||||
* If a value still exists for the same key, subsequent `get(key)` calls fall
|
||||
* back to that stored value.
|
||||
*/
|
||||
removeResolver(key: string) {
|
||||
this.resolvers.delete(key)
|
||||
}
|
||||
|
||||
/**
|
||||
* Removes the stored value for `key`.
|
||||
*
|
||||
* If a resolver still exists for the same key, `get(key)` continues to
|
||||
* resolve through that resolver.
|
||||
*/
|
||||
removeValue(key: string) {
|
||||
this.values.delete(key)
|
||||
}
|
||||
|
||||
/**
|
||||
* Registers a lazy resource provider for `key`.
|
||||
*
|
||||
* The resolver is called every time `get(key)` is executed, and its result
|
||||
* takes precedence over any value previously stored with `setValue(key, ...)`.
|
||||
*/
|
||||
setResolver<T>(key: string, resolver: ResourceResolver<T>) {
|
||||
this.resolvers.set(key, resolver as ResourceResolver<unknown>)
|
||||
}
|
||||
|
||||
/**
|
||||
* Stores an eager value for `key`.
|
||||
*
|
||||
* Use this when the resource is already available and does not need to be
|
||||
* computed on demand. This value is only returned when no resolver is
|
||||
* registered for the same key.
|
||||
*/
|
||||
setValue<T>(key: string, value: T) {
|
||||
this.values.set(key, value)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6,16 +6,16 @@ import { bindingRecordSchema } from './bindings'
|
||||
describe('bindingRecordSchema', () => {
|
||||
it('accepts generic host-level module record without business coupling', () => {
|
||||
const parsed = parse(bindingRecordSchema, {
|
||||
moduleId: 'board-main',
|
||||
ownerSessionId: 'extension-session-1',
|
||||
ownerExtensionId: 'demo-plugin',
|
||||
config: { mountPoint: 'widgets' },
|
||||
kitId: 'kit.widget',
|
||||
kitModuleType: 'panel',
|
||||
state: 'announced',
|
||||
runtime: 'electron',
|
||||
moduleId: 'board-main',
|
||||
ownerExtensionId: 'demo-plugin',
|
||||
ownerSessionId: 'extension-session-1',
|
||||
revision: 1,
|
||||
runtime: 'electron',
|
||||
state: 'announced',
|
||||
updatedAt: Date.now(),
|
||||
config: { mountPoint: 'widgets' },
|
||||
})
|
||||
|
||||
expect(parsed.kitModuleType).toBe('panel')
|
||||
@@ -25,16 +25,16 @@ describe('bindingRecordSchema', () => {
|
||||
it('rejects an unsupported module state', () => {
|
||||
expect(() =>
|
||||
parse(bindingRecordSchema, {
|
||||
moduleId: 'board-main',
|
||||
ownerSessionId: 'extension-session-1',
|
||||
ownerExtensionId: 'demo-plugin',
|
||||
config: { mountPoint: 'widgets' },
|
||||
kitId: 'kit.widget',
|
||||
kitModuleType: 'panel',
|
||||
state: 'booting',
|
||||
runtime: 'electron',
|
||||
moduleId: 'board-main',
|
||||
ownerExtensionId: 'demo-plugin',
|
||||
ownerSessionId: 'extension-session-1',
|
||||
revision: 1,
|
||||
runtime: 'electron',
|
||||
state: 'booting',
|
||||
updatedAt: 1712500000000,
|
||||
config: { mountPoint: 'widgets' },
|
||||
}),
|
||||
).toThrowError()
|
||||
})
|
||||
@@ -42,16 +42,16 @@ describe('bindingRecordSchema', () => {
|
||||
it('rejects a negative revision', () => {
|
||||
expect(() =>
|
||||
parse(bindingRecordSchema, {
|
||||
moduleId: 'board-main',
|
||||
ownerSessionId: 'extension-session-1',
|
||||
ownerExtensionId: 'demo-plugin',
|
||||
config: { mountPoint: 'widgets' },
|
||||
kitId: 'kit.widget',
|
||||
kitModuleType: 'panel',
|
||||
state: 'announced',
|
||||
runtime: 'electron',
|
||||
moduleId: 'board-main',
|
||||
ownerExtensionId: 'demo-plugin',
|
||||
ownerSessionId: 'extension-session-1',
|
||||
revision: -1,
|
||||
runtime: 'electron',
|
||||
state: 'announced',
|
||||
updatedAt: 1712500000000,
|
||||
config: { mountPoint: 'widgets' },
|
||||
}),
|
||||
).toThrowError()
|
||||
})
|
||||
@@ -63,21 +63,21 @@ describe('bindingRecordSchema', () => {
|
||||
|
||||
expect(() =>
|
||||
parse(bindingRecordSchema, {
|
||||
moduleId: 'board-main',
|
||||
ownerSessionId: 'extension-session-1',
|
||||
ownerExtensionId: 'demo-plugin',
|
||||
config: {
|
||||
big: 1n,
|
||||
callback: () => undefined,
|
||||
mountPoint: new ConfigShape(),
|
||||
symbol: Symbol('nope'),
|
||||
},
|
||||
kitId: 'kit.widget',
|
||||
kitModuleType: 'panel',
|
||||
state: 'announced',
|
||||
runtime: 'electron',
|
||||
moduleId: 'board-main',
|
||||
ownerExtensionId: 'demo-plugin',
|
||||
ownerSessionId: 'extension-session-1',
|
||||
revision: 1,
|
||||
runtime: 'electron',
|
||||
state: 'announced',
|
||||
updatedAt: 1712500000000,
|
||||
config: {
|
||||
mountPoint: new ConfigShape(),
|
||||
callback: () => undefined,
|
||||
symbol: Symbol('nope'),
|
||||
big: 1n,
|
||||
},
|
||||
}),
|
||||
).toThrowError()
|
||||
})
|
||||
|
||||
@@ -34,31 +34,18 @@ export const bindingStateValues = ['announced', 'active', 'degraded', 'withdrawn
|
||||
* - A Valibot schema for one binding record
|
||||
*/
|
||||
export const bindingRecordSchema = object({
|
||||
moduleId: string(),
|
||||
ownerSessionId: string(),
|
||||
ownerExtensionId: string(),
|
||||
config: hostDataRecordSchema,
|
||||
kitId: string(),
|
||||
kitModuleType: string(),
|
||||
state: picklist(bindingStateValues),
|
||||
runtime: picklist(pluginRuntimeValues),
|
||||
moduleId: string(),
|
||||
ownerExtensionId: string(),
|
||||
ownerSessionId: string(),
|
||||
revision: nonNegativeIntegerSchema,
|
||||
runtime: picklist(pluginRuntimeValues),
|
||||
state: picklist(bindingStateValues),
|
||||
updatedAt: nonNegativeIntegerSchema,
|
||||
config: hostDataRecordSchema,
|
||||
})
|
||||
|
||||
/**
|
||||
* Describes one valid binding lifecycle state.
|
||||
*
|
||||
* Use when:
|
||||
* - Typing host-owned binding records
|
||||
*
|
||||
* Expects:
|
||||
* - Values come from {@link bindingStateValues}
|
||||
*
|
||||
* Returns:
|
||||
* - The union of valid binding state literals
|
||||
*/
|
||||
export type BindingState = typeof bindingStateValues[number]
|
||||
/**
|
||||
* Describes one host-managed binding record.
|
||||
*
|
||||
@@ -74,16 +61,16 @@ export type BindingState = typeof bindingStateValues[number]
|
||||
* - A serializable binding snapshot including lifecycle metadata and config
|
||||
*/
|
||||
export interface BindingRecord<C extends HostDataRecord = HostDataRecord> {
|
||||
moduleId: string
|
||||
ownerSessionId: string
|
||||
ownerExtensionId: string
|
||||
config: C
|
||||
kitId: string
|
||||
kitModuleType: string
|
||||
state: BindingState
|
||||
runtime: (typeof pluginRuntimeValues)[number]
|
||||
moduleId: string
|
||||
ownerExtensionId: string
|
||||
ownerSessionId: string
|
||||
revision: number
|
||||
runtime: (typeof pluginRuntimeValues)[number]
|
||||
state: BindingState
|
||||
updatedAt: number
|
||||
config: C
|
||||
}
|
||||
/**
|
||||
* Describes the validated output shape of {@link bindingRecordSchema}.
|
||||
@@ -98,3 +85,16 @@ export interface BindingRecord<C extends HostDataRecord = HostDataRecord> {
|
||||
* - The inferred Valibot output type for one binding record
|
||||
*/
|
||||
export type BindingRecordOutput = InferOutput<typeof bindingRecordSchema>
|
||||
/**
|
||||
* Describes one valid binding lifecycle state.
|
||||
*
|
||||
* Use when:
|
||||
* - Typing host-owned binding records
|
||||
*
|
||||
* Expects:
|
||||
* - Values come from {@link bindingStateValues}
|
||||
*
|
||||
* Returns:
|
||||
* - The union of valid binding state literals
|
||||
*/
|
||||
export type BindingState = typeof bindingStateValues[number]
|
||||
|
||||
@@ -6,15 +6,15 @@ import { kitDescriptorSchema } from './kits'
|
||||
describe('kitDescriptorSchema', () => {
|
||||
it('accepts a generic host-level kit descriptor without business coupling', () => {
|
||||
const parsed = parse(kitDescriptorSchema, {
|
||||
kitId: 'kit.widget',
|
||||
version: '1.0.0',
|
||||
capabilities: [
|
||||
{
|
||||
key: 'kit.widget.module',
|
||||
actions: ['announce', 'activate', 'update', 'withdraw'],
|
||||
key: 'kit.widget.module',
|
||||
},
|
||||
],
|
||||
kitId: 'kit.widget',
|
||||
runtimes: ['electron', 'web'],
|
||||
version: '1.0.0',
|
||||
})
|
||||
|
||||
expect(parsed.kitId).toBe('kit.widget')
|
||||
@@ -24,10 +24,10 @@ describe('kitDescriptorSchema', () => {
|
||||
it('rejects an unsupported runtime', () => {
|
||||
expect(() =>
|
||||
parse(kitDescriptorSchema, {
|
||||
kitId: 'kit.widget',
|
||||
version: '1.0.0',
|
||||
capabilities: [],
|
||||
kitId: 'kit.widget',
|
||||
runtimes: ['browser'],
|
||||
version: '1.0.0',
|
||||
}),
|
||||
).toThrowError()
|
||||
})
|
||||
|
||||
@@ -17,10 +17,6 @@ import { pluginRuntimeSchema } from './types'
|
||||
* - A Valibot schema for one kit capability descriptor
|
||||
*/
|
||||
export const kitCapabilitySchema = object({
|
||||
key: pipe(
|
||||
string(),
|
||||
description('Stable capability key exposed by this kit.'),
|
||||
),
|
||||
actions: pipe(
|
||||
array(pipe(
|
||||
string(),
|
||||
@@ -28,6 +24,10 @@ export const kitCapabilitySchema = object({
|
||||
)),
|
||||
description('Allowed actions for this capability key.'),
|
||||
),
|
||||
key: pipe(
|
||||
string(),
|
||||
description('Stable capability key exposed by this kit.'),
|
||||
),
|
||||
})
|
||||
|
||||
/**
|
||||
@@ -43,18 +43,14 @@ export const kitCapabilitySchema = object({
|
||||
* - A Valibot schema for one kit descriptor
|
||||
*/
|
||||
export const kitDescriptorSchema = object({
|
||||
kitId: pipe(
|
||||
string(),
|
||||
description('Stable identifier for the host-registered kit.'),
|
||||
),
|
||||
version: pipe(
|
||||
string(),
|
||||
description('Semantic version of the kit contract.'),
|
||||
),
|
||||
capabilities: pipe(
|
||||
array(kitCapabilitySchema),
|
||||
description('Capabilities exposed by this kit descriptor.'),
|
||||
),
|
||||
kitId: pipe(
|
||||
string(),
|
||||
description('Stable identifier for the host-registered kit.'),
|
||||
),
|
||||
runtimes: pipe(
|
||||
array(pipe(
|
||||
pluginRuntimeSchema,
|
||||
@@ -62,6 +58,10 @@ export const kitDescriptorSchema = object({
|
||||
)),
|
||||
description('Runtimes where this kit can be used.'),
|
||||
),
|
||||
version: pipe(
|
||||
string(),
|
||||
description('Semantic version of the kit contract.'),
|
||||
),
|
||||
})
|
||||
|
||||
/**
|
||||
|
||||
+3
-3
@@ -21,13 +21,13 @@ interface TestWidgetKitClient {
|
||||
}
|
||||
|
||||
export const testWidgetKit = {
|
||||
id: 'kit.widget.test',
|
||||
version: '1.0.0',
|
||||
createClient() {
|
||||
return {
|
||||
mount() {},
|
||||
}
|
||||
},
|
||||
id: 'kit.widget.test',
|
||||
version: '1.0.0',
|
||||
} satisfies KitRef<TestWidgetKitClient>
|
||||
|
||||
export default defineExtension({
|
||||
@@ -36,7 +36,7 @@ export default defineExtension({
|
||||
const module = await ctx.modules.register({
|
||||
id: 'test-injected-host-apis-module',
|
||||
permissions: {
|
||||
apis: [{ key: testWidgetKit.id, actions: ['invoke'] }],
|
||||
apis: [{ actions: ['invoke'], key: testWidgetKit.id }],
|
||||
},
|
||||
})
|
||||
|
||||
|
||||
@@ -12,8 +12,8 @@
|
||||
* - A discriminated union describing the active transport and its required handles
|
||||
*/
|
||||
export type PluginTransport
|
||||
= | { kind: 'in-memory' }
|
||||
| { kind: 'websocket', url: string, protocols?: string[] }
|
||||
| { kind: 'web-worker', worker: Worker }
|
||||
= | { kind: 'electron', target: 'main' | 'renderer', webContentsId?: number }
|
||||
| { kind: 'in-memory' }
|
||||
| { kind: 'node-worker', worker: import('node:worker_threads').Worker }
|
||||
| { kind: 'electron', target: 'main' | 'renderer', webContentsId?: number }
|
||||
| { kind: 'web-worker', worker: Worker }
|
||||
| { kind: 'websocket', protocols?: string[], url: string }
|
||||
|
||||
@@ -84,19 +84,19 @@ export const pluginBindingApiWithdrawEventName = 'proj-airi:plugin-sdk:apis:clie
|
||||
export const pluginBindingRegistryResourceKey = 'proj-airi:plugin-sdk:resources:bindings'
|
||||
|
||||
/**
|
||||
* Builds the kit-scoped resource key used for binding write access.
|
||||
* Identifies which binding should transition to the active state.
|
||||
*
|
||||
* Use when:
|
||||
* - Declaring per-kit binding permissions
|
||||
* - Calling `apis.bindings.activate(...)`
|
||||
*
|
||||
* Expects:
|
||||
* - `kitId` matches the host-registered kit identifier
|
||||
* - `moduleId` points at an existing host-managed binding
|
||||
*
|
||||
* Returns:
|
||||
* - The resource key string for bindings owned by the given kit
|
||||
* - A minimal activation request payload
|
||||
*/
|
||||
export function getKitBindingResourceKey(kitId: string) {
|
||||
return `proj-airi:plugin-sdk:resources:kits:${kitId}:bindings`
|
||||
export interface ActivateBindingInput {
|
||||
moduleId: string
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -113,26 +113,44 @@ export function getKitBindingResourceKey(kitId: string) {
|
||||
* - A serializable binding declaration payload
|
||||
*/
|
||||
export interface AnnounceBindingInput<C extends HostDataRecord = HostDataRecord> {
|
||||
moduleId: string
|
||||
config: C
|
||||
kitId: string
|
||||
kitModuleType: string
|
||||
config: C
|
||||
moduleId: string
|
||||
}
|
||||
|
||||
/**
|
||||
* Identifies which binding should transition to the active state.
|
||||
* Describes the concrete client object returned by {@link createBindings}.
|
||||
*
|
||||
* Use when:
|
||||
* - Calling `apis.bindings.activate(...)`
|
||||
* - Typing `apis.bindings`
|
||||
*
|
||||
* Expects:
|
||||
* - `moduleId` points at an existing host-managed binding
|
||||
* - The caller uses the same method set as the runtime-created bindings client
|
||||
*
|
||||
* Returns:
|
||||
* - A minimal activation request payload
|
||||
* - The inferred bindings client surface
|
||||
*/
|
||||
export interface ActivateBindingInput {
|
||||
moduleId: string
|
||||
export type BindingClient = ReturnType<typeof createBindings>
|
||||
|
||||
/**
|
||||
* Defines the host-side callbacks needed by the low-level bindings client.
|
||||
*
|
||||
* Use when:
|
||||
* - Wiring `session.apis.bindings` to host-owned registry logic
|
||||
*
|
||||
* Expects:
|
||||
* - Each callback returns the cloned binding record state observed by plugin code
|
||||
*
|
||||
* Returns:
|
||||
* - The callback contract consumed by {@link createBindings}
|
||||
*/
|
||||
export interface BindingClientBindings<C extends HostDataRecord = HostDataRecord> {
|
||||
activate: (input: ActivateBindingInput) => BindingRecord<C> | Promise<BindingRecord<C>>
|
||||
announce: (input: AnnounceBindingInput<C>) => BindingRecord<C> | Promise<BindingRecord<C>>
|
||||
list: () => BindingRecord<C>[] | Promise<BindingRecord<C>[]>
|
||||
update: (input: UpdateBindingInput<C>) => BindingRecord<C> | Promise<BindingRecord<C>>
|
||||
withdraw: (input: WithdrawBindingInput) => BindingRecord<C> | Promise<BindingRecord<C>>
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -168,38 +186,6 @@ export interface WithdrawBindingInput {
|
||||
moduleId: string
|
||||
}
|
||||
|
||||
/**
|
||||
* Defines the host-side callbacks needed by the low-level bindings client.
|
||||
*
|
||||
* Use when:
|
||||
* - Wiring `session.apis.bindings` to host-owned registry logic
|
||||
*
|
||||
* Expects:
|
||||
* - Each callback returns the cloned binding record state observed by plugin code
|
||||
*
|
||||
* Returns:
|
||||
* - The callback contract consumed by {@link createBindings}
|
||||
*/
|
||||
export interface BindingClientBindings<C extends HostDataRecord = HostDataRecord> {
|
||||
list: () => Promise<BindingRecord<C>[]> | BindingRecord<C>[]
|
||||
announce: (input: AnnounceBindingInput<C>) => Promise<BindingRecord<C>> | BindingRecord<C>
|
||||
activate: (input: ActivateBindingInput) => Promise<BindingRecord<C>> | BindingRecord<C>
|
||||
update: (input: UpdateBindingInput<C>) => Promise<BindingRecord<C>> | BindingRecord<C>
|
||||
withdraw: (input: WithdrawBindingInput) => Promise<BindingRecord<C>> | BindingRecord<C>
|
||||
}
|
||||
|
||||
function createMissingBindingError(method: string) {
|
||||
return new Error(`Plugin binding API binding missing for \`${method}\`.`)
|
||||
}
|
||||
|
||||
function requireBinding<TBinding>(binding: TBinding | undefined, method: string): TBinding {
|
||||
if (!binding) {
|
||||
throw createMissingBindingError(method)
|
||||
}
|
||||
|
||||
return binding
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates the low-level bindings client exposed on `session.apis`.
|
||||
*
|
||||
@@ -217,14 +203,14 @@ export function createBindings<C extends HostDataRecord = HostDataRecord>(
|
||||
bindings?: BindingClientBindings<C>,
|
||||
) {
|
||||
return {
|
||||
async list() {
|
||||
return await requireBinding(bindings, 'bindings.list').list()
|
||||
async activate(input: ActivateBindingInput) {
|
||||
return await requireBinding(bindings, 'bindings.activate').activate(input)
|
||||
},
|
||||
async announce(input: AnnounceBindingInput<C>) {
|
||||
return await requireBinding(bindings, 'bindings.announce').announce(input)
|
||||
},
|
||||
async activate(input: ActivateBindingInput) {
|
||||
return await requireBinding(bindings, 'bindings.activate').activate(input)
|
||||
async list() {
|
||||
return await requireBinding(bindings, 'bindings.list').list()
|
||||
},
|
||||
async update(input: UpdateBindingInput<C>) {
|
||||
return await requireBinding(bindings, 'bindings.update').update(input)
|
||||
@@ -236,15 +222,29 @@ export function createBindings<C extends HostDataRecord = HostDataRecord>(
|
||||
}
|
||||
|
||||
/**
|
||||
* Describes the concrete client object returned by {@link createBindings}.
|
||||
* Builds the kit-scoped resource key used for binding write access.
|
||||
*
|
||||
* Use when:
|
||||
* - Typing `apis.bindings`
|
||||
* - Declaring per-kit binding permissions
|
||||
*
|
||||
* Expects:
|
||||
* - The caller uses the same method set as the runtime-created bindings client
|
||||
* - `kitId` matches the host-registered kit identifier
|
||||
*
|
||||
* Returns:
|
||||
* - The inferred bindings client surface
|
||||
* - The resource key string for bindings owned by the given kit
|
||||
*/
|
||||
export type BindingClient = ReturnType<typeof createBindings>
|
||||
export function getKitBindingResourceKey(kitId: string) {
|
||||
return `proj-airi:plugin-sdk:resources:kits:${kitId}:bindings`
|
||||
}
|
||||
|
||||
function createMissingBindingError(method: string) {
|
||||
return new Error(`Plugin binding API binding missing for \`${method}\`.`)
|
||||
}
|
||||
|
||||
function requireBinding<TBinding>(binding: TBinding | undefined, method: string): TBinding {
|
||||
if (!binding) {
|
||||
throw createMissingBindingError(method)
|
||||
}
|
||||
|
||||
return binding
|
||||
}
|
||||
|
||||
@@ -20,10 +20,24 @@ import { createResources } from './resources'
|
||||
* - A map of callback groups consumed by {@link createApis}
|
||||
*/
|
||||
export interface PluginApiBindings {
|
||||
kits?: KitClientBindings
|
||||
bindings?: BindingClientBindings
|
||||
kits?: KitClientBindings
|
||||
}
|
||||
|
||||
/**
|
||||
* Describes the concrete API object returned by {@link createApis}.
|
||||
*
|
||||
* Use when:
|
||||
* - Typing host-backed client APIs for kit runtimes
|
||||
*
|
||||
* Expects:
|
||||
* - The caller uses the same shape as the runtime-created API object
|
||||
*
|
||||
* Returns:
|
||||
* - The inferred plugin API client surface
|
||||
*/
|
||||
export type PluginApis = ReturnType<typeof createApis>
|
||||
|
||||
/**
|
||||
* Creates the low-level plugin API surface exposed to plugin code.
|
||||
*
|
||||
@@ -40,24 +54,10 @@ export interface PluginApiBindings {
|
||||
export function createApis(ctx: EventContext<any, any>, bindings: PluginApiBindings = {}) {
|
||||
return {
|
||||
...createResources(ctx),
|
||||
kits: createKits(ctx, bindings.kits),
|
||||
bindings: createBindings(ctx, bindings.bindings),
|
||||
kits: createKits(ctx, bindings.kits),
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Describes the concrete API object returned by {@link createApis}.
|
||||
*
|
||||
* Use when:
|
||||
* - Typing host-backed client APIs for kit runtimes
|
||||
*
|
||||
* Expects:
|
||||
* - The caller uses the same shape as the runtime-created API object
|
||||
*
|
||||
* Returns:
|
||||
* - The inferred plugin API client surface
|
||||
*/
|
||||
export type PluginApis = ReturnType<typeof createApis>
|
||||
export * from './bindings'
|
||||
export * from './kits'
|
||||
export * from './resources'
|
||||
|
||||
@@ -42,6 +42,20 @@ export const pluginKitApiGetCapabilitiesEventName = 'proj-airi:plugin-sdk:apis:c
|
||||
*/
|
||||
export const pluginKitRegistryResourceKey = 'proj-airi:plugin-sdk:resources:kits'
|
||||
|
||||
/**
|
||||
* Describes the concrete client object returned by {@link createKits}.
|
||||
*
|
||||
* Use when:
|
||||
* - Typing `apis.kits`
|
||||
*
|
||||
* Expects:
|
||||
* - The caller uses the same method set as the runtime-created kits client
|
||||
*
|
||||
* Returns:
|
||||
* - The inferred kits client surface
|
||||
*/
|
||||
export type KitClient = ReturnType<typeof createKits>
|
||||
|
||||
/**
|
||||
* Defines the host-side callbacks needed by the low-level kit client.
|
||||
*
|
||||
@@ -56,20 +70,8 @@ export const pluginKitRegistryResourceKey = 'proj-airi:plugin-sdk:resources:kits
|
||||
* - The callback contract consumed by {@link createKits}
|
||||
*/
|
||||
export interface KitClientBindings<TKit extends KitDescriptor = KitDescriptor> {
|
||||
getCapabilities: (kitId: string) => KitCapabilityDescriptor[] | Promise<KitCapabilityDescriptor[]>
|
||||
list: () => Promise<TKit[]> | TKit[]
|
||||
getCapabilities: (kitId: string) => Promise<KitCapabilityDescriptor[]> | KitCapabilityDescriptor[]
|
||||
}
|
||||
|
||||
function createMissingBindingError(method: string) {
|
||||
return new Error(`Plugin kit API binding missing for \`${method}\`.`)
|
||||
}
|
||||
|
||||
function requireBinding<TBinding>(binding: TBinding | undefined, method: string): TBinding {
|
||||
if (!binding) {
|
||||
throw createMissingBindingError(method)
|
||||
}
|
||||
|
||||
return binding
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -89,25 +91,23 @@ export function createKits<TKit extends KitDescriptor = KitDescriptor>(
|
||||
bindings?: KitClientBindings<TKit>,
|
||||
) {
|
||||
return {
|
||||
async list() {
|
||||
return await requireBinding(bindings, 'kits.list').list()
|
||||
},
|
||||
async getCapabilities(kitId: string) {
|
||||
return await requireBinding(bindings, 'kits.getCapabilities').getCapabilities(kitId)
|
||||
},
|
||||
async list() {
|
||||
return await requireBinding(bindings, 'kits.list').list()
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Describes the concrete client object returned by {@link createKits}.
|
||||
*
|
||||
* Use when:
|
||||
* - Typing `apis.kits`
|
||||
*
|
||||
* Expects:
|
||||
* - The caller uses the same method set as the runtime-created kits client
|
||||
*
|
||||
* Returns:
|
||||
* - The inferred kits client surface
|
||||
*/
|
||||
export type KitClient = ReturnType<typeof createKits>
|
||||
function createMissingBindingError(method: string) {
|
||||
return new Error(`Plugin kit API binding missing for \`${method}\`.`)
|
||||
}
|
||||
|
||||
function requireBinding<TBinding>(binding: TBinding | undefined, method: string): TBinding {
|
||||
if (!binding) {
|
||||
throw createMissingBindingError(method)
|
||||
}
|
||||
|
||||
return binding
|
||||
}
|
||||
|
||||
@@ -15,8 +15,8 @@ import { defineInvokeEventa } from '@moeru/eventa'
|
||||
*/
|
||||
export interface CapabilityDescriptor {
|
||||
key: string
|
||||
state: 'announced' | 'ready' | 'degraded' | 'withdrawn'
|
||||
metadata?: Record<string, unknown>
|
||||
state: 'announced' | 'degraded' | 'ready' | 'withdrawn'
|
||||
updatedAt: number
|
||||
}
|
||||
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
import { defineConfig } from 'tsdown'
|
||||
|
||||
export default defineConfig({
|
||||
dts: true,
|
||||
entry: [
|
||||
'src/index.ts',
|
||||
'src/plugin-host/index.ts',
|
||||
'src/plugin-host/runtimes/node/index.ts',
|
||||
'src/plugin-host/runtimes/web/index.ts',
|
||||
],
|
||||
dts: true,
|
||||
format: 'esm',
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user