diff --git a/packages/kernel-utils/src/index.test.ts b/packages/kernel-utils/src/index.test.ts index 67cef419d..a95b774a0 100644 --- a/packages/kernel-utils/src/index.test.ts +++ b/packages/kernel-utils/src/index.test.ts @@ -25,6 +25,7 @@ describe('index', () => { 'makeDefaultExo', 'makeDefaultInterface', 'makeDiscoverableExo', + 'makeFacet', 'mergeDisjointRecords', 'retry', 'retryWithBackoff', diff --git a/packages/kernel-utils/src/index.ts b/packages/kernel-utils/src/index.ts index 934437874..b7ab66943 100644 --- a/packages/kernel-utils/src/index.ts +++ b/packages/kernel-utils/src/index.ts @@ -1,6 +1,7 @@ export { makeDefaultInterface, makeDefaultExo } from './exo.ts'; export { makeDiscoverableExo } from './discoverable.ts'; export type { DiscoverableExo } from './discoverable.ts'; +export { makeFacet } from './make-facet.ts'; export type { JsonSchema, MethodSchema } from './schema.ts'; export { fetchValidatedJson } from './fetchValidatedJson.ts'; export { abortableDelay, delay, makeCounter } from './misc.ts'; diff --git a/packages/kernel-utils/src/make-facet.test.ts b/packages/kernel-utils/src/make-facet.test.ts new file mode 100644 index 000000000..269f1cf45 --- /dev/null +++ b/packages/kernel-utils/src/make-facet.test.ts @@ -0,0 +1,125 @@ +import { describe, it, expect, vi } from 'vitest'; + +import { makeFacet } from './make-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/kernel-utils/src/make-facet.ts b/packages/kernel-utils/src/make-facet.ts new file mode 100644 index 000000000..052e24fe1 --- /dev/null +++ b/packages/kernel-utils/src/make-facet.ts @@ -0,0 +1,69 @@ +import type { Methods } from '@endo/exo'; + +import { makeDefaultExo } from './exo.ts'; + +/** + * 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 FacetMethods[MethodNames]; + } + + return makeDefaultExo(name, methods as FacetMethods); +} +harden(makeFacet);