@peers-app/peers-sdk 0.23.9 → 0.23.11

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 (35) hide show
  1. package/dist/context/user-context-singleton.js +19 -4
  2. package/dist/contracts/__tests__/builder.test.js +25 -0
  3. package/dist/contracts/__tests__/contract-provider-router.test.js +45 -0
  4. package/dist/contracts/__tests__/contract-proxy.test.js +67 -0
  5. package/dist/contracts/__tests__/contract-resolvers.test.js +133 -0
  6. package/dist/contracts/__tests__/remote-contract-transport.test.d.ts +1 -0
  7. package/dist/contracts/__tests__/remote-contract-transport.test.js +89 -0
  8. package/dist/contracts/builder.d.ts +47 -1
  9. package/dist/contracts/builder.js +80 -22
  10. package/dist/contracts/contract-provider-router.d.ts +6 -0
  11. package/dist/contracts/contract-provider-router.js +25 -3
  12. package/dist/contracts/contract-proxy.d.ts +41 -1
  13. package/dist/contracts/contract-proxy.js +103 -0
  14. package/dist/contracts/contract-resolvers.d.ts +47 -13
  15. package/dist/contracts/contract-resolvers.js +125 -35
  16. package/dist/contracts/extract.d.ts +4 -1
  17. package/dist/contracts/extract.js +3 -1
  18. package/dist/contracts/index.d.ts +4 -3
  19. package/dist/contracts/index.js +10 -2
  20. package/dist/contracts/remote-contract-transport.d.ts +69 -0
  21. package/dist/contracts/remote-contract-transport.js +95 -0
  22. package/dist/contracts/types.d.ts +57 -0
  23. package/dist/data/persistent-vars.d.ts +8 -0
  24. package/dist/data/persistent-vars.js +56 -11
  25. package/dist/data/tool-access-approvals.d.ts +2 -2
  26. package/dist/data/tools.d.ts +4 -0
  27. package/dist/data/tools.js +7 -0
  28. package/dist/package-loader/isolated-artifact.js +16 -1
  29. package/dist/package-loader/isolated-runtime.d.ts +5 -2
  30. package/dist/package-loader/package-loader.d.ts +15 -1
  31. package/dist/package-loader/package-loader.js +48 -0
  32. package/dist/package-loader/resolve-consumed-contracts.d.ts +6 -2
  33. package/dist/package-loader/resolve-consumed-contracts.js +30 -2
  34. package/dist/package-loader/resolve-consumed-contracts.test.js +43 -0
  35. package/package.json +1 -1
@@ -135,12 +135,27 @@ function getAllTables() {
135
135
  return tableMap;
136
136
  }
137
137
  if (!rpc_types_1.isClient) {
138
+ /**
139
+ * Resolve a table for a renderer RPC call.
140
+ *
141
+ * Prefers a full (code/package-registered) instance. When no package on this
142
+ * device has registered `tableName`, falls back to a synced
143
+ * `TableDefinitions` record so a renderer whose UI bundle is ahead of the
144
+ * package the host loaded can still reach the table's data.
145
+ */
138
146
  async function getTable(dataContextId, tableName) {
139
147
  const userContext = await getUserContext();
140
- const table = userContext
141
- .getDataContext(dataContextId || userContext.userId)
142
- .tableContainer.getTable(tableName);
143
- return table;
148
+ const tableContainer = userContext.getDataContext(dataContextId || userContext.userId).tableContainer;
149
+ try {
150
+ return tableContainer.getTable(tableName);
151
+ }
152
+ catch (err) {
153
+ const fallback = await tableContainer.getFallbackTable(tableName);
154
+ if (fallback) {
155
+ return fallback;
156
+ }
157
+ throw err;
158
+ }
144
159
  }
145
160
  // TODO: add check that client has permission to make this call
146
161
  rpc_types_1.rpcServerCalls.tableMethodCall = async (dataContextId, tableName, methodName, ...args) => {
@@ -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
  }