@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.
- package/dist/context/user-context-singleton.js +19 -4
- 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/persistent-vars.d.ts +8 -0
- package/dist/data/persistent-vars.js +56 -11
- package/dist/data/tool-access-approvals.d.ts +2 -2
- package/dist/data/tools.d.ts +4 -0
- package/dist/data/tools.js +7 -0
- package/dist/package-loader/isolated-artifact.js +16 -1
- package/dist/package-loader/isolated-runtime.d.ts +5 -2
- 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
|
@@ -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
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
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 @@
|
|
|
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
|
}
|