Skip to content
Open
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
2 changes: 2 additions & 0 deletions packages/kernel-utils/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

### Added

- Add a `./described` export with a combinator namespace `S` (`S.string`/`S.number`/`S.boolean`/`S.arrayOf`/`S.record`/`S.object`/`S.nothing` leaves, plus `S.arg`/`S.method`/`S.interface`) that authors an `@endo/patterns` interface guard and a matching `MethodSchema` from a single source, so a discoverable exo's enforced shape and its `__getDescription__` hint cannot drift ([#958](https://github.com/MetaMask/ocap-kernel/pull/958))
- Add an optional `required` field to `MethodSchema` (mirroring `required` on object `JsonSchema`) naming which arguments are required, and a `{ required }` option on `methodArgsToStruct` that validates unlisted arguments as optional, so a method's argument schema can faithfully represent the optional trailing arguments its guard already allows ([#958](https://github.com/MetaMask/ocap-kernel/pull/958))
- Add `getLibp2pRelayHome()` to the `./nodejs` exports, returning the libp2p relay's bookkeeping directory (default `~/.libp2p-relay`, overridable via `$LIBP2P_RELAY_HOME`) — kept separate from `$OCAP_HOME` so one relay can serve daemons with different OCAP_HOMEs ([#952](https://github.com/MetaMask/ocap-kernel/pull/952))
- `startRelay()` accepts an optional `publicIp` that is fed to libp2p's `appendAnnounce`, so a relay running on a NAT-backed host can announce its publicly-reachable IPv4 alongside its bound NIC addresses ([#952](https://github.com/MetaMask/ocap-kernel/pull/952))

Expand Down
10 changes: 10 additions & 0 deletions packages/kernel-utils/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -59,6 +59,16 @@
"default": "./dist/discoverable.cjs"
}
},
"./described": {
"import": {
"types": "./dist/described.d.mts",
"default": "./dist/described.mjs"
},
"require": {
"types": "./dist/described.d.cts",
"default": "./dist/described.cjs"
}
},
"./libp2p": {
"import": {
"types": "./dist/libp2p-relay.d.mts",
Expand Down
201 changes: 201 additions & 0 deletions packages/kernel-utils/src/described.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,201 @@
import {
matches,
getInterfaceGuardPayload,
getMethodGuardPayload,
} from '@endo/patterns';
import { describe, expect, it } from 'vitest';

import { S } from './described.ts';

type MethodGuardPayload = {
argGuards: unknown[];
optionalArgGuards?: unknown[];
returnGuard: unknown;
};

const payloadOf = (guard: unknown): MethodGuardPayload =>
getMethodGuardPayload(guard as never) as unknown as MethodGuardPayload;

describe('leaves', () => {
it.each([
{
name: 'string',
described: S.string('a word'),
schema: { type: 'string', description: 'a word' },
ok: 'hello',
bad: 42,
},
{
name: 'number',
described: S.number(),
schema: { type: 'number' },
ok: 42,
bad: 'hello',
},
{
name: 'boolean',
described: S.boolean(),
schema: { type: 'boolean' },
ok: true,
bad: 1,
},
])(
'builds a $name leaf whose pattern and schema agree',
({ described, schema, ok, bad }) => {
expect(described.schema).toStrictEqual(schema);
expect(matches(ok, described.pattern)).toBe(true);
expect(matches(bad, described.pattern)).toBe(false);
},
);

it('builds an arrayOf leaf', () => {
const described = S.arrayOf(S.number(), 'the summands');
expect(described.schema).toStrictEqual({
type: 'array',
items: { type: 'number' },
description: 'the summands',
});
expect(matches([1, 2, 3], described.pattern)).toBe(true);
expect(matches(['a'], described.pattern)).toBe(false);
});

it('builds an open record leaf that allows any keys', () => {
const described = S.record('attachments');
expect(described.schema).toStrictEqual({
type: 'object',
properties: {},
additionalProperties: true,
description: 'attachments',
});
expect(matches({ anything: 1, goes: 'here' }, described.pattern)).toBe(
true,
);
expect(matches(42, described.pattern)).toBe(false);
});

it('builds a closed object leaf with required and optional properties', () => {
const described = S.object(
{ id: S.string(), label: S.string() },
{ optional: ['label'] },
);
expect(described.schema).toStrictEqual({
type: 'object',
properties: { id: { type: 'string' }, label: { type: 'string' } },
required: ['id'],
additionalProperties: false,
});
expect(matches({ id: 'x' }, described.pattern)).toBe(true);
expect(matches({ id: 'x', label: 'y' }, described.pattern)).toBe(true);
expect(matches({ label: 'y' }, described.pattern)).toBe(false);
});

it('rejects extra keys on a closed object leaf', () => {
const described = S.object({ id: S.string() });
expect(matches({ id: 'x' }, described.pattern)).toBe(true);
expect(matches({ id: 'x', extra: 1 }, described.pattern)).toBe(false);
});

it('builds a void return leaf with no schema', () => {
const described = S.nothing();
expect(described.schema).toBeUndefined();
expect(matches(undefined, described.pattern)).toBe(true);
expect(matches('something', described.pattern)).toBe(false);
});
});

describe('S.method', () => {
it('builds a guard and schema from named args', () => {
const method = S.method(
'Add a list of numbers.',
[S.arg('summands', S.arrayOf(S.number()))],
S.number('The sum of the numbers.'),
);
expect(method.schema).toStrictEqual({
description: 'Add a list of numbers.',
args: { summands: { type: 'array', items: { type: 'number' } } },
required: ['summands'],
returns: { type: 'number', description: 'The sum of the numbers.' },
});
const payload = payloadOf(method.guard);
expect(payload.argGuards).toHaveLength(1);
expect(payload.optionalArgGuards ?? []).toHaveLength(0);
});

it('omits `returns` from the schema for a void method', () => {
const method = S.method(
'Return a final response.',
[S.arg('final', S.string())],
S.nothing(),
);
expect(method.schema.returns).toBeUndefined();
expect('returns' in method.schema).toBe(false);
});

it('places optional args in the guard as trailing optionals', () => {
const method = S.method(
'Return a final response.',
[
S.arg('final', S.string()),
S.arg('attachments', S.record(), { optional: true }),
],
S.nothing(),
);
const payload = payloadOf(method.guard);
expect(payload.argGuards).toHaveLength(1);
expect(payload.optionalArgGuards).toHaveLength(1);
expect(method.schema.args).toStrictEqual({
final: { type: 'string' },
attachments: {
type: 'object',
properties: {},
additionalProperties: true,
},
});
// The optional arg is omitted from `required`, so the schema's enforced
// shape matches the guard's optional trailing arg.
expect(method.schema.required).toStrictEqual(['final']);
});

it('handles a no-arg method', () => {
const method = S.method('Get the moon phase.', [], S.string());
expect(method.schema.args).toStrictEqual({});
expect(payloadOf(method.guard).argGuards).toHaveLength(0);
});

it('throws when an optional argument precedes a required one', () => {
expect(() =>
S.method(
'bad',
[S.arg('a', S.string(), { optional: true }), S.arg('b', S.string())],
S.nothing(),
),
).toThrow(/optional arguments must be trailing/u);
});
});

describe('S.interface', () => {
it('collects method guards and schemas, defaulting unlisted methods to passable', () => {
const { interfaceGuard, schemas } = S.interface('Math', {
add: S.method(
'Add a list of numbers.',
[S.arg('summands', S.arrayOf(S.number()))],
S.number('The sum of the numbers.'),
),
count: S.method(
'Count characters.',
[S.arg('word', S.string('The string to measure.'))],
S.number(),
),
});

expect(Object.keys(schemas)).toStrictEqual(['add', 'count']);
const payload = getInterfaceGuardPayload(interfaceGuard) as unknown as {
interfaceName: string;
methodGuards: Record<string, unknown>;
defaultGuards?: string;
};
expect(payload.interfaceName).toBe('Math');
expect(Object.keys(payload.methodGuards)).toStrictEqual(['add', 'count']);
expect(payload.defaultGuards).toBe('passable');
});
});
Loading
Loading