diff --git a/packages/omnium-gatherum/package.json b/packages/omnium-gatherum/package.json index 161d861da..62f19937a 100644 --- a/packages/omnium-gatherum/package.json +++ b/packages/omnium-gatherum/package.json @@ -44,14 +44,20 @@ }, "dependencies": { "@endo/eventual-send": "^1.3.4", + "@endo/exo": "^1.5.12", "@metamask/kernel-browser-runtime": "workspace:^", "@metamask/kernel-shims": "workspace:^", "@metamask/kernel-ui": "workspace:^", "@metamask/kernel-utils": "workspace:^", "@metamask/logger": "workspace:^", + "@metamask/ocap-kernel": "workspace:^", "@metamask/streams": "workspace:^", + "@metamask/superstruct": "^3.2.1", + "@metamask/utils": "^11.9.0", + "immer": "^10.1.1", "react": "^17.0.2", "react-dom": "^17.0.2", + "semver": "^7.7.1", "ses": "^1.14.0" }, "devDependencies": { @@ -68,6 +74,7 @@ "@types/chrome": "^0.0.313", "@types/react": "^17.0.11", "@types/react-dom": "^17.0.11", + "@types/semver": "^7.7.1", "@types/webextension-polyfill": "^0", "@typescript-eslint/eslint-plugin": "^8.29.0", "@typescript-eslint/parser": "^8.29.0", diff --git a/packages/omnium-gatherum/src/background.ts b/packages/omnium-gatherum/src/background.ts index 4ea6aa780..b73855fc6 100644 --- a/packages/omnium-gatherum/src/background.ts +++ b/packages/omnium-gatherum/src/background.ts @@ -12,19 +12,27 @@ import type { import { delay, isJsonRpcMessage, stringify } from '@metamask/kernel-utils'; import type { JsonRpcMessage } from '@metamask/kernel-utils'; import { Logger } from '@metamask/logger'; +import type { ClusterConfig } from '@metamask/ocap-kernel'; import { ChromeRuntimeDuplexStream } from '@metamask/streams/browser'; -defineGlobals(); +import { + CapletController, + makeChromeStorageAdapter, +} from './controllers/index.ts'; +import type { + CapletControllerFacet, + CapletManifest, + LaunchResult, +} from './controllers/index.ts'; const OFFSCREEN_DOCUMENT_PATH = '/offscreen.html'; const logger = new Logger('background'); +const globals = defineGlobals(); let bootPromise: Promise | null = null; -let kernelP: Promise; -let ping: () => Promise; // With this we can click the extension action button to wake up the service worker. chrome.action.onClicked.addListener(() => { - ping?.().catch(logger.error); + omnium.ping?.().catch(logger.error); }); // Install/update @@ -104,12 +112,54 @@ async function main(): Promise { }, }); - kernelP = backgroundCapTP.getKernel(); + const kernelP = backgroundCapTP.getKernel(); + globals.setKernelP(kernelP); - ping = async (): Promise => { + globals.setPing(async (): Promise => { const result = await E(kernelP).ping(); logger.info(result); - }; + }); + + // Create storage adapter + const storageAdapter = makeChromeStorageAdapter(); + + // Create CapletController with attenuated kernel access + // Controller creates its own storage internally + const capletController = await CapletController.make( + { logger: logger.subLogger({ tags: ['caplet'] }) }, + { + adapter: storageAdapter, + // Wrap launchSubcluster to return subclusterId + launchSubcluster: async ( + config: ClusterConfig, + ): Promise => { + // Get current subcluster count + const statusBefore = await E(kernelP).getStatus(); + const beforeIds = new Set( + statusBefore.subclusters.map((subcluster) => subcluster.id), + ); + + // Launch the subcluster + await E(kernelP).launchSubcluster(config); + + // Get status after and find the new subcluster + const statusAfter = await E(kernelP).getStatus(); + const newSubcluster = statusAfter.subclusters.find( + (subcluster) => !beforeIds.has(subcluster.id), + ); + + if (!newSubcluster) { + throw new Error('Failed to determine subclusterId after launch'); + } + + return { subclusterId: newSubcluster.id }; + }, + terminateSubcluster: async (subclusterId: string): Promise => { + await E(kernelP).terminateSubcluster(subclusterId); + }, + }, + ); + globals.setCapletController(capletController); try { await offscreenStream.drain((message) => { @@ -129,10 +179,25 @@ async function main(): Promise { } } +type GlobalSetters = { + setKernelP: (value: Promise) => void; + setPing: (value: () => Promise) => void; + setCapletController: (value: CapletControllerFacet) => void; +}; + /** * Define globals accessible via the background console. + * + * @returns A device for setting the global values. */ -function defineGlobals(): void { +function defineGlobals(): GlobalSetters { + Object.defineProperty(globalThis, 'E', { + configurable: false, + enumerable: true, + writable: false, + value: E, + }); + Object.defineProperty(globalThis, 'omnium', { configurable: false, enumerable: true, @@ -140,6 +205,10 @@ function defineGlobals(): void { value: {}, }); + let kernelP: Promise; + let ping: (() => Promise) | undefined; + let capletController: CapletControllerFacet; + Object.defineProperties(globalThis.omnium, { ping: { get: () => ping, @@ -147,13 +216,30 @@ function defineGlobals(): void { getKernel: { value: async () => kernelP, }, + caplet: { + value: harden({ + install: async (manifest: CapletManifest, bundle?: unknown) => + E(capletController).install(manifest, bundle), + uninstall: async (capletId: string) => + E(capletController).uninstall(capletId), + list: async () => E(capletController).list(), + get: async (capletId: string) => E(capletController).get(capletId), + getByService: async (serviceName: string) => + E(capletController).getByService(serviceName), + }), + }, }); harden(globalThis.omnium); - Object.defineProperty(globalThis, 'E', { - configurable: false, - enumerable: true, - writable: false, - value: E, - }); + return { + setKernelP: (value) => { + kernelP = value; + }, + setPing: (value) => { + ping = value; + }, + setCapletController: (value) => { + capletController = value; + }, + }; } diff --git a/packages/omnium-gatherum/src/controllers/base-controller.test.ts b/packages/omnium-gatherum/src/controllers/base-controller.test.ts new file mode 100644 index 000000000..1bfa82f9b --- /dev/null +++ b/packages/omnium-gatherum/src/controllers/base-controller.test.ts @@ -0,0 +1,313 @@ +import { makeDefaultExo } from '@metamask/kernel-utils/exo'; +import type { Logger } from '@metamask/logger'; +import { describe, it, expect, vi, beforeEach } from 'vitest'; + +import { Controller } from './base-controller.ts'; +import type { ControllerConfig } from './base-controller.ts'; +import { ControllerStorage } from './storage/controller-storage.ts'; +import type { StorageAdapter } from './storage/types.ts'; +import { makeMockStorageAdapter } from '../../test/utils.ts'; + +/** + * Test state for the concrete test controller. + */ +type TestState = { + items: Record; + count: number; +}; + +/** + * Test methods for the concrete test controller. + */ +type TestMethods = { + addItem: (id: string, name: string, value: number) => Promise; + removeItem: (id: string) => Promise; + getItem: (id: string) => Promise<{ name: string; value: number } | undefined>; + getCount: () => Promise; + clearState: () => void; + getState: () => Readonly; +}; + +/** + * Concrete controller for testing the abstract Controller base class. + */ +class TestController extends Controller< + 'TestController', + TestState, + TestMethods +> { + // eslint-disable-next-line no-restricted-syntax -- TypeScript doesn't support # for constructors + private constructor(storage: ControllerStorage, logger: Logger) { + super('TestController', storage, logger); + harden(this); + } + + static async make( + config: ControllerConfig, + adapter: StorageAdapter, + ): Promise { + const storage = await ControllerStorage.make({ + namespace: 'test', + adapter, + defaultState: { + items: {}, + count: 0, + }, + logger: config.logger, + debounceMs: 0, + }); + + const controller = new TestController(storage, config.logger); + return controller.makeFacet(); + } + + makeFacet(): TestMethods { + return makeDefaultExo('TestController', { + addItem: async ( + id: string, + name: string, + value: number, + ): Promise => { + this.logger.info(`Adding item: ${id}`); + this.update((draft) => { + draft.items[id] = { name, value }; + draft.count += 1; + }); + }, + removeItem: async (id: string): Promise => { + this.logger.info(`Removing item: ${id}`); + this.update((draft) => { + delete draft.items[id]; + draft.count -= 1; + }); + }, + getItem: async ( + id: string, + ): Promise<{ name: string; value: number } | undefined> => { + return this.state.items[id]; + }, + getCount: async (): Promise => { + return this.state.count; + }, + clearState: (): void => { + this.clearState(); + }, + getState: (): Readonly => { + return this.state; + }, + }); + } +} +harden(TestController); + +describe('Controller', () => { + const mockLogger = { + info: vi.fn(), + warn: vi.fn(), + error: vi.fn(), + debug: vi.fn(), + subLogger: vi.fn().mockReturnThis(), + }; + + const config: ControllerConfig = { + logger: mockLogger as unknown as ControllerConfig['logger'], + }; + + beforeEach(() => { + vi.clearAllMocks(); + }); + + describe('state access', () => { + it('provides read-only access to state', async () => { + const mockAdapter = makeMockStorageAdapter(); + await mockAdapter.set('test.items', { foo: { name: 'Foo', value: 42 } }); + await mockAdapter.set('test.count', 1); + + const controller = await TestController.make(config, mockAdapter); + + const item = await controller.getItem('foo'); + + expect(item).toStrictEqual({ name: 'Foo', value: 42 }); + }); + + it('returns undefined for non-existent items', async () => { + const mockAdapter = makeMockStorageAdapter(); + const controller = await TestController.make(config, mockAdapter); + + const item = await controller.getItem('nonexistent'); + + expect(item).toBeUndefined(); + }); + + it('reflects initial state count', async () => { + const mockAdapter = makeMockStorageAdapter(); + await mockAdapter.set('test.items', { + a: { name: 'A', value: 1 }, + b: { name: 'B', value: 2 }, + }); + await mockAdapter.set('test.count', 2); + + const controller = await TestController.make(config, mockAdapter); + + const count = await controller.getCount(); + + expect(count).toBe(2); + }); + }); + + describe('state updates', () => { + it('updates state through update method', async () => { + const mockAdapter = makeMockStorageAdapter(); + const controller = await TestController.make(config, mockAdapter); + + await controller.addItem('test', 'Test Item', 100); + + const item = await controller.getItem('test'); + expect(item).toStrictEqual({ name: 'Test Item', value: 100 }); + }); + + it('increments count when adding items', async () => { + const mockAdapter = makeMockStorageAdapter(); + const controller = await TestController.make(config, mockAdapter); + + await controller.addItem('a', 'Item A', 1); + await controller.addItem('b', 'Item B', 2); + + const count = await controller.getCount(); + expect(count).toBe(2); + }); + + it('decrements count when removing items', async () => { + const mockAdapter = makeMockStorageAdapter(); + await mockAdapter.set('test.items', { + a: { name: 'A', value: 1 }, + b: { name: 'B', value: 2 }, + }); + await mockAdapter.set('test.count', 2); + + const controller = await TestController.make(config, mockAdapter); + + await controller.removeItem('a'); + + const count = await controller.getCount(); + expect(count).toBe(1); + }); + + it('removes item from state', async () => { + const mockAdapter = makeMockStorageAdapter(); + await mockAdapter.set('test.items', { foo: { name: 'Foo', value: 42 } }); + await mockAdapter.set('test.count', 1); + + const controller = await TestController.make(config, mockAdapter); + + await controller.removeItem('foo'); + + const item = await controller.getItem('foo'); + expect(item).toBeUndefined(); + }); + + it('persists state modifications to storage', async () => { + const mockAdapter = makeMockStorageAdapter(); + const controller = await TestController.make(config, mockAdapter); + + await controller.addItem('a', 'A', 1); + await controller.addItem('b', 'B', 2); + await controller.removeItem('a'); + + // Wait for debounced persistence + await new Promise((resolve) => setTimeout(resolve, 10)); + + // Check that state was persisted + const items = await mockAdapter.get('test.items'); + const count = await mockAdapter.get('test.count'); + expect(items).toStrictEqual({ b: { name: 'B', value: 2 } }); + expect(count).toBe(1); + }); + }); + + describe('logging', () => { + it('logs through provided logger', async () => { + const mockAdapter = makeMockStorageAdapter(); + const controller = await TestController.make(config, mockAdapter); + + await controller.addItem('test', 'Test', 1); + + expect(mockLogger.info).toHaveBeenCalledWith('Adding item: test'); + }); + + it('logs remove operations', async () => { + const mockAdapter = makeMockStorageAdapter(); + await mockAdapter.set('test.items', { foo: { name: 'Foo', value: 42 } }); + await mockAdapter.set('test.count', 1); + + const controller = await TestController.make(config, mockAdapter); + + await controller.removeItem('foo'); + + expect(mockLogger.info).toHaveBeenCalledWith('Removing item: foo'); + }); + }); + + describe('clearState', () => { + it('clears state through clearState method', async () => { + const mockAdapter = makeMockStorageAdapter(); + const controller = await TestController.make(config, mockAdapter); + await controller.addItem('a', 'A', 1); + + const stateBefore = controller.getState(); + expect(stateBefore.items).toStrictEqual({ a: { name: 'A', value: 1 } }); + expect(stateBefore.count).toBe(1); + + controller.clearState(); + + const stateAfter = controller.getState(); + expect(stateAfter.items).toStrictEqual({}); + expect(stateAfter.count).toBe(0); + }); + + it('persists cleared state', async () => { + const mockAdapter = makeMockStorageAdapter(); + const controller = await TestController.make(config, mockAdapter); + await controller.addItem('a', 'A', 1); + + // Wait for persistence + await new Promise((resolve) => setTimeout(resolve, 10)); + + controller.clearState(); + + // Wait for persistence + await new Promise((resolve) => setTimeout(resolve, 10)); + + const items = await mockAdapter.get('test.items'); + const count = await mockAdapter.get('test.count'); + expect(items).toStrictEqual({}); + expect(count).toBe(0); + }); + }); + + describe('makeFacet', () => { + it('returns hardened exo with all methods', async () => { + const mockAdapter = makeMockStorageAdapter(); + const facet = await TestController.make(config, mockAdapter); + + expect(typeof facet.addItem).toBe('function'); + expect(typeof facet.removeItem).toBe('function'); + expect(typeof facet.getItem).toBe('function'); + expect(typeof facet.getCount).toBe('function'); + expect(typeof facet.clearState).toBe('function'); + expect(typeof facet.getState).toBe('function'); + }); + + it('methods work correctly through exo', async () => { + const mockAdapter = makeMockStorageAdapter(); + const facet = await TestController.make(config, mockAdapter); + + await facet.addItem('x', 'X', 10); + const item = await facet.getItem('x'); + const count = await facet.getCount(); + + expect(item).toStrictEqual({ name: 'X', value: 10 }); + expect(count).toBe(1); + }); + }); +}); diff --git a/packages/omnium-gatherum/src/controllers/base-controller.ts b/packages/omnium-gatherum/src/controllers/base-controller.ts new file mode 100644 index 000000000..5b049576f --- /dev/null +++ b/packages/omnium-gatherum/src/controllers/base-controller.ts @@ -0,0 +1,138 @@ +import type { Logger } from '@metamask/logger'; +import type { Json } from '@metamask/utils'; + +import type { ControllerStorage } from './storage/controller-storage.ts'; + +/** + * Base type for controller methods. + * Controllers expose their public API through a methods object. + */ +export type ControllerMethods = Record unknown>; + +/** + * Configuration passed to all controllers during initialization. + */ +export type ControllerConfig = { + logger: Logger; +}; + +/** + * Abstract base class for controllers. + * + * Provides state management via ControllerStorage with: + * - Synchronous state access via `this.state` + * - Async state updates via `this.update()` + * - Automatic persistence handled by storage layer + * + * Subclasses must: + * - Call `super()` in constructor with name, storage, and logger + * - Call `harden(this)` at the end of their constructor + * - Implement `makeFacet()` to return a hardened exo with public API + * + * @template ControllerName - Literal string type for the controller name + * @template State - The state object shape (must be JSON-serializable) + * @template Methods - The public method interface + * + * @example + * ```typescript + * class MyController extends Controller<'MyController', MyState, MyMethods> { + * private constructor(storage: ControllerStorage, logger: Logger) { + * super('MyController', storage, logger); + * harden(this); + * } + * + * static create(config: ControllerConfig, deps: MyDeps): MyMethods { + * const controller = new MyController(deps.storage, config.logger); + * return controller.makeFacet(); + * } + * + * makeFacet(): MyMethods { + * return makeDefaultExo('MyController', { ... }); + * } + * } + * ``` + */ +export abstract class Controller< + ControllerName extends string, + State extends Record, + Methods extends ControllerMethods, +> { + readonly #name: ControllerName; + + readonly #storage: ControllerStorage; + + readonly #logger: Logger; + + /** + * Protected constructor - subclasses must call this via super(). + * + * @param name - Controller name for debugging/logging. + * @param storage - ControllerStorage instance for state management. + * @param logger - Logger instance. + */ + protected constructor( + name: ControllerName, + storage: ControllerStorage, + logger: Logger, + ) { + this.#name = name; + this.#storage = storage; + this.#logger = logger; + // Note: Subclass must call harden(this) after its own initialization + } + + /** + * Controller name for debugging/logging. + * + * @returns The controller name. + */ + protected get name(): ControllerName { + return this.#name; + } + + /** + * Current state (readonly). + * Provides synchronous access to in-memory state. + * + * @returns The current readonly state. + */ + protected get state(): Readonly { + return this.#storage.state; + } + + /** + * Logger instance for this controller. + * + * @returns The logger instance. + */ + protected get logger(): Logger { + return this.#logger; + } + + /** + * Update state using an immer producer function. + * State is updated synchronously in memory. + * Persistence is handled automatically by the storage layer (debounced). + * + * @param producer - Function that mutates a draft of the state. + */ + protected update(producer: (draft: State) => void): void { + this.#storage.update(producer); + } + + /** + * Clear storage and reset to default state. + */ + clearState(): void { + this.#storage.clear(); + } + + /** + * Returns the hardened exo with public methods. + * Subclasses implement this to define their public interface. + * + * @returns A hardened exo object with the controller's public methods. + */ + abstract makeFacet(): Methods; +} +harden(Controller); diff --git a/packages/omnium-gatherum/src/controllers/caplet/caplet-controller.test.ts b/packages/omnium-gatherum/src/controllers/caplet/caplet-controller.test.ts new file mode 100644 index 000000000..ce483096b --- /dev/null +++ b/packages/omnium-gatherum/src/controllers/caplet/caplet-controller.test.ts @@ -0,0 +1,467 @@ +import type { Json } from '@metamask/utils'; +import { describe, it, expect, vi, beforeEach } from 'vitest'; + +import { CapletController } from './caplet-controller.ts'; +import type { CapletManifest } from './types.ts'; +import { makeMockStorageAdapter } from '../../../test/utils.ts'; +import type { StorageAdapter } from '../storage/types.ts'; +import type { ControllerConfig } from '../types.ts'; + +/** + * Seed a mock adapter with caplet controller state. + * + * @param adapter - The adapter to seed. + * @param caplets - The caplets to pre-populate. + * @returns A promise that resolves when seeding is complete. + */ +async function seedAdapter( + adapter: StorageAdapter, + caplets: Record, +): Promise { + await adapter.set('caplet.caplets', caplets as Json); +} + +describe('CapletController.make', () => { + const mockLogger = { + info: vi.fn(), + warn: vi.fn(), + error: vi.fn(), + debug: vi.fn(), + subLogger: vi.fn().mockReturnThis(), + }; + + const mockLaunchSubcluster = vi.fn(); + const mockTerminateSubcluster = vi.fn(); + + const config: ControllerConfig = { + logger: mockLogger as unknown as ControllerConfig['logger'], + }; + + const validManifest: CapletManifest = { + id: 'com.example.test', + name: 'Test Caplet', + version: '1.0.0', + bundleSpec: 'https://example.com/bundle.json', + requestedServices: ['keyring'], + providedServices: ['signer'], + }; + + beforeEach(() => { + vi.clearAllMocks(); + vi.mocked(mockLaunchSubcluster).mockResolvedValue({ + subclusterId: 'subcluster-123', + }); + }); + + describe('install', () => { + it('installs a caplet successfully', async () => { + const mockAdapter = makeMockStorageAdapter(); + const controller = await CapletController.make(config, { + adapter: mockAdapter, + launchSubcluster: mockLaunchSubcluster, + terminateSubcluster: mockTerminateSubcluster, + }); + + const result = await controller.install(validManifest); + + expect(result).toStrictEqual({ + capletId: 'com.example.test', + subclusterId: 'subcluster-123', + }); + }); + + it('validates the manifest', async () => { + const mockAdapter = makeMockStorageAdapter(); + const controller = await CapletController.make(config, { + adapter: mockAdapter, + launchSubcluster: mockLaunchSubcluster, + terminateSubcluster: mockTerminateSubcluster, + }); + + const invalidManifest = { id: 'invalid' } as CapletManifest; + + await expect(controller.install(invalidManifest)).rejects.toThrow( + 'Invalid caplet manifest for invalid', + ); + }); + + it('throws if caplet already installed', async () => { + const mockAdapter = makeMockStorageAdapter(); + await seedAdapter(mockAdapter, { + 'com.example.test': { + manifest: validManifest, + subclusterId: 'subcluster-123', + installedAt: 1000, + }, + }); + const controller = await CapletController.make(config, { + adapter: mockAdapter, + launchSubcluster: mockLaunchSubcluster, + terminateSubcluster: mockTerminateSubcluster, + }); + + await expect(controller.install(validManifest)).rejects.toThrow( + 'Caplet com.example.test is already installed', + ); + }); + + it('launches subcluster with correct config', async () => { + const mockAdapter = makeMockStorageAdapter(); + const controller = await CapletController.make(config, { + adapter: mockAdapter, + launchSubcluster: mockLaunchSubcluster, + terminateSubcluster: mockTerminateSubcluster, + }); + + await controller.install(validManifest); + + expect(mockLaunchSubcluster).toHaveBeenCalledWith({ + bootstrap: 'com.example.test', + vats: { + 'com.example.test': { + bundleSpec: 'https://example.com/bundle.json', + }, + }, + }); + }); + + it('stores caplet with manifest, subclusterId, and installedAt', async () => { + vi.useFakeTimers(); + vi.setSystemTime(new Date('2024-01-15T12:00:00Z')); + + const mockAdapter = makeMockStorageAdapter(); + const controller = await CapletController.make(config, { + adapter: mockAdapter, + launchSubcluster: mockLaunchSubcluster, + terminateSubcluster: mockTerminateSubcluster, + }); + + await controller.install(validManifest); + + const caplet = await controller.get('com.example.test'); + expect(caplet).toBeDefined(); + expect(caplet?.manifest).toStrictEqual(validManifest); + expect(caplet?.subclusterId).toBe('subcluster-123'); + expect(caplet?.installedAt).toBe(Date.now()); + + vi.useRealTimers(); + }); + + it('preserves existing caplets when installing', async () => { + const mockAdapter = makeMockStorageAdapter(); + await seedAdapter(mockAdapter, { + 'com.other.caplet': { + manifest: { ...validManifest, id: 'com.other.caplet' }, + subclusterId: 'subcluster-other', + installedAt: 500, + }, + }); + const controller = await CapletController.make(config, { + adapter: mockAdapter, + launchSubcluster: mockLaunchSubcluster, + terminateSubcluster: mockTerminateSubcluster, + }); + + await controller.install(validManifest); + + const caplets = await controller.list(); + const capletIds = caplets.map((caplet) => caplet.manifest.id).sort(); + expect(capletIds).toStrictEqual(['com.example.test', 'com.other.caplet']); + }); + + it('logs installation progress', async () => { + const mockAdapter = makeMockStorageAdapter(); + const controller = await CapletController.make(config, { + adapter: mockAdapter, + launchSubcluster: mockLaunchSubcluster, + terminateSubcluster: mockTerminateSubcluster, + }); + + await controller.install(validManifest); + + expect(mockLogger.info).toHaveBeenCalledWith( + 'Installing caplet: com.example.test', + ); + expect(mockLogger.info).toHaveBeenCalledWith( + 'Caplet com.example.test installed with subcluster subcluster-123', + ); + }); + }); + + describe('uninstall', () => { + it('uninstalls a caplet successfully', async () => { + const mockAdapter = makeMockStorageAdapter(); + await seedAdapter(mockAdapter, { + 'com.example.test': { + manifest: validManifest, + subclusterId: 'subcluster-123', + installedAt: 1000, + }, + }); + const controller = await CapletController.make(config, { + adapter: mockAdapter, + launchSubcluster: mockLaunchSubcluster, + terminateSubcluster: mockTerminateSubcluster, + }); + + await controller.uninstall('com.example.test'); + + expect(mockTerminateSubcluster).toHaveBeenCalledWith('subcluster-123'); + }); + + it('throws if caplet not found', async () => { + const mockAdapter = makeMockStorageAdapter(); + const controller = await CapletController.make(config, { + adapter: mockAdapter, + launchSubcluster: mockLaunchSubcluster, + terminateSubcluster: mockTerminateSubcluster, + }); + + await expect( + controller.uninstall('com.example.notfound'), + ).rejects.toThrow('Caplet com.example.notfound not found'); + }); + + it('removes caplet from state', async () => { + const mockAdapter = makeMockStorageAdapter(); + await seedAdapter(mockAdapter, { + 'com.example.test': { + manifest: validManifest, + subclusterId: 'subcluster-123', + installedAt: 1000, + }, + }); + const controller = await CapletController.make(config, { + adapter: mockAdapter, + launchSubcluster: mockLaunchSubcluster, + terminateSubcluster: mockTerminateSubcluster, + }); + + await controller.uninstall('com.example.test'); + + const caplet = await controller.get('com.example.test'); + expect(caplet).toBeUndefined(); + }); + + it('preserves other caplets when uninstalling', async () => { + const mockAdapter = makeMockStorageAdapter(); + await seedAdapter(mockAdapter, { + 'com.other.caplet': { + manifest: { ...validManifest, id: 'com.other.caplet' }, + subclusterId: 'subcluster-other', + installedAt: 500, + }, + 'com.example.test': { + manifest: validManifest, + subclusterId: 'subcluster-123', + installedAt: 1000, + }, + }); + const controller = await CapletController.make(config, { + adapter: mockAdapter, + launchSubcluster: mockLaunchSubcluster, + terminateSubcluster: mockTerminateSubcluster, + }); + + await controller.uninstall('com.example.test'); + + const caplets = await controller.list(); + const capletIds = caplets.map((caplet) => caplet.manifest.id); + expect(capletIds).toStrictEqual(['com.other.caplet']); + }); + + it('logs uninstallation progress', async () => { + const mockAdapter = makeMockStorageAdapter(); + await seedAdapter(mockAdapter, { + 'com.example.test': { + manifest: validManifest, + subclusterId: 'subcluster-123', + installedAt: 1000, + }, + }); + const controller = await CapletController.make(config, { + adapter: mockAdapter, + launchSubcluster: mockLaunchSubcluster, + terminateSubcluster: mockTerminateSubcluster, + }); + + await controller.uninstall('com.example.test'); + + expect(mockLogger.info).toHaveBeenCalledWith( + 'Uninstalling caplet: com.example.test', + ); + expect(mockLogger.info).toHaveBeenCalledWith( + 'Caplet com.example.test uninstalled', + ); + }); + }); + + describe('list', () => { + it('returns empty array when no caplets installed', async () => { + const mockAdapter = makeMockStorageAdapter(); + const controller = await CapletController.make(config, { + adapter: mockAdapter, + launchSubcluster: mockLaunchSubcluster, + terminateSubcluster: mockTerminateSubcluster, + }); + + const result = await controller.list(); + + expect(result).toStrictEqual([]); + }); + + it('returns all installed caplets', async () => { + const manifest2: CapletManifest = { + ...validManifest, + id: 'com.example.test2', + name: 'Test Caplet 2', + }; + const mockAdapter = makeMockStorageAdapter(); + await seedAdapter(mockAdapter, { + 'com.example.test': { + manifest: validManifest, + subclusterId: 'subcluster-1', + installedAt: 1000, + }, + 'com.example.test2': { + manifest: manifest2, + subclusterId: 'subcluster-2', + installedAt: 2000, + }, + }); + const controller = await CapletController.make(config, { + adapter: mockAdapter, + launchSubcluster: mockLaunchSubcluster, + terminateSubcluster: mockTerminateSubcluster, + }); + + const result = await controller.list(); + + expect(result).toHaveLength(2); + expect(result).toContainEqual({ + manifest: validManifest, + subclusterId: 'subcluster-1', + installedAt: 1000, + }); + expect(result).toContainEqual({ + manifest: manifest2, + subclusterId: 'subcluster-2', + installedAt: 2000, + }); + }); + }); + + describe('get', () => { + it('returns caplet if exists', async () => { + const mockAdapter = makeMockStorageAdapter(); + await seedAdapter(mockAdapter, { + 'com.example.test': { + manifest: validManifest, + subclusterId: 'subcluster-123', + installedAt: 1705320000000, + }, + }); + const controller = await CapletController.make(config, { + adapter: mockAdapter, + launchSubcluster: mockLaunchSubcluster, + terminateSubcluster: mockTerminateSubcluster, + }); + + const result = await controller.get('com.example.test'); + + expect(result).toStrictEqual({ + manifest: validManifest, + subclusterId: 'subcluster-123', + installedAt: 1705320000000, + }); + }); + + it('returns undefined if caplet not found', async () => { + const mockAdapter = makeMockStorageAdapter(); + const controller = await CapletController.make(config, { + adapter: mockAdapter, + launchSubcluster: mockLaunchSubcluster, + terminateSubcluster: mockTerminateSubcluster, + }); + + const result = await controller.get('com.example.notfound'); + + expect(result).toBeUndefined(); + }); + }); + + describe('getByService', () => { + it('returns caplet providing the service', async () => { + const mockAdapter = makeMockStorageAdapter(); + await seedAdapter(mockAdapter, { + 'com.example.test': { + manifest: validManifest, + subclusterId: 'subcluster-123', + installedAt: 1000, + }, + }); + const controller = await CapletController.make(config, { + adapter: mockAdapter, + launchSubcluster: mockLaunchSubcluster, + terminateSubcluster: mockTerminateSubcluster, + }); + + const result = await controller.getByService('signer'); + + expect(result).toBeDefined(); + expect(result?.manifest.id).toBe('com.example.test'); + }); + + it('returns undefined if no caplet provides the service', async () => { + const mockAdapter = makeMockStorageAdapter(); + await seedAdapter(mockAdapter, { + 'com.example.test': { + manifest: validManifest, + subclusterId: 'subcluster-123', + installedAt: 1000, + }, + }); + const controller = await CapletController.make(config, { + adapter: mockAdapter, + launchSubcluster: mockLaunchSubcluster, + terminateSubcluster: mockTerminateSubcluster, + }); + + const result = await controller.getByService('unknown-service'); + + expect(result).toBeUndefined(); + }); + + it('returns a matching caplet when multiple provide the service', async () => { + const manifest2: CapletManifest = { + ...validManifest, + id: 'com.example.test2', + name: 'Test Caplet 2', + providedServices: ['signer', 'verifier'], + }; + const mockAdapter = makeMockStorageAdapter(); + await seedAdapter(mockAdapter, { + 'com.example.test': { + manifest: validManifest, + subclusterId: 'subcluster-1', + installedAt: 1000, + }, + 'com.example.test2': { + manifest: manifest2, + subclusterId: 'subcluster-2', + installedAt: 2000, + }, + }); + const controller = await CapletController.make(config, { + adapter: mockAdapter, + launchSubcluster: mockLaunchSubcluster, + terminateSubcluster: mockTerminateSubcluster, + }); + + const result = await controller.getByService('signer'); + + // Returns a match (object key order is not guaranteed) + expect(result?.manifest.providedServices).toContain('signer'); + }); + }); +}); diff --git a/packages/omnium-gatherum/src/controllers/caplet/caplet-controller.ts b/packages/omnium-gatherum/src/controllers/caplet/caplet-controller.ts new file mode 100644 index 000000000..3f7a062d4 --- /dev/null +++ b/packages/omnium-gatherum/src/controllers/caplet/caplet-controller.ts @@ -0,0 +1,288 @@ +import { makeDefaultExo } from '@metamask/kernel-utils/exo'; +import type { Logger } from '@metamask/logger'; +import type { ClusterConfig } from '@metamask/ocap-kernel'; + +import type { + CapletId, + CapletManifest, + InstalledCaplet, + InstallResult, + LaunchResult, +} from './types.ts'; +import { isCapletManifest } from './types.ts'; +import { Controller } from '../base-controller.ts'; +import type { ControllerConfig } from '../base-controller.ts'; +import { ControllerStorage } from '../storage/controller-storage.ts'; +import type { StorageAdapter } from '../storage/types.ts'; + +/** + * Caplet controller persistent state. + * This is the shape of the state managed by the CapletController + * through the ControllerStorage abstraction. + */ +export type CapletControllerState = { + /** Installed caplets keyed by caplet ID */ + caplets: Record; +}; + +/** + * Methods exposed by the CapletController. + */ +export type CapletControllerFacet = { + /** + * Install a caplet. + * + * @param manifest - The caplet manifest. + * @param _bundle - The caplet bundle (currently unused, bundle loaded from bundleSpec). + * @returns The installation result. + */ + install: ( + manifest: CapletManifest, + _bundle?: unknown, + ) => Promise; + + /** + * Uninstall a caplet. + * + * @param capletId - The ID of the caplet to uninstall. + */ + uninstall: (capletId: CapletId) => Promise; + + /** + * List all installed caplets. + * + * @returns Array of installed caplets. + */ + list: () => Promise; + + /** + * Get a specific installed caplet. + * + * @param capletId - The caplet ID. + * @returns The installed caplet or undefined if not found. + */ + get: (capletId: CapletId) => Promise; + + /** + * Find a caplet that provides a specific service. + * + * @param serviceName - The service name to search for. + * @returns The installed caplet or undefined if not found. + */ + getByService: (serviceName: string) => Promise; +}; + +/** + * Dependencies for the CapletController. + * These are attenuated - only the methods needed are provided. + */ +export type CapletControllerDeps = { + /** Storage adapter for creating controller storage */ + adapter: StorageAdapter; + /** Launch a subcluster for a caplet */ + launchSubcluster: (config: ClusterConfig) => Promise; + /** Terminate a caplet's subcluster */ + terminateSubcluster: (subclusterId: string) => Promise; +}; + +/** + * Controller for managing caplet lifecycle. + * + * The CapletController manages: + * - Installing caplets (validating manifest, launching subcluster, storing metadata) + * - Uninstalling caplets (terminating subcluster, removing metadata) + * - Querying installed caplets + */ +export class CapletController extends Controller< + 'CapletController', + CapletControllerState, + CapletControllerFacet +> { + readonly #launchSubcluster: (config: ClusterConfig) => Promise; + + readonly #terminateSubcluster: (subclusterId: string) => Promise; + + /** + * Private constructor - use static create() method. + * + * @param storage - ControllerStorage for caplet state. + * @param logger - Logger instance. + * @param launchSubcluster - Function to launch a subcluster. + * @param terminateSubcluster - Function to terminate a subcluster. + */ + // eslint-disable-next-line no-restricted-syntax -- TypeScript doesn't support # for constructors + private constructor( + storage: ControllerStorage, + logger: Logger, + launchSubcluster: (config: ClusterConfig) => Promise, + terminateSubcluster: (subclusterId: string) => Promise, + ) { + super('CapletController', storage, logger); + this.#launchSubcluster = launchSubcluster; + this.#terminateSubcluster = terminateSubcluster; + harden(this); + } + + /** + * Create a CapletController and return its public methods. + * + * @param config - Controller configuration. + * @param deps - Controller dependencies (attenuated for POLA). + * @returns A hardened CapletController exo. + */ + static async make( + config: ControllerConfig, + deps: CapletControllerDeps, + ): Promise { + // Create storage internally + const storage = await ControllerStorage.make({ + namespace: 'caplet', + adapter: deps.adapter, + defaultState: { caplets: {} }, + logger: config.logger.subLogger({ tags: ['storage'] }), + }); + + const controller = new CapletController( + storage, + config.logger, + deps.launchSubcluster, + deps.terminateSubcluster, + ); + return controller.makeFacet(); + } + + /** + * Returns the hardened exo with public methods. + * + * @returns A hardened exo object with the controller's public methods. + */ + makeFacet(): CapletControllerFacet { + return makeDefaultExo('CapletController', { + install: async ( + manifest: CapletManifest, + _bundle?: unknown, + ): Promise => { + return this.#install(manifest, _bundle); + }, + uninstall: async (capletId: CapletId): Promise => { + return this.#uninstall(capletId); + }, + list: async (): Promise => { + return this.#list(); + }, + get: async (capletId: CapletId): Promise => { + return this.#get(capletId); + }, + getByService: async ( + serviceName: string, + ): Promise => { + return this.#getByService(serviceName); + }, + }); + } + + /** + * Install a caplet. + * + * @param manifest - The caplet manifest. + * @param _bundle - The caplet bundle (currently unused). + * @returns The installation result. + */ + async #install( + manifest: CapletManifest, + _bundle?: unknown, + ): Promise { + const { id } = manifest; + this.logger.info(`Installing caplet: ${id}`); + + // Validate manifest + if (!isCapletManifest(manifest)) { + throw new Error(`Invalid caplet manifest for ${id}`); + } + + // Check if already installed + if (this.state.caplets[id] !== undefined) { + throw new Error(`Caplet ${id} is already installed`); + } + + // Create cluster config for this caplet + const clusterConfig: ClusterConfig = { + bootstrap: id, + vats: { + [id]: { + bundleSpec: manifest.bundleSpec, + }, + }, + }; + + // Launch subcluster + const { subclusterId } = await this.#launchSubcluster(clusterConfig); + + this.update((draft) => { + draft.caplets[id] = { + manifest, + subclusterId, + installedAt: Date.now(), + }; + }); + + this.logger.info(`Caplet ${id} installed with subcluster ${subclusterId}`); + return { capletId: id, subclusterId }; + } + + /** + * Uninstall a caplet. + * + * @param capletId - The ID of the caplet to uninstall. + */ + async #uninstall(capletId: CapletId): Promise { + this.logger.info(`Uninstalling caplet: ${capletId}`); + + const caplet = this.state.caplets[capletId]; + if (caplet === undefined) { + throw new Error(`Caplet ${capletId} not found`); + } + + // Terminate the subcluster + await this.#terminateSubcluster(caplet.subclusterId); + + this.update((draft) => { + delete draft.caplets[capletId]; + }); + + this.logger.info(`Caplet ${capletId} uninstalled`); + } + + /** + * Get all installed caplets. + * + * @returns Array of all installed caplets. + */ + #list(): InstalledCaplet[] { + return Object.values(this.state.caplets); + } + + /** + * Get an installed caplet by ID. + * + * @param capletId - The caplet ID to retrieve. + * @returns The installed caplet or undefined if not found. + */ + #get(capletId: CapletId): InstalledCaplet | undefined { + return this.state.caplets[capletId]; + } + + /** + * Find a caplet that provides a specific service. + * + * @param serviceName - The service name to search for. + * @returns The installed caplet or undefined if not found. + */ + #getByService(serviceName: string): InstalledCaplet | undefined { + const caplets = this.#list(); + return caplets.find((caplet: InstalledCaplet) => + caplet.manifest.providedServices.includes(serviceName), + ); + } +} +harden(CapletController); diff --git a/packages/omnium-gatherum/src/controllers/caplet/index.ts b/packages/omnium-gatherum/src/controllers/caplet/index.ts new file mode 100644 index 000000000..af216b869 --- /dev/null +++ b/packages/omnium-gatherum/src/controllers/caplet/index.ts @@ -0,0 +1,23 @@ +export type { + CapletId, + SemVer, + CapletManifest, + InstalledCaplet, + InstallResult, + LaunchResult, +} from './types.ts'; +export { + isCapletId, + isSemVer, + isCapletManifest, + assertCapletManifest, + CapletIdStruct, + SemVerStruct, + CapletManifestStruct, +} from './types.ts'; +export type { + CapletControllerFacet, + CapletControllerDeps, + CapletControllerState, +} from './caplet-controller.ts'; +export { CapletController } from './caplet-controller.ts'; diff --git a/packages/omnium-gatherum/src/controllers/caplet/types.test.ts b/packages/omnium-gatherum/src/controllers/caplet/types.test.ts new file mode 100644 index 000000000..a48da144a --- /dev/null +++ b/packages/omnium-gatherum/src/controllers/caplet/types.test.ts @@ -0,0 +1,138 @@ +import { describe, it, expect } from 'vitest'; + +import { + isCapletId, + isSemVer, + isCapletManifest, + assertCapletManifest, +} from './types.ts'; + +describe('isCapletId', () => { + it.each([ + ['com.example.test', true], + ['simple', true], + ['bitcoin-signer', true], + ['test_caplet', true], + ['My-Caplet', true], + ['123', true], + ['a.b.c.d', true], + ])('validates "%s" as %s', (value, expected) => { + expect(isCapletId(value)).toBe(expected); + }); + + it.each([ + ['', false], // Empty + ['has space', false], // Whitespace + ['has\ttab', false], // Tab + ['has\nnewline', false], // Newline + ['café', false], // Non-ASCII + ['🎉', false], // Emoji + [123, false], // Not a string + [null, false], + [undefined, false], + [{}, false], + ])('rejects %s', (value, expected) => { + expect(isCapletId(value)).toBe(expected); + }); +}); + +describe('isSemVer', () => { + it.each([ + ['1.0.0', true], + ['0.0.1', true], + ['10.20.30', true], + ['1.0.0-alpha', true], + ['1.0.0-alpha.1', true], + ['0.0.0', true], + ['999.999.999', true], + ['1.2.3-0', true], + ])('validates "%s" as %s', (value, expected) => { + expect(isSemVer(value)).toBe(expected); + }); + + it.each([ + ['1.0', false], + ['1', false], + ['v1.0.0', false], // No 'v' prefix + ['1.0.0.0', false], + ['', false], + ['not-a-version', false], + ['1.0.0+build.123', false], // Build metadata not supported (semver strips it) + ['1.0.0-beta+build', false], // Build metadata not supported + [123, false], + [null, false], + [undefined, false], + ])('rejects %s', (value, expected) => { + expect(isSemVer(value)).toBe(expected); + }); +}); + +describe('isCapletManifest', () => { + const validManifest = { + id: 'com.example.test', + name: 'Test Caplet', + version: '1.0.0', + bundleSpec: 'https://example.com/bundle.json', + requestedServices: ['keyring'], + providedServices: ['signer'], + }; + + it('validates a complete manifest', () => { + expect(isCapletManifest(validManifest)).toBe(true); + }); + + it('validates a manifest with empty service arrays', () => { + const manifest = { + ...validManifest, + requestedServices: [], + providedServices: [], + }; + expect(isCapletManifest(manifest)).toBe(true); + }); + + it('rejects manifest with invalid id', () => { + expect(isCapletManifest({ ...validManifest, id: 'has space' })).toBe(false); + }); + + it('rejects manifest with invalid version', () => { + expect(isCapletManifest({ ...validManifest, version: '1.0' })).toBe(false); + }); + + it('rejects manifest missing required field', () => { + const { name: _name, ...missingName } = validManifest; + expect(isCapletManifest(missingName)).toBe(false); + }); + + it('rejects null', () => { + expect(isCapletManifest(null)).toBe(false); + }); + + it('rejects non-object', () => { + expect(isCapletManifest('string')).toBe(false); + }); +}); + +describe('assertCapletManifest', () => { + const validManifest = { + id: 'com.example.test', + name: 'Test Caplet', + version: '1.0.0', + bundleSpec: 'https://example.com/bundle.json', + requestedServices: [], + providedServices: [], + }; + + it('does not throw for valid manifest', () => { + expect(() => assertCapletManifest(validManifest)).not.toThrow(); + }); + + it('throws for invalid manifest', () => { + expect(() => assertCapletManifest({ id: '' })).toThrow( + 'Invalid CapletManifest', + ); + }); + + it('throws for null', () => { + expect(() => assertCapletManifest(null)).toThrow('Invalid CapletManifest'); + }); +}); diff --git a/packages/omnium-gatherum/src/controllers/caplet/types.ts b/packages/omnium-gatherum/src/controllers/caplet/types.ts new file mode 100644 index 000000000..68a3ff10d --- /dev/null +++ b/packages/omnium-gatherum/src/controllers/caplet/types.ts @@ -0,0 +1,109 @@ +import { array, define, is, object, string } from '@metamask/superstruct'; +import type { Infer } from '@metamask/superstruct'; +import semverValid from 'semver/functions/valid'; + +/** + * Unique identifier for a Caplet (any non-empty ASCII string without whitespace). + */ +export type CapletId = string; + +/** + * Validate CapletId format. + * Requires non-empty ASCII string with no whitespace. + * + * @param value - The value to validate. + * @returns True if valid CapletId format. + */ +export const isCapletId = (value: unknown): value is CapletId => + typeof value === 'string' && + value.length > 0 && + // eslint-disable-next-line no-control-regex + /^[\x00-\x7F]+$/u.test(value) && // ASCII only + !/\s/u.test(value); // No whitespace + +export const CapletIdStruct = define('CapletId', isCapletId); + +/** + * Semantic version string (e.g., "1.0.0"). + */ +export type SemVer = string; + +/** + * Validate SemVer format using the semver package. + * Requires strict format without 'v' prefix (e.g., "1.0.0" not "v1.0.0"). + * + * @param value - The value to validate. + * @returns True if valid SemVer format. + */ +export const isSemVer = (value: unknown): value is SemVer => + typeof value === 'string' && + // semver.valid() is lenient and strips 'v' prefix, so check that cleaned value equals original + semverValid(value) === value; + +export const SemVerStruct = define('SemVer', isSemVer); + +/** + * Superstruct schema for validating CapletManifest objects. + */ +export const CapletManifestStruct = object({ + id: CapletIdStruct, + name: string(), + version: SemVerStruct, + bundleSpec: string(), + requestedServices: array(string()), + providedServices: array(string()), +}); + +/** + * Metadata that defines a Caplet's identity, dependencies, and capabilities. + */ +export type CapletManifest = Infer; + +/** + * Type guard for CapletManifest validation. + * + * @param value - The value to validate. + * @returns True if the value is a valid CapletManifest. + */ +export const isCapletManifest = (value: unknown): value is CapletManifest => + is(value, CapletManifestStruct); + +/** + * Assert that a value is a valid CapletManifest. + * + * @param value - The value to validate. + * @throws If the value is not a valid CapletManifest. + */ +export function assertCapletManifest( + value: unknown, +): asserts value is CapletManifest { + if (!isCapletManifest(value)) { + throw new Error('Invalid CapletManifest'); + } +} + +/** + * Record for an installed Caplet. + * Combines manifest with runtime identifiers. + */ +export type InstalledCaplet = { + manifest: CapletManifest; + subclusterId: string; + installedAt: number; +}; + +/** + * Result of installing a Caplet. + */ +export type InstallResult = { + capletId: CapletId; + subclusterId: string; +}; + +/** + * Result of launching a subcluster. + * This is the interface expected by CapletController's deps. + */ +export type LaunchResult = { + subclusterId: string; +}; diff --git a/packages/omnium-gatherum/src/controllers/facet.test.ts b/packages/omnium-gatherum/src/controllers/facet.test.ts new file mode 100644 index 000000000..7cb784897 --- /dev/null +++ b/packages/omnium-gatherum/src/controllers/facet.test.ts @@ -0,0 +1,125 @@ +import { describe, it, expect, vi } from 'vitest'; + +import { makeFacet } from './facet.ts'; + +describe('makeFacet', () => { + const makeSourceObject = () => ({ + method1: vi.fn().mockReturnValue('result1'), + method2: vi.fn().mockReturnValue('result2'), + method3: vi.fn().mockReturnValue('result3'), + asyncMethod: vi.fn().mockResolvedValue('asyncResult'), + }); + + it('creates a facet with only specified methods', () => { + const source = makeSourceObject(); + + const facet = makeFacet('TestFacet', source, ['method1', 'method2']); + + expect(facet.method1).toBeDefined(); + expect(facet.method2).toBeDefined(); + expect((facet as Record).method3).toBeUndefined(); + expect((facet as Record).asyncMethod).toBeUndefined(); + }); + + it('facet methods call the source methods', () => { + const source = makeSourceObject(); + + const facet = makeFacet('TestFacet', source, ['method1']); + facet.method1(); + + expect(source.method1).toHaveBeenCalledOnce(); + }); + + it('facet methods return the same result as source', () => { + const source = makeSourceObject(); + + const facet = makeFacet('TestFacet', source, ['method1']); + const result = facet.method1(); + + expect(result).toBe('result1'); + }); + + it('facet methods pass arguments to source', () => { + const source = makeSourceObject(); + + const facet = makeFacet('TestFacet', source, ['method1']); + facet.method1('arg1', 'arg2'); + + expect(source.method1).toHaveBeenCalledWith('arg1', 'arg2'); + }); + + it('works with async methods', async () => { + const source = makeSourceObject(); + + const facet = makeFacet('TestFacet', source, ['asyncMethod']); + const result = await facet.asyncMethod(); + + expect(result).toBe('asyncResult'); + expect(source.asyncMethod).toHaveBeenCalledOnce(); + }); + + it('creates facet with single method', () => { + const source = makeSourceObject(); + + const facet = makeFacet('SingleMethodFacet', source, ['method1']); + + expect(facet.method1).toBeDefined(); + // Verify only the specified method is accessible + expect((facet as Record).method2).toBeUndefined(); + expect((facet as Record).method3).toBeUndefined(); + }); + + it('creates facet with all methods', () => { + const source = makeSourceObject(); + + const facet = makeFacet('AllMethodsFacet', source, [ + 'method1', + 'method2', + 'method3', + 'asyncMethod', + ]); + + expect(facet.method1).toBeDefined(); + expect(facet.method2).toBeDefined(); + expect(facet.method3).toBeDefined(); + expect(facet.asyncMethod).toBeDefined(); + }); + + it('throws when method does not exist on source', () => { + const source = makeSourceObject(); + + expect(() => + makeFacet('TestFacet', source, ['nonExistent' as keyof typeof source]), + ).toThrow( + "makeFacet: Method 'nonExistent' not found on source or is not a function", + ); + }); + + it('throws when property is not a function', () => { + const source = { + method1: vi.fn(), + notAMethod: 'string value', + }; + + expect(() => + // @ts-expect-error Destructive testing + makeFacet('TestFacet', source, ['notAMethod' as keyof typeof source]), + ).toThrow( + "makeFacet: Method 'notAMethod' not found on source or is not a function", + ); + }); + + it('preserves this context when methods use it', () => { + const source = { + value: 42, + getValue(this: { value: number }): number { + return this.value; + }, + }; + + const facet = makeFacet('TestFacet', source, ['getValue']); + const result = facet.getValue(); + + expect(result).toBe(42); + }); +}); diff --git a/packages/omnium-gatherum/src/controllers/facet.ts b/packages/omnium-gatherum/src/controllers/facet.ts new file mode 100644 index 000000000..1825ceebd --- /dev/null +++ b/packages/omnium-gatherum/src/controllers/facet.ts @@ -0,0 +1,71 @@ +import type { Methods } from '@endo/exo'; +import { makeDefaultExo } from '@metamask/kernel-utils/exo'; + +/** + * Extract keys from Source that are callable functions. + * Filters to string | symbol to match RemotableMethodName from @endo/pass-style. + */ +type MethodKeys = { + [Key in keyof Source]: Source[Key] extends CallableFunction ? Key : never; +}[keyof Source] & + (string | symbol); + +type BoundMethod = Func extends CallableFunction + ? OmitThisParameter + : never; + +type FacetMethods> = Methods & { + [Key in MethodNames]: BoundMethod; +}; + +/** + * Create an attenuated facet of a source object that exposes only specific methods. + * + * This enforces POLA (Principle of Least Authority) by allowing Controller A + * to receive only the methods it needs from Controller B. + * + * @param name - Name for the facet (used in debugging/logging). + * @param source - The source object containing methods. + * @param methodNames - Array of method names to expose. + * @returns A hardened facet exo with only the specified methods. + * @example + * ```typescript + * // StorageController exposes full interface internally + * const storageController = makeStorageController(config); + * + * // CapletController only needs get/set, not clear/getAll + * const storageFacet = makeFacet('CapletStorage', storageController, ['get', 'set']); + * const capletController = CapletController.make({ storage: storageFacet }); + * ``` + */ +export function makeFacet< + Source extends Record, + MethodNames extends MethodKeys, +>( + name: string, + source: Source, + methodNames: readonly MethodNames[], +): FacetMethods { + const methods: Partial> = {}; + + for (const methodName of methodNames) { + const method = source[methodName]; + if (typeof method !== 'function') { + throw new Error( + `makeFacet: Method '${String( + methodName, + )}' not found on source or is not a function`, + ); + } + // Bind the method to preserve 'this' context if needed + methods[methodName] = (method as CallableFunction).bind( + source, + ) as BoundMethod as FacetMethods< + Source, + MethodNames + >[MethodNames]; + } + + return makeDefaultExo(name, methods as FacetMethods); +} +harden(makeFacet); diff --git a/packages/omnium-gatherum/src/controllers/index.ts b/packages/omnium-gatherum/src/controllers/index.ts new file mode 100644 index 000000000..120d56561 --- /dev/null +++ b/packages/omnium-gatherum/src/controllers/index.ts @@ -0,0 +1,38 @@ +// Base controller +export { Controller } from './base-controller.ts'; +export type { ControllerConfig, ControllerMethods, FacetOf } from './types.ts'; +export { makeFacet } from './facet.ts'; + +// Storage +export type { + NamespacedStorage, + StorageAdapter, + ControllerStorageConfig, +} from './storage/index.ts'; +export { + makeChromeStorageAdapter, + ControllerStorage, +} from './storage/index.ts'; + +// Caplet +export type { + CapletId, + SemVer, + CapletManifest, + InstalledCaplet, + InstallResult, + LaunchResult, + CapletControllerState, + CapletControllerFacet, + CapletControllerDeps, +} from './caplet/index.ts'; +export { + isCapletId, + isSemVer, + isCapletManifest, + assertCapletManifest, + CapletIdStruct, + SemVerStruct, + CapletManifestStruct, + CapletController, +} from './caplet/index.ts'; diff --git a/packages/omnium-gatherum/src/controllers/storage/chrome-storage.test.ts b/packages/omnium-gatherum/src/controllers/storage/chrome-storage.test.ts new file mode 100644 index 000000000..403fe7dcb --- /dev/null +++ b/packages/omnium-gatherum/src/controllers/storage/chrome-storage.test.ts @@ -0,0 +1,132 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; + +import { makeChromeStorageAdapter } from './chrome-storage.ts'; + +describe('makeChromeStorageAdapter', () => { + const mockStorage = { + get: vi.fn().mockResolvedValue({}), + set: vi.fn(), + remove: vi.fn(), + }; + + beforeEach(() => { + mockStorage.get.mockResolvedValue({}); + }); + + describe('get', () => { + it('returns value for existing key', async () => { + mockStorage.get.mockResolvedValue({ testKey: 'testValue' }); + + const adapter = makeChromeStorageAdapter( + mockStorage as unknown as chrome.storage.StorageArea, + ); + const result = await adapter.get('testKey'); + + expect(result).toBe('testValue'); + expect(mockStorage.get).toHaveBeenCalledWith('testKey'); + }); + + it('returns undefined for non-existent key', async () => { + mockStorage.get.mockResolvedValue({}); + + const adapter = makeChromeStorageAdapter( + mockStorage as unknown as chrome.storage.StorageArea, + ); + const result = await adapter.get('nonExistent'); + + expect(result).toBeUndefined(); + }); + + it('returns complex objects', async () => { + const complexValue = { nested: { data: [1, 2, 3] } }; + mockStorage.get.mockResolvedValue({ complex: complexValue }); + + const adapter = makeChromeStorageAdapter( + mockStorage as unknown as chrome.storage.StorageArea, + ); + const result = await adapter.get('complex'); + + expect(result).toStrictEqual(complexValue); + }); + }); + + describe('set', () => { + it('sets a value', async () => { + const adapter = makeChromeStorageAdapter( + mockStorage as unknown as chrome.storage.StorageArea, + ); + await adapter.set('key', 'value'); + + expect(mockStorage.set).toHaveBeenCalledWith({ key: 'value' }); + }); + + it('sets complex objects', async () => { + const complexValue = { nested: { data: [1, 2, 3] } }; + + const adapter = makeChromeStorageAdapter( + mockStorage as unknown as chrome.storage.StorageArea, + ); + await adapter.set('complex', complexValue); + + expect(mockStorage.set).toHaveBeenCalledWith({ complex: complexValue }); + }); + }); + + describe('delete', () => { + it('deletes a key', async () => { + const adapter = makeChromeStorageAdapter( + mockStorage as unknown as chrome.storage.StorageArea, + ); + await adapter.delete('keyToDelete'); + + expect(mockStorage.remove).toHaveBeenCalledWith('keyToDelete'); + }); + }); + + describe('keys', () => { + it('returns all keys when no prefix provided', async () => { + mockStorage.get.mockResolvedValue({ + key1: 'value1', + key2: 'value2', + other: 'value3', + }); + + const adapter = makeChromeStorageAdapter( + mockStorage as unknown as chrome.storage.StorageArea, + ); + const result = await adapter.keys(); + + expect(result).toStrictEqual(['key1', 'key2', 'other']); + expect(mockStorage.get).toHaveBeenCalledWith(null); + }); + + it('filters keys by prefix', async () => { + mockStorage.get.mockResolvedValue({ + 'prefix.key1': 'value1', + 'prefix.key2': 'value2', + other: 'value3', + }); + + const adapter = makeChromeStorageAdapter( + mockStorage as unknown as chrome.storage.StorageArea, + ); + const result = await adapter.keys('prefix.'); + + expect(result).toStrictEqual(['prefix.key1', 'prefix.key2']); + }); + + it('returns empty array when no keys match prefix', async () => { + mockStorage.get.mockResolvedValue({ + key1: 'value1', + key2: 'value2', + }); + + const adapter = makeChromeStorageAdapter( + mockStorage as unknown as chrome.storage.StorageArea, + ); + const result = await adapter.keys('nonexistent.'); + + expect(result).toStrictEqual([]); + }); + }); +}); diff --git a/packages/omnium-gatherum/src/controllers/storage/chrome-storage.ts b/packages/omnium-gatherum/src/controllers/storage/chrome-storage.ts new file mode 100644 index 000000000..4c0134757 --- /dev/null +++ b/packages/omnium-gatherum/src/controllers/storage/chrome-storage.ts @@ -0,0 +1,38 @@ +import type { Json } from '@metamask/utils'; + +import type { StorageAdapter } from './types.ts'; + +/** + * Create a storage adapter backed by Chrome Storage API. + * + * @param storage - The Chrome storage area to use (defaults to chrome.storage.local). + * @returns A hardened StorageAdapter instance. + */ +export function makeChromeStorageAdapter( + storage: chrome.storage.StorageArea = chrome.storage.local, +): StorageAdapter { + return harden({ + async get(key: string): Promise { + const result = await storage.get(key); + return result[key] as Value | undefined; + }, + + async set(key: string, value: Json): Promise { + await storage.set({ [key]: value }); + }, + + async delete(key: string): Promise { + await storage.remove(key); + }, + + async keys(prefix?: string): Promise { + const all = await storage.get(null); + const allKeys = Object.keys(all); + if (prefix === undefined) { + return allKeys; + } + return allKeys.filter((k) => k.startsWith(prefix)); + }, + }); +} +harden(makeChromeStorageAdapter); diff --git a/packages/omnium-gatherum/src/controllers/storage/controller-storage.test.ts b/packages/omnium-gatherum/src/controllers/storage/controller-storage.test.ts new file mode 100644 index 000000000..93ea2b5c2 --- /dev/null +++ b/packages/omnium-gatherum/src/controllers/storage/controller-storage.test.ts @@ -0,0 +1,513 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; + +import { ControllerStorage } from './controller-storage.ts'; +import type { StorageAdapter } from './types.ts'; + +type TestState = { + installed: string[]; + manifests: Record; + count: number; +}; + +describe('ControllerStorage', () => { + const mockAdapter: StorageAdapter = { + get: vi.fn(), + set: vi.fn(), + delete: vi.fn(), + keys: vi.fn(), + }; + + const mockLogger = { + info: vi.fn(), + warn: vi.fn(), + error: vi.fn(), + debug: vi.fn(), + subLogger: vi.fn().mockReturnThis(), + }; + + const defaultState: TestState = { + installed: [], + manifests: {}, + count: 0, + }; + + beforeEach(() => { + vi.clearAllMocks(); + vi.mocked(mockAdapter.get).mockResolvedValue(undefined); + vi.mocked(mockAdapter.set).mockResolvedValue(undefined); + vi.mocked(mockAdapter.delete).mockResolvedValue(undefined); + vi.mocked(mockAdapter.keys).mockResolvedValue([]); + }); + + describe('initialization', () => { + it('loads existing state from storage on creation', async () => { + vi.mocked(mockAdapter.keys).mockResolvedValue([ + 'test.installed', + 'test.manifests', + ]); + vi.mocked(mockAdapter.get).mockImplementation(async (key: string) => { + if (key === 'test.installed') { + return ['app1']; + } + if (key === 'test.manifests') { + return { app1: { name: 'App 1' } }; + } + return undefined; + }); + + const storage = await ControllerStorage.make({ + namespace: 'test', + adapter: mockAdapter, + defaultState, + logger: mockLogger as never, + debounceMs: 0, + }); + + expect(storage.state.installed).toStrictEqual(['app1']); + expect(storage.state.manifests).toStrictEqual({ + app1: { name: 'App 1' }, + }); + }); + + it('uses defaults for missing keys', async () => { + vi.mocked(mockAdapter.keys).mockResolvedValue(['test.installed']); + vi.mocked(mockAdapter.get).mockImplementation(async (key: string) => { + if (key === 'test.installed') { + return ['existing']; + } + return undefined; + }); + + const storage = await ControllerStorage.make({ + namespace: 'test', + adapter: mockAdapter, + defaultState: { + installed: [] as string[], + manifests: {}, + metadata: { version: 1 }, + }, + logger: mockLogger as never, + debounceMs: 0, + }); + + expect(storage.state.installed).toStrictEqual(['existing']); + expect(storage.state.manifests).toStrictEqual({}); + expect(storage.state.metadata).toStrictEqual({ version: 1 }); + }); + + it('uses all defaults when storage is empty', async () => { + vi.mocked(mockAdapter.keys).mockResolvedValue([]); + + const storage = await ControllerStorage.make({ + namespace: 'test', + adapter: mockAdapter, + defaultState, + logger: mockLogger as never, + debounceMs: 0, + }); + + expect(storage.state.installed).toStrictEqual([]); + expect(storage.state.manifests).toStrictEqual({}); + expect(storage.state.count).toBe(0); + }); + + it('returns hardened state copy', async () => { + const storage = await ControllerStorage.make({ + namespace: 'test', + adapter: mockAdapter, + defaultState: { items: ['original'] as string[] }, + logger: mockLogger as never, + debounceMs: 0, + }); + + // Get a reference to the state + const state1 = storage.state; + + // Modifications to the returned state should not affect the internal state + // (In SES environment, this would throw; in tests, we verify isolation) + try { + (state1 as { items: string[] }).items.push('modified'); + } catch { + // Expected in SES environment + } + + // Get a fresh state - it should still have the original value + const state2 = storage.state; + expect(state2.items).toStrictEqual(['original']); + }); + }); + + describe('state access', () => { + it('provides readonly access to current state', async () => { + vi.mocked(mockAdapter.keys).mockResolvedValue(['ns.count']); + vi.mocked(mockAdapter.get).mockResolvedValue(42); + + const storage = await ControllerStorage.make({ + namespace: 'ns', + adapter: mockAdapter, + defaultState: { count: 0 }, + logger: mockLogger as never, + debounceMs: 0, + }); + + expect(storage.state.count).toBe(42); + }); + }); + + describe('update', () => { + it('persists only modified top-level keys', async () => { + const storage = await ControllerStorage.make({ + namespace: 'test', + adapter: mockAdapter, + defaultState, + logger: mockLogger as never, + debounceMs: 0, + }); + + storage.update((draft) => { + draft.installed.push('new-app'); + // manifests and count not modified + }); + + // Wait for persistence (debounced but set to 0ms) + await new Promise((resolve) => setTimeout(resolve, 10)); + + expect(mockAdapter.set).toHaveBeenCalledTimes(1); + expect(mockAdapter.set).toHaveBeenCalledWith('test.installed', [ + 'new-app', + ]); + }); + + it('updates in-memory state immediately', async () => { + const storage = await ControllerStorage.make({ + namespace: 'test', + adapter: mockAdapter, + defaultState, + logger: mockLogger as never, + debounceMs: 0, + }); + + storage.update((draft) => { + draft.installed.push('item1'); + }); + + // State updated synchronously + expect(storage.state.installed).toStrictEqual(['item1']); + }); + + it('does not persist when no changes made', async () => { + // Clear any pending operations from previous tests + await new Promise((resolve) => setTimeout(resolve, 15)); + vi.clearAllMocks(); + + const storage = await ControllerStorage.make({ + namespace: 'test', + adapter: mockAdapter, + defaultState, + logger: mockLogger as never, + debounceMs: 0, + }); + + storage.update((draft) => { + // No actual changes + draft.count = 0; + }); + + // Wait for potential persistence + await new Promise((resolve) => setTimeout(resolve, 10)); + + expect(mockAdapter.set).not.toHaveBeenCalled(); + }); + + it('persists multiple modified keys', async () => { + const storage = await ControllerStorage.make({ + namespace: 'test', + adapter: mockAdapter, + defaultState: { a: 1, b: 2, c: 3 }, + logger: mockLogger as never, + debounceMs: 0, + }); + + storage.update((draft) => { + draft.a = 10; + draft.c = 30; + }); + + // Wait for persistence + await new Promise((resolve) => setTimeout(resolve, 10)); + + expect(mockAdapter.set).toHaveBeenCalledTimes(2); + expect(mockAdapter.set).toHaveBeenCalledWith('test.a', 10); + expect(mockAdapter.set).toHaveBeenCalledWith('test.c', 30); + }); + + it('updates state even if persistence fails (fire-and-forget)', async () => { + vi.mocked(mockAdapter.set).mockRejectedValue(new Error('Storage error')); + + const storage = await ControllerStorage.make({ + namespace: 'test', + adapter: mockAdapter, + defaultState, + logger: mockLogger as never, + debounceMs: 0, + }); + + storage.update((draft) => { + draft.count = 100; + }); + + // State updated immediately despite persistence failure + expect(storage.state.count).toBe(100); + + // Wait for persistence attempt + await new Promise((resolve) => setTimeout(resolve, 10)); + + // Error should be logged + expect(mockLogger.error).toHaveBeenCalledWith( + 'Failed to persist state changes:', + expect.any(Error), + ); + }); + + it('handles nested object modifications', async () => { + const storage = await ControllerStorage.make({ + namespace: 'test', + adapter: mockAdapter, + defaultState, + logger: mockLogger as never, + debounceMs: 0, + }); + + storage.update((draft) => { + draft.manifests['new-app'] = { name: 'New App' }; + }); + + // Wait for persistence + await new Promise((resolve) => setTimeout(resolve, 10)); + + expect(mockAdapter.set).toHaveBeenCalledWith('test.manifests', { + 'new-app': { name: 'New App' }, + }); + }); + + it('handles array operations', async () => { + vi.mocked(mockAdapter.keys).mockResolvedValue(['test.installed']); + vi.mocked(mockAdapter.get).mockResolvedValue(['app1', 'app2']); + + const storage = await ControllerStorage.make({ + namespace: 'test', + adapter: mockAdapter, + defaultState, + logger: mockLogger as never, + debounceMs: 0, + }); + + storage.update((draft) => { + draft.installed = draft.installed.filter((id) => id !== 'app1'); + }); + + // Wait for persistence + await new Promise((resolve) => setTimeout(resolve, 10)); + + expect(mockAdapter.set).toHaveBeenCalledWith('test.installed', ['app2']); + }); + + it('handles delete operations on nested objects', async () => { + vi.mocked(mockAdapter.keys).mockResolvedValue(['test.manifests']); + vi.mocked(mockAdapter.get).mockResolvedValue({ + app1: { name: 'App 1' }, + app2: { name: 'App 2' }, + }); + + const storage = await ControllerStorage.make({ + namespace: 'test', + adapter: mockAdapter, + defaultState, + logger: mockLogger as never, + debounceMs: 0, + }); + + storage.update((draft) => { + delete draft.manifests.app1; + }); + + // Wait for persistence + await new Promise((resolve) => setTimeout(resolve, 10)); + + expect(mockAdapter.set).toHaveBeenCalledWith('test.manifests', { + app2: { name: 'App 2' }, + }); + }); + }); + + describe('namespace isolation', () => { + it('uses different prefixes for different namespaces', async () => { + await ControllerStorage.make({ + namespace: 'caplet', + adapter: mockAdapter, + defaultState: { value: 1 }, + logger: mockLogger as never, + debounceMs: 0, + }); + + await ControllerStorage.make({ + namespace: 'service', + adapter: mockAdapter, + defaultState: { value: 2 }, + logger: mockLogger as never, + debounceMs: 0, + }); + + expect(mockAdapter.keys).toHaveBeenCalledWith('caplet.'); + expect(mockAdapter.keys).toHaveBeenCalledWith('service.'); + }); + }); + + describe('debouncing with key accumulation', () => { + it('accumulates modified keys across multiple updates', async () => { + vi.useFakeTimers(); + const storage = await ControllerStorage.make({ + namespace: 'test', + adapter: mockAdapter, + defaultState: { a: 0, b: 0, c: 0 }, + logger: mockLogger as never, + debounceMs: 100, + }); + + // First update: modifies a and b + storage.update((draft) => { + draft.a = 1; + draft.b = 1; + }); + + // Second update at t=50ms: modifies only a + vi.advanceTimersByTime(50); + storage.update((draft) => { + draft.a = 2; + }); + + // Timer should fire at t=100ms (from first update) + vi.advanceTimersByTime(50); + await vi.runAllTimersAsync(); + + // Both a and b should be persisted (accumulated keys) + expect(mockAdapter.set).toHaveBeenCalledWith('test.a', 2); + expect(mockAdapter.set).toHaveBeenCalledWith('test.b', 1); + + vi.useRealTimers(); + }); + + it('does not reset timer on subsequent writes', async () => { + vi.useFakeTimers(); + const storage = await ControllerStorage.make({ + namespace: 'test', + adapter: mockAdapter, + defaultState: { a: 0 }, + logger: mockLogger as never, + debounceMs: 100, + }); + + storage.update((draft) => { + draft.a = 1; + }); + + // Second write at t=90ms (before first timer fires) + vi.advanceTimersByTime(90); + storage.update((draft) => { + draft.a = 2; + }); + + // Timer fires at t=100ms (NOT reset to t=190ms) + vi.advanceTimersByTime(10); + await vi.runAllTimersAsync(); + + expect(mockAdapter.set).toHaveBeenCalledWith('test.a', 2); + + vi.useRealTimers(); + }); + + it('writes immediately when idle > debounceMs', async () => { + vi.useFakeTimers(); + const storage = await ControllerStorage.make({ + namespace: 'test', + adapter: mockAdapter, + defaultState: { a: 0 }, + logger: mockLogger as never, + debounceMs: 100, + }); + + storage.update((draft) => { + draft.a = 1; + }); + await vi.runAllTimersAsync(); + vi.clearAllMocks(); + + // Wait 150ms (> debounceMs) + vi.advanceTimersByTime(150); + + // Next write should be immediate (no debounce) + storage.update((draft) => { + draft.a = 2; + }); + await vi.runAllTimersAsync(); + + expect(mockAdapter.set).toHaveBeenCalledWith('test.a', 2); + + vi.useRealTimers(); + }); + }); + + describe('clear', () => { + it('resets state to default', async () => { + const testDefaultState = { items: [] as string[], count: 0 }; + const storage = await ControllerStorage.make({ + namespace: 'test', + adapter: mockAdapter, + defaultState: testDefaultState, + logger: mockLogger as never, + debounceMs: 0, + }); + + // Modify state + storage.update((draft) => { + draft.items.push('item1'); + draft.count = 1; + }); + + expect(storage.state.items).toStrictEqual(['item1']); + expect(storage.state.count).toBe(1); + + // Clear + storage.clear(); + + expect(storage.state.items).toStrictEqual([]); + expect(storage.state.count).toBe(0); + }); + + it('persists cleared state', async () => { + const clearDefaultState = { a: 0, b: 0 }; + const storage = await ControllerStorage.make({ + namespace: 'test', + adapter: mockAdapter, + defaultState: clearDefaultState, + logger: mockLogger as never, + debounceMs: 0, + }); + + storage.update((draft) => { + draft.a = 5; + draft.b = 10; + }); + + await new Promise((resolve) => setTimeout(resolve, 10)); + vi.clearAllMocks(); + + storage.clear(); + + await new Promise((resolve) => setTimeout(resolve, 10)); + + expect(mockAdapter.set).toHaveBeenCalledWith('test.a', 0); + expect(mockAdapter.set).toHaveBeenCalledWith('test.b', 0); + }); + }); +}); diff --git a/packages/omnium-gatherum/src/controllers/storage/controller-storage.ts b/packages/omnium-gatherum/src/controllers/storage/controller-storage.ts new file mode 100644 index 000000000..a2c1939e9 --- /dev/null +++ b/packages/omnium-gatherum/src/controllers/storage/controller-storage.ts @@ -0,0 +1,317 @@ +import type { Logger } from '@metamask/logger'; +import type { Json } from '@metamask/utils'; +import { enablePatches, produce } from 'immer'; +import type { Patch } from 'immer'; + +import type { StorageAdapter } from './types.ts'; + +// Enable immer patches globally (called once at module load) +enablePatches(); + +// TODO: Add migration utility for converting from per-key storage format +// (e.g., caplet.{id}.manifest) to consolidated state format (caplet.manifests) +// when there is deployed data to migrate. + +/** + * Configuration for creating a ControllerStorage instance. + */ +export type ControllerStorageConfig> = { + /** The namespace prefix for storage keys (e.g., 'caplet') */ + namespace: string; + /** The underlying storage adapter */ + adapter: StorageAdapter; + /** Default state values - used for initialization and type inference */ + defaultState: State; + /** Logger for storage operations */ + logger: Logger; + /** Debounce delay in milliseconds (default: 100, set to 0 for tests) */ + debounceMs?: number; +}; + +/** + * Internal options passed to constructor after async initialization. + */ +type ControllerStorageOptions> = + ControllerStorageConfig & { + /** Initial state loaded from storage */ + initialState: State; + }; + +/** + * ControllerStorage provides a simplified state management interface for controllers. + * + * Features: + * - Flat top-level key mapping: `state.foo` maps to `{namespace}.foo` in storage + * - Immer-based updates with automatic change detection + * - Synchronous state updates with debounced persistence + * - Only modified top-level keys are persisted + * - Fire-and-forget persistence (errors logged but don't rollback state) + * - Eager loading on initialization + * + * @template State - The state object type (must have Json-serializable values) + */ +export class ControllerStorage> { + readonly #adapter: StorageAdapter; + + readonly #prefix: string; + + readonly #defaultState: State; + + readonly #logger: Logger; + + readonly #debounceMs: number; + + #state: State; + + #pendingPersist: ReturnType | null = null; + + readonly #pendingKeys: Set = new Set(); + + #lastWriteTime: number = 0; + + /** + * Private constructor - use static make() factory method. + * + * @param options - Configuration including initial loaded state. + */ + // eslint-disable-next-line no-restricted-syntax -- TypeScript doesn't support # for constructors + private constructor(options: ControllerStorageOptions) { + this.#adapter = options.adapter; + this.#prefix = `${options.namespace}.`; + this.#defaultState = options.defaultState; + this.#logger = options.logger; + this.#debounceMs = options.debounceMs ?? 100; + this.#state = options.initialState; + } + + /** + * Create a ControllerStorage instance for a controller. + * + * This factory function: + * 1. Loads existing state from storage for the namespace + * 2. Merges with defaults (storage values take precedence) + * 3. Returns a hardened ControllerStorage instance + * + * @param config - Configuration including namespace, adapter, and default state. + * @returns Promise resolving to a hardened ControllerStorage instance. + * + * @example + * ```typescript + * const capletState = await ControllerStorage.make({ + * namespace: 'caplet', + * adapter: storageAdapter, + * defaultState: { installed: [], manifests: {} }, + * logger: logger.subLogger({ tags: ['storage'] }), + * }); + * + * // Read state + * console.log(capletState.state.installed); + * + * // Update state (synchronous) + * capletState.update(draft => { + * draft.installed.push('com.example.app'); + * }); + * ``` + */ + static async make>( + config: ControllerStorageConfig, + ): Promise> { + const initialState = await this.#loadState(config); + return harden( + new ControllerStorage({ + ...config, + initialState, + }), + ); + } + + /** + * Load all state from storage, merging with defaults. + * Storage values take precedence over defaults. + * + * @param config - Configuration with adapter, namespace, and defaults. + * @returns The merged state object. + */ + static async #loadState>( + config: ControllerStorageConfig, + ): Promise { + const { namespace, adapter, defaultState } = config; + const prefix = `${namespace}.`; + const allKeys = await adapter.keys(prefix); + + // Start with a copy of defaults + const state = { ...defaultState }; + + // Load and merge values from storage + await Promise.all( + allKeys.map(async (fullKey) => { + const key = fullKey.slice(prefix.length) as keyof State; + const value = await adapter.get(fullKey); + if (value !== undefined) { + state[key] = value as State[keyof State]; + } + }), + ); + + return produce({}, (draft) => { + Object.assign(draft, state); + }) as State; + } + + /** + * Current state (readonly, deeply frozen by immer). + * Access individual properties: `storage.state.installed` + * + * @returns The current readonly state. + */ + get state(): Readonly { + return this.#state; + } + + /** + * Update state using an immer producer function. + * State is updated synchronously in memory. + * Persistence is queued and debounced (fire-and-forget). + * + * @param producer - Function that mutates a draft of the state or returns new state + * + * @example + * ```typescript + * // Mutate draft + * storage.update(draft => { + * draft.installed.push('com.example.app'); + * draft.manifests['com.example.app'] = manifest; + * }); + */ + update(producer: (draft: State) => void | State): void { + // Capture state before operations to avoid race conditions + const stateSnapshot = this.#state; + + // Use immer's produce with patches callback to track changes + let patches: Patch[] = []; + const nextState = produce(stateSnapshot, producer, (patchList) => { + patches = patchList; + }); + + // No changes - nothing to do + if (patches.length === 0) { + return; + } + + // Update in-memory state immediately + this.#state = nextState; + + // Queue debounced persistence (fire-and-forget) + this.#schedulePersist(patches); + } + + /** + * Clear all state and reset to default values. + * Updates state synchronously, persistence is debounced. + */ + clear(): void { + this.update((draft) => { + Object.assign(draft, this.#defaultState); + }); + } + + /** + * Schedule debounced persistence with key accumulation. + * Implements bounded latency (timer not reset) and immediate writes after idle. + * + * @param patches - Immer patches describing changes. + */ + #schedulePersist(patches: Patch[]): void { + const now = Date.now(); + const timeSinceLastWrite = now - this.#lastWriteTime; + this.#lastWriteTime = now; + + const modifiedKeys = this.#getModifiedKeys(patches); + for (const key of modifiedKeys) { + this.#pendingKeys.add(key); + } + + if ( + timeSinceLastWrite > this.#debounceMs && + this.#pendingPersist === null + ) { + this.#flushPendingWrites(); + return; + } + + if (this.#pendingPersist === null) { + this.#pendingPersist = setTimeout(() => { + this.#flushPendingWrites(); + }, this.#debounceMs); + } + // else: timer already running, just accumulate keys, don't reset + } + + /** + * Flush pending writes to storage. + * Captures accumulated keys and persists current state values. + */ + #flushPendingWrites(): void { + if (this.#pendingKeys.size === 0) { + this.#pendingPersist = null; + return; + } + + const keysToWrite = new Set(this.#pendingKeys); + this.#pendingKeys.clear(); + this.#pendingPersist = null; + + // Persist current state values for accumulated keys + this.#persistAccumulatedKeys(this.#state, keysToWrite).catch((error) => { + this.#logger.error('Failed to persist state changes:', error); + }); + } + + /** + * Persist accumulated keys to storage. + * Always persists current state values (last-write-wins). + * + * @param state - The current state to persist from. + * @param keys - Set of top-level keys to persist. + */ + async #persistAccumulatedKeys( + state: State, + keys: Set, + ): Promise { + await Promise.all( + Array.from(keys).map(async (key) => { + const storageKey = this.#buildKey(key); + const value = state[key as keyof State]; + await this.#adapter.set(storageKey, value as Json); + }), + ); + } + + /** + * Extract top-level keys that were modified from immer patches. + * + * @param patches - Array of immer patches describing changes. + * @returns Set of modified top-level keys. + */ + #getModifiedKeys(patches: Patch[]): Set { + const keys = new Set(); + for (const patch of patches) { + // The first element of path is always the top-level key + if (patch.path.length > 0) { + keys.add(String(patch.path[0])); + } + } + return keys; + } + + /** + * Build a storage key from a state property name. + * + * @param stateKey - The state property name. + * @returns The namespaced storage key. + */ + #buildKey(stateKey: string): string { + return `${this.#prefix}${stateKey}`; + } +} +harden(ControllerStorage); diff --git a/packages/omnium-gatherum/src/controllers/storage/index.ts b/packages/omnium-gatherum/src/controllers/storage/index.ts new file mode 100644 index 000000000..8f0382e45 --- /dev/null +++ b/packages/omnium-gatherum/src/controllers/storage/index.ts @@ -0,0 +1,4 @@ +export type { NamespacedStorage, StorageAdapter } from './types.ts'; +export type { ControllerStorageConfig } from './controller-storage.ts'; +export { makeChromeStorageAdapter } from './chrome-storage.ts'; +export { ControllerStorage } from './controller-storage.ts'; diff --git a/packages/omnium-gatherum/src/controllers/storage/types.ts b/packages/omnium-gatherum/src/controllers/storage/types.ts new file mode 100644 index 000000000..dab4a14a4 --- /dev/null +++ b/packages/omnium-gatherum/src/controllers/storage/types.ts @@ -0,0 +1,88 @@ +import type { Json } from '@metamask/utils'; + +/** + * Low-level storage adapter interface. + * Wraps platform-specific storage APIs (e.g., chrome.storage.local). + */ +export type StorageAdapter = { + /** + * Get a value from storage. + * + * @param key - The storage key. + * @returns The stored value, or undefined if not found. + */ + get: (key: string) => Promise; + + /** + * Set a value in storage. + * + * @param key - The storage key. + * @param value - The value to store. + */ + set: (key: string, value: Json) => Promise; + + /** + * Delete a value from storage. + * + * @param key - The storage key. + */ + delete: (key: string) => Promise; + + /** + * Get all keys matching a prefix. + * + * @param prefix - Optional prefix to filter keys. + * @returns Array of matching keys. + */ + keys: (prefix?: string) => Promise; +}; + +/** + * Storage interface bound to a specific namespace. + * Controllers receive this instead of raw storage access. + * Keys are automatically prefixed with the namespace. + */ +export type NamespacedStorage = { + /** + * Get a value from the namespaced storage. + * + * @param key - The key within this namespace. + * @returns The stored value, or undefined if not found. + */ + get: (key: string) => Promise; + + /** + * Set a value in the namespaced storage. + * + * @param key - The key within this namespace. + * @param value - The value to store. + */ + set: (key: string, value: Json) => Promise; + + /** + * Delete a value from the namespaced storage. + * + * @param key - The key within this namespace. + */ + delete: (key: string) => Promise; + + /** + * Check if a key exists in the namespaced storage. + * + * @param key - The key within this namespace. + * @returns True if the key exists. + */ + has: (key: string) => Promise; + + /** + * Get all keys within this namespace. + * + * @returns Array of keys (without namespace prefix). + */ + keys: () => Promise; + + /** + * Clear all values in this namespace. + */ + clear: () => Promise; +}; diff --git a/packages/omnium-gatherum/src/controllers/types.ts b/packages/omnium-gatherum/src/controllers/types.ts new file mode 100644 index 000000000..84f2287e4 --- /dev/null +++ b/packages/omnium-gatherum/src/controllers/types.ts @@ -0,0 +1,19 @@ +import type { Methods } from '@endo/exo'; + +// Re-export from base-controller for backward compatibility +export type { ControllerConfig, ControllerMethods } from './base-controller.ts'; + +/** + * Type helper for defining facet interfaces. + * Extracts a subset of methods from a controller type for POLA attenuation. + * + * @example + * ```typescript + * type StorageReadFacet = FacetOf; + * type StorageWriteFacet = FacetOf; + * ``` + */ +export type FacetOf< + TController extends Methods, + TMethodNames extends keyof TController, +> = Pick; diff --git a/packages/omnium-gatherum/src/global.d.ts b/packages/omnium-gatherum/src/global.d.ts index a275d71d9..7e1d58bf2 100644 --- a/packages/omnium-gatherum/src/global.d.ts +++ b/packages/omnium-gatherum/src/global.d.ts @@ -1,5 +1,11 @@ import type { KernelFacade } from '@metamask/kernel-browser-runtime'; +import type { + CapletManifest, + InstalledCaplet, + InstallResult, +} from './controllers/index.ts'; + // Type declarations for omnium dev console API. declare global { /** @@ -33,6 +39,66 @@ declare global { * ``` */ getKernel: () => Promise; + + /** + * Caplet management API. + */ + caplet: { + /** + * Install a caplet. + * + * @param manifest - The caplet manifest. + * @param bundle - Optional bundle (currently unused). + * @returns The installation result. + * @example + * ```typescript + * const result = await omnium.caplet.install({ + * id: 'com.example.test', + * name: 'Test Caplet', + * version: '1.0.0', + * bundleSpec: '/path/to/bundle.json', + * requestedServices: [], + * providedServices: ['test'], + * }); + * ``` + */ + install: ( + manifest: CapletManifest, + bundle?: unknown, + ) => Promise; + + /** + * Uninstall a caplet. + * + * @param capletId - The ID of the caplet to uninstall. + */ + uninstall: (capletId: string) => Promise; + + /** + * List all installed caplets. + * + * @returns Array of installed caplets. + */ + list: () => Promise; + + /** + * Get a specific installed caplet. + * + * @param capletId - The caplet ID. + * @returns The installed caplet or undefined if not found. + */ + get: (capletId: string) => Promise; + + /** + * Find a caplet that provides a specific service. + * + * @param serviceName - The service name to search for. + * @returns The installed caplet or undefined if not found. + */ + getByService: ( + serviceName: string, + ) => Promise; + }; }; } diff --git a/packages/omnium-gatherum/src/manifest.json b/packages/omnium-gatherum/src/manifest.json index 8f815cecd..653d0b8bd 100644 --- a/packages/omnium-gatherum/src/manifest.json +++ b/packages/omnium-gatherum/src/manifest.json @@ -10,7 +10,7 @@ "action": { "default_popup": "popup.html" }, - "permissions": ["offscreen", "unlimitedStorage"], + "permissions": ["offscreen", "storage", "unlimitedStorage"], "sandbox": { "pages": ["iframe.html"] }, diff --git a/packages/omnium-gatherum/src/types/semver.d.ts b/packages/omnium-gatherum/src/types/semver.d.ts new file mode 100644 index 000000000..9a6ab706d --- /dev/null +++ b/packages/omnium-gatherum/src/types/semver.d.ts @@ -0,0 +1,7 @@ +declare module 'semver/functions/valid' { + function valid( + version: string | null | undefined, + optionsOrLoose?: boolean | { loose?: boolean; includePrerelease?: boolean }, + ): string | null; + export default valid; +} diff --git a/packages/omnium-gatherum/test/e2e/smoke.test.ts b/packages/omnium-gatherum/test/e2e/smoke.test.ts index 96640f725..f2ec0f92d 100644 --- a/packages/omnium-gatherum/test/e2e/smoke.test.ts +++ b/packages/omnium-gatherum/test/e2e/smoke.test.ts @@ -1,7 +1,7 @@ import { test, expect } from '@playwright/test'; import type { Page, BrowserContext } from '@playwright/test'; -import { loadExtension } from '../helpers.ts'; +import { loadExtension } from './utils.ts'; test.describe.configure({ mode: 'serial' }); diff --git a/packages/omnium-gatherum/test/helpers.ts b/packages/omnium-gatherum/test/e2e/utils.ts similarity index 96% rename from packages/omnium-gatherum/test/helpers.ts rename to packages/omnium-gatherum/test/e2e/utils.ts index a8306e37a..1caa88d6d 100644 --- a/packages/omnium-gatherum/test/helpers.ts +++ b/packages/omnium-gatherum/test/e2e/utils.ts @@ -6,7 +6,7 @@ export { sessionPath } from '@ocap/repo-tools/test-utils/extension'; const extensionPath = path.resolve( path.dirname(fileURLToPath(import.meta.url)), - '../dist', + '../../dist', ); export const loadExtension = async (contextId?: string) => { diff --git a/packages/omnium-gatherum/test/utils.ts b/packages/omnium-gatherum/test/utils.ts new file mode 100644 index 000000000..c6294a8ca --- /dev/null +++ b/packages/omnium-gatherum/test/utils.ts @@ -0,0 +1,31 @@ +import type { Json } from '@metamask/utils'; + +import type { StorageAdapter } from '../src/controllers/storage/types.ts'; + +/** + * Create a mock StorageAdapter for testing. + * + * @returns A mock storage adapter backed by an in-memory Map. + */ +export function makeMockStorageAdapter(): StorageAdapter { + const store = new Map(); + + return { + async get(key: string): Promise { + return store.get(key) as Value | undefined; + }, + async set(key: string, value: Json): Promise { + store.set(key, value); + }, + async delete(key: string): Promise { + store.delete(key); + }, + async keys(prefix?: string): Promise { + const allKeys = Array.from(store.keys()); + if (prefix === undefined) { + return allKeys; + } + return allKeys.filter((k) => k.startsWith(prefix)); + }, + }; +} diff --git a/vitest.config.ts b/vitest.config.ts index 07b25111d..35bac3850 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -75,10 +75,10 @@ export default defineConfig({ lines: 100, }, 'packages/extension/**': { - statements: 1.35, + statements: 1.29, functions: 0, branches: 0, - lines: 1.36, + lines: 1.31, }, 'packages/kernel-agents/**': { statements: 88.16, @@ -87,10 +87,10 @@ export default defineConfig({ lines: 88.13, }, 'packages/kernel-browser-runtime/**': { - statements: 84.4, + statements: 84.16, functions: 78.3, branches: 81.11, - lines: 84.63, + lines: 84.4, }, 'packages/kernel-errors/**': { statements: 99.24, @@ -165,10 +165,10 @@ export default defineConfig({ lines: 95.42, }, 'packages/omnium-gatherum/**': { - statements: 4.34, - functions: 4.76, - branches: 0, - lines: 4.41, + statements: 60.5, + functions: 61.44, + branches: 71.11, + lines: 60.42, }, 'packages/remote-iterables/**': { statements: 100, diff --git a/yarn.lock b/yarn.lock index 8fdc13dfa..a41a32194 100644 --- a/yarn.lock +++ b/yarn.lock @@ -3919,6 +3919,7 @@ __metadata: dependencies: "@arethetypeswrong/cli": "npm:^0.17.4" "@endo/eventual-send": "npm:^1.3.4" + "@endo/exo": "npm:^1.5.12" "@metamask/auto-changelog": "npm:^5.3.0" "@metamask/eslint-config": "npm:^15.0.0" "@metamask/eslint-config-nodejs": "npm:^15.0.0" @@ -3928,7 +3929,10 @@ __metadata: "@metamask/kernel-ui": "workspace:^" "@metamask/kernel-utils": "workspace:^" "@metamask/logger": "workspace:^" + "@metamask/ocap-kernel": "workspace:^" "@metamask/streams": "workspace:^" + "@metamask/superstruct": "npm:^3.2.1" + "@metamask/utils": "npm:^11.9.0" "@ocap/cli": "workspace:^" "@ocap/repo-tools": "workspace:^" "@playwright/test": "npm:^1.54.2" @@ -3937,6 +3941,7 @@ __metadata: "@types/chrome": "npm:^0.0.313" "@types/react": "npm:^17.0.11" "@types/react-dom": "npm:^17.0.11" + "@types/semver": "npm:^7.7.1" "@types/webextension-polyfill": "npm:^0" "@typescript-eslint/eslint-plugin": "npm:^8.29.0" "@typescript-eslint/parser": "npm:^8.29.0" @@ -3952,12 +3957,14 @@ __metadata: eslint-plugin-n: "npm:^17.17.0" eslint-plugin-prettier: "npm:^5.2.6" eslint-plugin-promise: "npm:^7.2.1" + immer: "npm:^10.1.1" jsdom: "npm:^27.4.0" playwright: "npm:^1.54.2" prettier: "npm:^3.5.3" react: "npm:^17.0.2" react-dom: "npm:^17.0.2" rimraf: "npm:^6.0.1" + semver: "npm:^7.7.1" ses: "npm:^1.14.0" tsx: "npm:^4.20.6" turbo: "npm:^2.5.6" @@ -5422,10 +5429,10 @@ __metadata: languageName: node linkType: hard -"@types/semver@npm:^7.3.6": - version: 7.7.0 - resolution: "@types/semver@npm:7.7.0" - checksum: 10/ee4514c6c852b1c38f951239db02f9edeea39f5310fad9396a00b51efa2a2d96b3dfca1ae84c88181ea5b7157c57d32d7ef94edacee36fbf975546396b85ba5b +"@types/semver@npm:^7.3.6, @types/semver@npm:^7.7.1": + version: 7.7.1 + resolution: "@types/semver@npm:7.7.1" + checksum: 10/8f09e7e6ca3ded67d78ba7a8f7535c8d9cf8ced83c52e7f3ac3c281fe8c689c3fe475d199d94390dc04fc681d51f2358b430bb7b2e21c62de24f2bee2c719068 languageName: node linkType: hard @@ -9980,6 +9987,13 @@ __metadata: languageName: node linkType: hard +"immer@npm:^10.1.1": + version: 10.2.0 + resolution: "immer@npm:10.2.0" + checksum: 10/d73e218c8f8ffbb39f9290dfafa478b94af73403dcf26b5672eef35233bb30f09ffe231f8a78a6c9cb442968510edd89e851776ec90a5ddfa82cee6db6b35137 + languageName: node + linkType: hard + "immer@npm:^9.0.6": version: 9.0.21 resolution: "immer@npm:9.0.21"