@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.
- package/dist/contracts/__tests__/builder.test.js +25 -0
- package/dist/contracts/__tests__/contract-provider-router.test.js +45 -0
- package/dist/contracts/__tests__/contract-proxy.test.js +67 -0
- package/dist/contracts/__tests__/contract-resolvers.test.js +133 -0
- package/dist/contracts/__tests__/remote-contract-transport.test.d.ts +1 -0
- package/dist/contracts/__tests__/remote-contract-transport.test.js +89 -0
- package/dist/contracts/builder.d.ts +47 -1
- package/dist/contracts/builder.js +80 -22
- package/dist/contracts/contract-provider-router.d.ts +6 -0
- package/dist/contracts/contract-provider-router.js +25 -3
- package/dist/contracts/contract-proxy.d.ts +41 -1
- package/dist/contracts/contract-proxy.js +103 -0
- package/dist/contracts/contract-resolvers.d.ts +47 -13
- package/dist/contracts/contract-resolvers.js +125 -35
- package/dist/contracts/extract.d.ts +4 -1
- package/dist/contracts/extract.js +3 -1
- package/dist/contracts/index.d.ts +4 -3
- package/dist/contracts/index.js +10 -2
- package/dist/contracts/remote-contract-transport.d.ts +69 -0
- package/dist/contracts/remote-contract-transport.js +95 -0
- package/dist/contracts/types.d.ts +57 -0
- package/dist/data/tools.d.ts +4 -0
- package/dist/data/tools.js +7 -0
- package/dist/package-loader/isolated-artifact.d.ts +44 -5
- package/dist/package-loader/isolated-artifact.js +82 -4
- package/dist/package-loader/isolated-artifact.test.js +51 -3
- package/dist/package-loader/isolated-runtime.d.ts +17 -2
- package/dist/package-loader/isolated-runtime.js +3 -1
- package/dist/package-loader/isolated-table-gateway.d.ts +1 -1
- package/dist/package-loader/isolated-table-gateway.js +54 -0
- package/dist/package-loader/isolated-table-gateway.test.js +61 -0
- package/dist/package-loader/package-loader.d.ts +15 -1
- package/dist/package-loader/package-loader.js +48 -0
- package/dist/package-loader/resolve-consumed-contracts.d.ts +6 -2
- package/dist/package-loader/resolve-consumed-contracts.js +30 -2
- package/dist/package-loader/resolve-consumed-contracts.test.js +43 -0
- 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 @@
|
|
|
1
|
+
export {};
|
|
@@ -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
|
|
112
|
+
const contractHandleDeferreds = new WeakMap();
|
|
110
113
|
/**
|
|
111
|
-
* Create a deferred
|
|
112
|
-
* {@link
|
|
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
|
|
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
|
-
|
|
130
|
+
contractHandleDeferreds.set(handle, { resolve, reject, settled: false });
|
|
128
131
|
return handle;
|
|
129
132
|
}
|
|
130
133
|
/**
|
|
131
|
-
*
|
|
132
|
-
*
|
|
134
|
+
* Create a deferred consumed-contract handle. Settlement is SDK-owned via
|
|
135
|
+
* {@link settleConsumedContractHandle} / {@link rejectConsumedContractHandle}.
|
|
133
136
|
*
|
|
134
|
-
* @internal
|
|
137
|
+
* @internal
|
|
135
138
|
*/
|
|
136
|
-
function
|
|
137
|
-
|
|
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
|
|
145
|
+
throw new Error("Unknown contract handle");
|
|
140
146
|
}
|
|
141
147
|
if (deferred.settled) {
|
|
142
|
-
throw new Error("
|
|
148
|
+
throw new Error("Contract handle has already been settled");
|
|
143
149
|
}
|
|
144
150
|
deferred.settled = true;
|
|
145
|
-
deferred
|
|
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
|
-
|
|
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. */
|