style: lint

This commit is contained in:
Neko Ayaka
2026-08-26 19:49:58 +08:00
parent e60a04a4ec
commit 98f40d7d0b
1625 changed files with 75216 additions and 75203 deletions
+9 -9
View File
@@ -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
}
/**
+10 -10
View File
@@ -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')
+1 -1
View File
@@ -35,9 +35,9 @@ export async function createModule(
ctx.subscriptions.add({ dispose })
return {
dispose,
id: module.id,
kits: module.kits,
subscriptions: module.subscriptions,
dispose,
}
}
+29 -29
View File
@@ -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
}
+1 -1
View File
@@ -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'
+5 -5
View File
@@ -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')
+17 -17
View File
@@ -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),
}
}
+158 -158
View File
@@ -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
}
}
@@ -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()}`,
}
}
}
@@ -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',
})
@@ -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.'),
),
})
/**
@@ -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
}