@peers-app/peers-sdk 0.25.18 → 0.25.19
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/README.md +9 -1
- package/dist/contracts/__tests__/action-dispatch.test.d.ts +1 -0
- package/dist/contracts/__tests__/action-dispatch.test.js +339 -0
- package/dist/contracts/__tests__/generic-tool-dispatch.test.d.ts +1 -0
- package/dist/contracts/__tests__/generic-tool-dispatch.test.js +173 -0
- package/dist/contracts/action-dispatch.d.ts +12 -0
- package/dist/contracts/action-dispatch.js +73 -0
- package/dist/contracts/contract-provider-router.js +7 -1
- package/dist/contracts/contract-proxy.d.ts +47 -0
- package/dist/contracts/contract-proxy.js +152 -16
- package/dist/contracts/index.d.ts +2 -1
- package/dist/contracts/index.js +5 -2
- package/dist/contracts/system/contract-catalog-contract.d.ts +2 -0
- package/dist/data/messages.d.ts +3 -3
- package/dist/data/package-versions.d.ts +10 -10
- package/dist/data/tool-access.d.ts +3 -1
- package/dist/data/tool-access.js +5 -2
- package/dist/data/tool-access.test.js +7 -0
- package/dist/data/tools.d.ts +7 -3
- package/dist/data/tools.js +7 -1
- package/dist/invites/invite-messages.d.ts +2 -2
- package/dist/package-loader/host-authority-loading.test.d.ts +1 -0
- package/dist/package-loader/host-authority-loading.test.js +87 -0
- package/dist/package-loader/host-dispatch-policy.test.d.ts +1 -0
- package/dist/package-loader/host-dispatch-policy.test.js +56 -0
- package/dist/package-loader/isolated-artifact.d.ts +16 -2
- package/dist/package-loader/isolated-artifact.js +40 -3
- package/dist/package-loader/isolated-artifact.test.js +38 -0
- package/dist/package-loader/isolated-runtime.d.ts +15 -0
- package/dist/package-loader/package-loader.d.ts +2 -0
- package/dist/package-loader/package-loader.js +60 -0
- package/dist/package-loader/package-loader.test.js +1 -1
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -69,6 +69,14 @@ The fastest way to build a Peers package is to start from the template:
|
|
|
69
69
|
- [Documentation](https://peers-app.github.io) — architecture, package development, and API reference
|
|
70
70
|
- [GitHub](https://github.com/peers-app) — source repositories and package template
|
|
71
71
|
|
|
72
|
+
## Tool authority propagation
|
|
73
|
+
|
|
74
|
+
Dynamic tool dispatch carries trusted signed identity and a strict delegated ceiling. A tool declares `authorityPropagation: "cap"` (default) or `"preserve"` in its metadata, and the runtime honors the declaration the same way it honors a declared `accessLevel`: installing a package is the trust decision. There is no separate operator grant.
|
|
75
|
+
|
|
76
|
+
`accessLevel` is independent admission policy. A `preserve` tool with no entry level admits at None (0); an ordinary unclassified tool retains Self (100). Inside strict dispatch, an ordinary tool contributes its own effective level to the outgoing ceiling, while a `preserve` tool omits it but still retains the live user role, any inherited ceiling, and the contract consume ceiling. An Admin entering a Reader `preserve` router can therefore select an Admin target, but an Admin arriving through an ordinary Reader tool stays capped at Reader. Even None `preserve` tools require trusted identity and context. Strict dispatch denies step-up and unsupported remote or cross-context hops.
|
|
77
|
+
|
|
78
|
+
Host packages select public targets through `context.dispatchTool({contractId, version, toolName, providerPackageId}, args)` and package-owned canonical aliases through `context.dispatchAction(alias, args)`. Isolated packages use `callConsumedTool`; all use the same runtime authorizer. Private aliases stay pinned to their owning implementation and target levels are read live on every call. Catalog `authorizedActions` is advisory; execution rechecks. Tool arguments never supply authority.
|
|
79
|
+
|
|
72
80
|
## License
|
|
73
81
|
|
|
74
|
-
MIT
|
|
82
|
+
MIT
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,339 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
const group_member_roles_1 = require("../../data/group-member-roles");
|
|
4
|
+
const utils_1 = require("../../utils");
|
|
5
|
+
const VOICE_ACTION_CONTRACT_ID = "00muxgugn6xvs8f2r80ru205t";
|
|
6
|
+
const contract_provider_router_1 = require("../contract-provider-router");
|
|
7
|
+
const contract_proxy_1 = require("../contract-proxy");
|
|
8
|
+
const contract_transport_1 = require("../contract-transport");
|
|
9
|
+
function groupContext() {
|
|
10
|
+
const userId = (0, utils_1.newid)();
|
|
11
|
+
const groupId = (0, utils_1.newid)();
|
|
12
|
+
const founderUserId = (0, utils_1.newid)();
|
|
13
|
+
let role = group_member_roles_1.GroupMemberRole.Writer;
|
|
14
|
+
const dataContext = {
|
|
15
|
+
dataContextId: groupId,
|
|
16
|
+
groupId,
|
|
17
|
+
tableContainer: {
|
|
18
|
+
getTable: (meta) => {
|
|
19
|
+
if (meta.name === "Groups")
|
|
20
|
+
return { get: async () => ({ groupId, founderUserId }) };
|
|
21
|
+
if (meta.name === "GroupMembers")
|
|
22
|
+
return { findOne: async () => ({ userId, role }) };
|
|
23
|
+
throw new Error(`Unexpected table ${meta.name}`);
|
|
24
|
+
},
|
|
25
|
+
},
|
|
26
|
+
};
|
|
27
|
+
Object.assign(dataContext, {
|
|
28
|
+
userContext: {
|
|
29
|
+
userId,
|
|
30
|
+
loadingPromise: Promise.resolve(),
|
|
31
|
+
userDataContext: dataContext,
|
|
32
|
+
getDataContext: () => dataContext,
|
|
33
|
+
},
|
|
34
|
+
});
|
|
35
|
+
return {
|
|
36
|
+
dataContext,
|
|
37
|
+
userId,
|
|
38
|
+
groupId,
|
|
39
|
+
setRole: (value) => {
|
|
40
|
+
role = value;
|
|
41
|
+
},
|
|
42
|
+
};
|
|
43
|
+
}
|
|
44
|
+
function tool(name, level, toolFn) {
|
|
45
|
+
return {
|
|
46
|
+
tool: {
|
|
47
|
+
name,
|
|
48
|
+
toolId: (0, utils_1.newid)(),
|
|
49
|
+
usageDescription: name,
|
|
50
|
+
accessLevel: level,
|
|
51
|
+
code: "",
|
|
52
|
+
inputSchema: { type: "simple", fields: [] },
|
|
53
|
+
outputSchema: { type: "simple", fields: [] },
|
|
54
|
+
},
|
|
55
|
+
toolFn,
|
|
56
|
+
};
|
|
57
|
+
}
|
|
58
|
+
describe("strict voice/touch action transport in a group", () => {
|
|
59
|
+
it("leaves ordinary host-tool admission outside the action scope on its existing user-role path", async () => {
|
|
60
|
+
const group = groupContext();
|
|
61
|
+
const contractId = (0, utils_1.newid)();
|
|
62
|
+
const effect = jest.fn(async () => "written");
|
|
63
|
+
const dispatch = (0, contract_proxy_1.createContractProvider)({
|
|
64
|
+
contractId,
|
|
65
|
+
version: 1,
|
|
66
|
+
tools: { write: tool("write", 40, effect) },
|
|
67
|
+
});
|
|
68
|
+
const call = {
|
|
69
|
+
contractId,
|
|
70
|
+
version: 1,
|
|
71
|
+
member: "tool",
|
|
72
|
+
name: "write",
|
|
73
|
+
operation: "run",
|
|
74
|
+
args: [{}],
|
|
75
|
+
};
|
|
76
|
+
const context = {
|
|
77
|
+
callerUserId: group.userId,
|
|
78
|
+
dataContext: group.dataContext,
|
|
79
|
+
authority: {
|
|
80
|
+
userId: group.userId,
|
|
81
|
+
userAccessLevel: 40,
|
|
82
|
+
assistantAccessLevel: 20,
|
|
83
|
+
callingToolAccessLevel: 20,
|
|
84
|
+
strictActionScope: false,
|
|
85
|
+
},
|
|
86
|
+
};
|
|
87
|
+
await expect(dispatch(call, context)).resolves.toBe("written");
|
|
88
|
+
group.setRole(group_member_roles_1.GroupMemberRole.Reader);
|
|
89
|
+
await expect(dispatch(call, context)).rejects.toThrow("Reader (20)");
|
|
90
|
+
expect(effect).toHaveBeenCalledTimes(1);
|
|
91
|
+
});
|
|
92
|
+
function setup(authority) {
|
|
93
|
+
const group = groupContext();
|
|
94
|
+
const writes = jest.fn(async () => ({ result: "milk" }));
|
|
95
|
+
const resolution = {
|
|
96
|
+
contractId: VOICE_ACTION_CONTRACT_ID,
|
|
97
|
+
version: 1,
|
|
98
|
+
tools: {
|
|
99
|
+
invoke: tool("invoke", 20, (input, context) => context.dispatchAction?.(input.tool, input.args ?? {})),
|
|
100
|
+
},
|
|
101
|
+
actionAliases: {
|
|
102
|
+
list_items: "list",
|
|
103
|
+
add_item: "add",
|
|
104
|
+
reader_hop: "reader",
|
|
105
|
+
writer_hop: "writer",
|
|
106
|
+
writer_self: "writer-self",
|
|
107
|
+
owner: "history",
|
|
108
|
+
self: "self",
|
|
109
|
+
},
|
|
110
|
+
actionTools: {
|
|
111
|
+
list: tool("list", 20, async () => ({ result: [] })),
|
|
112
|
+
add: tool("add", 40, writes),
|
|
113
|
+
reader: tool("reader", 20, (_, context) => context.dispatchAction?.("add_item", {})),
|
|
114
|
+
writer: tool("writer", 40, (_, context) => context.dispatchAction?.("owner", {})),
|
|
115
|
+
"writer-self": tool("writer-self", 40, (_, context) => context.dispatchAction?.("self", {})),
|
|
116
|
+
history: tool("history", 80, writes),
|
|
117
|
+
self: tool("self", undefined, writes),
|
|
118
|
+
},
|
|
119
|
+
};
|
|
120
|
+
Object.assign(resolution.tools.invoke.tool, { authorityPropagation: "preserve" });
|
|
121
|
+
const context = {
|
|
122
|
+
callerUserId: group.userId,
|
|
123
|
+
localUserId: group.userId,
|
|
124
|
+
dataContext: group.dataContext,
|
|
125
|
+
authority,
|
|
126
|
+
};
|
|
127
|
+
const [client, server] = (0, contract_transport_1.inProcessTransportPair)();
|
|
128
|
+
const session = (0, contract_proxy_1.createContractProviderSession)(resolution, server, { context });
|
|
129
|
+
const consumer = (0, contract_proxy_1.createContractConsumer)({
|
|
130
|
+
contractId: VOICE_ACTION_CONTRACT_ID,
|
|
131
|
+
version: 1,
|
|
132
|
+
name: "Voice Skill",
|
|
133
|
+
description: "",
|
|
134
|
+
tables: [],
|
|
135
|
+
observables: [],
|
|
136
|
+
events: [],
|
|
137
|
+
tools: [{ name: "invoke", usageDescription: "invoke", inputFields: [], outputFields: [] }],
|
|
138
|
+
}, client, { dataContextId: group.groupId });
|
|
139
|
+
return {
|
|
140
|
+
...group,
|
|
141
|
+
writes,
|
|
142
|
+
resolution,
|
|
143
|
+
context,
|
|
144
|
+
consumer,
|
|
145
|
+
dispose: () => {
|
|
146
|
+
consumer.dispose();
|
|
147
|
+
session.dispose();
|
|
148
|
+
},
|
|
149
|
+
};
|
|
150
|
+
}
|
|
151
|
+
it("allows direct Writer dispatch while a group Reader can only list, including touch envelopes", async () => {
|
|
152
|
+
const p = setup();
|
|
153
|
+
try {
|
|
154
|
+
await expect(p.consumer.tools.invoke({ tool: "add_item", args: { name: "milk" } })).resolves.toEqual({ result: "milk" });
|
|
155
|
+
p.setRole(group_member_roles_1.GroupMemberRole.Reader);
|
|
156
|
+
await expect(p.consumer.tools.invoke({ tool: "list_items" })).resolves.toEqual({
|
|
157
|
+
result: [],
|
|
158
|
+
});
|
|
159
|
+
await expect(p.consumer.tools.invoke({
|
|
160
|
+
tool: "add_item",
|
|
161
|
+
args: {
|
|
162
|
+
name: "milk",
|
|
163
|
+
userId: p.userId,
|
|
164
|
+
userAccessLevel: 100,
|
|
165
|
+
assistantAccessLevel: 100,
|
|
166
|
+
directUserInvocation: true,
|
|
167
|
+
hostApprovedApprovalId: (0, utils_1.newid)(),
|
|
168
|
+
},
|
|
169
|
+
})).rejects.toThrow("Permission denied");
|
|
170
|
+
expect(p.writes).toHaveBeenCalledTimes(1);
|
|
171
|
+
expect(await (0, contract_proxy_1.authorizedContractActionNames)(p.resolution, p.context)).toEqual(expect.not.arrayContaining(["add_item", "owner", "self"]));
|
|
172
|
+
}
|
|
173
|
+
finally {
|
|
174
|
+
p.dispose();
|
|
175
|
+
}
|
|
176
|
+
});
|
|
177
|
+
it("caps Reader tools through invoke, denies Owner and Self from Writer tools, and ignores grants", async () => {
|
|
178
|
+
const p = setup();
|
|
179
|
+
try {
|
|
180
|
+
await expect(p.consumer.tools.invoke({ tool: "reader_hop" })).rejects.toThrow("Permission denied");
|
|
181
|
+
await expect(p.consumer.tools.invoke({ tool: "writer_hop" })).rejects.toThrow("Permission denied");
|
|
182
|
+
await expect(p.consumer.tools.invoke({ tool: "self" })).rejects.toThrow("Permission denied");
|
|
183
|
+
p.setRole(group_member_roles_1.GroupMemberRole.Owner);
|
|
184
|
+
await expect(p.consumer.tools.invoke({ tool: "writer_hop" })).rejects.toThrow("Permission denied");
|
|
185
|
+
p.setRole(group_member_roles_1.GroupMemberRole.Founder);
|
|
186
|
+
await expect(p.consumer.tools.invoke({ tool: "writer_self" })).rejects.toThrow("Permission denied");
|
|
187
|
+
expect(p.writes).not.toHaveBeenCalled();
|
|
188
|
+
}
|
|
189
|
+
finally {
|
|
190
|
+
p.dispose();
|
|
191
|
+
}
|
|
192
|
+
});
|
|
193
|
+
it("rechecks role and live metadata after catalog projection", async () => {
|
|
194
|
+
const p = setup();
|
|
195
|
+
try {
|
|
196
|
+
expect(await (0, contract_proxy_1.authorizedContractActionNames)(p.resolution, p.context)).toContain("add_item");
|
|
197
|
+
p.setRole(group_member_roles_1.GroupMemberRole.Reader);
|
|
198
|
+
await expect(p.consumer.tools.invoke({ tool: "add_item" })).rejects.toThrow("Permission denied");
|
|
199
|
+
p.setRole(group_member_roles_1.GroupMemberRole.Writer);
|
|
200
|
+
if (!p.resolution.actionTools)
|
|
201
|
+
throw new Error("Missing actions");
|
|
202
|
+
p.resolution.actionTools.add.tool.accessLevel = 80;
|
|
203
|
+
await expect(p.consumer.tools.invoke({ tool: "add_item" })).rejects.toThrow("Permission denied");
|
|
204
|
+
await expect(p.consumer.tools.invoke({ tool: "invented" })).rejects.toThrow("Unknown action");
|
|
205
|
+
expect(p.writes).not.toHaveBeenCalled();
|
|
206
|
+
}
|
|
207
|
+
finally {
|
|
208
|
+
p.dispose();
|
|
209
|
+
}
|
|
210
|
+
});
|
|
211
|
+
it("refuses a Reader wrapper with no checked actions before an old raw handler can run", async () => {
|
|
212
|
+
const p = setup();
|
|
213
|
+
try {
|
|
214
|
+
delete p.resolution.actionAliases;
|
|
215
|
+
await expect(p.consumer.tools.invoke({ tool: "add_item" })).rejects.toThrow("Action dispatcher requires registered checked actions");
|
|
216
|
+
expect(p.writes).not.toHaveBeenCalled();
|
|
217
|
+
}
|
|
218
|
+
finally {
|
|
219
|
+
p.dispose();
|
|
220
|
+
}
|
|
221
|
+
});
|
|
222
|
+
it("retains a delegated Reader frame from a trusted caller and fails missing identity closed", async () => {
|
|
223
|
+
const p = setup({ userAccessLevel: 40, assistantAccessLevel: 20, strictActionScope: true });
|
|
224
|
+
try {
|
|
225
|
+
await expect(p.consumer.tools.invoke({ tool: "add_item" })).rejects.toThrow("Permission denied");
|
|
226
|
+
delete p.context.callerUserId;
|
|
227
|
+
delete p.context.localUserId;
|
|
228
|
+
await expect(p.consumer.tools.invoke({ tool: "list_items", args: { callerUserId: p.userId } })).rejects.toThrow("missing caller identity");
|
|
229
|
+
expect(p.writes).not.toHaveBeenCalled();
|
|
230
|
+
}
|
|
231
|
+
finally {
|
|
232
|
+
p.dispose();
|
|
233
|
+
}
|
|
234
|
+
});
|
|
235
|
+
it("refuses remote forwarding rather than dropping a strict ceiling", async () => {
|
|
236
|
+
const [client, server] = (0, contract_transport_1.inProcessTransportPair)();
|
|
237
|
+
const resolveEndpoint = jest.fn();
|
|
238
|
+
const router = (0, contract_provider_router_1.createContractProviderRouter)(server, {
|
|
239
|
+
context: {
|
|
240
|
+
authority: { userAccessLevel: 40, assistantAccessLevel: 20, strictActionScope: true },
|
|
241
|
+
},
|
|
242
|
+
authorizeCall: () => ({ context: {} }),
|
|
243
|
+
resolveEndpoint,
|
|
244
|
+
});
|
|
245
|
+
try {
|
|
246
|
+
await expect(client.emit(contract_transport_1.CONTRACT_REQUEST_CHANNEL, {
|
|
247
|
+
contractId: (0, utils_1.newid)(),
|
|
248
|
+
version: 1,
|
|
249
|
+
member: "tool",
|
|
250
|
+
name: "write",
|
|
251
|
+
operation: "run",
|
|
252
|
+
args: [],
|
|
253
|
+
targetDeviceId: (0, utils_1.newid)(),
|
|
254
|
+
})).rejects.toThrow("cannot target another device");
|
|
255
|
+
expect(resolveEndpoint).not.toHaveBeenCalled();
|
|
256
|
+
}
|
|
257
|
+
finally {
|
|
258
|
+
router.dispose();
|
|
259
|
+
}
|
|
260
|
+
});
|
|
261
|
+
});
|
|
262
|
+
describe("declared tool authority propagation", () => {
|
|
263
|
+
it.each([
|
|
264
|
+
[60, undefined, 20, true, 60, true],
|
|
265
|
+
[60, 20, 20, true, 40, false],
|
|
266
|
+
[60, undefined, 20, false, 40, false],
|
|
267
|
+
[0, undefined, undefined, true, 0, true],
|
|
268
|
+
[0, undefined, undefined, true, 20, false],
|
|
269
|
+
[20, undefined, 40, true, 20, false],
|
|
270
|
+
])("generic host propagation U=%s A=%s entry=%s preserve=%s target=%s", async (userLevel, ceiling, entryLevel, preserve, targetLevel, allowed) => {
|
|
271
|
+
const group = groupContext();
|
|
272
|
+
group.setRole(userLevel);
|
|
273
|
+
const effect = jest.fn(() => "executed");
|
|
274
|
+
const router = tool("route", entryLevel, (_, context) => context.dispatchAction?.("act", {}));
|
|
275
|
+
if (preserve)
|
|
276
|
+
Object.assign(router.tool, { authorityPropagation: "preserve" });
|
|
277
|
+
const contractId = (0, utils_1.newid)();
|
|
278
|
+
const dispatch = (0, contract_proxy_1.createContractProvider)({
|
|
279
|
+
contractId,
|
|
280
|
+
version: 1,
|
|
281
|
+
tools: { route: router },
|
|
282
|
+
actionAliases: { act: "target" },
|
|
283
|
+
actionTools: { target: tool("target", targetLevel, effect) },
|
|
284
|
+
});
|
|
285
|
+
const result = dispatch({ contractId, version: 1, member: "tool", name: "route", operation: "run", args: [{}] }, {
|
|
286
|
+
callerUserId: group.userId,
|
|
287
|
+
dataContext: group.dataContext,
|
|
288
|
+
authority: { userAccessLevel: userLevel, assistantAccessLevel: ceiling },
|
|
289
|
+
});
|
|
290
|
+
if (allowed)
|
|
291
|
+
await expect(result).resolves.toBe("executed");
|
|
292
|
+
else
|
|
293
|
+
await expect(result).rejects.toThrow(/Permission denied/);
|
|
294
|
+
expect(effect).toHaveBeenCalledTimes(allowed ? 1 : 0);
|
|
295
|
+
});
|
|
296
|
+
it.each([
|
|
297
|
+
[VOICE_ACTION_CONTRACT_ID, 1, "invoke"],
|
|
298
|
+
["00mskqamhdq1327mqehy58pps", 3, "voice-hub-run-action"],
|
|
299
|
+
])("honors declared preserve on %s v%s %s without raising an inherited ceiling", async (contractId, version, name) => {
|
|
300
|
+
const group = groupContext();
|
|
301
|
+
const effect = jest.fn(() => ({ writerTargetExecuted: true }));
|
|
302
|
+
const resolution = {
|
|
303
|
+
contractId,
|
|
304
|
+
version,
|
|
305
|
+
tools: {
|
|
306
|
+
[name]: tool(name, 20, (_, context) => context.dispatchAction?.("write", {})),
|
|
307
|
+
},
|
|
308
|
+
actionAliases: { write: "write" },
|
|
309
|
+
actionTools: { write: tool("write", 40, effect) },
|
|
310
|
+
};
|
|
311
|
+
const call = {
|
|
312
|
+
contractId,
|
|
313
|
+
version,
|
|
314
|
+
member: "tool",
|
|
315
|
+
name,
|
|
316
|
+
operation: "run",
|
|
317
|
+
args: [{}],
|
|
318
|
+
};
|
|
319
|
+
const context = { callerUserId: group.userId, dataContext: group.dataContext };
|
|
320
|
+
// An ordinary Reader wrapper caps a direct Writer at Reader inside the action scope.
|
|
321
|
+
const capped = (0, contract_proxy_1.createContractProvider)(resolution);
|
|
322
|
+
await expect(capped(call, context)).rejects.toThrow("Permission denied: strict delegation");
|
|
323
|
+
expect(effect).not.toHaveBeenCalled();
|
|
324
|
+
// The same wrapper declared preserve passes the Writer through to a Writer target.
|
|
325
|
+
Object.assign(resolution.tools[name].tool, { authorityPropagation: "preserve" });
|
|
326
|
+
const preserving = (0, contract_proxy_1.createContractProvider)(resolution);
|
|
327
|
+
await expect(preserving(call, context)).resolves.toEqual({ writerTargetExecuted: true });
|
|
328
|
+
// An inherited Reader frame from an upstream ordinary tool is never raised.
|
|
329
|
+
await expect(preserving(call, {
|
|
330
|
+
...context,
|
|
331
|
+
authority: {
|
|
332
|
+
userAccessLevel: 40,
|
|
333
|
+
assistantAccessLevel: 20,
|
|
334
|
+
strictActionScope: true,
|
|
335
|
+
},
|
|
336
|
+
})).rejects.toThrow("Permission denied: strict delegation");
|
|
337
|
+
expect(effect).toHaveBeenCalledTimes(1);
|
|
338
|
+
});
|
|
339
|
+
});
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,173 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
const index_1 = require("../../index");
|
|
4
|
+
test("host admission for an omitted preserve level is None and the declared mode is honored", async () => {
|
|
5
|
+
const userId = (0, index_1.newid)();
|
|
6
|
+
const groupId = (0, index_1.newid)();
|
|
7
|
+
const contractId = (0, index_1.newid)();
|
|
8
|
+
let role = index_1.GroupMemberRole.None;
|
|
9
|
+
const dc = {
|
|
10
|
+
dataContextId: groupId,
|
|
11
|
+
groupId,
|
|
12
|
+
tableContainer: {
|
|
13
|
+
getTable: (meta) => meta.name === "Groups"
|
|
14
|
+
? { get: async () => ({ founderUserId: (0, index_1.newid)() }) }
|
|
15
|
+
: { findOne: async () => ({ role }) },
|
|
16
|
+
},
|
|
17
|
+
};
|
|
18
|
+
Object.assign(dc, {
|
|
19
|
+
userContext: {
|
|
20
|
+
userId,
|
|
21
|
+
loadingPromise: Promise.resolve(),
|
|
22
|
+
userDataContext: dc,
|
|
23
|
+
getDataContext: () => dc,
|
|
24
|
+
},
|
|
25
|
+
});
|
|
26
|
+
const route = {
|
|
27
|
+
tool: { toolId: (0, index_1.newid)(), name: "route", authorityPropagation: "preserve" },
|
|
28
|
+
toolFn: (name, ctx) => ctx.dispatchAction(name, {}),
|
|
29
|
+
};
|
|
30
|
+
const ordinary = {
|
|
31
|
+
tool: { toolId: (0, index_1.newid)(), name: "ordinary" },
|
|
32
|
+
toolFn: () => "ordinary",
|
|
33
|
+
};
|
|
34
|
+
const targets = Object.fromEntries([0, 20].map((level) => [
|
|
35
|
+
String(level),
|
|
36
|
+
{
|
|
37
|
+
tool: { toolId: (0, index_1.newid)(), name: String(level), accessLevel: level },
|
|
38
|
+
toolFn: () => "executed",
|
|
39
|
+
},
|
|
40
|
+
]));
|
|
41
|
+
const provider = (0, index_1.createContractProvider)({
|
|
42
|
+
contractId,
|
|
43
|
+
version: 1,
|
|
44
|
+
tools: { route, ordinary },
|
|
45
|
+
actionAliases: { zero: "0", reader: "20" },
|
|
46
|
+
actionTools: targets,
|
|
47
|
+
});
|
|
48
|
+
const call = (name, action = "zero") => provider({
|
|
49
|
+
contractId,
|
|
50
|
+
version: 1,
|
|
51
|
+
member: "tool",
|
|
52
|
+
name,
|
|
53
|
+
operation: "run",
|
|
54
|
+
args: [action],
|
|
55
|
+
dataContextId: groupId,
|
|
56
|
+
}, { callerUserId: userId, dataContext: dc });
|
|
57
|
+
role = index_1.GroupMemberRole.None;
|
|
58
|
+
await expect(call("route")).resolves.toBe("executed");
|
|
59
|
+
await expect(call("route", "reader")).rejects.toThrow(/Permission denied/);
|
|
60
|
+
await expect(call("ordinary")).rejects.toThrow(/Permission denied/);
|
|
61
|
+
role = index_1.GroupMemberRole.Admin;
|
|
62
|
+
await expect(call("route", "reader")).resolves.toBe("executed");
|
|
63
|
+
// The same wrapper without a preserve declaration is an ordinary unclassified (Self) tool.
|
|
64
|
+
route.tool.authorityPropagation = undefined;
|
|
65
|
+
await expect(call("route", "reader")).rejects.toThrow(/Permission denied/);
|
|
66
|
+
});
|
|
67
|
+
test("host tool schema retains omitted admission for request-based defaults", () => {
|
|
68
|
+
const tool = index_1.toolSchema.parse({
|
|
69
|
+
toolId: (0, index_1.newid)(),
|
|
70
|
+
name: "route",
|
|
71
|
+
usageDescription: "route",
|
|
72
|
+
inputSchema: { type: "simple" },
|
|
73
|
+
outputSchema: { type: "simple" },
|
|
74
|
+
authorityPropagation: "preserve",
|
|
75
|
+
});
|
|
76
|
+
expect(tool.accessLevel).toBeUndefined();
|
|
77
|
+
});
|
|
78
|
+
test("generic dispatch binds a capped strict frame and rejects cross-context and remote targets", async () => {
|
|
79
|
+
const userId = (0, index_1.newid)();
|
|
80
|
+
const groupId = (0, index_1.newid)();
|
|
81
|
+
const contractId = (0, index_1.newid)();
|
|
82
|
+
const dc = {
|
|
83
|
+
dataContextId: groupId,
|
|
84
|
+
groupId,
|
|
85
|
+
tableContainer: {
|
|
86
|
+
getTable: (meta) => meta.name === "Groups"
|
|
87
|
+
? { get: async () => ({ founderUserId: (0, index_1.newid)() }) }
|
|
88
|
+
: { findOne: async () => ({ role: index_1.GroupMemberRole.Admin }) },
|
|
89
|
+
},
|
|
90
|
+
};
|
|
91
|
+
Object.assign(dc, {
|
|
92
|
+
userContext: {
|
|
93
|
+
userId,
|
|
94
|
+
loadingPromise: Promise.resolve(),
|
|
95
|
+
userDataContext: dc,
|
|
96
|
+
getDataContext: () => dc,
|
|
97
|
+
},
|
|
98
|
+
});
|
|
99
|
+
const selected = {
|
|
100
|
+
contractId: (0, index_1.newid)(),
|
|
101
|
+
version: 1,
|
|
102
|
+
toolName: "write",
|
|
103
|
+
providerPackageId: (0, index_1.newid)(),
|
|
104
|
+
};
|
|
105
|
+
const bridge = jest.fn(async (_ref, _args, context) => {
|
|
106
|
+
expect(context.authority).toMatchObject({
|
|
107
|
+
userId,
|
|
108
|
+
assistantAccessLevel: 20,
|
|
109
|
+
strictActionScope: true,
|
|
110
|
+
});
|
|
111
|
+
return "routed";
|
|
112
|
+
});
|
|
113
|
+
const router = {
|
|
114
|
+
tool: { name: "route", toolId: (0, index_1.newid)(), accessLevel: 20 },
|
|
115
|
+
toolFn: (args, ctx) => ctx.dispatchTool(args, {}),
|
|
116
|
+
};
|
|
117
|
+
const dispatch = (0, index_1.createContractProvider)({ contractId, version: 1, forwardCallContext: true, tools: { route: router } }, { dispatchTool: bridge });
|
|
118
|
+
const call = (ref) => dispatch({
|
|
119
|
+
contractId,
|
|
120
|
+
version: 1,
|
|
121
|
+
member: "tool",
|
|
122
|
+
name: "route",
|
|
123
|
+
operation: "run",
|
|
124
|
+
args: [ref],
|
|
125
|
+
dataContextId: groupId,
|
|
126
|
+
}, { callerUserId: userId, dataContext: dc });
|
|
127
|
+
await expect(call(selected)).resolves.toBe("routed");
|
|
128
|
+
await expect(call({ ...selected, dataContextId: (0, index_1.newid)() })).rejects.toThrow("Cross-context");
|
|
129
|
+
await expect(call({ ...selected, targetDeviceId: (0, index_1.newid)() })).rejects.toThrow("cannot target another device");
|
|
130
|
+
expect(bridge).toHaveBeenCalledTimes(1);
|
|
131
|
+
});
|
|
132
|
+
test("host catalogs use declared preservation for an omitted target admission level", async () => {
|
|
133
|
+
const { authorizedContractActionNames } = await Promise.resolve().then(() => require("../../index"));
|
|
134
|
+
const userId = (0, index_1.newid)();
|
|
135
|
+
const groupId = (0, index_1.newid)();
|
|
136
|
+
const contractId = (0, index_1.newid)();
|
|
137
|
+
const dc = {
|
|
138
|
+
dataContextId: groupId,
|
|
139
|
+
groupId,
|
|
140
|
+
tableContainer: {
|
|
141
|
+
getTable: (meta) => meta.name === "Groups"
|
|
142
|
+
? { get: async () => ({ founderUserId: (0, index_1.newid)() }) }
|
|
143
|
+
: { findOne: async () => ({ role: index_1.GroupMemberRole.Reader }) },
|
|
144
|
+
},
|
|
145
|
+
};
|
|
146
|
+
Object.assign(dc, {
|
|
147
|
+
userContext: {
|
|
148
|
+
userId,
|
|
149
|
+
loadingPromise: Promise.resolve(),
|
|
150
|
+
userDataContext: dc,
|
|
151
|
+
getDataContext: () => dc,
|
|
152
|
+
},
|
|
153
|
+
});
|
|
154
|
+
const zero = {
|
|
155
|
+
tool: { name: "zero", toolId: (0, index_1.newid)(), authorityPropagation: "preserve" },
|
|
156
|
+
toolFn: () => "executed",
|
|
157
|
+
};
|
|
158
|
+
const route = {
|
|
159
|
+
tool: { name: "route", toolId: (0, index_1.newid)(), accessLevel: 20, authorityPropagation: "preserve" },
|
|
160
|
+
toolFn: (_, ctx) => ctx.dispatchAction("none", {}),
|
|
161
|
+
};
|
|
162
|
+
const resolution = {
|
|
163
|
+
contractId,
|
|
164
|
+
version: 1,
|
|
165
|
+
tools: { route, zero },
|
|
166
|
+
actionAliases: { none: "zero" },
|
|
167
|
+
actionTools: { zero },
|
|
168
|
+
};
|
|
169
|
+
const context = { callerUserId: userId, dataContext: dc };
|
|
170
|
+
expect(await authorizedContractActionNames(resolution, context)).toEqual(["none"]);
|
|
171
|
+
const provider = (0, index_1.createContractProvider)(resolution);
|
|
172
|
+
await expect(provider({ contractId, version: 1, member: "tool", name: "route", operation: "run", args: [{}] }, context)).resolves.toBe("executed");
|
|
173
|
+
});
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
import type { IIsolatedCallPrincipal } from "../package-loader/isolated-runtime";
|
|
2
|
+
import type { IContractToolTarget } from "./contract-proxy";
|
|
3
|
+
/** Declared outgoing authority mode for a tool; honored like a declared access level. */
|
|
4
|
+
export type AuthorityPropagation = "cap" | "preserve";
|
|
5
|
+
/** Resolve omitted admission from the declared propagation mode: None for `preserve`, Self otherwise. */
|
|
6
|
+
export declare function dispatchAdmissionLevel(level: number | undefined, requested?: AuthorityPropagation): number;
|
|
7
|
+
/** Apply the shared dispatch decision to a host-resolved role and effective target level. */
|
|
8
|
+
export declare function authorizeToolDispatch(principal: IIsolatedCallPrincipal, toolAccessLevel: number, mode: AuthorityPropagation, strict: boolean): IIsolatedCallPrincipal;
|
|
9
|
+
/** Narrow a trusted call frame at an edge; ordinary-tool capping applies only in the action scope. */
|
|
10
|
+
export declare function narrowActionPrincipal(principal: IIsolatedCallPrincipal, strictActionScope: boolean, consumeCeiling?: number): IIsolatedCallPrincipal;
|
|
11
|
+
/** Invoke a selected package tool over the signed-in UI host transport. */
|
|
12
|
+
export declare function invokeHostTool(target: IContractToolTarget, args: unknown): Promise<any>;
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.dispatchAdmissionLevel = dispatchAdmissionLevel;
|
|
4
|
+
exports.authorizeToolDispatch = authorizeToolDispatch;
|
|
5
|
+
exports.narrowActionPrincipal = narrowActionPrincipal;
|
|
6
|
+
exports.invokeHostTool = invokeHostTool;
|
|
7
|
+
const tool_access_1 = require("../data/tool-access");
|
|
8
|
+
const contract_proxy_1 = require("./contract-proxy");
|
|
9
|
+
const host_contract_transport_1 = require("./host-contract-transport");
|
|
10
|
+
/** Resolve omitted admission from the declared propagation mode: None for `preserve`, Self otherwise. */
|
|
11
|
+
function dispatchAdmissionLevel(level, requested = "cap") {
|
|
12
|
+
return (0, tool_access_1.recordAccessLevel)({ accessLevel: level, authorityPropagation: requested });
|
|
13
|
+
}
|
|
14
|
+
/** Apply the shared dispatch decision to a host-resolved role and effective target level. */
|
|
15
|
+
function authorizeToolDispatch(principal, toolAccessLevel, mode, strict) {
|
|
16
|
+
const incoming = strict ? narrowActionPrincipal(principal, true) : principal;
|
|
17
|
+
const decision = (0, tool_access_1.decideToolAccess)({
|
|
18
|
+
userAccessLevel: incoming.userAccessLevel,
|
|
19
|
+
assistantAccessLevel: strict ? incoming.assistantAccessLevel : undefined,
|
|
20
|
+
toolAccessLevel,
|
|
21
|
+
});
|
|
22
|
+
if (decision.kind !== "allow")
|
|
23
|
+
throw new Error(`Permission denied: ${decision.kind === "approval_required" ? "strict delegation. " : ""}${decision.reason}`);
|
|
24
|
+
return {
|
|
25
|
+
...incoming,
|
|
26
|
+
strictActionScope: strict,
|
|
27
|
+
callingToolAccessLevel: toolAccessLevel,
|
|
28
|
+
authorityPropagation: mode,
|
|
29
|
+
};
|
|
30
|
+
}
|
|
31
|
+
/** Narrow a trusted call frame at an edge; ordinary-tool capping applies only in the action scope. */
|
|
32
|
+
function narrowActionPrincipal(principal, strictActionScope, consumeCeiling = 100) {
|
|
33
|
+
const levels = [
|
|
34
|
+
principal.userAccessLevel,
|
|
35
|
+
principal.assistantAccessLevel ?? principal.userAccessLevel,
|
|
36
|
+
consumeCeiling,
|
|
37
|
+
];
|
|
38
|
+
if (strictActionScope &&
|
|
39
|
+
principal.authorityPropagation !== "preserve" &&
|
|
40
|
+
principal.callingToolAccessLevel !== undefined) {
|
|
41
|
+
levels.push(principal.callingToolAccessLevel);
|
|
42
|
+
}
|
|
43
|
+
return { ...principal, strictActionScope, assistantAccessLevel: Math.min(...levels) };
|
|
44
|
+
}
|
|
45
|
+
/** Invoke a selected package tool over the signed-in UI host transport. */
|
|
46
|
+
async function invokeHostTool(target, args) {
|
|
47
|
+
if (target.targetDeviceId)
|
|
48
|
+
throw new Error("Strict delegated calls cannot target another device until authority transport is supported");
|
|
49
|
+
const transport = await (0, host_contract_transport_1.waitForHostContractTransport)({ timeoutMs: 10_000 });
|
|
50
|
+
const consumer = (0, contract_proxy_1.createContractConsumer)({
|
|
51
|
+
contractId: target.contractId,
|
|
52
|
+
version: target.version,
|
|
53
|
+
name: "Runtime Tool",
|
|
54
|
+
description: "Checked package tool",
|
|
55
|
+
tables: [],
|
|
56
|
+
observables: [],
|
|
57
|
+
events: [],
|
|
58
|
+
tools: [
|
|
59
|
+
{
|
|
60
|
+
name: target.toolName,
|
|
61
|
+
usageDescription: "Invoke a tool",
|
|
62
|
+
inputFields: [],
|
|
63
|
+
outputFields: [],
|
|
64
|
+
},
|
|
65
|
+
],
|
|
66
|
+
}, transport, { providerPackageId: target.providerPackageId, dataContextId: target.dataContextId });
|
|
67
|
+
try {
|
|
68
|
+
return await consumer.tools[target.toolName](args);
|
|
69
|
+
}
|
|
70
|
+
finally {
|
|
71
|
+
consumer.dispose();
|
|
72
|
+
}
|
|
73
|
+
}
|
|
@@ -116,7 +116,13 @@ function createContractProviderRouter(transport, opts) {
|
|
|
116
116
|
if (disposed) {
|
|
117
117
|
throw new Error("Contract provider router is disposed");
|
|
118
118
|
}
|
|
119
|
-
const context =
|
|
119
|
+
const context = {
|
|
120
|
+
...(authorization.context ?? inboundContext),
|
|
121
|
+
authority: inboundContext.authority,
|
|
122
|
+
};
|
|
123
|
+
if (targetDeviceId && context.authority?.strictActionScope) {
|
|
124
|
+
throw new Error("Strict delegated calls cannot target another device until authority transport is supported");
|
|
125
|
+
}
|
|
120
126
|
const routedCall = {
|
|
121
127
|
...call,
|
|
122
128
|
dataContextId: authorization.dataContextId,
|