@peers-app/peers-sdk 0.23.10 → 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/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.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
|
@@ -2,7 +2,22 @@
|
|
|
2
2
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
3
|
exports.createContractProviderRouter = createContractProviderRouter;
|
|
4
4
|
const contract_transport_1 = require("./contract-transport");
|
|
5
|
-
const routeKey = (contractId, version, providerPackageId, dataContextId) => JSON.stringify([
|
|
5
|
+
const routeKey = (contractId, version, providerPackageId, dataContextId, targetDeviceId) => JSON.stringify([
|
|
6
|
+
contractId,
|
|
7
|
+
version,
|
|
8
|
+
providerPackageId ?? null,
|
|
9
|
+
dataContextId ?? null,
|
|
10
|
+
targetDeviceId ?? null,
|
|
11
|
+
]);
|
|
12
|
+
/**
|
|
13
|
+
* Reject remote-target operations the mesh path does not support yet. Only tool runs
|
|
14
|
+
* cross devices; subscriptions, tables, observables, and events stay local.
|
|
15
|
+
*/
|
|
16
|
+
function assertRemoteTargetCallSupported(call) {
|
|
17
|
+
if (call.member !== "tool" || call.operation !== "run") {
|
|
18
|
+
throw new Error(`Contract ${call.member} '${call.name}'.${call.operation} cannot target a remote device; only tool runs are remotely callable`);
|
|
19
|
+
}
|
|
20
|
+
}
|
|
6
21
|
/**
|
|
7
22
|
* Own the sole {@link CONTRACT_REQUEST_CHANNEL} handler for a shared connection.
|
|
8
23
|
*
|
|
@@ -91,7 +106,11 @@ function createContractProviderRouter(transport, opts) {
|
|
|
91
106
|
if (isSubscribe && subscriptionId) {
|
|
92
107
|
pendingSubscriptionIds.add(subscriptionId);
|
|
93
108
|
}
|
|
109
|
+
const targetDeviceId = call.targetDeviceId || undefined;
|
|
94
110
|
try {
|
|
111
|
+
if (targetDeviceId) {
|
|
112
|
+
assertRemoteTargetCallSupported(call);
|
|
113
|
+
}
|
|
95
114
|
const inboundContext = opts.context ?? {};
|
|
96
115
|
const authorization = await opts.authorizeCall(call, inboundContext);
|
|
97
116
|
if (disposed) {
|
|
@@ -106,14 +125,17 @@ function createContractProviderRouter(transport, opts) {
|
|
|
106
125
|
contractId: call.contractId,
|
|
107
126
|
version: call.version,
|
|
108
127
|
dataContextId: authorization.dataContextId,
|
|
128
|
+
targetDeviceId,
|
|
109
129
|
context,
|
|
110
130
|
notify,
|
|
111
131
|
};
|
|
112
|
-
|
|
132
|
+
// Provider selection happens on the executing device, so a remote route is
|
|
133
|
+
// cached by target device rather than by a locally selected provider package.
|
|
134
|
+
const providerPackageId = !targetDeviceId && opts.resolveProviderPackageId
|
|
113
135
|
? await opts.resolveProviderPackageId(endpointRequest)
|
|
114
136
|
: undefined;
|
|
115
137
|
endpointRequest.providerPackageId = providerPackageId;
|
|
116
|
-
const endpointKey = routeKey(call.contractId, call.version, providerPackageId, authorization.dataContextId);
|
|
138
|
+
const endpointKey = routeKey(call.contractId, call.version, providerPackageId, authorization.dataContextId, targetDeviceId);
|
|
117
139
|
const endpoint = await getEndpoint(endpointKey, endpointRequest);
|
|
118
140
|
const result = await endpoint.handleCall(routedCall, context);
|
|
119
141
|
if (isSubscribe && subscriptionId) {
|
|
@@ -4,7 +4,7 @@ import type { IToolInstance } from "../data/tools";
|
|
|
4
4
|
import type { Event, ISubscriptionResult } from "../events";
|
|
5
5
|
import { type Observable } from "../observable";
|
|
6
6
|
import { type IContractTransport } from "./contract-transport";
|
|
7
|
-
import type { IContractDefinition } from "./types";
|
|
7
|
+
import type { IContractDefinition, IRemoteContractConsumer } from "./types";
|
|
8
8
|
/**
|
|
9
9
|
* The RPC event name used to proxy a single {@link IContractCall} over a
|
|
10
10
|
* {@link Connection} (`connection.emit(CONTRACT_CALL_RPC, call)` on the consumer
|
|
@@ -53,6 +53,15 @@ export interface IContractCall {
|
|
|
53
53
|
* id to the local provider user's personal context.
|
|
54
54
|
*/
|
|
55
55
|
dataContextId?: string;
|
|
56
|
+
/**
|
|
57
|
+
* Device that should execute this call when it is not the local device. Set by a
|
|
58
|
+
* {@link createRemoteContractConsumer} device consumer and honored only by the caller's own
|
|
59
|
+
* trusted host, which forwards the call over the device mesh and strips this field
|
|
60
|
+
* before it leaves the device. A provider router serving *remote* callers rejects any
|
|
61
|
+
* call that still carries it, so one device can never relay another device's call
|
|
62
|
+
* under its own identity.
|
|
63
|
+
*/
|
|
64
|
+
targetDeviceId?: string;
|
|
56
65
|
/**
|
|
57
66
|
* For any subscription call (a generic `event`, a table `dataChanged`, or an observable
|
|
58
67
|
* value stream): the consumer-chosen id that ties a `"subscribe"` (and its later
|
|
@@ -236,6 +245,8 @@ export interface IContractToolContext {
|
|
|
236
245
|
* consumer and provider boundaries. Disposal is idempotent and prevents new remote operations;
|
|
237
246
|
* cached observable reads remain available afterward.
|
|
238
247
|
*
|
|
248
|
+
* To run tools on another device use {@link createRemoteContractConsumer} instead.
|
|
249
|
+
*
|
|
239
250
|
* @param contract The contract definition (shape) being consumed.
|
|
240
251
|
* @param transport Bidirectional channel to the provider; requests go out on
|
|
241
252
|
* {@link CONTRACT_REQUEST_CHANNEL}, notifications arrive on {@link CONTRACT_NOTIFY_CHANNEL}.
|
|
@@ -244,6 +255,29 @@ export interface IContractToolContext {
|
|
|
244
255
|
export declare function createContractConsumer(contract: IContractDefinition, transport: IContractTransport, opts?: {
|
|
245
256
|
dataContextId?: string;
|
|
246
257
|
}): IContractConsumer;
|
|
258
|
+
/**
|
|
259
|
+
* Build the **remote** consumer side of a contract: a factory of tools-only consumers whose
|
|
260
|
+
* calls execute on other devices (the runtime behind {@link PackageBuilder.remote}).
|
|
261
|
+
*
|
|
262
|
+
* `device(deviceId)` returns a consumer of the contract that stamps
|
|
263
|
+
* {@link IContractCall.targetDeviceId} on every tool call. The trusted local host router
|
|
264
|
+
* behind `transport` recognizes the target, forwards the call over the device mesh, and the
|
|
265
|
+
* remote device authorizes it against the caller's group role and the tool's
|
|
266
|
+
* {@link IContractTool.remoteAccessLevel}. Consumers are cached per device; passing this
|
|
267
|
+
* device's own id resolves locally. No notify listener or observable mirror is created
|
|
268
|
+
* because subscriptions do not cross devices yet, so `tables`, `observables`, and `events`
|
|
269
|
+
* throw on access and `loadingPromise` resolves immediately. Disposing the factory disposes
|
|
270
|
+
* every device consumer; a device consumer may also be disposed on its own.
|
|
271
|
+
*
|
|
272
|
+
* @param contract The contract definition (shape) whose tools are called remotely.
|
|
273
|
+
* @param transport Channel to the caller's own trusted host router (never to the remote
|
|
274
|
+
* device directly).
|
|
275
|
+
* @param opts.dataContextId Data context every call is scoped to. Cross-account calls must
|
|
276
|
+
* name the shared group whose membership the remote device checks.
|
|
277
|
+
*/
|
|
278
|
+
export declare function createRemoteContractConsumer(contract: IContractDefinition, transport: IContractTransport, opts?: {
|
|
279
|
+
dataContextId?: string;
|
|
280
|
+
}): IRemoteContractConsumer<IContractConsumer>;
|
|
247
281
|
/**
|
|
248
282
|
* The live implementation a **provider** dispatches contract calls to. This is the
|
|
249
283
|
* runtime-callable counterpart to {@link IResolvedContract} (which additionally tracks
|
|
@@ -279,6 +313,12 @@ export interface IContractCallContext {
|
|
|
279
313
|
callerUserId?: string;
|
|
280
314
|
/** User id of the local device executing the call. */
|
|
281
315
|
localUserId?: string;
|
|
316
|
+
/**
|
|
317
|
+
* Device the call arrived from when it was made by another device. Absent for
|
|
318
|
+
* same-device callers. Informational for providers; authorization is decided by the
|
|
319
|
+
* host router before dispatch.
|
|
320
|
+
*/
|
|
321
|
+
callerDeviceId?: string;
|
|
282
322
|
/**
|
|
283
323
|
* Provider-side data context used to resolve the caller's group/personal role.
|
|
284
324
|
* In-process only — never serialized across a connection.
|
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
3
|
exports.sameUserContractPermissionCheck = exports.CONTRACT_CALL_RPC = void 0;
|
|
4
4
|
exports.createContractConsumer = createContractConsumer;
|
|
5
|
+
exports.createRemoteContractConsumer = createRemoteContractConsumer;
|
|
5
6
|
exports.createContractProvider = createContractProvider;
|
|
6
7
|
exports.createContractProviderEndpoint = createContractProviderEndpoint;
|
|
7
8
|
exports.createContractProviderSession = createContractProviderSession;
|
|
@@ -66,6 +67,8 @@ exports.CONTRACT_CALL_RPC = contract_transport_1.CONTRACT_REQUEST_CHANNEL;
|
|
|
66
67
|
* consumer and provider boundaries. Disposal is idempotent and prevents new remote operations;
|
|
67
68
|
* cached observable reads remain available afterward.
|
|
68
69
|
*
|
|
70
|
+
* To run tools on another device use {@link createRemoteContractConsumer} instead.
|
|
71
|
+
*
|
|
69
72
|
* @param contract The contract definition (shape) being consumed.
|
|
70
73
|
* @param transport Bidirectional channel to the provider; requests go out on
|
|
71
74
|
* {@link CONTRACT_REQUEST_CHANNEL}, notifications arrive on {@link CONTRACT_NOTIFY_CHANNEL}.
|
|
@@ -334,6 +337,106 @@ function createContractConsumer(contract, transport, opts = {}) {
|
|
|
334
337
|
consumer.loadingPromise = Promise.all(observableLoads).then(() => undefined);
|
|
335
338
|
return consumer;
|
|
336
339
|
}
|
|
340
|
+
/**
|
|
341
|
+
* Build a bag whose every string property access throws, used for the contract members a
|
|
342
|
+
* remote consumer cannot reach yet. Symbol access (inspection, `then` probing) stays inert.
|
|
343
|
+
*/
|
|
344
|
+
function unreachableRemoteMembers(member) {
|
|
345
|
+
return new Proxy({}, {
|
|
346
|
+
get(_target, prop) {
|
|
347
|
+
if (typeof prop !== "string" || prop === "then" || prop === "toJSON") {
|
|
348
|
+
return undefined;
|
|
349
|
+
}
|
|
350
|
+
throw new Error(`Contract ${member} '${prop}' is not remotely accessible yet; only tools can be called on a remote device`);
|
|
351
|
+
},
|
|
352
|
+
});
|
|
353
|
+
}
|
|
354
|
+
/**
|
|
355
|
+
* Build the **remote** consumer side of a contract: a factory of tools-only consumers whose
|
|
356
|
+
* calls execute on other devices (the runtime behind {@link PackageBuilder.remote}).
|
|
357
|
+
*
|
|
358
|
+
* `device(deviceId)` returns a consumer of the contract that stamps
|
|
359
|
+
* {@link IContractCall.targetDeviceId} on every tool call. The trusted local host router
|
|
360
|
+
* behind `transport` recognizes the target, forwards the call over the device mesh, and the
|
|
361
|
+
* remote device authorizes it against the caller's group role and the tool's
|
|
362
|
+
* {@link IContractTool.remoteAccessLevel}. Consumers are cached per device; passing this
|
|
363
|
+
* device's own id resolves locally. No notify listener or observable mirror is created
|
|
364
|
+
* because subscriptions do not cross devices yet, so `tables`, `observables`, and `events`
|
|
365
|
+
* throw on access and `loadingPromise` resolves immediately. Disposing the factory disposes
|
|
366
|
+
* every device consumer; a device consumer may also be disposed on its own.
|
|
367
|
+
*
|
|
368
|
+
* @param contract The contract definition (shape) whose tools are called remotely.
|
|
369
|
+
* @param transport Channel to the caller's own trusted host router (never to the remote
|
|
370
|
+
* device directly).
|
|
371
|
+
* @param opts.dataContextId Data context every call is scoped to. Cross-account calls must
|
|
372
|
+
* name the shared group whose membership the remote device checks.
|
|
373
|
+
*/
|
|
374
|
+
function createRemoteContractConsumer(contract, transport, opts = {}) {
|
|
375
|
+
const { contractId, version } = contract;
|
|
376
|
+
const { dataContextId } = opts;
|
|
377
|
+
const consumers = new Map();
|
|
378
|
+
let disposed = false;
|
|
379
|
+
const createDeviceConsumer = (targetDeviceId) => {
|
|
380
|
+
let deviceDisposed = false;
|
|
381
|
+
const tools = {};
|
|
382
|
+
for (const tool of contract.tools) {
|
|
383
|
+
const name = tool.name;
|
|
384
|
+
tools[name] = (...args) => deviceDisposed
|
|
385
|
+
? Promise.reject(new Error(`Remote contract consumer ${contractId} v${version} for device ${targetDeviceId} is disposed`))
|
|
386
|
+
: transport.emit(contract_transport_1.CONTRACT_REQUEST_CHANNEL, {
|
|
387
|
+
contractId,
|
|
388
|
+
version,
|
|
389
|
+
member: "tool",
|
|
390
|
+
name,
|
|
391
|
+
operation: "run",
|
|
392
|
+
args,
|
|
393
|
+
dataContextId,
|
|
394
|
+
targetDeviceId,
|
|
395
|
+
});
|
|
396
|
+
}
|
|
397
|
+
return {
|
|
398
|
+
contractId,
|
|
399
|
+
version,
|
|
400
|
+
tables: unreachableRemoteMembers("table"),
|
|
401
|
+
tools,
|
|
402
|
+
observables: unreachableRemoteMembers("observable"),
|
|
403
|
+
events: unreachableRemoteMembers("event"),
|
|
404
|
+
loadingPromise: Promise.resolve(),
|
|
405
|
+
dispose: () => {
|
|
406
|
+
deviceDisposed = true;
|
|
407
|
+
consumers.delete(targetDeviceId);
|
|
408
|
+
},
|
|
409
|
+
};
|
|
410
|
+
};
|
|
411
|
+
return {
|
|
412
|
+
contractId,
|
|
413
|
+
version,
|
|
414
|
+
device(deviceId) {
|
|
415
|
+
if (disposed) {
|
|
416
|
+
throw new Error(`Remote contract consumer ${contractId} v${version} is disposed`);
|
|
417
|
+
}
|
|
418
|
+
if (typeof deviceId !== "string" || deviceId.trim() === "") {
|
|
419
|
+
throw new Error("device(deviceId) requires a non-empty device id");
|
|
420
|
+
}
|
|
421
|
+
let consumer = consumers.get(deviceId);
|
|
422
|
+
if (!consumer) {
|
|
423
|
+
consumer = createDeviceConsumer(deviceId);
|
|
424
|
+
consumers.set(deviceId, consumer);
|
|
425
|
+
}
|
|
426
|
+
return consumer;
|
|
427
|
+
},
|
|
428
|
+
dispose() {
|
|
429
|
+
if (disposed) {
|
|
430
|
+
return;
|
|
431
|
+
}
|
|
432
|
+
disposed = true;
|
|
433
|
+
for (const consumer of [...consumers.values()]) {
|
|
434
|
+
consumer.dispose();
|
|
435
|
+
}
|
|
436
|
+
consumers.clear();
|
|
437
|
+
},
|
|
438
|
+
};
|
|
439
|
+
}
|
|
337
440
|
/**
|
|
338
441
|
* Legacy gate that allows calls only when caller and provider user ids match.
|
|
339
442
|
*
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import type { DataContext } from "../context/data-context";
|
|
2
2
|
import type { UserContext } from "../context/user-context";
|
|
3
3
|
import type { IDeviceInfo } from "../data/devices";
|
|
4
|
-
import { type IContractProviderRouter } from "./contract-provider-router";
|
|
4
|
+
import { type IAuthorizedContractRoute, type IContractProviderRouter } from "./contract-provider-router";
|
|
5
5
|
import { type IContractCall, type IContractResolution } from "./contract-proxy";
|
|
6
6
|
import type { IContractTransport } from "./contract-transport";
|
|
7
7
|
/**
|
|
@@ -48,6 +48,17 @@ export declare function registerContractResolver(contractId: string, version: nu
|
|
|
48
48
|
* yet adopted provider selection; multiple scoped providers remain ambiguous.
|
|
49
49
|
*/
|
|
50
50
|
export declare function getContractResolver(contractId: string, version: number, providerPackageId?: string): ContractResolver | undefined;
|
|
51
|
+
/**
|
|
52
|
+
* Verify that a connection- or message-authenticated remote identity matches a valid
|
|
53
|
+
* self-signed Users record in the provider's personal or shared-group data.
|
|
54
|
+
*
|
|
55
|
+
* Unsigned discovery stubs, malformed signatures, and key mismatches fail closed. This is
|
|
56
|
+
* the identity half of remote authorization; trust and role decisions build on it.
|
|
57
|
+
*
|
|
58
|
+
* @param userContext Provider's user context whose data is searched.
|
|
59
|
+
* @param remoteIdentity Identity derived from the verified transport.
|
|
60
|
+
*/
|
|
61
|
+
export declare function verifyRemoteContractIdentity(userContext: UserContext, remoteIdentity: ContractRemoteIdentity): Promise<boolean>;
|
|
51
62
|
/**
|
|
52
63
|
* Check whether a connection-authenticated remote identity has full contract authority.
|
|
53
64
|
*
|
|
@@ -56,6 +67,29 @@ export declare function getContractResolver(contractId: string, version: number,
|
|
|
56
67
|
* {@link TrustLevel.Self}. Unsigned discovery stubs and key mismatches fail closed.
|
|
57
68
|
*/
|
|
58
69
|
export declare function isSelfTrustedContractCaller(userContext: UserContext, remoteIdentity: ContractRemoteIdentity): Promise<boolean>;
|
|
70
|
+
/**
|
|
71
|
+
* Authorize one inbound contract call from **another device**.
|
|
72
|
+
*
|
|
73
|
+
* Layered policy, evaluated in order:
|
|
74
|
+
* 1. Calls that still carry a `targetDeviceId` are denied: a remote caller may not use
|
|
75
|
+
* this device as a relay under its identity. The tool-access-control contract is
|
|
76
|
+
* local-only.
|
|
77
|
+
* 2. The caller's keys must match a valid signed Users record known to the provider.
|
|
78
|
+
* 3. A Self-trusted caller (the provider's own account, or an account granted exact
|
|
79
|
+
* `TrustLevel.Self`) keeps full access; an omitted data context selects the provider's
|
|
80
|
+
* personal context.
|
|
81
|
+
* 4. Any other verified caller is treated as a **group member**: the call must name a
|
|
82
|
+
* group the provider belongs to, must be a tool run, the contract definition registered
|
|
83
|
+
* for that group must mark the tool with {@link IContractTool.remoteAccessLevel}, and
|
|
84
|
+
* the caller's role in that group must meet it. Missing loader, definition, or level
|
|
85
|
+
* fails closed.
|
|
86
|
+
*
|
|
87
|
+
* @param call Inbound call.
|
|
88
|
+
* @param userContext Provider's user context.
|
|
89
|
+
* @param remoteIdentity Identity derived from the verified transport (never from the call).
|
|
90
|
+
* @returns The canonical route and caller context for the router.
|
|
91
|
+
*/
|
|
92
|
+
export declare function authorizeRemoteContractCall(call: IContractCall, userContext: UserContext, remoteIdentity: ContractRemoteIdentity): Promise<IAuthorizedContractRoute>;
|
|
59
93
|
/**
|
|
60
94
|
* Create a provider router for one authenticated UI-to-local-host session.
|
|
61
95
|
*
|
|
@@ -79,27 +113,27 @@ export declare function createLocalUserContextContractProviderRouter(transport:
|
|
|
79
113
|
resolveVariable?: LocalContractVariableResolver;
|
|
80
114
|
}): IContractProviderRouter;
|
|
81
115
|
/**
|
|
82
|
-
* Create the local-object provider router for one verified device
|
|
116
|
+
* Create the local-object provider router for one verified remote device, whether it is
|
|
117
|
+
* reached over a direct {@link Connection} or through device messages.
|
|
83
118
|
*
|
|
84
|
-
* The router derives caller identity from the
|
|
85
|
-
* authorizes every call before invoking a
|
|
86
|
-
* contexts to the local user's personal
|
|
87
|
-
*
|
|
88
|
-
*
|
|
89
|
-
* retain their existing behavior after authorization.
|
|
119
|
+
* The router derives caller identity from the transport owner supplied by the host,
|
|
120
|
+
* authorizes every call with {@link authorizeRemoteContractCall} before invoking a
|
|
121
|
+
* registered resolver, normalizes omitted data contexts to the local user's personal
|
|
122
|
+
* context for Self callers, and caches stateful endpoints per contract/version/data-context
|
|
123
|
+
* route. Group members reach only tools marked with `remoteAccessLevel`.
|
|
90
124
|
*
|
|
91
|
-
* @param transport Contract transport backed by one verified
|
|
125
|
+
* @param transport Contract transport backed by one verified remote device.
|
|
92
126
|
* @param opts.userContext Local user context that owns provider data.
|
|
93
|
-
* @param opts.remoteIdentity Identity derived from the verified remote
|
|
127
|
+
* @param opts.remoteIdentity Identity derived from the verified remote device.
|
|
94
128
|
*/
|
|
95
129
|
export declare function createUserContextContractProviderRouter(transport: IContractTransport, opts: {
|
|
96
130
|
userContext: UserContext;
|
|
97
131
|
remoteIdentity: ContractRemoteIdentity;
|
|
98
132
|
}): IContractProviderRouter;
|
|
99
133
|
/**
|
|
100
|
-
* Provider-side request/response glue: apply
|
|
101
|
-
*
|
|
102
|
-
*
|
|
134
|
+
* Provider-side request/response glue: apply {@link authorizeRemoteContractCall}, resolve
|
|
135
|
+
* the contract for the authorized data context, build a stateless provider, and execute
|
|
136
|
+
* the call.
|
|
103
137
|
*
|
|
104
138
|
* Used by transports (e.g. the connection `CONTRACT_CALL_RPC` handler) to run an inbound
|
|
105
139
|
* {@link IContractCall} against local state.
|
|
@@ -2,10 +2,14 @@
|
|
|
2
2
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
3
|
exports.registerContractResolver = registerContractResolver;
|
|
4
4
|
exports.getContractResolver = getContractResolver;
|
|
5
|
+
exports.verifyRemoteContractIdentity = verifyRemoteContractIdentity;
|
|
5
6
|
exports.isSelfTrustedContractCaller = isSelfTrustedContractCaller;
|
|
7
|
+
exports.authorizeRemoteContractCall = authorizeRemoteContractCall;
|
|
6
8
|
exports.createLocalUserContextContractProviderRouter = createLocalUserContextContractProviderRouter;
|
|
7
9
|
exports.createUserContextContractProviderRouter = createUserContextContractProviderRouter;
|
|
8
10
|
exports.handleContractCall = handleContractCall;
|
|
11
|
+
const access_level_1 = require("../data/access-level");
|
|
12
|
+
const group_permissions_1 = require("../data/group-permissions");
|
|
9
13
|
const user_permissions_1 = require("../data/user-permissions");
|
|
10
14
|
const user_trust_levels_1 = require("../data/user-trust-levels");
|
|
11
15
|
const users_1 = require("../data/users");
|
|
@@ -13,6 +17,7 @@ const socket_type_1 = require("../device/socket.type");
|
|
|
13
17
|
const system_ids_1 = require("../system-ids");
|
|
14
18
|
const contract_provider_router_1 = require("./contract-provider-router");
|
|
15
19
|
const contract_proxy_1 = require("./contract-proxy");
|
|
20
|
+
const remote_contract_transport_1 = require("./remote-contract-transport");
|
|
16
21
|
const types_1 = require("./types");
|
|
17
22
|
const resolvers = new Map();
|
|
18
23
|
const resolverKey = (contractId, version, providerPackageId) => providerPackageId
|
|
@@ -60,15 +65,17 @@ function getContractResolver(contractId, version, providerPackageId) {
|
|
|
60
65
|
return onlyScopedResolver;
|
|
61
66
|
}
|
|
62
67
|
/**
|
|
63
|
-
*
|
|
68
|
+
* Verify that a connection- or message-authenticated remote identity matches a valid
|
|
69
|
+
* self-signed Users record in the provider's personal or shared-group data.
|
|
64
70
|
*
|
|
65
|
-
*
|
|
66
|
-
*
|
|
67
|
-
*
|
|
71
|
+
* Unsigned discovery stubs, malformed signatures, and key mismatches fail closed. This is
|
|
72
|
+
* the identity half of remote authorization; trust and role decisions build on it.
|
|
73
|
+
*
|
|
74
|
+
* @param userContext Provider's user context whose data is searched.
|
|
75
|
+
* @param remoteIdentity Identity derived from the verified transport.
|
|
68
76
|
*/
|
|
69
|
-
async function
|
|
77
|
+
async function verifyRemoteContractIdentity(userContext, remoteIdentity) {
|
|
70
78
|
const identityContexts = [userContext.userDataContext, ...userContext.groupDataContexts.values()];
|
|
71
|
-
let hasVerifiedIdentity = false;
|
|
72
79
|
for (const dataContext of identityContexts) {
|
|
73
80
|
const user = await (0, users_1.Users)(dataContext).get(remoteIdentity.userId);
|
|
74
81
|
if (!user?.signature ||
|
|
@@ -79,38 +86,113 @@ async function isSelfTrustedContractCaller(userContext, remoteIdentity) {
|
|
|
79
86
|
}
|
|
80
87
|
try {
|
|
81
88
|
(0, user_permissions_1.verifyUserSignature)(user);
|
|
82
|
-
|
|
83
|
-
break;
|
|
89
|
+
return true;
|
|
84
90
|
}
|
|
85
91
|
catch {
|
|
86
92
|
// A different context may contain the valid signed record.
|
|
87
93
|
}
|
|
88
94
|
}
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
95
|
+
return false;
|
|
96
|
+
}
|
|
97
|
+
/**
|
|
98
|
+
* Whether the provider's personal context assigns exactly {@link TrustLevel.Self} to a
|
|
99
|
+
* (previously identity-verified) remote user. The provider's own account is always Self.
|
|
100
|
+
*/
|
|
101
|
+
async function hasSelfTrust(userContext, remoteIdentity) {
|
|
92
102
|
const trustLevel = remoteIdentity.userId === userContext.userId
|
|
93
103
|
? socket_type_1.TrustLevel.Self
|
|
94
104
|
: ((await (0, user_trust_levels_1.UserTrustLevels)(userContext.userDataContext).get(remoteIdentity.userId))
|
|
95
105
|
?.trustLevel ?? socket_type_1.TrustLevel.Unknown);
|
|
96
106
|
return trustLevel === socket_type_1.TrustLevel.Self;
|
|
97
107
|
}
|
|
98
|
-
|
|
108
|
+
/**
|
|
109
|
+
* Check whether a connection-authenticated remote identity has full contract authority.
|
|
110
|
+
*
|
|
111
|
+
* The identity must match a valid self-signed Users record in the provider's personal or
|
|
112
|
+
* shared-group data, and the provider's personal context must assign the caller exactly
|
|
113
|
+
* {@link TrustLevel.Self}. Unsigned discovery stubs and key mismatches fail closed.
|
|
114
|
+
*/
|
|
115
|
+
async function isSelfTrustedContractCaller(userContext, remoteIdentity) {
|
|
116
|
+
if (!(await verifyRemoteContractIdentity(userContext, remoteIdentity))) {
|
|
117
|
+
return false;
|
|
118
|
+
}
|
|
119
|
+
return hasSelfTrust(userContext, remoteIdentity);
|
|
120
|
+
}
|
|
121
|
+
const permissionDenied = (call, reason) => new Error(`Permission denied: ${call.member} '${call.name}'.${call.operation} on contract ${call.contractId}${reason ? ` (${reason})` : ""}`);
|
|
122
|
+
/**
|
|
123
|
+
* Authorize one inbound contract call from **another device**.
|
|
124
|
+
*
|
|
125
|
+
* Layered policy, evaluated in order:
|
|
126
|
+
* 1. Calls that still carry a `targetDeviceId` are denied: a remote caller may not use
|
|
127
|
+
* this device as a relay under its identity. The tool-access-control contract is
|
|
128
|
+
* local-only.
|
|
129
|
+
* 2. The caller's keys must match a valid signed Users record known to the provider.
|
|
130
|
+
* 3. A Self-trusted caller (the provider's own account, or an account granted exact
|
|
131
|
+
* `TrustLevel.Self`) keeps full access; an omitted data context selects the provider's
|
|
132
|
+
* personal context.
|
|
133
|
+
* 4. Any other verified caller is treated as a **group member**: the call must name a
|
|
134
|
+
* group the provider belongs to, must be a tool run, the contract definition registered
|
|
135
|
+
* for that group must mark the tool with {@link IContractTool.remoteAccessLevel}, and
|
|
136
|
+
* the caller's role in that group must meet it. Missing loader, definition, or level
|
|
137
|
+
* fails closed.
|
|
138
|
+
*
|
|
139
|
+
* @param call Inbound call.
|
|
140
|
+
* @param userContext Provider's user context.
|
|
141
|
+
* @param remoteIdentity Identity derived from the verified transport (never from the call).
|
|
142
|
+
* @returns The canonical route and caller context for the router.
|
|
143
|
+
*/
|
|
144
|
+
async function authorizeRemoteContractCall(call, userContext, remoteIdentity) {
|
|
145
|
+
if (call.targetDeviceId) {
|
|
146
|
+
throw permissionDenied(call, "remote callers cannot relay through this device");
|
|
147
|
+
}
|
|
99
148
|
if (call.contractId === system_ids_1.toolAccessControlContractId) {
|
|
100
149
|
throw new Error("Permission denied: tool access control is local-only");
|
|
101
150
|
}
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
151
|
+
if (!(await verifyRemoteContractIdentity(userContext, remoteIdentity))) {
|
|
152
|
+
throw permissionDenied(call, "unverified caller identity");
|
|
153
|
+
}
|
|
154
|
+
if (await hasSelfTrust(userContext, remoteIdentity)) {
|
|
155
|
+
const dataContextId = call.dataContextId || userContext.userId;
|
|
156
|
+
return {
|
|
157
|
+
dataContextId,
|
|
158
|
+
context: {
|
|
159
|
+
callerUserId: remoteIdentity.userId,
|
|
160
|
+
localUserId: userContext.userId,
|
|
161
|
+
callerDeviceId: remoteIdentity.deviceId,
|
|
162
|
+
dataContext: userContext.getDataContext(dataContextId),
|
|
163
|
+
},
|
|
164
|
+
};
|
|
165
|
+
}
|
|
166
|
+
// Group-member path: explicit shared group, tool runs only, remoteAccessLevel required.
|
|
167
|
+
const dataContextId = call.dataContextId;
|
|
168
|
+
if (!dataContextId || !userContext.groupIds().includes(dataContextId)) {
|
|
169
|
+
throw permissionDenied(call, "a shared group data context is required");
|
|
170
|
+
}
|
|
171
|
+
if (call.member !== "tool" || call.operation !== "run") {
|
|
172
|
+
throw permissionDenied(call, "only tool runs are remotely callable");
|
|
173
|
+
}
|
|
174
|
+
const dataContext = userContext.getDataContext(dataContextId);
|
|
175
|
+
const definition = dataContext.packageLoader?.contractRegistry?.getDefinition(call.contractId, call.version);
|
|
176
|
+
const tool = definition?.tools.find((entry) => entry.name === call.name);
|
|
177
|
+
if (!tool) {
|
|
178
|
+
throw permissionDenied(call, "tool is not provided in this data context");
|
|
179
|
+
}
|
|
180
|
+
if (typeof tool.remoteAccessLevel !== "number") {
|
|
181
|
+
throw permissionDenied(call, "tool is not remotely callable");
|
|
182
|
+
}
|
|
183
|
+
const requiredLevel = (0, access_level_1.resolveAccessLevel)(tool.remoteAccessLevel);
|
|
184
|
+
const callerLevel = await (0, group_permissions_1.getUserAccessLevel)(dataContext, remoteIdentity.userId);
|
|
185
|
+
if (callerLevel <= access_level_1.AccessLevel.None || callerLevel < requiredLevel) {
|
|
186
|
+
throw permissionDenied(call, `requires ${(0, access_level_1.getAccessLevelLabel)(requiredLevel)} (${requiredLevel}), caller has ${(0, access_level_1.getAccessLevelLabel)(callerLevel)} (${callerLevel})`);
|
|
110
187
|
}
|
|
111
188
|
return {
|
|
112
189
|
dataContextId,
|
|
113
|
-
context
|
|
190
|
+
context: {
|
|
191
|
+
callerUserId: remoteIdentity.userId,
|
|
192
|
+
localUserId: userContext.userId,
|
|
193
|
+
callerDeviceId: remoteIdentity.deviceId,
|
|
194
|
+
dataContext,
|
|
195
|
+
},
|
|
114
196
|
};
|
|
115
197
|
}
|
|
116
198
|
function getAuthorizedLocalDataContext(userContext, requestedId) {
|
|
@@ -127,6 +209,13 @@ function getActiveProviderPackageId(dataContext, contractId, version) {
|
|
|
127
209
|
return dataContext.packageLoader?.getActiveContractProviderPackageId?.(contractId, version);
|
|
128
210
|
}
|
|
129
211
|
async function resolveDataContextEndpoint(dataContext, request, resolveVariable) {
|
|
212
|
+
const remoteEndpoint = (0, remote_contract_transport_1.resolveRemoteTargetEndpoint)(request, {
|
|
213
|
+
localDeviceId: dataContext.userContext?.deviceId(),
|
|
214
|
+
userContext: dataContext.userContext,
|
|
215
|
+
});
|
|
216
|
+
if (remoteEndpoint) {
|
|
217
|
+
return remoteEndpoint;
|
|
218
|
+
}
|
|
130
219
|
if (dataContext.packageLoader?.resolveContractEndpoint) {
|
|
131
220
|
return dataContext.packageLoader.resolveContractEndpoint(request, {
|
|
132
221
|
resolveVariable: resolveVariable
|
|
@@ -207,29 +296,30 @@ function createLocalUserContextContractProviderRouter(transport, opts) {
|
|
|
207
296
|
});
|
|
208
297
|
}
|
|
209
298
|
/**
|
|
210
|
-
* Create the local-object provider router for one verified device
|
|
299
|
+
* Create the local-object provider router for one verified remote device, whether it is
|
|
300
|
+
* reached over a direct {@link Connection} or through device messages.
|
|
211
301
|
*
|
|
212
|
-
* The router derives caller identity from the
|
|
213
|
-
* authorizes every call before invoking a
|
|
214
|
-
* contexts to the local user's personal
|
|
215
|
-
*
|
|
216
|
-
*
|
|
217
|
-
* retain their existing behavior after authorization.
|
|
302
|
+
* The router derives caller identity from the transport owner supplied by the host,
|
|
303
|
+
* authorizes every call with {@link authorizeRemoteContractCall} before invoking a
|
|
304
|
+
* registered resolver, normalizes omitted data contexts to the local user's personal
|
|
305
|
+
* context for Self callers, and caches stateful endpoints per contract/version/data-context
|
|
306
|
+
* route. Group members reach only tools marked with `remoteAccessLevel`.
|
|
218
307
|
*
|
|
219
|
-
* @param transport Contract transport backed by one verified
|
|
308
|
+
* @param transport Contract transport backed by one verified remote device.
|
|
220
309
|
* @param opts.userContext Local user context that owns provider data.
|
|
221
|
-
* @param opts.remoteIdentity Identity derived from the verified remote
|
|
310
|
+
* @param opts.remoteIdentity Identity derived from the verified remote device.
|
|
222
311
|
*/
|
|
223
312
|
function createUserContextContractProviderRouter(transport, opts) {
|
|
224
313
|
const { userContext, remoteIdentity } = opts;
|
|
225
314
|
const context = {
|
|
226
315
|
callerUserId: remoteIdentity.userId,
|
|
227
316
|
localUserId: userContext.userId,
|
|
317
|
+
callerDeviceId: remoteIdentity.deviceId,
|
|
228
318
|
dataContext: userContext.userDataContext,
|
|
229
319
|
};
|
|
230
320
|
return (0, contract_provider_router_1.createContractProviderRouter)(transport, {
|
|
231
321
|
context,
|
|
232
|
-
authorizeCall: (call) =>
|
|
322
|
+
authorizeCall: (call) => authorizeRemoteContractCall(call, userContext, remoteIdentity),
|
|
233
323
|
resolveProviderPackageId: ({ contractId, version, dataContextId }) => {
|
|
234
324
|
const dataContext = userContext.getDataContext(dataContextId || userContext.userId);
|
|
235
325
|
return getActiveProviderPackageId(dataContext, contractId, version);
|
|
@@ -241,9 +331,9 @@ function createUserContextContractProviderRouter(transport, opts) {
|
|
|
241
331
|
});
|
|
242
332
|
}
|
|
243
333
|
/**
|
|
244
|
-
* Provider-side request/response glue: apply
|
|
245
|
-
*
|
|
246
|
-
*
|
|
334
|
+
* Provider-side request/response glue: apply {@link authorizeRemoteContractCall}, resolve
|
|
335
|
+
* the contract for the authorized data context, build a stateless provider, and execute
|
|
336
|
+
* the call.
|
|
247
337
|
*
|
|
248
338
|
* Used by transports (e.g. the connection `CONTRACT_CALL_RPC` handler) to run an inbound
|
|
249
339
|
* {@link IContractCall} against local state.
|
|
@@ -254,7 +344,7 @@ function createUserContextContractProviderRouter(transport, opts) {
|
|
|
254
344
|
*/
|
|
255
345
|
async function handleContractCall(call, opts) {
|
|
256
346
|
const { userContext, remoteIdentity } = opts;
|
|
257
|
-
const authorization = await
|
|
347
|
+
const authorization = await authorizeRemoteContractCall(call, userContext, remoteIdentity);
|
|
258
348
|
const routedCall = {
|
|
259
349
|
...call,
|
|
260
350
|
dataContextId: authorization.dataContextId,
|
|
@@ -29,6 +29,8 @@ export interface IExtractableToolInstance {
|
|
|
29
29
|
outputSchema?: {
|
|
30
30
|
fields: IField[];
|
|
31
31
|
};
|
|
32
|
+
/** See {@link IContractTool.remoteAccessLevel}. */
|
|
33
|
+
remoteAccessLevel?: number;
|
|
32
34
|
};
|
|
33
35
|
}
|
|
34
36
|
/** Override the schemaToFields implementation (useful for tests). */
|
|
@@ -41,6 +43,7 @@ export declare function setSchemaToFieldsFn(fn: (schema: z.AnyZodObject) => IFie
|
|
|
41
43
|
export declare function extractTableShape(def: IExtractableTableDef): IContractTable;
|
|
42
44
|
/**
|
|
43
45
|
* Extract the contract-level tool shape from a tool instance.
|
|
44
|
-
* Uses the tool record's `inputSchema.fields` and `outputSchema.fields
|
|
46
|
+
* Uses the tool record's `inputSchema.fields` and `outputSchema.fields`, and carries
|
|
47
|
+
* the optional `remoteAccessLevel` policy through unchanged.
|
|
45
48
|
*/
|
|
46
49
|
export declare function extractToolShape(inst: IExtractableToolInstance): IContractTool;
|
|
@@ -38,7 +38,8 @@ function extractTableShape(def) {
|
|
|
38
38
|
}
|
|
39
39
|
/**
|
|
40
40
|
* Extract the contract-level tool shape from a tool instance.
|
|
41
|
-
* Uses the tool record's `inputSchema.fields` and `outputSchema.fields
|
|
41
|
+
* Uses the tool record's `inputSchema.fields` and `outputSchema.fields`, and carries
|
|
42
|
+
* the optional `remoteAccessLevel` policy through unchanged.
|
|
42
43
|
*/
|
|
43
44
|
function extractToolShape(inst) {
|
|
44
45
|
const { tool } = inst;
|
|
@@ -47,5 +48,6 @@ function extractToolShape(inst) {
|
|
|
47
48
|
usageDescription: tool.usageDescription,
|
|
48
49
|
inputFields: tool.inputSchema?.fields ?? [],
|
|
49
50
|
outputFields: tool.outputSchema?.fields ?? [],
|
|
51
|
+
...(tool.remoteAccessLevel !== undefined ? { remoteAccessLevel: tool.remoteAccessLevel } : {}),
|
|
50
52
|
};
|
|
51
53
|
}
|