Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions packages/kernel-utils/src/index.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@ describe('index', () => {
'makeDefaultExo',
'makeDefaultInterface',
'makeDiscoverableExo',
'makeFacet',
'mergeDisjointRecords',
'retry',
'retryWithBackoff',
Expand Down
1 change: 1 addition & 0 deletions packages/kernel-utils/src/index.ts
Original file line number Diff line number Diff line change
@@ -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';
Expand Down
125 changes: 125 additions & 0 deletions packages/kernel-utils/src/make-facet.test.ts
Original file line number Diff line number Diff line change
@@ -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<string, unknown>).method3).toBeUndefined();
expect((facet as Record<string, unknown>).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<string, unknown>).method2).toBeUndefined();
expect((facet as Record<string, unknown>).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);
});
});
69 changes: 69 additions & 0 deletions packages/kernel-utils/src/make-facet.ts
Original file line number Diff line number Diff line change
@@ -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<Source> = {
[Key in keyof Source]: Source[Key] extends CallableFunction ? Key : never;
}[keyof Source] &
(string | symbol);

type BoundMethod<Func> = Func extends CallableFunction
? OmitThisParameter<Func>
: never;

type FacetMethods<Source, MethodNames extends MethodKeys<Source>> = Methods & {
[Key in MethodNames]: BoundMethod<Source[Key]>;
};

/**
* 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<string, unknown>,
MethodNames extends MethodKeys<Source>,
>(
name: string,
source: Source,
methodNames: readonly MethodNames[],
): FacetMethods<Source, MethodNames> {
const methods: Partial<FacetMethods<Source, MethodNames>> = {};

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<Source, MethodNames>[MethodNames];
}

return makeDefaultExo(name, methods as FacetMethods<Source, MethodNames>);
}
harden(makeFacet);
Loading