@procedures/client 0.1.0 → 0.2.0
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/CHANGELOG.md +26 -0
- package/README.md +1 -1
- package/dist/.tsbuildinfo +1 -1
- package/dist/adapters/http/server/transport.d.ts.map +1 -1
- package/dist/adapters/http/server/transport.js +1 -0
- package/dist/adapters/http/server/transport.js.map +1 -1
- package/dist/browser.d.ts +5 -2
- package/dist/browser.d.ts.map +1 -1
- package/dist/browser.js +4 -1
- package/dist/browser.js.map +1 -1
- package/dist/client/client.d.ts +9 -1
- package/dist/client/client.d.ts.map +1 -1
- package/dist/client/client.js +27 -6
- package/dist/client/client.js.map +1 -1
- package/dist/client/types.d.ts +9 -0
- package/dist/client/types.d.ts.map +1 -1
- package/dist/client/types.js.map +1 -1
- package/dist/procedures/capability.d.ts +111 -0
- package/dist/procedures/capability.d.ts.map +1 -0
- package/dist/procedures/capability.js +269 -0
- package/dist/procedures/capability.js.map +1 -0
- package/dist/procedures/cli/discover.d.ts +3 -3
- package/dist/procedures/cli/discover.js +3 -3
- package/dist/procedures/cli/index.js +5 -3
- package/dist/procedures/cli/index.js.map +1 -1
- package/dist/procedures/cli/init.js +1 -1
- package/dist/procedures/collection/procedures.d.ts.map +1 -1
- package/dist/procedures/collection/procedures.js +12 -0
- package/dist/procedures/collection/procedures.js.map +1 -1
- package/dist/procedures/core/array.d.ts.map +1 -1
- package/dist/procedures/core/array.js +3 -1
- package/dist/procedures/core/array.js.map +1 -1
- package/dist/procedures/core/comparison.d.ts.map +1 -1
- package/dist/procedures/core/comparison.js +3 -1
- package/dist/procedures/core/comparison.js.map +1 -1
- package/dist/procedures/core/index.d.ts.map +1 -1
- package/dist/procedures/core/index.js +5 -2
- package/dist/procedures/core/index.js.map +1 -1
- package/dist/procedures/core/math.d.ts.map +1 -1
- package/dist/procedures/core/math.js +3 -1
- package/dist/procedures/core/math.js.map +1 -1
- package/dist/procedures/core/meta.d.ts.map +1 -1
- package/dist/procedures/core/meta.js +11 -6
- package/dist/procedures/core/meta.js.map +1 -1
- package/dist/procedures/core/object.d.ts.map +1 -1
- package/dist/procedures/core/object.js +3 -1
- package/dist/procedures/core/object.js.map +1 -1
- package/dist/procedures/core/string.d.ts.map +1 -1
- package/dist/procedures/core/string.js +3 -1
- package/dist/procedures/core/string.js.map +1 -1
- package/dist/procedures/core/type.d.ts.map +1 -1
- package/dist/procedures/core/type.js +3 -1
- package/dist/procedures/core/type.js.map +1 -1
- package/dist/procedures/define-procedure.d.ts.map +1 -1
- package/dist/procedures/define-procedure.js +5 -4
- package/dist/procedures/define-procedure.js.map +1 -1
- package/dist/procedures/effects.d.ts +15 -0
- package/dist/procedures/effects.d.ts.map +1 -0
- package/dist/procedures/effects.js +18 -0
- package/dist/procedures/effects.js.map +1 -0
- package/dist/procedures/index.d.ts +5 -2
- package/dist/procedures/index.d.ts.map +1 -1
- package/dist/procedures/index.js +8 -23
- package/dist/procedures/index.js.map +1 -1
- package/dist/procedures/interceptors.d.ts.map +1 -1
- package/dist/procedures/interceptors.js +2 -1
- package/dist/procedures/interceptors.js.map +1 -1
- package/dist/procedures/invoke.d.ts +31 -3
- package/dist/procedures/invoke.d.ts.map +1 -1
- package/dist/procedures/invoke.js +78 -12
- package/dist/procedures/invoke.js.map +1 -1
- package/dist/procedures/module.d.ts +13 -8
- package/dist/procedures/module.d.ts.map +1 -1
- package/dist/procedures/module.js +19 -9
- package/dist/procedures/module.js.map +1 -1
- package/dist/procedures/types.d.ts +100 -2
- package/dist/procedures/types.d.ts.map +1 -1
- package/dist/procedures/types.js.map +1 -1
- package/dist/register.d.ts +8 -0
- package/dist/register.d.ts.map +1 -0
- package/dist/register.js +9 -0
- package/dist/register.js.map +1 -0
- package/dist/server/host-policy.d.ts +24 -2
- package/dist/server/host-policy.d.ts.map +1 -1
- package/dist/server/host-policy.js +22 -0
- package/dist/server/host-policy.js.map +1 -1
- package/dist/server/procedure-server.d.ts +16 -2
- package/dist/server/procedure-server.d.ts.map +1 -1
- package/dist/server/procedure-server.js +26 -1
- package/dist/server/procedure-server.js.map +1 -1
- package/package.json +6 -4
- package/src/adapters/http/server/transport.ts +1 -0
- package/src/browser.ts +28 -0
- package/src/client/client.ts +33 -6
- package/src/client/types.ts +11 -0
- package/src/procedures/capability.ts +333 -0
- package/src/procedures/cli/discover.ts +3 -3
- package/src/procedures/cli/index.ts +5 -3
- package/src/procedures/cli/init.ts +1 -1
- package/src/procedures/collection/procedures.ts +12 -0
- package/src/procedures/core/array.ts +3 -1
- package/src/procedures/core/comparison.ts +3 -1
- package/src/procedures/core/index.ts +5 -2
- package/src/procedures/core/math.ts +3 -1
- package/src/procedures/core/meta.ts +11 -6
- package/src/procedures/core/object.ts +3 -1
- package/src/procedures/core/string.ts +3 -1
- package/src/procedures/core/type.ts +3 -1
- package/src/procedures/define-procedure.ts +5 -4
- package/src/procedures/effects.ts +24 -0
- package/src/procedures/index.ts +33 -22
- package/src/procedures/interceptors.ts +2 -1
- package/src/procedures/invoke.ts +106 -13
- package/src/procedures/module.ts +19 -9
- package/src/procedures/types.ts +113 -2
- package/src/register.ts +10 -0
- package/src/server/host-policy.ts +44 -2
- package/src/server/procedure-server.ts +44 -2
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Effect declarations for groups of procedures (CAPABILITIES-2026-10). This file imports only
|
|
3
|
+
* types, so each file of procedures can use it without an import cycle.
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import type { AnyProcedure, Effects } from "./types.js";
|
|
7
|
+
|
|
8
|
+
/** The effects of a procedure that reads and changes nothing: its output comes from its input. */
|
|
9
|
+
export const PURE_EFFECTS: Effects = Object.freeze({ reads: Object.freeze([]), writes: Object.freeze([]) });
|
|
10
|
+
|
|
11
|
+
/** The effects of a procedure that reads the procedure registry and changes nothing. */
|
|
12
|
+
export const REGISTRY_READ_EFFECTS: Effects = Object.freeze({ reads: Object.freeze(["registry"]), writes: Object.freeze([]) });
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* This function gives copies of the procedures with the effects in their metadata. A procedure
|
|
16
|
+
* that declares its own effects keeps them.
|
|
17
|
+
*/
|
|
18
|
+
export function withEffects<T extends AnyProcedure>(effects: Effects, procedures: readonly T[]): T[] {
|
|
19
|
+
return procedures.map((procedure) =>
|
|
20
|
+
(procedure.metadata as { effects?: unknown } | undefined)?.effects !== undefined
|
|
21
|
+
? procedure
|
|
22
|
+
: ({ ...procedure, metadata: { ...procedure.metadata, effects } } as T)
|
|
23
|
+
);
|
|
24
|
+
}
|
package/src/procedures/index.ts
CHANGED
|
@@ -24,11 +24,39 @@ export type {
|
|
|
24
24
|
ProcedureModule,
|
|
25
25
|
RegistrationOptions,
|
|
26
26
|
ProcedureRegistryLike,
|
|
27
|
+
Effects,
|
|
28
|
+
PermittedEffects,
|
|
29
|
+
Register,
|
|
30
|
+
ClearanceOf,
|
|
31
|
+
ResourcesOf,
|
|
32
|
+
Capability,
|
|
33
|
+
CapabilityRequest,
|
|
34
|
+
ClearanceOrder,
|
|
35
|
+
NestedCallOptions,
|
|
27
36
|
} from "./types.js";
|
|
28
37
|
|
|
29
38
|
// Type utilities
|
|
30
39
|
export { pathToKey, keyToPath } from "./types.js";
|
|
31
40
|
|
|
41
|
+
// Capabilities (CAPABILITIES-2026-10)
|
|
42
|
+
export {
|
|
43
|
+
STANDARD_RESOURCES,
|
|
44
|
+
UNLIMITED_CAPABILITY,
|
|
45
|
+
ladder,
|
|
46
|
+
effectsOf,
|
|
47
|
+
isReadOnly,
|
|
48
|
+
isDestructive,
|
|
49
|
+
isOpenWorld,
|
|
50
|
+
toolHints,
|
|
51
|
+
narrowCapability,
|
|
52
|
+
capabilityRefusal,
|
|
53
|
+
examinesNestedCall,
|
|
54
|
+
explainInvocation,
|
|
55
|
+
checkEffects,
|
|
56
|
+
} from "./capability.js";
|
|
57
|
+
export { PURE_EFFECTS, REGISTRY_READ_EFFECTS, withEffects } from "./effects.js";
|
|
58
|
+
export type { ToolHints, CapabilityRefusal, CapabilityRefusalReason, InvocationExplanation, EffectsViolation } from "./capability.js";
|
|
59
|
+
|
|
32
60
|
// Registry
|
|
33
61
|
export { ProcedureRegistry, RegistryError, PROCEDURE_REGISTRY, assertValidPath } from "./registry.js";
|
|
34
62
|
|
|
@@ -61,6 +89,7 @@ export {
|
|
|
61
89
|
moduleClosure,
|
|
62
90
|
registerModules,
|
|
63
91
|
createRegistry,
|
|
92
|
+
registerClientProcedures,
|
|
64
93
|
} from "./module.js";
|
|
65
94
|
export type { ModuleInput } from "./module.js";
|
|
66
95
|
|
|
@@ -202,27 +231,9 @@ export type {
|
|
|
202
231
|
// procedures moved to @procedures/store (roadmap 3.2): the program store.
|
|
203
232
|
|
|
204
233
|
// =============================================================================
|
|
205
|
-
//
|
|
234
|
+
// Registration
|
|
206
235
|
// =============================================================================
|
|
207
236
|
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
// Register core procedures when this module is imported
|
|
213
|
-
// This ensures client.* procedures are available without explicit registration
|
|
214
|
-
try {
|
|
215
|
-
for (const proc of _coreProcedures) {
|
|
216
|
-
if (!PROCEDURE_REGISTRY.has(proc.path)) {
|
|
217
|
-
PROCEDURE_REGISTRY.register(proc);
|
|
218
|
-
}
|
|
219
|
-
}
|
|
220
|
-
// Also register meta-procedures
|
|
221
|
-
for (const proc of _metaProcedures) {
|
|
222
|
-
if (!PROCEDURE_REGISTRY.has(proc.path)) {
|
|
223
|
-
PROCEDURE_REGISTRY.register(proc);
|
|
224
|
-
}
|
|
225
|
-
}
|
|
226
|
-
} catch {
|
|
227
|
-
// Ignore errors during auto-registration (e.g., if already registered)
|
|
228
|
-
}
|
|
237
|
+
// Importing this package registers nothing (ARCHITECTURE-PROPOSALS-2026-10, P9). The
|
|
238
|
+
// "./register" entry registers the procedures of `clientModule` in the global registry, and
|
|
239
|
+
// `createRegistry()` adds them to each new registry.
|
|
@@ -98,7 +98,8 @@ export function timeoutInterceptor(options: TimeoutInterceptorOptions): Intercep
|
|
|
98
98
|
|
|
99
99
|
let output: ProcedureOutput<unknown>;
|
|
100
100
|
try {
|
|
101
|
-
|
|
101
|
+
// The deadline goes into the capability: the nested calls and the remote calls see it
|
|
102
|
+
output = await Promise.race([next({ signal, capability: { timeoutMs: ms } }), deadline]);
|
|
102
103
|
} catch (error) {
|
|
103
104
|
stop();
|
|
104
105
|
throw error;
|
package/src/procedures/invoke.ts
CHANGED
|
@@ -13,14 +13,24 @@
|
|
|
13
13
|
* Interceptors run around each invocation, the nested calls too (roadmap 1.2): policy, tracing,
|
|
14
14
|
* timeouts and metrics for every host. `interceptors.ts` has a timeout, a tracing and a metrics
|
|
15
15
|
* interceptor.
|
|
16
|
+
*
|
|
17
|
+
* Each invocation operates with a capability (CAPABILITIES-2026-10, `capability.ts`). A nested
|
|
18
|
+
* call gets the same capability or a narrower one. The admission refuses a procedure whose
|
|
19
|
+
* declared effects the capability does not permit (`FORBIDDEN`). The host can give resources
|
|
20
|
+
* for the capability (`ctx.resources`) and a filter of the output that leaves the host.
|
|
16
21
|
*/
|
|
17
22
|
|
|
18
23
|
import type {
|
|
19
24
|
AnyProcedure,
|
|
25
|
+
Capability,
|
|
26
|
+
CapabilityRequest,
|
|
27
|
+
ClearanceOrder,
|
|
28
|
+
NestedCallOptions,
|
|
20
29
|
ProcedureClient,
|
|
21
30
|
ProcedureContext,
|
|
22
31
|
ProcedurePath,
|
|
23
32
|
RepositoryProvider,
|
|
33
|
+
ResourcesOf,
|
|
24
34
|
} from "./types.js";
|
|
25
35
|
import { pathToKey } from "./types.js";
|
|
26
36
|
import { validateSchema } from "./schema.js";
|
|
@@ -28,6 +38,8 @@ import type { ProcedureRegistry } from "./registry.js";
|
|
|
28
38
|
import { PROCEDURE_REGISTRY } from "./registry.js";
|
|
29
39
|
import { nestedCallRefusal } from "./policy.js";
|
|
30
40
|
import { scopeOf, withScope, type HydrationEvent } from "./ref.js";
|
|
41
|
+
import { UNLIMITED_CAPABILITY, capabilityRefusal, examinesNestedCall, narrowCapability, type CapabilityRefusalReason } from "./capability.js";
|
|
42
|
+
import { timeoutInterceptor } from "./interceptors.js";
|
|
31
43
|
import type { EventBus } from "../events/types.js";
|
|
32
44
|
import { getGlobalEventBus, withEventBusSignal } from "../events/bus.js";
|
|
33
45
|
|
|
@@ -45,7 +57,9 @@ export type InvocationErrorCode =
|
|
|
45
57
|
| "OUTPUT_VALIDATION_ERROR"
|
|
46
58
|
| "NO_OUTPUT"
|
|
47
59
|
| "ABORTED"
|
|
48
|
-
| "TIMEOUT"
|
|
60
|
+
| "TIMEOUT"
|
|
61
|
+
/** The capability of the call does not permit it (CAPABILITIES-2026-10). `reason` tells why. */
|
|
62
|
+
| "FORBIDDEN";
|
|
49
63
|
|
|
50
64
|
/** An error of the invocation itself. An error that the handler throws passes through unchanged. */
|
|
51
65
|
export class InvocationError extends Error {
|
|
@@ -53,11 +67,14 @@ export class InvocationError extends Error {
|
|
|
53
67
|
readonly code: InvocationErrorCode;
|
|
54
68
|
readonly path: ProcedurePath;
|
|
55
69
|
readonly retryable: boolean = false;
|
|
70
|
+
/** The reason of a `FORBIDDEN` error: `effects-undeclared`, `effects-exceeded` or `clearance`. */
|
|
71
|
+
readonly reason: CapabilityRefusalReason | undefined;
|
|
56
72
|
|
|
57
|
-
constructor(code: InvocationErrorCode, message: string, path: ProcedurePath) {
|
|
73
|
+
constructor(code: InvocationErrorCode, message: string, path: ProcedurePath, reason?: CapabilityRefusalReason) {
|
|
58
74
|
super(message);
|
|
59
75
|
this.code = code;
|
|
60
76
|
this.path = path;
|
|
77
|
+
this.reason = reason;
|
|
61
78
|
}
|
|
62
79
|
}
|
|
63
80
|
|
|
@@ -108,6 +125,20 @@ export interface InvokeOptions {
|
|
|
108
125
|
interceptors?: readonly Interceptor[] | undefined;
|
|
109
126
|
/** The invocation whose handler makes this call. The context client sets it for a nested call. */
|
|
110
127
|
parent?: Invocation | undefined;
|
|
128
|
+
/**
|
|
129
|
+
* The capability of the call: its clearance, permitted effects and deadline. The host makes it
|
|
130
|
+
* from its authentication. The nested calls get it, or a narrower one. Absent: no limits.
|
|
131
|
+
*/
|
|
132
|
+
capability?: Capability | undefined;
|
|
133
|
+
/** The order of the clearance values. Narrowing two different clearances needs it. */
|
|
134
|
+
clearanceOrder?: ClearanceOrder | undefined;
|
|
135
|
+
/** The resources of an invocation for its capability (`ctx.resources`). */
|
|
136
|
+
resources?: ((invocation: Invocation) => ResourcesOf) | undefined;
|
|
137
|
+
/**
|
|
138
|
+
* The filter of the output that leaves the host (the call of the host, not its nested calls).
|
|
139
|
+
* It drops a refused stream item. A refused value gives `FORBIDDEN` (reason `clearance`).
|
|
140
|
+
*/
|
|
141
|
+
admitItem?: ((item: unknown, invocation: Invocation) => boolean) | undefined;
|
|
111
142
|
}
|
|
112
143
|
|
|
113
144
|
// =============================================================================
|
|
@@ -130,12 +161,16 @@ export interface Invocation {
|
|
|
130
161
|
readonly parent: Invocation | undefined;
|
|
131
162
|
/** 0 for a call of a host, 1 for its nested calls, and so on. */
|
|
132
163
|
readonly depth: number;
|
|
164
|
+
/** The capability of the invocation (frozen). */
|
|
165
|
+
readonly capability: Capability;
|
|
133
166
|
}
|
|
134
167
|
|
|
135
168
|
/** The changes that an interceptor gives to the next step: the handler and the nested calls get them. */
|
|
136
169
|
export interface InvocationChanges {
|
|
137
170
|
signal?: AbortSignal | undefined;
|
|
138
171
|
metadata?: Record<string, unknown> | undefined;
|
|
172
|
+
/** A request for a narrower capability. The framework calculates the result: it is never wider. */
|
|
173
|
+
capability?: CapabilityRequest | undefined;
|
|
139
174
|
}
|
|
140
175
|
|
|
141
176
|
/** The rest of the chain: the next interceptors, then the validation and the handler. */
|
|
@@ -256,8 +291,15 @@ export async function invokeProcedure<TOutput = unknown>(
|
|
|
256
291
|
signal: options.signal,
|
|
257
292
|
parent: options.parent,
|
|
258
293
|
depth: options.parent ? options.parent.depth + 1 : 0,
|
|
294
|
+
capability: options.capability ?? UNLIMITED_CAPABILITY,
|
|
259
295
|
};
|
|
260
|
-
|
|
296
|
+
// A deadline that the parent does not have (from a host, a request or a narrowed nested call)
|
|
297
|
+
// ends the invocation at that time, as the timeout interceptor does
|
|
298
|
+
const deadline = invocation.capability.deadline;
|
|
299
|
+
const ownDeadline = deadline !== undefined && options.parent?.capability.deadline !== deadline;
|
|
300
|
+
const interceptors = ownDeadline
|
|
301
|
+
? [timeoutInterceptor({ ms: Math.max(0, deadline - Date.now()), appliesTo: () => true }), ...(options.interceptors ?? [])]
|
|
302
|
+
: (options.interceptors ?? []);
|
|
261
303
|
if (interceptors.length === 0) return runInvocation<TOutput>(invocation, options);
|
|
262
304
|
|
|
263
305
|
const dispatch = (index: number, current: Invocation, currentOptions: InvokeOptions): Promise<ProcedureOutput<unknown>> => {
|
|
@@ -269,8 +311,16 @@ export async function invokeProcedure<TOutput = unknown>(
|
|
|
269
311
|
...current,
|
|
270
312
|
signal: changes.signal ?? current.signal,
|
|
271
313
|
metadata: changes.metadata ?? current.metadata,
|
|
314
|
+
capability: changes.capability
|
|
315
|
+
? narrowCapability(current.capability, changes.capability, currentOptions.clearanceOrder)
|
|
316
|
+
: current.capability,
|
|
272
317
|
};
|
|
273
|
-
return dispatch(index + 1, changed, {
|
|
318
|
+
return dispatch(index + 1, changed, {
|
|
319
|
+
...currentOptions,
|
|
320
|
+
signal: changed.signal,
|
|
321
|
+
metadata: changed.metadata,
|
|
322
|
+
capability: changed.capability,
|
|
323
|
+
});
|
|
274
324
|
});
|
|
275
325
|
};
|
|
276
326
|
return dispatch(0, invocation, options) as Promise<ProcedureOutput<TOutput>>;
|
|
@@ -282,6 +332,14 @@ async function runInvocation<TOutput>(invocation: Invocation, options: InvokeOpt
|
|
|
282
332
|
const handler = procedure.handler!;
|
|
283
333
|
if (options.signal?.aborted) throw abortError(path);
|
|
284
334
|
|
|
335
|
+
// The admission: before the input validation, so a refused call learns nothing from its input
|
|
336
|
+
const parent = invocation.parent;
|
|
337
|
+
if (!parent || examinesNestedCall(parent.procedure, parent.capability, invocation.capability)) {
|
|
338
|
+
const refusal = capabilityRefusal(procedure, invocation.capability);
|
|
339
|
+
if (refusal?.reason === "deadline") throw new InvocationError("TIMEOUT", refusal.message, path);
|
|
340
|
+
if (refusal) throw new InvocationError("FORBIDDEN", refusal.message, path, refusal.reason);
|
|
341
|
+
}
|
|
342
|
+
|
|
285
343
|
let data: unknown = input;
|
|
286
344
|
if (!options.inputValidated) {
|
|
287
345
|
// A synchronous schema gives its result at once, so the handler starts before an await
|
|
@@ -301,11 +359,32 @@ async function runInvocation<TOutput>(invocation: Invocation, options: InvokeOpt
|
|
|
301
359
|
}
|
|
302
360
|
|
|
303
361
|
// The handler starts here, before the first await (the site's trace depends on it)
|
|
304
|
-
const result: unknown = await handler(data, createProcedureContext(procedure, options, invocation));
|
|
362
|
+
const result: unknown = await handler(data, createProcedureContext(procedure, { ...options, capability: invocation.capability }, invocation));
|
|
363
|
+
// The output filter examines only what leaves the host
|
|
364
|
+
const admit = invocation.depth === 0 ? options.admitItem : undefined;
|
|
305
365
|
if (isAsyncIterable(result)) {
|
|
306
|
-
|
|
366
|
+
const items = validatedItems(procedure, result, options.signal);
|
|
367
|
+
return {
|
|
368
|
+
kind: "stream",
|
|
369
|
+
items: (admit ? admittedItems(items, admit, invocation) : items) as AsyncGenerator<TOutput, void, undefined>,
|
|
370
|
+
};
|
|
371
|
+
}
|
|
372
|
+
const value = await validateOutput(procedure, result);
|
|
373
|
+
if (admit && !admit(value, invocation)) {
|
|
374
|
+
throw new InvocationError("FORBIDDEN", `The clearance of the call does not permit the output of ${pathToKey(path)}`, path, "clearance");
|
|
375
|
+
}
|
|
376
|
+
return { kind: "value", value: value as TOutput };
|
|
377
|
+
}
|
|
378
|
+
|
|
379
|
+
/** The items that the output filter of the host admits. */
|
|
380
|
+
async function* admittedItems(
|
|
381
|
+
items: AsyncGenerator<unknown, void, undefined>,
|
|
382
|
+
admit: (item: unknown, invocation: Invocation) => boolean,
|
|
383
|
+
invocation: Invocation,
|
|
384
|
+
): AsyncGenerator<unknown, void, undefined> {
|
|
385
|
+
for await (const item of items) {
|
|
386
|
+
if (admit(item, invocation)) yield item;
|
|
307
387
|
}
|
|
308
|
-
return { kind: "value", value: (await validateOutput(procedure, result)) as TOutput };
|
|
309
388
|
}
|
|
310
389
|
|
|
311
390
|
/** This function finds the procedure at a path in the registry of the options, then runs it. */
|
|
@@ -379,6 +458,8 @@ export function createProcedureContext(
|
|
|
379
458
|
path: procedure.path,
|
|
380
459
|
client,
|
|
381
460
|
registry: options.registry ?? PROCEDURE_REGISTRY,
|
|
461
|
+
capability: options.capability ?? UNLIMITED_CAPABILITY,
|
|
462
|
+
resources: invocation && options.resources ? options.resources(invocation) : (EMPTY_RESOURCES as ResourcesOf),
|
|
382
463
|
};
|
|
383
464
|
if (options.signal) context.signal = options.signal;
|
|
384
465
|
if (options.repository) context.repository = options.repository;
|
|
@@ -391,23 +472,35 @@ export function createProcedureContext(
|
|
|
391
472
|
return context;
|
|
392
473
|
}
|
|
393
474
|
|
|
475
|
+
/** The resources of an invocation when the host gives none. */
|
|
476
|
+
const EMPTY_RESOURCES = Object.freeze({});
|
|
477
|
+
|
|
394
478
|
/** The client of a procedure's context: it runs the nested calls from the registry. */
|
|
395
479
|
function registryClient(caller: AnyProcedure, options: InvokeOptions): ProcedureClient {
|
|
396
|
-
const start = <TOutput>(path: ProcedurePath, input: unknown): Promise<ProcedureOutput<TOutput>> => {
|
|
480
|
+
const start = <TOutput>(path: ProcedurePath, input: unknown, callOptions?: NestedCallOptions): Promise<ProcedureOutput<TOutput>> => {
|
|
397
481
|
// The call policy of the host, with the immediate caller (BUGS-2026-07 H18, deep dive MCP-3).
|
|
398
482
|
// It runs before the lookup, so a refused path tells nothing about the procedure.
|
|
399
483
|
const refusal = nestedCallRefusal(caller, path, options);
|
|
400
484
|
if (refusal) {
|
|
401
485
|
return Promise.reject(new InvocationError(refusal.code, refusal.message, path));
|
|
402
486
|
}
|
|
403
|
-
|
|
487
|
+
// A nested call gets the capability of its caller, or a narrower one that it asks for
|
|
488
|
+
const narrow = callOptions?.narrow;
|
|
489
|
+
if (!narrow) return invokePath<TOutput>(path, input, options);
|
|
490
|
+
let capability: Capability;
|
|
491
|
+
try {
|
|
492
|
+
capability = narrowCapability(options.capability, narrow, options.clearanceOrder);
|
|
493
|
+
} catch (error) {
|
|
494
|
+
return Promise.reject(error);
|
|
495
|
+
}
|
|
496
|
+
return invokePath<TOutput>(path, input, { ...options, capability });
|
|
404
497
|
};
|
|
405
498
|
return {
|
|
406
|
-
async call<TInput, TOutput>(path: ProcedurePath, input: TInput): Promise<TOutput> {
|
|
407
|
-
return outputValue(await start<TOutput>(path, input), path);
|
|
499
|
+
async call<TInput, TOutput>(path: ProcedurePath, input: TInput, callOptions?: NestedCallOptions): Promise<TOutput> {
|
|
500
|
+
return outputValue(await start<TOutput>(path, input, callOptions), path);
|
|
408
501
|
},
|
|
409
|
-
stream<TInput, TOutput>(path: ProcedurePath, input: TInput): AsyncIterable<TOutput> {
|
|
410
|
-
return streamOutput(() => start<TOutput>(path, input));
|
|
502
|
+
stream<TInput, TOutput>(path: ProcedurePath, input: TInput, callOptions?: NestedCallOptions): AsyncIterable<TOutput> {
|
|
503
|
+
return streamOutput(() => start<TOutput>(path, input, callOptions));
|
|
411
504
|
},
|
|
412
505
|
};
|
|
413
506
|
}
|
package/src/procedures/module.ts
CHANGED
|
@@ -1,10 +1,10 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Procedure modules and explicit registries (architecture review, roadmap 3.1).
|
|
3
3
|
*
|
|
4
|
-
* Importing a package
|
|
5
|
-
*
|
|
6
|
-
*
|
|
7
|
-
*
|
|
4
|
+
* Importing a package registers nothing (ARCHITECTURE-PROPOSALS-2026-10, P9). A host with its own
|
|
5
|
+
* registry gives it the module of each package: the `./module` entry exports the procedures as
|
|
6
|
+
* data. The global `PROCEDURE_REGISTRY` gets the procedures of a package from its `./register`
|
|
7
|
+
* entry (`import "@procedures/git/register"`).
|
|
8
8
|
*
|
|
9
9
|
* ```ts
|
|
10
10
|
* import { createRegistry, Client, LocalTransport } from "@procedures/client";
|
|
@@ -18,16 +18,15 @@
|
|
|
18
18
|
* rule of a server all use the registry of the invocation (`ctx.registry`).
|
|
19
19
|
*/
|
|
20
20
|
|
|
21
|
-
import { ProcedureRegistry, RegistryError } from "./registry.js";
|
|
21
|
+
import { PROCEDURE_REGISTRY, ProcedureRegistry, RegistryError } from "./registry.js";
|
|
22
22
|
import { coreProcedures } from "./core/index.js";
|
|
23
23
|
import { metaProcedures as definitionProcedures } from "./define-procedure.js";
|
|
24
24
|
import type { AnyProcedure, ProcedureModule, RegistrationOptions } from "./types.js";
|
|
25
25
|
|
|
26
26
|
/**
|
|
27
|
-
* The procedures
|
|
28
|
-
*
|
|
29
|
-
*
|
|
30
|
-
* explicit registry has what the global registry has.
|
|
27
|
+
* The procedures of `@procedures/client`: the control flow (`client.chain`, `client.map` and the
|
|
28
|
+
* others) and `procedure.define`/`get`/`list`/`delete`. `createRegistry()` always registers this
|
|
29
|
+
* module. `import "@procedures/client/register"` registers it in the global registry.
|
|
31
30
|
*/
|
|
32
31
|
export const clientModule: ProcedureModule = {
|
|
33
32
|
name: "@procedures/client",
|
|
@@ -105,3 +104,14 @@ export function registerModules(
|
|
|
105
104
|
export function createRegistry(...modules: ModuleInput[]): ProcedureRegistry {
|
|
106
105
|
return registerModules(new ProcedureRegistry(), [clientModule, ...modules]);
|
|
107
106
|
}
|
|
107
|
+
|
|
108
|
+
/**
|
|
109
|
+
* This function registers the procedures of `clientModule` in a registry: the global registry by
|
|
110
|
+
* default. A procedure that the registry has already stays. The `./register` entry of the package
|
|
111
|
+
* starts this function.
|
|
112
|
+
*/
|
|
113
|
+
export function registerClientProcedures(registry: ProcedureRegistry = PROCEDURE_REGISTRY): void {
|
|
114
|
+
for (const procedure of clientModule.procedures) {
|
|
115
|
+
if (!registry.has(procedure.path)) registry.register(procedure);
|
|
116
|
+
}
|
|
117
|
+
}
|
package/src/procedures/types.ts
CHANGED
|
@@ -69,10 +69,101 @@ export interface ProcedureMetadata {
|
|
|
69
69
|
*/
|
|
70
70
|
calls?: readonly string[];
|
|
71
71
|
|
|
72
|
+
/**
|
|
73
|
+
* The side effects of the procedure (CAPABILITIES-2026-10). A host with a restriction of
|
|
74
|
+
* effects refuses a procedure without this declaration.
|
|
75
|
+
*/
|
|
76
|
+
effects?: Effects;
|
|
77
|
+
|
|
72
78
|
/** Custom metadata fields */
|
|
73
79
|
[key: string]: unknown;
|
|
74
80
|
}
|
|
75
81
|
|
|
82
|
+
// =============================================================================
|
|
83
|
+
// Capabilities (CAPABILITIES-2026-10)
|
|
84
|
+
// =============================================================================
|
|
85
|
+
|
|
86
|
+
/**
|
|
87
|
+
* The side effects of a procedure. The resource names are strings that the application chooses.
|
|
88
|
+
* The standard names are `fs` (the files of the host), `network` (systems outside the host),
|
|
89
|
+
* `process` (it starts any program) and `registry` (the procedure registry).
|
|
90
|
+
*/
|
|
91
|
+
export interface Effects {
|
|
92
|
+
/** The resources that the procedure reads. Absent: not known. */
|
|
93
|
+
readonly reads?: readonly string[];
|
|
94
|
+
/** The resources that the procedure changes. An empty list: it changes nothing. Absent: not known. */
|
|
95
|
+
readonly writes?: readonly string[];
|
|
96
|
+
/** True: a second call with the same input changes nothing more. The default is false. */
|
|
97
|
+
readonly idempotent?: boolean;
|
|
98
|
+
/**
|
|
99
|
+
* True: the procedure can delete or overwrite data that it did not make. The default is true for
|
|
100
|
+
* a procedure that writes, as in MCP. A procedure that writes only new data declares false.
|
|
101
|
+
*/
|
|
102
|
+
readonly destructive?: boolean;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/** The effects that a capability permits. An absent field permits everything of its kind. */
|
|
106
|
+
export interface PermittedEffects {
|
|
107
|
+
/** The resources that a call can read: names, or `"any"`. */
|
|
108
|
+
readonly reads?: readonly string[] | "any";
|
|
109
|
+
/** The resources that a call can change: names, or `"any"`. An empty list: no change. */
|
|
110
|
+
readonly writes?: readonly string[] | "any";
|
|
111
|
+
/** False: a call cannot start a destructive procedure. */
|
|
112
|
+
readonly destructive?: boolean;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/**
|
|
116
|
+
* The types that an application gives to the framework, by declaration merging:
|
|
117
|
+
*
|
|
118
|
+
* ```ts
|
|
119
|
+
* declare module "@procedures/client" {
|
|
120
|
+
* interface Register {
|
|
121
|
+
* clearance: "public" | "shared" | "private";
|
|
122
|
+
* resources: { world: World };
|
|
123
|
+
* }
|
|
124
|
+
* }
|
|
125
|
+
* ```
|
|
126
|
+
*/
|
|
127
|
+
export interface Register {}
|
|
128
|
+
|
|
129
|
+
/** The type of a clearance value: `Register["clearance"]`, or `unknown`. */
|
|
130
|
+
export type ClearanceOf = Register extends { clearance: infer C } ? C : unknown;
|
|
131
|
+
|
|
132
|
+
/** The type of `ctx.resources`: `Register["resources"]`, or a record of unknown values. */
|
|
133
|
+
export type ResourcesOf = Register extends { resources: infer R } ? R : Readonly<Record<string, unknown>>;
|
|
134
|
+
|
|
135
|
+
/**
|
|
136
|
+
* The permissions of one invocation. A nested call gets the same capability or a narrower one,
|
|
137
|
+
* and only the host makes a capability (from its authentication). The object is frozen.
|
|
138
|
+
*/
|
|
139
|
+
export interface Capability<C = ClearanceOf> {
|
|
140
|
+
/** The data that the call can see. Absent: no limit. The application gives the order. */
|
|
141
|
+
readonly clearance?: C;
|
|
142
|
+
/** The effects that the call and its nested calls can have. Absent: all effects. */
|
|
143
|
+
readonly effects?: PermittedEffects;
|
|
144
|
+
/** The time (milliseconds since the epoch) at which the call and its nested calls stop. */
|
|
145
|
+
readonly deadline?: number;
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
/**
|
|
149
|
+
* A request for a narrower capability (a nested call, an interceptor). The framework calculates
|
|
150
|
+
* the result, so a request for more has no effect.
|
|
151
|
+
*/
|
|
152
|
+
export interface CapabilityRequest<C = ClearanceOf> {
|
|
153
|
+
clearance?: C;
|
|
154
|
+
effects?: PermittedEffects;
|
|
155
|
+
/** The time from now, in milliseconds. The result is the earlier of the two deadlines. */
|
|
156
|
+
timeoutMs?: number;
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
/** The order of clearance values: a partial order with a meet (the highest common lower value). */
|
|
160
|
+
export interface ClearanceOrder<C = ClearanceOf> {
|
|
161
|
+
/** True when `a` is the same as or lower than `b`. */
|
|
162
|
+
leq(a: C, b: C): boolean;
|
|
163
|
+
/** The highest value that is the same as or lower than both. */
|
|
164
|
+
meet(a: C, b: C): C;
|
|
165
|
+
}
|
|
166
|
+
|
|
76
167
|
// =============================================================================
|
|
77
168
|
// Procedure Context
|
|
78
169
|
// =============================================================================
|
|
@@ -172,6 +263,18 @@ export interface ProcedureContext {
|
|
|
172
263
|
* `hydrateInput`, so a trace sees the events of its own program only.
|
|
173
264
|
*/
|
|
174
265
|
onRef?: ((event: HydrationEvent) => void) | undefined;
|
|
266
|
+
|
|
267
|
+
/**
|
|
268
|
+
* The capability of this invocation (CAPABILITIES-2026-10): its clearance, permitted effects
|
|
269
|
+
* and deadline. The nested calls of `ctx.client` get it, or a narrower one. Frozen.
|
|
270
|
+
*/
|
|
271
|
+
capability: Capability;
|
|
272
|
+
|
|
273
|
+
/**
|
|
274
|
+
* The resources that the host gives to this invocation, for its capability: a view of a
|
|
275
|
+
* database for a lower clearance, for example. Empty when the host gives none.
|
|
276
|
+
*/
|
|
277
|
+
resources: ResourcesOf;
|
|
175
278
|
}
|
|
176
279
|
|
|
177
280
|
/**
|
|
@@ -195,7 +298,8 @@ export interface ProcedureClient {
|
|
|
195
298
|
*/
|
|
196
299
|
call<TInput = unknown, TOutput = unknown>(
|
|
197
300
|
path: ProcedurePath,
|
|
198
|
-
input: TInput
|
|
301
|
+
input: TInput,
|
|
302
|
+
options?: NestedCallOptions
|
|
199
303
|
): Promise<TOutput>;
|
|
200
304
|
|
|
201
305
|
/**
|
|
@@ -205,10 +309,17 @@ export interface ProcedureClient {
|
|
|
205
309
|
*/
|
|
206
310
|
stream?<TInput = unknown, TOutput = unknown>(
|
|
207
311
|
path: ProcedurePath,
|
|
208
|
-
input: TInput
|
|
312
|
+
input: TInput,
|
|
313
|
+
options?: NestedCallOptions
|
|
209
314
|
): AsyncIterable<TOutput>;
|
|
210
315
|
}
|
|
211
316
|
|
|
317
|
+
/** The options of one nested call. */
|
|
318
|
+
export interface NestedCallOptions {
|
|
319
|
+
/** A narrower capability for the nested call. A request for more has no effect. */
|
|
320
|
+
narrow?: CapabilityRequest | undefined;
|
|
321
|
+
}
|
|
322
|
+
|
|
212
323
|
// =============================================================================
|
|
213
324
|
// Procedure Handler
|
|
214
325
|
// =============================================================================
|
package/src/register.ts
ADDED
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Importing this file registers the procedures of `@procedures/client` in the global registry:
|
|
3
|
+
* `import "@procedures/client/register"`. These are the control flow (`client.chain`, `client.map`
|
|
4
|
+
* and the others) and `procedure.define`/`get`/`list`/`delete`. The other entries of the package
|
|
5
|
+
* register nothing (ARCHITECTURE-PROPOSALS-2026-10, P9).
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import { registerClientProcedures } from "./procedures/module.js";
|
|
9
|
+
|
|
10
|
+
registerClientProcedures();
|
|
@@ -12,10 +12,14 @@
|
|
|
12
12
|
* procedures (a host for an outside caller sets it).
|
|
13
13
|
* - `timeoutMs`: the time limit of each call from a caller (not of its nested calls).
|
|
14
14
|
* - `token`, `browser`: the checks of the network transports (`client-server` applies them).
|
|
15
|
+
* - `capability`, `readOnly`, `clearanceOrder`, `resources`, `admitItem`: the capability of each
|
|
16
|
+
* call and what the host gives for it (CAPABILITIES-2026-10).
|
|
15
17
|
*/
|
|
16
18
|
|
|
17
|
-
import type { ProcedurePath } from "../procedures/types.js";
|
|
18
|
-
import type { Interceptor } from "../procedures/invoke.js";
|
|
19
|
+
import type { Capability, ClearanceOrder, ProcedurePath, ResourcesOf } from "../procedures/types.js";
|
|
20
|
+
import type { Interceptor, Invocation } from "../procedures/invoke.js";
|
|
21
|
+
import { narrowCapability } from "../procedures/capability.js";
|
|
22
|
+
import type { ServerRequest } from "./types.js";
|
|
19
23
|
import { timeoutInterceptor } from "../procedures/interceptors.js";
|
|
20
24
|
import type { BrowserGuardOptions } from "./browser-guard.js";
|
|
21
25
|
import type { ProcedureRegistry } from "../procedures/registry.js";
|
|
@@ -67,6 +71,20 @@ export interface HostPolicyOptions {
|
|
|
67
71
|
token?: string | undefined;
|
|
68
72
|
/** The browser checks of the network transports (`allowedOrigins`, `allowedHosts`, `allowGet`). */
|
|
69
73
|
browser?: BrowserGuardOptions | undefined;
|
|
74
|
+
/**
|
|
75
|
+
* The capability of each call from a caller: one value, or a function of the request. The
|
|
76
|
+
* function reads only what the transport authenticated (the token, a session), not a claim of
|
|
77
|
+
* the caller. Without it: no limits.
|
|
78
|
+
*/
|
|
79
|
+
capability?: Capability | ((request: ServerRequest<unknown>) => Capability | undefined) | undefined;
|
|
80
|
+
/** True: the callers start only the procedures that change nothing (`effects.writes` is empty). */
|
|
81
|
+
readOnly?: boolean | undefined;
|
|
82
|
+
/** The order of the clearance values. */
|
|
83
|
+
clearanceOrder?: ClearanceOrder | undefined;
|
|
84
|
+
/** The resources of each invocation for its capability (`ctx.resources`). */
|
|
85
|
+
resources?: ((invocation: Invocation) => ResourcesOf) | undefined;
|
|
86
|
+
/** The filter of the output that leaves the host. */
|
|
87
|
+
admitItem?: ((item: unknown, invocation: Invocation) => boolean) | undefined;
|
|
70
88
|
}
|
|
71
89
|
|
|
72
90
|
/** What a host lets its callers do. */
|
|
@@ -76,6 +94,22 @@ export interface HostPolicy {
|
|
|
76
94
|
readonly interceptors: readonly Interceptor[];
|
|
77
95
|
readonly token: string | undefined;
|
|
78
96
|
readonly browser: BrowserGuardOptions;
|
|
97
|
+
/** The capability of a call from a caller, or undefined for no limits. */
|
|
98
|
+
readonly capabilityOf: ((request?: ServerRequest<unknown>) => Capability | undefined) | undefined;
|
|
99
|
+
readonly clearanceOrder: ClearanceOrder | undefined;
|
|
100
|
+
readonly resources: ((invocation: Invocation) => ResourcesOf) | undefined;
|
|
101
|
+
readonly admitItem: ((item: unknown, invocation: Invocation) => boolean) | undefined;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/** The capability function of a policy: the capability of the options, narrowed by `readOnly`. */
|
|
105
|
+
function capabilityOfOptions(options: HostPolicyOptions): HostPolicy["capabilityOf"] {
|
|
106
|
+
const { capability, readOnly } = options;
|
|
107
|
+
if (capability === undefined && !readOnly) return undefined;
|
|
108
|
+
const readOnlyRequest = readOnly ? { effects: { writes: [] as readonly string[] } } : undefined;
|
|
109
|
+
return (request) => {
|
|
110
|
+
const base = typeof capability === "function" ? (request ? capability(request) : undefined) : capability;
|
|
111
|
+
return readOnlyRequest ? narrowCapability(base, readOnlyRequest, options.clearanceOrder) : base;
|
|
112
|
+
};
|
|
79
113
|
}
|
|
80
114
|
|
|
81
115
|
function exposeOf(options: HostPolicyOptions): ((path: ProcedurePath) => boolean) | undefined {
|
|
@@ -106,6 +140,10 @@ export function hostPolicy(options: HostPolicyOptions = {}): HostPolicy {
|
|
|
106
140
|
interceptors,
|
|
107
141
|
token: options.token,
|
|
108
142
|
browser: options.browser ?? {},
|
|
143
|
+
capabilityOf: capabilityOfOptions(options),
|
|
144
|
+
clearanceOrder: options.clearanceOrder,
|
|
145
|
+
resources: options.resources,
|
|
146
|
+
admitItem: options.admitItem,
|
|
109
147
|
};
|
|
110
148
|
}
|
|
111
149
|
|
|
@@ -117,5 +155,9 @@ export function procedureServerOptions(policy: HostPolicy, registry?: ProcedureR
|
|
|
117
155
|
...(policy.expose ? { expose: policy.expose } : {}),
|
|
118
156
|
strictCalls: policy.strictCalls,
|
|
119
157
|
...(policy.interceptors.length > 0 ? { interceptors: policy.interceptors } : {}),
|
|
158
|
+
...(policy.capabilityOf ? { capability: policy.capabilityOf } : {}),
|
|
159
|
+
...(policy.clearanceOrder ? { clearanceOrder: policy.clearanceOrder } : {}),
|
|
160
|
+
...(policy.resources ? { resources: policy.resources } : {}),
|
|
161
|
+
...(policy.admitItem ? { admitItem: policy.admitItem } : {}),
|
|
120
162
|
};
|
|
121
163
|
}
|