Skip to content

Commit 4df4962

Browse files
sirtimidclaude
andauthored
feat: Export endowment factories via sub-path (#3957)
## Summary - Adds `@metamask/snaps-execution-environments/endowments` sub-path export exposing all endowment factory modules: `timeout`, `interval`, `date`, `textEncoder`, `textDecoder`, `crypto`, `math`, `consoleEndowment`, `network` - Also exports `buildCommonEndowments` and types: `EndowmentFactory`, `EndowmentFactoryOptions`, `EndowmentFactoryResult`, `NotifyFunction`, `ConsoleEndowmentOptions`, `NetworkEndowmentOptions` - Generalizes the API for reuse by external SES-based projects like [ocap-kernel](MetaMask/ocap-kernel#935): - Extracts `NotifyFunction` from `BaseSnapExecutor` into the endowments module (re-exported for backward compat) - Renames `snapId` to `sourceLabel` in `EndowmentFactoryOptions` — Snaps passes `Snap: ${snapId}` internally, external consumers provide their own label - Adds `EndowmentFactoryResult` type with explicit `teardownFunction` for lifecycle management - **Type-safe required options at the barrel**: factory implementations accept the wide `EndowmentFactoryOptions` internally (no casts), and the barrel re-exports `consoleEndowment` / `network` with narrow factory types (`ConsoleEndowmentOptions`, `NetworkEndowmentOptions`). External consumers get compile-time errors when required options are missing: - `consoleEndowment.factory({})` → `Property 'sourceLabel' is missing` - `network.factory({})` → `Property 'notify' is missing` - **Prerequisite for consumers**: factories call the SES `harden()` global — the environment must have called `lockdown()` before invoking any factory - Snaps behavior is fully preserved — no changes to console output format or internal endowment wiring ## Test plan - [x] All existing endowment tests pass (console, network, timeout, interval, index integration) - [x] Barrel smoke test verifies all 9 modules + `buildCommonEndowments` are correctly exported - [x] Non-Snap label test (`ocap-kernel: vat-42`) validates generic `sourceLabel` usage - [x] Missing `sourceLabel` / `notify` tests verify descriptive error for external consumers - [x] Compile-time verification that narrow types reject `factory({})` at the barrel - [x] Build succeeds (ts-bridge ESM/CJS + LavaMoat webpack bundles) - [x] Lint passes with no new errors 🤖 Generated with [Claude Code](https://claude.com/claude-code) <!-- CURSOR_SUMMARY --> --- > [!NOTE] > **Medium Risk** > Introduces a new public entrypoint and changes endowment factory option contracts (`snapId`→`sourceLabel`, required `notify`/`sourceLabel`), which could affect downstream consumers and runtime if any call sites omit the newly-required options. > > **Overview** > Adds a new public subpath export, `@metamask/snaps-execution-environments/endowments`, exposing hardened endowment factory modules (plus `buildCommonEndowments`) and the associated factory/types for reuse by non-Snaps SES consumers. > > Refactors endowment factory typing and options: moves `NotifyFunction` into `commonEndowmentFactory`, introduces `EndowmentFactoryResult` with optional `teardownFunction`, and replaces `snapId` with a generic `sourceLabel` (Snaps now pass `Snap: ${snapId}` internally). Tightens runtime validation by requiring `sourceLabel` for the console factory and `notify` for the network factory, and expands tests (including `undefined` timeout/interval defaults) to cover the new contracts. > > <sup>Reviewed by [Cursor Bugbot](https://cursor.com/bugbot) for commit 46918a8. Bugbot is set up for automated code reviews on this repo. Configure [here](https://www.cursor.com/dashboard/bugbot).</sup> <!-- /CURSOR_SUMMARY --> --------- Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1 parent c2d6c7d commit 4df4962

15 files changed

Lines changed: 324 additions & 61 deletions

packages/snaps-execution-environments/CHANGELOG.md

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
77

88
## [Unreleased]
99

10+
### Added
11+
12+
- Export endowment factories via `@metamask/snaps-execution-environments/endowments` ([#3957](https://github.com/MetaMask/snaps/pull/3957))
13+
1014
## [11.0.2]
1115

1216
### Changed

packages/snaps-execution-environments/coverage.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
{
2-
"branches": 90.09,
2+
"branches": 92.03,
33
"functions": 95.34,
44
"lines": 92.74,
55
"statements": 91.69

packages/snaps-execution-environments/package.json

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,16 @@
2828
"default": "./dist/index.cjs"
2929
}
3030
},
31+
"./endowments": {
32+
"import": {
33+
"types": "./dist/endowments.d.mts",
34+
"default": "./dist/endowments.mjs"
35+
},
36+
"require": {
37+
"types": "./dist/endowments.d.cts",
38+
"default": "./dist/endowments.cjs"
39+
}
40+
},
3141
"./node-process": "./dist/webpack/node-process/bundle.js",
3242
"./node-thread": "./dist/webpack/node-thread/bundle.js",
3343
"./package.json": "./package.json"

packages/snaps-execution-environments/src/common/BaseSnapExecutor.ts

Lines changed: 1 addition & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -89,9 +89,7 @@ export type InvokeSnap = (
8989
args: InvokeSnapArgs | undefined,
9090
) => Promise<Json>;
9191

92-
export type NotifyFunction = (
93-
notification: Omit<JsonRpcNotification, 'jsonrpc'>,
94-
) => Promise<void>;
92+
export type { NotifyFunction } from './endowments/commonEndowmentFactory';
9593

9694
export class BaseSnapExecutor {
9795
readonly #snapData: Map<string, SnapData>;

packages/snaps-execution-environments/src/common/endowments/commonEndowmentFactory.ts

Lines changed: 50 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,5 @@
1+
import type { JsonRpcNotification } from '@metamask/utils';
2+
13
import consoleEndowment from './console';
24
import crypto from './crypto';
35
import date from './date';
@@ -7,19 +9,64 @@ import network from './network';
79
import textDecoder from './textDecoder';
810
import textEncoder from './textEncoder';
911
import timeout from './timeout';
10-
import type { NotifyFunction } from '../BaseSnapExecutor';
1112
import { rootRealmGlobal } from '../globalObject';
1213

14+
/**
15+
* A function for sending JSON-RPC notifications from an endowment.
16+
* Used by endowments that perform outbound operations (e.g., network `fetch`)
17+
* to signal request lifecycle events.
18+
*/
19+
export type NotifyFunction = (
20+
notification: Omit<JsonRpcNotification, 'jsonrpc'>,
21+
) => Promise<void>;
22+
23+
/**
24+
* Options passed to endowment factory functions.
25+
*/
1326
export type EndowmentFactoryOptions = {
14-
snapId?: string;
27+
/**
28+
* A label identifying the source of endowment interactions, used as a
29+
* prefix in console output. For example, passing `"MyApp"` causes console
30+
* messages to be prefixed with `[MyApp]`.
31+
*/
32+
sourceLabel?: string;
33+
34+
/**
35+
* A notification callback used by endowments that perform outbound
36+
* operations (e.g., network `fetch`).
37+
*/
1538
notify?: NotifyFunction;
1639
};
1740

41+
/**
42+
* The object returned by an endowment factory. Contains the endowment values
43+
* keyed by their global name (e.g., `setTimeout`, `Date`) and an optional
44+
* teardown function for lifecycle management.
45+
*/
46+
export type EndowmentFactoryResult = {
47+
/**
48+
* An optional function that performs cleanup when active resources (e.g.,
49+
* pending timers or open network connections) should be released. Must not
50+
* render endowments unusable — only restore them to their initial state,
51+
* since they may be reused without reconstruction.
52+
*/
53+
teardownFunction?: () => Promise<void> | void;
54+
[key: string]: unknown;
55+
};
56+
57+
/**
58+
* Describes an endowment factory module. Each module exposes the names of
59+
* the endowments it provides and a factory function that produces them.
60+
*/
1861
export type EndowmentFactory = {
1962
names: readonly string[];
20-
factory: (options?: EndowmentFactoryOptions) => { [key: string]: unknown };
63+
factory: (options?: EndowmentFactoryOptions) => EndowmentFactoryResult;
2164
};
2265

66+
/**
67+
* Describes a simple global value that should be hardened and exposed as an
68+
* endowment without additional attenuation.
69+
*/
2370
export type CommonEndowmentSpecification = {
2471
endowment: unknown;
2572
name: string;

packages/snaps-execution-environments/src/common/endowments/console.test.ts

Lines changed: 65 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,7 @@ describe('Console endowment', () => {
1616

1717
it('returns console properties from rootRealmGlobal', () => {
1818
const { console }: { console: Partial<typeof rootRealmGlobal.console> } =
19-
consoleEndowment.factory({ snapId: MOCK_SNAP_ID });
19+
consoleEndowment.factory({ sourceLabel: `Snap: ${MOCK_SNAP_ID}` });
2020
const consoleProperties = Object.getOwnPropertyNames(
2121
rootRealmGlobal.console,
2222
);
@@ -33,12 +33,16 @@ describe('Console endowment', () => {
3333

3434
describe('log', () => {
3535
it('does not return the original console.log', () => {
36-
const { console } = consoleEndowment.factory({ snapId: MOCK_SNAP_ID });
36+
const { console } = consoleEndowment.factory({
37+
sourceLabel: `Snap: ${MOCK_SNAP_ID}`,
38+
});
3739
expect(console.log).not.toStrictEqual(rootRealmGlobal.console.log);
3840
});
3941

40-
it('will log a message identifying the source of the call (snap id)', () => {
41-
const { console } = consoleEndowment.factory({ snapId: MOCK_SNAP_ID });
42+
it('prefixes output with the source label', () => {
43+
const { console } = consoleEndowment.factory({
44+
sourceLabel: `Snap: ${MOCK_SNAP_ID}`,
45+
});
4246
const logSpy = jest.spyOn(rootRealmGlobal.console, 'log');
4347
console.log('This is a log message.');
4448
expect(logSpy).toHaveBeenCalledTimes(1);
@@ -48,7 +52,9 @@ describe('Console endowment', () => {
4852
});
4953

5054
it('can handle non-string message types', () => {
51-
const { console } = consoleEndowment.factory({ snapId: MOCK_SNAP_ID });
55+
const { console } = consoleEndowment.factory({
56+
sourceLabel: `Snap: ${MOCK_SNAP_ID}`,
57+
});
5258
const logSpy = jest.spyOn(rootRealmGlobal.console, 'log');
5359
console.log(12345);
5460
console.log({ foo: 'bar' });
@@ -66,12 +72,16 @@ describe('Console endowment', () => {
6672

6773
describe('error', () => {
6874
it('does not return the original console.error', () => {
69-
const { console } = consoleEndowment.factory({ snapId: MOCK_SNAP_ID });
75+
const { console } = consoleEndowment.factory({
76+
sourceLabel: `Snap: ${MOCK_SNAP_ID}`,
77+
});
7078
expect(console.error).not.toStrictEqual(rootRealmGlobal.console.error);
7179
});
7280

73-
it('will log a message identifying the source of the call (snap id)', () => {
74-
const { console } = consoleEndowment.factory({ snapId: MOCK_SNAP_ID });
81+
it('prefixes output with the source label', () => {
82+
const { console } = consoleEndowment.factory({
83+
sourceLabel: `Snap: ${MOCK_SNAP_ID}`,
84+
});
7585
const errorSpy = jest.spyOn(rootRealmGlobal.console, 'error');
7686
console.error('This is an error message.');
7787
expect(errorSpy).toHaveBeenCalledTimes(1);
@@ -83,12 +93,16 @@ describe('Console endowment', () => {
8393

8494
describe('assert', () => {
8595
it('does not return the original console.assert', () => {
86-
const { console } = consoleEndowment.factory({ snapId: MOCK_SNAP_ID });
96+
const { console } = consoleEndowment.factory({
97+
sourceLabel: `Snap: ${MOCK_SNAP_ID}`,
98+
});
8799
expect(console.assert).not.toStrictEqual(rootRealmGlobal.console.assert);
88100
});
89101

90-
it('will log a message identifying the source of the call (snap id)', () => {
91-
const { console } = consoleEndowment.factory({ snapId: MOCK_SNAP_ID });
102+
it('prefixes output with the source label', () => {
103+
const { console } = consoleEndowment.factory({
104+
sourceLabel: `Snap: ${MOCK_SNAP_ID}`,
105+
});
92106
const assertSpy = jest.spyOn(rootRealmGlobal.console, 'assert');
93107
console.assert(1 > 2, 'This is an assert message.');
94108
expect(assertSpy).toHaveBeenCalledTimes(1);
@@ -101,12 +115,16 @@ describe('Console endowment', () => {
101115

102116
describe('debug', () => {
103117
it('does not return the original console.debug', () => {
104-
const { console } = consoleEndowment.factory({ snapId: MOCK_SNAP_ID });
118+
const { console } = consoleEndowment.factory({
119+
sourceLabel: `Snap: ${MOCK_SNAP_ID}`,
120+
});
105121
expect(console.debug).not.toStrictEqual(rootRealmGlobal.console.debug);
106122
});
107123

108-
it('will log a message identifying the source of the call (snap id)', () => {
109-
const { console } = consoleEndowment.factory({ snapId: MOCK_SNAP_ID });
124+
it('prefixes output with the source label', () => {
125+
const { console } = consoleEndowment.factory({
126+
sourceLabel: `Snap: ${MOCK_SNAP_ID}`,
127+
});
110128
const debugSpy = jest.spyOn(rootRealmGlobal.console, 'debug');
111129
console.debug('This is a debug message.');
112130
expect(debugSpy).toHaveBeenCalledTimes(1);
@@ -118,12 +136,16 @@ describe('Console endowment', () => {
118136

119137
describe('info', () => {
120138
it('does not return the original console.info', () => {
121-
const { console } = consoleEndowment.factory({ snapId: MOCK_SNAP_ID });
139+
const { console } = consoleEndowment.factory({
140+
sourceLabel: `Snap: ${MOCK_SNAP_ID}`,
141+
});
122142
expect(console.info).not.toStrictEqual(rootRealmGlobal.console.info);
123143
});
124144

125-
it('will log a message identifying the source of the call (snap id)', () => {
126-
const { console } = consoleEndowment.factory({ snapId: MOCK_SNAP_ID });
145+
it('prefixes output with the source label', () => {
146+
const { console } = consoleEndowment.factory({
147+
sourceLabel: `Snap: ${MOCK_SNAP_ID}`,
148+
});
127149
const infoSpy = jest.spyOn(rootRealmGlobal.console, 'info');
128150
console.info('This is an info message.');
129151
expect(infoSpy).toHaveBeenCalledTimes(1);
@@ -135,12 +157,16 @@ describe('Console endowment', () => {
135157

136158
describe('warn', () => {
137159
it('does not return the original console.warn', () => {
138-
const { console } = consoleEndowment.factory({ snapId: MOCK_SNAP_ID });
160+
const { console } = consoleEndowment.factory({
161+
sourceLabel: `Snap: ${MOCK_SNAP_ID}`,
162+
});
139163
expect(console.warn).not.toStrictEqual(rootRealmGlobal.console.warn);
140164
});
141165

142-
it('will log a message identifying the source of the call (snap id)', () => {
143-
const { console } = consoleEndowment.factory({ snapId: MOCK_SNAP_ID });
166+
it('prefixes output with the source label', () => {
167+
const { console } = consoleEndowment.factory({
168+
sourceLabel: `Snap: ${MOCK_SNAP_ID}`,
169+
});
144170
const warnSpy = jest.spyOn(rootRealmGlobal.console, 'warn');
145171
console.warn('This is a warn message.');
146172
expect(warnSpy).toHaveBeenCalledTimes(1);
@@ -149,4 +175,23 @@ describe('Console endowment', () => {
149175
);
150176
});
151177
});
178+
179+
it('throws when sourceLabel is not provided', () => {
180+
expect(() => consoleEndowment.factory()).toThrow(
181+
'The "sourceLabel" option is required by the console endowment factory.',
182+
);
183+
184+
expect(() => consoleEndowment.factory({})).toThrow(
185+
'The "sourceLabel" option is required by the console endowment factory.',
186+
);
187+
});
188+
189+
it('supports arbitrary source labels for non-Snap consumers', () => {
190+
const { console } = consoleEndowment.factory({
191+
sourceLabel: 'ocap-kernel: vat-42',
192+
});
193+
const logSpy = jest.spyOn(rootRealmGlobal.console, 'log');
194+
console.log('test message');
195+
expect(logSpy).toHaveBeenCalledWith('[ocap-kernel: vat-42] test message');
196+
});
152197
});

packages/snaps-execution-environments/src/common/endowments/console.ts

Lines changed: 16 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -13,8 +13,9 @@ export const consoleAttenuatedMethods = new Set([
1313
]);
1414

1515
/**
16-
* A set of all the `console` values that will be passed to the snap. This has
17-
* all the values that are available in both the browser and Node.js.
16+
* A set of all the `console` method names that will be included in the
17+
* attenuated console object. Covers values available in both browser and
18+
* Node.js.
1819
*/
1920
export const consoleMethods = new Set([
2021
'debug',
@@ -52,13 +53,13 @@ type ConsoleFunctions = {
5253
* Gets the appropriate (prepended) message to pass to one of the attenuated
5354
* method calls.
5455
*
55-
* @param snapId - Id of the snap that we're getting a message for.
56-
* @param message - The id of the snap that will interact with the endowment.
56+
* @param sourceLabel - Label identifying the source of the console call.
57+
* @param message - The first argument passed to the console method.
5758
* @param args - The array of additional arguments.
5859
* @returns An array of arguments to be passed into an attenuated console method call.
5960
*/
60-
function getMessage(snapId: string, message: unknown, ...args: unknown[]) {
61-
const prefix = `[Snap: ${snapId}]`;
61+
function getMessage(sourceLabel: string, message: unknown, ...args: unknown[]) {
62+
const prefix = `[${sourceLabel}]`;
6263

6364
// If the first argument is a string, prepend the prefix to the message, and keep the
6465
// rest of the arguments as-is.
@@ -72,15 +73,18 @@ function getMessage(snapId: string, message: unknown, ...args: unknown[]) {
7273
}
7374

7475
/**
75-
* Create a a {@link console} object, with the same properties as the global
76+
* Create a {@link console} object, with the same properties as the global
7677
* {@link console} object, but with some methods replaced.
7778
*
7879
* @param options - Factory options used in construction of the endowment.
79-
* @param options.snapId - The id of the snap that will interact with the endowment.
80+
* @param options.sourceLabel - Label identifying the source of the console call.
8081
* @returns The {@link console} object with the replaced methods.
8182
*/
82-
function createConsole({ snapId }: EndowmentFactoryOptions = {}) {
83-
assert(snapId !== undefined);
83+
function createConsole({ sourceLabel }: EndowmentFactoryOptions = {}) {
84+
assert(
85+
sourceLabel !== undefined,
86+
'The "sourceLabel" option is required by the console endowment factory.',
87+
);
8488
const keys = Object.getOwnPropertyNames(
8589
rootRealmGlobal.console,
8690
) as (keyof typeof console)[];
@@ -103,15 +107,15 @@ function createConsole({ snapId }: EndowmentFactoryOptions = {}) {
103107
) => {
104108
rootRealmGlobal.console.assert(
105109
value,
106-
...getMessage(snapId, message, ...optionalParams),
110+
...getMessage(sourceLabel, message, ...optionalParams),
107111
);
108112
},
109113
...consoleFunctions.reduce<ConsoleFunctions>((target, key) => {
110114
return {
111115
...target,
112116
[key]: (message?: unknown, ...optionalParams: any[]) => {
113117
rootRealmGlobal.console[key](
114-
...getMessage(snapId, message, ...optionalParams),
118+
...getMessage(sourceLabel, message, ...optionalParams),
115119
);
116120
},
117121
};

packages/snaps-execution-environments/src/common/endowments/endowments.test.browser.ts

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -43,7 +43,10 @@ describe('endowments', () => {
4343
const modules = buildCommonEndowments();
4444
modules.forEach((endowment) =>
4545
// @ts-expect-error: Partial mock.
46-
endowment.factory({ snapId: MOCK_SNAP_ID, notify: mockNotify }),
46+
endowment.factory({
47+
sourceLabel: `Snap: ${MOCK_SNAP_ID}`,
48+
notify: mockNotify,
49+
}),
4750
);
4851

4952
// Specially attenuated endowments or endowments that require
@@ -70,7 +73,7 @@ describe('endowments', () => {
7073
});
7174
const { Date: DateAttenuated } = date.factory();
7275
const { console: consoleAttenuated } = consoleEndowment.factory({
73-
snapId: MOCK_SNAP_ID,
76+
sourceLabel: `Snap: ${MOCK_SNAP_ID}`,
7477
});
7578

7679
const TEST_ENDOWMENTS = {

0 commit comments

Comments
 (0)