diff --git a/packages/permission-controller/CHANGELOG.md b/packages/permission-controller/CHANGELOG.md index f909aed1b4a..c9e6f6ab06a 100644 --- a/packages/permission-controller/CHANGELOG.md +++ b/packages/permission-controller/CHANGELOG.md @@ -10,6 +10,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added - Add `createPermissionMiddlewareV2`, a `JsonRpcEngineV2` variant of the standalone permission middleware factory ([#8532](https://github.com/MetaMask/core/pull/8532)) +- Add `messenger` option to permission specification builders, allowing restricted-method specs to receive a scoped messenger in place of `methodHooks` ([#8551](https://github.com/MetaMask/core/pull/8551)) + - Use the `actionNames` field on the specification builder and `createRestrictedMethodMessenger` to construct the scoped messenger. ### Changed @@ -25,6 +27,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Deprecate `createPermissionMiddleware` in favor of `createPermissionMiddlewareV2`, which targets `JsonRpcEngineV2` ([#8532](https://github.com/MetaMask/core/pull/8532)) +### Removed + +- **BREAKING:** Remove `factoryHooks`, `validatorHooks`, and related fields from permission specification builders ([#8551](https://github.com/MetaMask/core/pull/8551)) + ## [12.3.0] ### Added diff --git a/packages/permission-controller/src/Permission.test.ts b/packages/permission-controller/src/Permission.test.ts index 95ace518c42..a7fdf7a631c 100644 --- a/packages/permission-controller/src/Permission.test.ts +++ b/packages/permission-controller/src/Permission.test.ts @@ -1,5 +1,13 @@ -import type { CaveatConstraint, PermissionConstraint } from '.'; -import { constructPermission } from '.'; +import { Messenger } from '@metamask/messenger'; + +import type { + CaveatConstraint, + PermissionConstraint, + PermissionSpecificationBuilder, + RestrictedMethodSpecificationConstraint, +} from '.'; +import { constructPermission, PermissionType } from '.'; +import { createRestrictedMethodMessenger } from './createRestrictedMethodMessenger'; import { findCaveat } from './Permission'; describe('constructPermission', () => { @@ -86,3 +94,73 @@ describe('findCaveat', () => { expect(findCaveat(permission, 'doesNotExist')).toBeUndefined(); }); }); + +describe('permission specification messenger option', () => { + type HostAction = { + type: 'Host:computeAnswer'; + handler: () => number; + }; + + const targetName = 'wallet_getAnswer'; + + type HostRootMessenger = Messenger<'Root', HostAction>; + + type SpecMessenger = ReturnType< + typeof createRestrictedMethodMessenger< + typeof targetName, + HostRootMessenger, + readonly ['Host:computeAnswer'] + > + >; + + const buildSpecificationBuilder = (): PermissionSpecificationBuilder< + PermissionType.RestrictedMethod, + { messenger: SpecMessenger }, + RestrictedMethodSpecificationConstraint + > => { + return ({ messenger }) => ({ + permissionType: PermissionType.RestrictedMethod, + targetName, + allowedCaveats: null, + methodImplementation: (): number => messenger.call('Host:computeAnswer'), + }); + }; + + const getRootMessenger = (): HostRootMessenger => { + const rootMessenger = new Messenger<'Root', HostAction>({ + namespace: 'Root', + }); + const hostMessenger = new Messenger< + 'Host', + HostAction, + never, + typeof rootMessenger + >({ namespace: 'Host', parent: rootMessenger }); + hostMessenger.registerActionHandler('Host:computeAnswer', () => 42); + return rootMessenger; + }; + + it('invokes the spec-declared action via the scoped messenger', () => { + const rootMessenger = getRootMessenger(); + const specificationBuilder = buildSpecificationBuilder(); + + const messenger = createRestrictedMethodMessenger({ + rootMessenger, + namespace: targetName, + actionNames: ['Host:computeAnswer'] as const, + }); + + const specification = specificationBuilder({ messenger }); + + expect(specification.targetName).toBe(targetName); + expect(specification.allowedCaveats).toBeNull(); + expect(specification.permissionType).toBe(PermissionType.RestrictedMethod); + expect( + specification.methodImplementation({ + method: targetName, + params: [], + context: { origin: 'example.com' }, + }), + ).toBe(42); + }); +}); diff --git a/packages/permission-controller/src/Permission.ts b/packages/permission-controller/src/Permission.ts index 3c369e1b88e..02bb2a507ca 100644 --- a/packages/permission-controller/src/Permission.ts +++ b/packages/permission-controller/src/Permission.ts @@ -484,15 +484,19 @@ export type PermissionSpecificationConstraint = * Options for {@link PermissionSpecificationBuilder} functions. */ type PermissionSpecificationBuilderOptions< - FactoryHooks extends Record, MethodHooks extends Record, - ValidatorHooks extends Record, + SpecMessenger = unknown, > = { targetName?: string; allowedCaveats?: Readonly> | null; - factoryHooks?: FactoryHooks; methodHooks?: MethodHooks; - validatorHooks?: ValidatorHooks; + /** + * A messenger scoped to this permission specification. The messenger is + * expected to have exactly the actions declared by the spec's `actionNames` + * delegated to it; {@link createRestrictedMethodMessenger} is the canonical + * way to construct it. + */ + messenger?: SpecMessenger; }; /** @@ -504,8 +508,6 @@ type PermissionSpecificationBuilderOptions< export type PermissionSpecificationBuilder< Type extends PermissionType, Options extends PermissionSpecificationBuilderOptions< - Record, - Record, Record >, Specification extends PermissionSpecificationConstraint & { @@ -513,26 +515,6 @@ export type PermissionSpecificationBuilder< }, > = (options: Options) => Specification; -/** - * A restricted method permission export object, containing the - * {@link PermissionSpecificationBuilder} function and "hook name" objects. - */ -export type PermissionSpecificationBuilderExportConstraint = { - targetName: string; - specificationBuilder: PermissionSpecificationBuilder< - PermissionType, - PermissionSpecificationBuilderOptions< - Record, - Record, - Record - >, - PermissionSpecificationConstraint - >; - factoryHookNames?: Record; - methodHookNames?: Record; - validatorHookNames?: Record; -}; - type ValidRestrictedMethodSpecification< Specification extends RestrictedMethodSpecificationConstraint, > = diff --git a/packages/permission-controller/src/createRestrictedMethodMessenger.test.ts b/packages/permission-controller/src/createRestrictedMethodMessenger.test.ts new file mode 100644 index 00000000000..0cf75cee098 --- /dev/null +++ b/packages/permission-controller/src/createRestrictedMethodMessenger.test.ts @@ -0,0 +1,119 @@ +import { Messenger } from '@metamask/messenger'; + +import { createRestrictedMethodMessenger } from './createRestrictedMethodMessenger'; + +type FooAction = { + type: 'Foo:ping'; + handler: () => string; +}; + +type BarAction = { + type: 'Bar:double'; + handler: (n: number) => number; +}; + +type RootActions = FooAction | BarAction; + +const getRootMessenger = (): Messenger<'Root', RootActions> => { + const messenger = new Messenger<'Root', RootActions>({ namespace: 'Root' }); + const fooMessenger = new Messenger<'Foo', FooAction, never, typeof messenger>( + { + namespace: 'Foo', + parent: messenger, + }, + ); + const barMessenger = new Messenger<'Bar', BarAction, never, typeof messenger>( + { + namespace: 'Bar', + parent: messenger, + }, + ); + fooMessenger.registerActionHandler('Foo:ping', () => 'pong'); + barMessenger.registerActionHandler('Bar:double', (value) => value * 2); + return messenger; +}; + +describe('createRestrictedMethodMessenger', () => { + it('returns undefined when actionNames is omitted', () => { + const rootMessenger = getRootMessenger(); + + expect( + createRestrictedMethodMessenger({ + rootMessenger, + namespace: 'wallet_example', + }), + ).toBeUndefined(); + }); + + it('returns undefined when actionNames is empty', () => { + const rootMessenger = getRootMessenger(); + + expect( + createRestrictedMethodMessenger({ + rootMessenger, + namespace: 'wallet_example', + // @ts-expect-error An empty array is rejected by the type system, but + // the runtime handles it as a no-op. + actionNames: [], + }), + ).toBeUndefined(); + }); + + it('exposes the requested action on the returned messenger', () => { + const rootMessenger = getRootMessenger(); + + const messenger = createRestrictedMethodMessenger({ + rootMessenger, + namespace: 'wallet_example', + actionNames: ['Foo:ping'] as const, + }); + + expect(messenger.call('Foo:ping')).toBe('pong'); + }); + + it('uses the provided namespace for the returned messenger', () => { + const rootMessenger = getRootMessenger(); + + const messenger = createRestrictedMethodMessenger({ + rootMessenger, + namespace: 'wallet_example', + actionNames: ['Foo:ping'] as const, + }); + + // Registering an action under a different namespace must fail with an + // error that names the messenger's configured namespace. + expect(() => + // @ts-expect-error Deliberately registering outside the child's action + // surface to probe its namespace. + messenger.registerActionHandler('Other:noop', () => undefined), + ).toThrow(/wallet_example/u); + }); + + it('rejects calls to actions that were not requested', () => { + const rootMessenger = getRootMessenger(); + + const messenger = createRestrictedMethodMessenger({ + rootMessenger, + namespace: 'wallet_example', + actionNames: ['Foo:ping'] as const, + }); + + expect(() => + // @ts-expect-error Intentionally calling an undelegated action. + messenger.call('Bar:double', 2), + ).toThrow(/Bar:double/u); + }); + + it('exposes every requested action when multiple are delegated', () => { + const rootMessenger = getRootMessenger(); + + const messenger = createRestrictedMethodMessenger({ + rootMessenger, + namespace: 'wallet_example', + actionNames: ['Foo:ping', 'Bar:double'] as const, + }); + + expect(messenger.call('Foo:ping')).toBe('pong'); + expect(messenger.call('Bar:double', 3)).toBe(6); + }); +}); diff --git a/packages/permission-controller/src/createRestrictedMethodMessenger.ts b/packages/permission-controller/src/createRestrictedMethodMessenger.ts new file mode 100644 index 00000000000..9a6c01e29db --- /dev/null +++ b/packages/permission-controller/src/createRestrictedMethodMessenger.ts @@ -0,0 +1,108 @@ +import type { + ActionConstraint, + EventConstraint, + MessengerActions, +} from '@metamask/messenger'; +import { Messenger } from '@metamask/messenger'; + +/** + * The subset of `RootMessenger`'s actions selected by `DelegatedActions`. + */ +type SelectedActions< + RootMessenger extends Messenger, + DelegatedActions extends readonly MessengerActions['type'][], +> = Extract< + MessengerActions, + { type: DelegatedActions[number] } +>; + +/** + * Create a child messenger scoped to a restricted-method permission + * specification, delegating only the spec's declared actions from the root + * messenger. This produces a minimally-scoped messenger whose action surface + * matches exactly what the spec has declared it needs. + * + * Returns `undefined` when `actionNames` is omitted — there is nothing to + * scope, and the builder can be invoked without a messenger. + * + * @param args - The arguments. + * @param args.rootMessenger - The root messenger to delegate actions from. + * @param args.namespace - The namespace for the scoped child messenger, + * typically the spec's `targetName`. + * @param args.actionNames - The action types the specification requires, + * typically the spec's declared `actionNames`. Must be a non-empty tuple of + * action types that exist on the root messenger. + * @returns A scoped child messenger with the requested actions delegated, or + * `undefined` if no actions were requested. + */ +export function createRestrictedMethodMessenger< + Namespace extends string, + RootMessenger extends Messenger, + DelegatedActions extends readonly [ + MessengerActions['type'], + ...MessengerActions['type'][], + ], +>(args: { + rootMessenger: RootMessenger; + namespace: Namespace; + actionNames: DelegatedActions; +}): Messenger< + Namespace, + SelectedActions, + never, + RootMessenger +>; + +export function createRestrictedMethodMessenger< + Namespace extends string, + RootMessenger extends Messenger, +>(args: { + rootMessenger: RootMessenger; + namespace: Namespace; + actionNames?: undefined; +}): undefined; + +export function createRestrictedMethodMessenger< + Namespace extends string, + RootMessenger extends Messenger, + DelegatedActions extends [ + MessengerActions['type'], + ...MessengerActions['type'][], + ], +>({ + rootMessenger, + namespace, + actionNames, +}: { + rootMessenger: RootMessenger; + namespace: Namespace; + actionNames?: DelegatedActions; +}): + | Messenger< + Namespace, + SelectedActions, + never, + RootMessenger + > + | undefined { + if (!actionNames?.length) { + return undefined; + } + + const restrictedMethodMessenger = new Messenger< + Namespace, + SelectedActions, + never, + RootMessenger + >({ + namespace, + parent: rootMessenger, + }); + + rootMessenger.delegate({ + actions: actionNames, + messenger: restrictedMethodMessenger, + }); + + return restrictedMethodMessenger; +} diff --git a/packages/permission-controller/src/index.ts b/packages/permission-controller/src/index.ts index 758510cc35a..36292db1874 100644 --- a/packages/permission-controller/src/index.ts +++ b/packages/permission-controller/src/index.ts @@ -1,4 +1,5 @@ export * from './Caveat'; +export { createRestrictedMethodMessenger } from './createRestrictedMethodMessenger'; export * from './errors'; export * from './Permission'; export * from './PermissionController';