@peers-app/peers-sdk 0.23.10 → 0.23.12

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (37) hide show
  1. package/dist/contracts/__tests__/builder.test.js +25 -0
  2. package/dist/contracts/__tests__/contract-provider-router.test.js +45 -0
  3. package/dist/contracts/__tests__/contract-proxy.test.js +67 -0
  4. package/dist/contracts/__tests__/contract-resolvers.test.js +133 -0
  5. package/dist/contracts/__tests__/remote-contract-transport.test.d.ts +1 -0
  6. package/dist/contracts/__tests__/remote-contract-transport.test.js +89 -0
  7. package/dist/contracts/builder.d.ts +47 -1
  8. package/dist/contracts/builder.js +80 -22
  9. package/dist/contracts/contract-provider-router.d.ts +6 -0
  10. package/dist/contracts/contract-provider-router.js +25 -3
  11. package/dist/contracts/contract-proxy.d.ts +41 -1
  12. package/dist/contracts/contract-proxy.js +103 -0
  13. package/dist/contracts/contract-resolvers.d.ts +47 -13
  14. package/dist/contracts/contract-resolvers.js +125 -35
  15. package/dist/contracts/extract.d.ts +4 -1
  16. package/dist/contracts/extract.js +3 -1
  17. package/dist/contracts/index.d.ts +4 -3
  18. package/dist/contracts/index.js +10 -2
  19. package/dist/contracts/remote-contract-transport.d.ts +69 -0
  20. package/dist/contracts/remote-contract-transport.js +95 -0
  21. package/dist/contracts/types.d.ts +57 -0
  22. package/dist/data/tools.d.ts +4 -0
  23. package/dist/data/tools.js +7 -0
  24. package/dist/package-loader/isolated-artifact.d.ts +44 -5
  25. package/dist/package-loader/isolated-artifact.js +82 -4
  26. package/dist/package-loader/isolated-artifact.test.js +51 -3
  27. package/dist/package-loader/isolated-runtime.d.ts +17 -2
  28. package/dist/package-loader/isolated-runtime.js +3 -1
  29. package/dist/package-loader/isolated-table-gateway.d.ts +1 -1
  30. package/dist/package-loader/isolated-table-gateway.js +54 -0
  31. package/dist/package-loader/isolated-table-gateway.test.js +61 -0
  32. package/dist/package-loader/package-loader.d.ts +15 -1
  33. package/dist/package-loader/package-loader.js +48 -0
  34. package/dist/package-loader/resolve-consumed-contracts.d.ts +6 -2
  35. package/dist/package-loader/resolve-consumed-contracts.js +30 -2
  36. package/dist/package-loader/resolve-consumed-contracts.test.js +43 -0
  37. package/package.json +1 -1
@@ -123,6 +123,31 @@ describe("definePackage", () => {
123
123
  expect(result.consumes[1].version).toBe(2);
124
124
  expect(result.consumes[1].handle).toBe(authHandle);
125
125
  });
126
+ it("records remote contracts as deferred handles without requiring a consume", () => {
127
+ let remoteHandle;
128
+ const result = (0, builder_1.definePackage)((pkg) => {
129
+ pkg.packageId = TEST_PKG_ID;
130
+ // A package may target its own contract on other devices.
131
+ const projects = pkg.contract(PROJECTS_ID, 1, "Projects");
132
+ projects.tools = [];
133
+ remoteHandle = pkg.remote(PROJECTS_ID, 1);
134
+ });
135
+ expect(result.consumes).toHaveLength(0);
136
+ expect(result.remotes).toHaveLength(1);
137
+ expect(result.remotes?.[0]).toMatchObject({ contractId: PROJECTS_ID, version: 1 });
138
+ expect(result.remotes?.[0].handle).toBe(remoteHandle);
139
+ expect(remoteHandle?.consumer).toBeInstanceOf(Promise);
140
+ });
141
+ it("validates remote declarations", () => {
142
+ expect(() => (0, builder_1.definePackage)((pkg) => {
143
+ pkg.packageId = TEST_PKG_ID;
144
+ pkg.remote("not-an-id", 1);
145
+ })).toThrow("remote contractId must be a valid peer ID");
146
+ expect(() => (0, builder_1.definePackage)((pkg) => {
147
+ pkg.packageId = TEST_PKG_ID;
148
+ pkg.remote(TASKS_ID, 0);
149
+ })).toThrow("remote version must be a positive integer");
150
+ });
126
151
  it("preserves consume options and returns a deferred handle", () => {
127
152
  const preferredProviderId = (0, utils_1.newid)();
128
153
  const result = (0, builder_1.definePackage)((pkg) => {
@@ -216,4 +216,49 @@ describe("contract provider router", () => {
216
216
  expect(endpoint.dispose).toHaveBeenCalledTimes(1);
217
217
  expect(await consumerTransport.emit(index_1.CONTRACT_REQUEST_CHANNEL, makeCall())).toBeUndefined();
218
218
  });
219
+ it("routes device-targeted tool calls by target without selecting a local provider", async () => {
220
+ const [consumerTransport, providerTransport] = (0, index_1.inProcessTransportPair)();
221
+ const resolveProviderPackageId = jest.fn(async () => "local-provider");
222
+ const resolveEndpoint = jest.fn((request) => makeEndpoint(`${request.providerPackageId ?? "none"}@${request.targetDeviceId ?? "local"}`));
223
+ const router = (0, index_1.createContractProviderRouter)(providerTransport, {
224
+ authorizeCall: () => ({ dataContextId: "group-context" }),
225
+ resolveProviderPackageId,
226
+ resolveEndpoint,
227
+ });
228
+ const toolCall = makeCall({ member: "tool", name: "ping", operation: "run", args: [{}] });
229
+ await expect(consumerTransport.emit(index_1.CONTRACT_REQUEST_CHANNEL, { ...toolCall, targetDeviceId: "device-b" })).resolves.toBe("none@device-b:run");
230
+ await consumerTransport.emit(index_1.CONTRACT_REQUEST_CHANNEL, {
231
+ ...toolCall,
232
+ targetDeviceId: "device-b",
233
+ });
234
+ await expect(consumerTransport.emit(index_1.CONTRACT_REQUEST_CHANNEL, { ...toolCall, targetDeviceId: "device-c" })).resolves.toBe("none@device-c:run");
235
+ await expect(consumerTransport.emit(index_1.CONTRACT_REQUEST_CHANNEL, toolCall)).resolves.toBe("local-provider@local:run");
236
+ // One endpoint per target device plus one local; provider selection only ran locally.
237
+ expect(resolveEndpoint).toHaveBeenCalledTimes(3);
238
+ expect(resolveProviderPackageId).toHaveBeenCalledTimes(1);
239
+ expect(resolveEndpoint.mock.calls[0][0]).toMatchObject({
240
+ targetDeviceId: "device-b",
241
+ dataContextId: "group-context",
242
+ providerPackageId: undefined,
243
+ });
244
+ router.dispose();
245
+ });
246
+ it("rejects device-targeted calls that are not tool runs", async () => {
247
+ const [consumerTransport, providerTransport] = (0, index_1.inProcessTransportPair)();
248
+ const resolveEndpoint = jest.fn(() => makeEndpoint("never"));
249
+ const router = (0, index_1.createContractProviderRouter)(providerTransport, {
250
+ authorizeCall: () => ({ dataContextId: "group-context" }),
251
+ resolveEndpoint,
252
+ });
253
+ await expect(consumerTransport.emit(index_1.CONTRACT_REQUEST_CHANNEL, makeCall({ targetDeviceId: "device-b" }))).rejects.toThrow("only tool runs are remotely callable");
254
+ await expect(consumerTransport.emit(index_1.CONTRACT_REQUEST_CHANNEL, makeCall({
255
+ member: "event",
256
+ name: "changed",
257
+ operation: "subscribe",
258
+ subscriptionId: (0, utils_1.newid)(),
259
+ targetDeviceId: "device-b",
260
+ }))).rejects.toThrow("only tool runs are remotely callable");
261
+ expect(resolveEndpoint).not.toHaveBeenCalled();
262
+ router.dispose();
263
+ });
219
264
  });
@@ -306,6 +306,73 @@ describe("contract proxy — consumer/provider round-trip", () => {
306
306
  expect(proxied).not.toHaveBeenCalled();
307
307
  });
308
308
  });
309
+ describe("contract proxy — remote (device-targeted) consumers", () => {
310
+ /** Wire a remote factory to a request handler that echoes the target device. */
311
+ function wireRemote(contract = makeContract({ observable: true })) {
312
+ const seen = [];
313
+ const [consumerTransport, providerTransport] = (0, contract_transport_1.inProcessTransportPair)();
314
+ const offRequest = providerTransport.on(contract_transport_1.CONTRACT_REQUEST_CHANNEL, async (call) => {
315
+ seen.push(call);
316
+ return { ok: call.targetDeviceId };
317
+ });
318
+ const remote = (0, contract_proxy_1.createRemoteContractConsumer)(contract, consumerTransport, {
319
+ dataContextId: "group-1",
320
+ });
321
+ cleanups.push(() => {
322
+ remote.dispose();
323
+ offRequest();
324
+ });
325
+ return { remote, seen, contract };
326
+ }
327
+ it("stamps targetDeviceId and the data context on every tool call", async () => {
328
+ const { remote, seen, contract } = wireRemote();
329
+ const deviceB = remote.device("device-b");
330
+ await expect(deviceB.tools["make-widget"]({ color: "red" })).resolves.toEqual({
331
+ ok: "device-b",
332
+ });
333
+ expect(seen).toHaveLength(1);
334
+ expect(seen[0]).toMatchObject({
335
+ member: "tool",
336
+ name: "make-widget",
337
+ operation: "run",
338
+ targetDeviceId: "device-b",
339
+ dataContextId: "group-1",
340
+ args: [{ color: "red" }],
341
+ });
342
+ // Nothing implicit hits the wire: no observable subscribe/get for remote consumers.
343
+ expect(seen.filter((call) => call.member === "observable")).toHaveLength(0);
344
+ expect(deviceB.contractId).toBe(contract.contractId);
345
+ expect(remote.contractId).toBe(contract.contractId);
346
+ await expect(deviceB.loadingPromise).resolves.toBeUndefined();
347
+ });
348
+ it("exposes only tools on a device consumer", () => {
349
+ const { remote } = wireRemote();
350
+ const deviceB = remote.device("device-b");
351
+ expect(() => deviceB.tables.Widgets).toThrow("not remotely accessible");
352
+ expect(() => deviceB.observables.widgetCount).toThrow("not remotely accessible");
353
+ expect(() => deviceB.events.anything).toThrow("not remotely accessible");
354
+ expect(Object.keys(deviceB.tools)).toEqual(["make-widget"]);
355
+ });
356
+ it("caches one consumer per device and rejects empty device ids", () => {
357
+ const { remote } = wireRemote();
358
+ expect(remote.device("device-b")).toBe(remote.device("device-b"));
359
+ expect(remote.device("device-b")).not.toBe(remote.device("device-c"));
360
+ expect(() => remote.device("")).toThrow("non-empty device id");
361
+ });
362
+ it("disposes device consumers with the factory and independently", async () => {
363
+ const { remote } = wireRemote();
364
+ const deviceA = remote.device("device-a");
365
+ const deviceB = remote.device("device-b");
366
+ deviceA.dispose();
367
+ await expect(deviceA.tools["make-widget"]({ color: "x" })).rejects.toThrow("disposed");
368
+ await expect(deviceB.tools["make-widget"]({ color: "x" })).resolves.toBeDefined();
369
+ // A disposed device consumer is evicted so the next lookup gets a fresh one.
370
+ expect(remote.device("device-a")).not.toBe(deviceA);
371
+ remote.dispose();
372
+ await expect(deviceB.tools["make-widget"]({ color: "x" })).rejects.toThrow("disposed");
373
+ expect(() => remote.device("device-c")).toThrow("disposed");
374
+ });
375
+ });
309
376
  describe("contract proxy — error handling", () => {
310
377
  it("rejects an unknown table method", async () => {
311
378
  const { consumer } = wire();
@@ -1,5 +1,6 @@
1
1
  "use strict";
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
+ const access_level_1 = require("../../data/access-level");
3
4
  const user_permissions_1 = require("../../data/user-permissions");
4
5
  const user_trust_levels_1 = require("../../data/user-trust-levels");
5
6
  const users_1 = require("../../data/users");
@@ -13,8 +14,18 @@ jest.mock("../../data/group-permissions", () => {
13
14
  return {
14
15
  ...actual,
15
16
  getUserRole: jest.fn(async () => actual.GroupMemberRole.Founder),
17
+ // The real module is loaded inside this factory (via ../context), which re-enters the
18
+ // mock and can yield two jest.fn instances. Shared global state keeps every instance
19
+ // in sync so tests can drive and observe the level the resolvers actually see.
20
+ getUserAccessLevel: jest.fn(async (dataContext, userId) => {
21
+ const state = globalThis.__remoteAccessLevelMock;
22
+ state?.calls.push([dataContext, userId]);
23
+ return state?.level ?? 80;
24
+ }),
16
25
  };
17
26
  });
27
+ const accessLevelMock = { level: 80, calls: [] };
28
+ globalThis.__remoteAccessLevelMock = accessLevelMock;
18
29
  jest.mock("../../data/user-trust-levels", () => ({
19
30
  UserTrustLevels: jest.fn(),
20
31
  }));
@@ -174,6 +185,128 @@ describe("Self-trusted contract caller authorization", () => {
174
185
  router.dispose();
175
186
  });
176
187
  });
188
+ describe("remote contract call authorization (group members)", () => {
189
+ const groupId = "group";
190
+ const contractId = (0, utils_1.newid)();
191
+ const toolCall = {
192
+ contractId,
193
+ version: 1,
194
+ member: "tool",
195
+ name: "ping",
196
+ operation: "run",
197
+ args: [{}],
198
+ dataContextId: groupId,
199
+ };
200
+ function setupGroupMember(opts) {
201
+ const { remoteIdentity, user } = makeIdentity();
202
+ const records = new Map();
203
+ const { groupContext, userContext } = setupUserContext((0, utils_1.newid)(), records);
204
+ records.set(groupContext, user);
205
+ mockTrustGet.mockResolvedValue({ trustLevel: socket_type_1.TrustLevel.Trusted });
206
+ accessLevelMock.level = opts.callerLevel;
207
+ accessLevelMock.calls = [];
208
+ const definition = {
209
+ contractId,
210
+ version: 1,
211
+ name: "Ping",
212
+ description: "",
213
+ tables: [],
214
+ tools: [
215
+ {
216
+ name: "ping",
217
+ usageDescription: "",
218
+ inputFields: [],
219
+ outputFields: [],
220
+ ...(opts.remoteAccessLevel !== undefined
221
+ ? { remoteAccessLevel: opts.remoteAccessLevel }
222
+ : {}),
223
+ },
224
+ { name: "localOnly", usageDescription: "", inputFields: [], outputFields: [] },
225
+ ],
226
+ observables: [],
227
+ events: [],
228
+ };
229
+ const groupDataContext = {
230
+ groupId,
231
+ packageLoader: {
232
+ contractRegistry: {
233
+ getDefinition: jest.fn((id, version) => id === contractId && version === 1 ? definition : undefined),
234
+ },
235
+ },
236
+ };
237
+ userContext.groupIds = jest.fn(() => [groupId]);
238
+ userContext.getDataContext = jest.fn((id) => id === groupId ? groupDataContext : { groupId: undefined });
239
+ return { remoteIdentity, userContext, groupDataContext };
240
+ }
241
+ beforeEach(() => {
242
+ jest.clearAllMocks();
243
+ });
244
+ it("allows a verified group member whose role meets the tool's remoteAccessLevel", async () => {
245
+ const { remoteIdentity, userContext, groupDataContext } = setupGroupMember({
246
+ remoteAccessLevel: access_level_1.AccessLevel.Writer,
247
+ callerLevel: access_level_1.AccessLevel.Writer,
248
+ });
249
+ const route = await (0, index_1.authorizeRemoteContractCall)(toolCall, userContext, remoteIdentity);
250
+ expect(route.dataContextId).toBe(groupId);
251
+ expect(route.context?.callerUserId).toBe(remoteIdentity.userId);
252
+ expect(route.context?.callerDeviceId).toBe(remoteIdentity.deviceId);
253
+ expect(route.context?.dataContext).toBe(groupDataContext);
254
+ expect(accessLevelMock.calls).toEqual([[groupDataContext, remoteIdentity.userId]]);
255
+ });
256
+ it("denies a group member below the tool's remoteAccessLevel", async () => {
257
+ const { remoteIdentity, userContext } = setupGroupMember({
258
+ remoteAccessLevel: access_level_1.AccessLevel.Admin,
259
+ callerLevel: access_level_1.AccessLevel.Writer,
260
+ });
261
+ await expect((0, index_1.authorizeRemoteContractCall)(toolCall, userContext, remoteIdentity)).rejects.toThrow("requires Admin (60)");
262
+ });
263
+ it("denies a remoteAccessLevel of 0 to callers with no role", async () => {
264
+ const { remoteIdentity, userContext } = setupGroupMember({
265
+ remoteAccessLevel: access_level_1.AccessLevel.None,
266
+ callerLevel: access_level_1.AccessLevel.None,
267
+ });
268
+ await expect((0, index_1.authorizeRemoteContractCall)(toolCall, userContext, remoteIdentity)).rejects.toThrow("Permission denied");
269
+ });
270
+ it("denies tools that are not marked remotely callable", async () => {
271
+ const { remoteIdentity, userContext } = setupGroupMember({ callerLevel: access_level_1.AccessLevel.Owner });
272
+ await expect((0, index_1.authorizeRemoteContractCall)({ ...toolCall, name: "localOnly" }, userContext, remoteIdentity)).rejects.toThrow("not remotely callable");
273
+ await expect((0, index_1.authorizeRemoteContractCall)(toolCall, userContext, remoteIdentity)).rejects.toThrow("not remotely callable");
274
+ });
275
+ it("denies tools missing from the registered contract definition", async () => {
276
+ const { remoteIdentity, userContext } = setupGroupMember({ callerLevel: access_level_1.AccessLevel.Owner });
277
+ await expect((0, index_1.authorizeRemoteContractCall)({ ...toolCall, name: "missing" }, userContext, remoteIdentity)).rejects.toThrow("not provided in this data context");
278
+ });
279
+ it("denies non-tool members and subscriptions for group callers", async () => {
280
+ const { remoteIdentity, userContext } = setupGroupMember({
281
+ remoteAccessLevel: access_level_1.AccessLevel.Reader,
282
+ callerLevel: access_level_1.AccessLevel.Owner,
283
+ });
284
+ await expect((0, index_1.authorizeRemoteContractCall)({ ...toolCall, member: "table", name: "Items", operation: "list" }, userContext, remoteIdentity)).rejects.toThrow("only tool runs are remotely callable");
285
+ await expect((0, index_1.authorizeRemoteContractCall)({ ...toolCall, member: "event", operation: "subscribe", subscriptionId: (0, utils_1.newid)() }, userContext, remoteIdentity)).rejects.toThrow("only tool runs are remotely callable");
286
+ });
287
+ it("requires an explicit shared-group data context", async () => {
288
+ const { remoteIdentity, userContext } = setupGroupMember({
289
+ remoteAccessLevel: access_level_1.AccessLevel.Reader,
290
+ callerLevel: access_level_1.AccessLevel.Owner,
291
+ });
292
+ await expect((0, index_1.authorizeRemoteContractCall)({ ...toolCall, dataContextId: undefined }, userContext, remoteIdentity)).rejects.toThrow("shared group data context is required");
293
+ await expect((0, index_1.authorizeRemoteContractCall)({ ...toolCall, dataContextId: "unknown-group" }, userContext, remoteIdentity)).rejects.toThrow("shared group data context is required");
294
+ });
295
+ it("rejects relayed calls that still carry a targetDeviceId", async () => {
296
+ const { remoteIdentity, userContext } = setupGroupMember({
297
+ remoteAccessLevel: access_level_1.AccessLevel.Reader,
298
+ callerLevel: access_level_1.AccessLevel.Owner,
299
+ });
300
+ await expect((0, index_1.authorizeRemoteContractCall)({ ...toolCall, targetDeviceId: (0, utils_1.newid)() }, userContext, remoteIdentity)).rejects.toThrow("cannot relay");
301
+ });
302
+ it("keeps full access for Self-trusted callers without consulting remoteAccessLevel", async () => {
303
+ const { remoteIdentity, userContext } = setupGroupMember({ callerLevel: access_level_1.AccessLevel.None });
304
+ mockTrustGet.mockResolvedValue({ trustLevel: socket_type_1.TrustLevel.Self });
305
+ const route = await (0, index_1.authorizeRemoteContractCall)({ ...toolCall, name: "localOnly" }, userContext, remoteIdentity);
306
+ expect(route.dataContextId).toBe(groupId);
307
+ expect(accessLevelMock.calls).toEqual([]);
308
+ });
309
+ });
177
310
  describe("local user-context contract routing", () => {
178
311
  function setupLocalRouter() {
179
312
  const userId = (0, utils_1.newid)();
@@ -0,0 +1,89 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ const index_1 = require("../index");
4
+ const makeRequest = (overrides = {}) => ({
5
+ contractId: "contract-a",
6
+ version: 1,
7
+ dataContextId: "group-1",
8
+ targetDeviceId: "device-b",
9
+ context: {},
10
+ notify: async () => undefined,
11
+ ...overrides,
12
+ });
13
+ const toolCall = {
14
+ contractId: "contract-a",
15
+ version: 1,
16
+ member: "tool",
17
+ name: "ping",
18
+ operation: "run",
19
+ args: [{ n: 1 }],
20
+ dataContextId: "group-1",
21
+ targetDeviceId: "device-b",
22
+ };
23
+ function makeTransport() {
24
+ const emit = jest.fn(async (_channel, ..._args) => "pong");
25
+ const transport = { emit, on: () => () => undefined };
26
+ return { transport, emit };
27
+ }
28
+ afterEach(() => {
29
+ (0, index_1.setRemoteContractTransportFactory)(undefined);
30
+ });
31
+ describe("remote contract endpoint", () => {
32
+ it("strips targetDeviceId and pins the authorized data context before emitting", async () => {
33
+ const { transport, emit } = makeTransport();
34
+ const factory = jest.fn(() => transport);
35
+ const endpoint = (0, index_1.createRemoteContractEndpoint)(makeRequest(), factory);
36
+ await expect(endpoint.handleCall(toolCall, {})).resolves.toBe("pong");
37
+ expect(factory).toHaveBeenCalledWith({ deviceId: "device-b", dataContextId: "group-1" });
38
+ expect(emit).toHaveBeenCalledTimes(1);
39
+ const [channel, sent] = emit.mock.calls[0];
40
+ expect(channel).toBe(index_1.CONTRACT_REQUEST_CHANNEL);
41
+ expect(sent).not.toHaveProperty("targetDeviceId");
42
+ expect(sent).toMatchObject({ name: "ping", dataContextId: "group-1", args: [{ n: 1 }] });
43
+ });
44
+ it("refuses to forward anything but tool runs and rejects after dispose", async () => {
45
+ const { transport, emit } = makeTransport();
46
+ const endpoint = (0, index_1.createRemoteContractEndpoint)(makeRequest(), () => transport);
47
+ await expect(endpoint.handleCall({ ...toolCall, member: "table", name: "Items", operation: "list" }, {})).rejects.toThrow("cannot be forwarded");
48
+ endpoint.dispose();
49
+ await expect(endpoint.handleCall(toolCall, {})).rejects.toThrow("disposed");
50
+ expect(emit).not.toHaveBeenCalled();
51
+ });
52
+ it("fails clearly when no transport factory is registered or the route is incomplete", () => {
53
+ expect(() => (0, index_1.createRemoteContractEndpoint)(makeRequest())).toThrow("no remote contract transport is registered");
54
+ expect(() => (0, index_1.createRemoteContractEndpoint)(makeRequest({ dataContextId: undefined }), () => makeTransport().transport)).toThrow("data context");
55
+ expect(() => (0, index_1.createRemoteContractEndpoint)(makeRequest({ targetDeviceId: undefined }), () => makeTransport().transport)).toThrow("target device");
56
+ });
57
+ });
58
+ describe("resolveRemoteTargetEndpoint", () => {
59
+ it("returns undefined for local routes and for the local device itself", () => {
60
+ (0, index_1.setRemoteContractTransportFactory)(() => makeTransport().transport);
61
+ expect((0, index_1.resolveRemoteTargetEndpoint)(makeRequest({ targetDeviceId: undefined }), {
62
+ localDeviceId: "device-a",
63
+ })).toBeUndefined();
64
+ expect((0, index_1.resolveRemoteTargetEndpoint)(makeRequest({ targetDeviceId: "device-a" }), {
65
+ localDeviceId: "device-a",
66
+ })).toBeUndefined();
67
+ });
68
+ it("prefers a factory scoped to the owning user context over the process default", async () => {
69
+ const scoped = makeTransport();
70
+ const fallback = makeTransport();
71
+ const userContext = {};
72
+ (0, index_1.setRemoteContractTransportFactory)(() => fallback.transport);
73
+ (0, index_1.setRemoteContractTransportFactory)(() => scoped.transport, { userContext });
74
+ const scopedEndpoint = (0, index_1.resolveRemoteTargetEndpoint)(makeRequest(), {
75
+ localDeviceId: "device-a",
76
+ userContext,
77
+ });
78
+ const fallbackEndpoint = (0, index_1.resolveRemoteTargetEndpoint)(makeRequest(), {
79
+ localDeviceId: "device-a",
80
+ });
81
+ await scopedEndpoint?.handleCall(toolCall, {});
82
+ await fallbackEndpoint?.handleCall(toolCall, {});
83
+ expect(scoped.emit).toHaveBeenCalledTimes(1);
84
+ expect(fallback.emit).toHaveBeenCalledTimes(1);
85
+ (0, index_1.setRemoteContractTransportFactory)(undefined, { userContext });
86
+ (0, index_1.setRemoteContractTransportFactory)(undefined);
87
+ expect(() => (0, index_1.resolveRemoteTargetEndpoint)(makeRequest(), { userContext })).toThrow("no remote contract transport is registered");
88
+ });
89
+ });
@@ -2,7 +2,7 @@ import type { ITableDefinition } from "../data/orm/table-definitions.type";
2
2
  import type { IToolInstance } from "../data/tools";
3
3
  import type { IContractConsumer, IContractConsumerBase } from "./contract-proxy";
4
4
  import type { IExtractableTableDef, IExtractableToolInstance } from "./extract";
5
- import type { IAlsoImplementsDeclaration, IConsumedContractHandle, IConsumedContractOptions, IContractDefinition, IContractEvent, IContractObservable, IPackageDefinitionResult } from "./types";
5
+ import type { IAlsoImplementsDeclaration, IConsumedContractHandle, IConsumedContractOptions, IContractDefinition, IContractEvent, IContractObservable, IPackageDefinitionResult, IRemoteContractHandle } from "./types";
6
6
  /**
7
7
  * Scoped builder for a single contract within a package. Returned by
8
8
  * `PackageBuilder.contract()`.
@@ -57,6 +57,20 @@ export declare class ContractBuilder {
57
57
  tableDefinitions: ITableDefinition[];
58
58
  };
59
59
  }
60
+ /**
61
+ * Shape shared by {@link IConsumedContractHandle} and {@link IRemoteContractHandle}:
62
+ * a non-thenable object whose `consumer` promise the SDK settles after load.
63
+ */
64
+ export interface IDeferredContractHandle<V> {
65
+ readonly consumer: Promise<V>;
66
+ }
67
+ /**
68
+ * Create a deferred contract handle. Settlement is SDK-owned via
69
+ * {@link settleContractHandle} / {@link rejectContractHandle}.
70
+ *
71
+ * @internal
72
+ */
73
+ export declare function createDeferredContractHandle<V>(): IDeferredContractHandle<V>;
60
74
  /**
61
75
  * Create a deferred consumed-contract handle. Settlement is SDK-owned via
62
76
  * {@link settleConsumedContractHandle} / {@link rejectConsumedContractHandle}.
@@ -64,6 +78,18 @@ export declare class ContractBuilder {
64
78
  * @internal
65
79
  */
66
80
  export declare function createConsumedContractHandle<T extends IContractConsumerBase | undefined = IContractConsumerBase | undefined>(): IConsumedContractHandle<T>;
81
+ /**
82
+ * Resolve a deferred contract handle. One-shot — throws if already settled.
83
+ *
84
+ * @internal Used by the package-loader when settling `consumes` / `remote` handles.
85
+ */
86
+ export declare function settleContractHandle<V>(handle: IDeferredContractHandle<V>, value: V): void;
87
+ /**
88
+ * Reject a deferred contract handle. One-shot — throws if already settled.
89
+ *
90
+ * @internal Used by the package-loader when settling `consumes` / `remote` handles.
91
+ */
92
+ export declare function rejectContractHandle(handle: IDeferredContractHandle<unknown>, reason: unknown): void;
67
93
  /**
68
94
  * Resolve a consumed-contract handle with a live proxy, or `undefined` for an
69
95
  * unmatched optional dependency. One-shot — throws if already settled.
@@ -92,6 +118,7 @@ export declare class PackageBuilder {
92
118
  private _version?;
93
119
  private _contracts;
94
120
  private _consumes;
121
+ private _remotes;
95
122
  /** Package-level assistants (not part of any contract). */
96
123
  assistants: unknown[];
97
124
  /** Package-level app navigation items (not part of any contract). */
@@ -138,6 +165,25 @@ export declare class PackageBuilder {
138
165
  consumes<T extends IContractConsumerBase = IContractConsumer>(contractId: string, version: number, opts?: Omit<IConsumedContractOptions, "optional"> & {
139
166
  optional?: false;
140
167
  }): IConsumedContractHandle<T>;
168
+ /**
169
+ * Declare that this package calls a contract's tools on **other devices**.
170
+ *
171
+ * A sibling of {@link consumes} for the mesh: the resolved
172
+ * {@link IRemoteContractConsumer} hands out a tools-only consumer per target device
173
+ * (`remote.device(deviceId).tools.x(...)`). No local provider is needed — a package
174
+ * commonly targets its *own* contract on a peer's device — and there is no `optional`
175
+ * flag because whether the remote device can serve the call is only known per call.
176
+ * The loader settles the handle once the contract definition is registered locally
177
+ * (from this package's `provides` or any installed package) and rejects it otherwise.
178
+ *
179
+ * Await `handle.consumer` from runtime code after package definition, not inside
180
+ * this callback.
181
+ *
182
+ * @typeParam T - Consumer proxy type (defaults to {@link IContractConsumer}).
183
+ * @param contractId - Must be a valid peer ID (25-char alphanumeric from `newid()`).
184
+ * @param version - Positive integer version number.
185
+ */
186
+ remote<T extends IContractConsumerBase = IContractConsumer>(contractId: string, version: number): IRemoteContractHandle<T>;
141
187
  /** @internal Finalize and return the package definition result. */
142
188
  _build(): IPackageDefinitionResult;
143
189
  }
@@ -1,7 +1,10 @@
1
1
  "use strict";
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
3
  exports.PackageBuilder = exports.ContractBuilder = void 0;
4
+ exports.createDeferredContractHandle = createDeferredContractHandle;
4
5
  exports.createConsumedContractHandle = createConsumedContractHandle;
6
+ exports.settleContractHandle = settleContractHandle;
7
+ exports.rejectContractHandle = rejectContractHandle;
5
8
  exports.settleConsumedContractHandle = settleConsumedContractHandle;
6
9
  exports.rejectConsumedContractHandle = rejectConsumedContractHandle;
7
10
  exports.definePackage = definePackage;
@@ -106,14 +109,14 @@ class ContractBuilder {
106
109
  }
107
110
  }
108
111
  exports.ContractBuilder = ContractBuilder;
109
- const consumedContractDeferreds = new WeakMap();
112
+ const contractHandleDeferreds = new WeakMap();
110
113
  /**
111
- * Create a deferred consumed-contract handle. Settlement is SDK-owned via
112
- * {@link settleConsumedContractHandle} / {@link rejectConsumedContractHandle}.
114
+ * Create a deferred contract handle. Settlement is SDK-owned via
115
+ * {@link settleContractHandle} / {@link rejectContractHandle}.
113
116
  *
114
117
  * @internal
115
118
  */
116
- function createConsumedContractHandle() {
119
+ function createDeferredContractHandle() {
117
120
  let resolve;
118
121
  let reject;
119
122
  const consumer = new Promise((res, rej) => {
@@ -124,25 +127,53 @@ function createConsumedContractHandle() {
124
127
  // error before any package code awaits the handle.
125
128
  consumer.catch(() => { });
126
129
  const handle = { consumer };
127
- consumedContractDeferreds.set(handle, { resolve, reject, settled: false });
130
+ contractHandleDeferreds.set(handle, { resolve, reject, settled: false });
128
131
  return handle;
129
132
  }
130
133
  /**
131
- * Resolve a consumed-contract handle with a live proxy, or `undefined` for an
132
- * unmatched optional dependency. One-shot — throws if already settled.
134
+ * Create a deferred consumed-contract handle. Settlement is SDK-owned via
135
+ * {@link settleConsumedContractHandle} / {@link rejectConsumedContractHandle}.
133
136
  *
134
- * @internal Used by the package-loader when settling `consumes` handles.
137
+ * @internal
135
138
  */
136
- function settleConsumedContractHandle(handle, consumer) {
137
- const deferred = consumedContractDeferreds.get(handle);
139
+ function createConsumedContractHandle() {
140
+ return createDeferredContractHandle();
141
+ }
142
+ function takeDeferred(handle) {
143
+ const deferred = contractHandleDeferreds.get(handle);
138
144
  if (!deferred) {
139
- throw new Error("Unknown consumed-contract handle");
145
+ throw new Error("Unknown contract handle");
140
146
  }
141
147
  if (deferred.settled) {
142
- throw new Error("Consumed-contract handle has already been settled");
148
+ throw new Error("Contract handle has already been settled");
143
149
  }
144
150
  deferred.settled = true;
145
- deferred.resolve(consumer);
151
+ return deferred;
152
+ }
153
+ /**
154
+ * Resolve a deferred contract handle. One-shot — throws if already settled.
155
+ *
156
+ * @internal Used by the package-loader when settling `consumes` / `remote` handles.
157
+ */
158
+ function settleContractHandle(handle, value) {
159
+ takeDeferred(handle).resolve(value);
160
+ }
161
+ /**
162
+ * Reject a deferred contract handle. One-shot — throws if already settled.
163
+ *
164
+ * @internal Used by the package-loader when settling `consumes` / `remote` handles.
165
+ */
166
+ function rejectContractHandle(handle, reason) {
167
+ takeDeferred(handle).reject(reason);
168
+ }
169
+ /**
170
+ * Resolve a consumed-contract handle with a live proxy, or `undefined` for an
171
+ * unmatched optional dependency. One-shot — throws if already settled.
172
+ *
173
+ * @internal Used by the package-loader when settling `consumes` handles.
174
+ */
175
+ function settleConsumedContractHandle(handle, consumer) {
176
+ settleContractHandle(handle, consumer);
146
177
  }
147
178
  /**
148
179
  * Reject a consumed-contract handle (e.g. required dependency missing).
@@ -151,15 +182,7 @@ function settleConsumedContractHandle(handle, consumer) {
151
182
  * @internal Used by the package-loader when settling `consumes` handles.
152
183
  */
153
184
  function rejectConsumedContractHandle(handle, reason) {
154
- const deferred = consumedContractDeferreds.get(handle);
155
- if (!deferred) {
156
- throw new Error("Unknown consumed-contract handle");
157
- }
158
- if (deferred.settled) {
159
- throw new Error("Consumed-contract handle has already been settled");
160
- }
161
- deferred.settled = true;
162
- deferred.reject(reason);
185
+ rejectContractHandle(handle, reason);
163
186
  }
164
187
  /**
165
188
  * Top-level builder passed to the `definePackage` callback.
@@ -175,6 +198,7 @@ class PackageBuilder {
175
198
  _version;
176
199
  _contracts = [];
177
200
  _consumes = [];
201
+ _remotes = [];
178
202
  /** Package-level assistants (not part of any contract). */
179
203
  assistants = [];
180
204
  /** Package-level app navigation items (not part of any contract). */
@@ -248,6 +272,39 @@ class PackageBuilder {
248
272
  });
249
273
  return handle;
250
274
  }
275
+ /**
276
+ * Declare that this package calls a contract's tools on **other devices**.
277
+ *
278
+ * A sibling of {@link consumes} for the mesh: the resolved
279
+ * {@link IRemoteContractConsumer} hands out a tools-only consumer per target device
280
+ * (`remote.device(deviceId).tools.x(...)`). No local provider is needed — a package
281
+ * commonly targets its *own* contract on a peer's device — and there is no `optional`
282
+ * flag because whether the remote device can serve the call is only known per call.
283
+ * The loader settles the handle once the contract definition is registered locally
284
+ * (from this package's `provides` or any installed package) and rejects it otherwise.
285
+ *
286
+ * Await `handle.consumer` from runtime code after package definition, not inside
287
+ * this callback.
288
+ *
289
+ * @typeParam T - Consumer proxy type (defaults to {@link IContractConsumer}).
290
+ * @param contractId - Must be a valid peer ID (25-char alphanumeric from `newid()`).
291
+ * @param version - Positive integer version number.
292
+ */
293
+ remote(contractId, version) {
294
+ if (!(0, utils_1.isid)(contractId)) {
295
+ throw new Error(`remote contractId must be a valid peer ID (25-char alphanumeric), got "${contractId}"`);
296
+ }
297
+ if (version < 1 || !Number.isInteger(version)) {
298
+ throw new Error(`remote version must be a positive integer, got ${version}`);
299
+ }
300
+ const handle = createDeferredContractHandle();
301
+ this._remotes.push({
302
+ contractId,
303
+ version,
304
+ handle: handle,
305
+ });
306
+ return handle;
307
+ }
251
308
  /** @internal Finalize and return the package definition result. */
252
309
  _build() {
253
310
  if (!this._packageId) {
@@ -274,6 +331,7 @@ class PackageBuilder {
274
331
  version: this._version,
275
332
  contracts,
276
333
  consumes: [...this._consumes],
334
+ remotes: [...this._remotes],
277
335
  alsoImplements: alsoImplementsMap,
278
336
  assistants: [...this.assistants],
279
337
  appNavs: [...this.appNavs],
@@ -33,6 +33,12 @@ export interface IContractProviderEndpointRequest {
33
33
  providerPackageId?: string;
34
34
  /** Canonical data context returned by the authorization hook. */
35
35
  dataContextId?: string;
36
+ /**
37
+ * Device that should execute calls on this route when it differs from the local
38
+ * device. Copied from {@link IContractCall.targetDeviceId} by the router; the endpoint
39
+ * resolver decides whether forwarding is permitted for this router's principal.
40
+ */
41
+ targetDeviceId?: string;
36
42
  /** Authorized caller context for this route. */
37
43
  context: IContractCallContext;
38
44
  /** Sink for provider-to-consumer subscription notifications. */