@peers-app/peers-sdk 0.23.10 → 0.23.12

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (37) hide show
  1. package/dist/contracts/__tests__/builder.test.js +25 -0
  2. package/dist/contracts/__tests__/contract-provider-router.test.js +45 -0
  3. package/dist/contracts/__tests__/contract-proxy.test.js +67 -0
  4. package/dist/contracts/__tests__/contract-resolvers.test.js +133 -0
  5. package/dist/contracts/__tests__/remote-contract-transport.test.d.ts +1 -0
  6. package/dist/contracts/__tests__/remote-contract-transport.test.js +89 -0
  7. package/dist/contracts/builder.d.ts +47 -1
  8. package/dist/contracts/builder.js +80 -22
  9. package/dist/contracts/contract-provider-router.d.ts +6 -0
  10. package/dist/contracts/contract-provider-router.js +25 -3
  11. package/dist/contracts/contract-proxy.d.ts +41 -1
  12. package/dist/contracts/contract-proxy.js +103 -0
  13. package/dist/contracts/contract-resolvers.d.ts +47 -13
  14. package/dist/contracts/contract-resolvers.js +125 -35
  15. package/dist/contracts/extract.d.ts +4 -1
  16. package/dist/contracts/extract.js +3 -1
  17. package/dist/contracts/index.d.ts +4 -3
  18. package/dist/contracts/index.js +10 -2
  19. package/dist/contracts/remote-contract-transport.d.ts +69 -0
  20. package/dist/contracts/remote-contract-transport.js +95 -0
  21. package/dist/contracts/types.d.ts +57 -0
  22. package/dist/data/tools.d.ts +4 -0
  23. package/dist/data/tools.js +7 -0
  24. package/dist/package-loader/isolated-artifact.d.ts +44 -5
  25. package/dist/package-loader/isolated-artifact.js +82 -4
  26. package/dist/package-loader/isolated-artifact.test.js +51 -3
  27. package/dist/package-loader/isolated-runtime.d.ts +17 -2
  28. package/dist/package-loader/isolated-runtime.js +3 -1
  29. package/dist/package-loader/isolated-table-gateway.d.ts +1 -1
  30. package/dist/package-loader/isolated-table-gateway.js +54 -0
  31. package/dist/package-loader/isolated-table-gateway.test.js +61 -0
  32. package/dist/package-loader/package-loader.d.ts +15 -1
  33. package/dist/package-loader/package-loader.js +48 -0
  34. package/dist/package-loader/resolve-consumed-contracts.d.ts +6 -2
  35. package/dist/package-loader/resolve-consumed-contracts.js +30 -2
  36. package/dist/package-loader/resolve-consumed-contracts.test.js +43 -0
  37. package/package.json +1 -1
@@ -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([contractId, version, providerPackageId ?? null, dataContextId ?? null]);
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
- const providerPackageId = opts.resolveProviderPackageId
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 connection.
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 connection owner supplied by the host,
85
- * authorizes every call before invoking a registered resolver, normalizes omitted data
86
- * contexts to the local user's personal context, and caches stateful endpoints per
87
- * contract/version/data-context route. The caller must match a valid signed Users record
88
- * and have exact Self trust in the provider's personal context. Explicit data-context ids
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 connection.
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 connection.
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 the signed-identity Self-trust host gate,
101
- * resolve the contract for the authorized data context, build a stateless provider, and
102
- * execute the call.
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
- * Check whether a connection-authenticated remote identity has full contract authority.
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
- * The identity must match a valid self-signed Users record in the provider's personal or
66
- * shared-group data, and the provider's personal context must assign the caller exactly
67
- * {@link TrustLevel.Self}. Unsigned discovery stubs and key mismatches fail closed.
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 isSelfTrustedContractCaller(userContext, remoteIdentity) {
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
- hasVerifiedIdentity = true;
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
- if (!hasVerifiedIdentity) {
90
- return false;
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
- async function authorizeUserContextCall(call, userContext, remoteIdentity) {
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
- const dataContextId = call.dataContextId || userContext.userId;
103
- const context = {
104
- callerUserId: remoteIdentity.userId,
105
- localUserId: userContext.userId,
106
- dataContext: userContext.getDataContext(dataContextId),
107
- };
108
- if (!(await isSelfTrustedContractCaller(userContext, remoteIdentity))) {
109
- throw new Error(`Permission denied: ${call.member} '${call.name}'.${call.operation} on contract ${call.contractId}`);
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 connection.
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 connection owner supplied by the host,
213
- * authorizes every call before invoking a registered resolver, normalizes omitted data
214
- * contexts to the local user's personal context, and caches stateful endpoints per
215
- * contract/version/data-context route. The caller must match a valid signed Users record
216
- * and have exact Self trust in the provider's personal context. Explicit data-context ids
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 connection.
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 connection.
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) => authorizeUserContextCall(call, userContext, remoteIdentity),
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 the signed-identity Self-trust host gate,
245
- * resolve the contract for the authorized data context, build a stateless provider, and
246
- * execute the call.
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 authorizeUserContextCall(call, userContext, remoteIdentity);
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
  }