Skip to content
Merged
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
6 changes: 6 additions & 0 deletions packages/permission-controller/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand All @@ -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
Expand Down
82 changes: 80 additions & 2 deletions packages/permission-controller/src/Permission.test.ts
Original file line number Diff line number Diff line change
@@ -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', () => {
Expand Down Expand Up @@ -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);
});
});
34 changes: 8 additions & 26 deletions packages/permission-controller/src/Permission.ts
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

More generally do we still want to keep PermittedHandlerExport or should that be replaced by MethodHandler?

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This would be an improvement but it's orthogonal to this PR. Also not entirely clear to me if now is the time to force these methods onto JsonRpcEngineV2.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think it is worth it to decide on this before we roll out this change. It can be done in a follow-up PR though

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We should probably just ship a non-breaking /v2 version of these methods if we do it.

Original file line number Diff line number Diff line change
Expand Up @@ -484,15 +484,19 @@ export type PermissionSpecificationConstraint =
* Options for {@link PermissionSpecificationBuilder} functions.
*/
type PermissionSpecificationBuilderOptions<
FactoryHooks extends Record<string, unknown>,
MethodHooks extends Record<string, unknown>,
ValidatorHooks extends Record<string, unknown>,
SpecMessenger = unknown,
> = {
targetName?: string;
allowedCaveats?: Readonly<NonEmptyArray<string>> | 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;
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It would be nice if we could infer the messenger ala createMethodMiddleware, but it may be too much work to refactor. WDYT?

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It's too much work to refactor. We should just do #4238 instead.

};

/**
Expand All @@ -504,35 +508,13 @@ type PermissionSpecificationBuilderOptions<
export type PermissionSpecificationBuilder<
Type extends PermissionType,
Options extends PermissionSpecificationBuilderOptions<
Record<string, unknown>,
Record<string, unknown>,
Record<string, unknown>
>,
Specification extends PermissionSpecificationConstraint & {
permissionType: Type;
},
> = (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<string, unknown>,
Record<string, unknown>,
Record<string, unknown>
>,
PermissionSpecificationConstraint
>;
factoryHookNames?: Record<string, true>;
methodHookNames?: Record<string, true>;
validatorHookNames?: Record<string, true>;
};

type ValidRestrictedMethodSpecification<
Specification extends RestrictedMethodSpecificationConstraint,
> =
Expand Down
Original file line number Diff line number Diff line change
@@ -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);
});
});
Original file line number Diff line number Diff line change
@@ -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<string, ActionConstraint, EventConstraint>,
DelegatedActions extends readonly MessengerActions<RootMessenger>['type'][],
> = Extract<
MessengerActions<RootMessenger>,
{ 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<string, ActionConstraint, EventConstraint>,
DelegatedActions extends readonly [
MessengerActions<RootMessenger>['type'],
...MessengerActions<RootMessenger>['type'][],
],
>(args: {
rootMessenger: RootMessenger;
namespace: Namespace;
actionNames: DelegatedActions;
}): Messenger<
Namespace,
SelectedActions<RootMessenger, DelegatedActions>,
never,
RootMessenger
>;

export function createRestrictedMethodMessenger<
Namespace extends string,
RootMessenger extends Messenger<string, ActionConstraint, EventConstraint>,
>(args: {
rootMessenger: RootMessenger;
namespace: Namespace;
actionNames?: undefined;
}): undefined;

export function createRestrictedMethodMessenger<
Namespace extends string,
RootMessenger extends Messenger<string, ActionConstraint, EventConstraint>,
DelegatedActions extends [
MessengerActions<RootMessenger>['type'],
...MessengerActions<RootMessenger>['type'][],
],
>({
rootMessenger,
namespace,
actionNames,
}: {
rootMessenger: RootMessenger;
namespace: Namespace;
actionNames?: DelegatedActions;
}):
| Messenger<
Namespace,
SelectedActions<RootMessenger, DelegatedActions>,
never,
RootMessenger
>
| undefined {
if (!actionNames?.length) {
return undefined;
}

const restrictedMethodMessenger = new Messenger<
Namespace,
SelectedActions<RootMessenger, DelegatedActions>,
never,
RootMessenger
>({
namespace,
parent: rootMessenger,
});

rootMessenger.delegate({
actions: actionNames,
messenger: restrictedMethodMessenger,
});

return restrictedMethodMessenger;
}
Loading
Loading