@procedures/client 0.8.1 → 0.9.1
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 +60 -0
- package/dist/.tsbuildinfo +1 -1
- package/dist/adapters/http/server/addressing.d.ts +78 -0
- package/dist/adapters/http/server/addressing.d.ts.map +1 -0
- package/dist/adapters/http/server/addressing.js +62 -0
- package/dist/adapters/http/server/addressing.js.map +1 -0
- package/dist/adapters/http/server/cors.d.ts.map +1 -1
- package/dist/adapters/http/server/cors.js +3 -0
- package/dist/adapters/http/server/cors.js.map +1 -1
- package/dist/adapters/http/server/handler.d.ts +9 -1
- package/dist/adapters/http/server/handler.d.ts.map +1 -1
- package/dist/adapters/http/server/handler.js +49 -23
- package/dist/adapters/http/server/handler.js.map +1 -1
- package/dist/adapters/http/server/strategies.d.ts +1 -0
- package/dist/adapters/http/server/strategies.d.ts.map +1 -1
- package/dist/adapters/http/server/strategies.js +6 -0
- package/dist/adapters/http/server/strategies.js.map +1 -1
- package/dist/adapters/http/server/transport.d.ts.map +1 -1
- package/dist/adapters/http/server/transport.js +7 -2
- package/dist/adapters/http/server/transport.js.map +1 -1
- package/dist/adapters/http/server/types.d.ts +7 -0
- package/dist/adapters/http/server/types.d.ts.map +1 -1
- package/dist/adapters/http/shared/strategies.d.ts +7 -0
- package/dist/adapters/http/shared/strategies.d.ts.map +1 -1
- package/dist/adapters/http/shared/strategies.js +26 -7
- package/dist/adapters/http/shared/strategies.js.map +1 -1
- package/dist/adapters/local/client/types.d.ts.map +1 -1
- package/dist/adapters/local/client/types.js +3 -1
- package/dist/adapters/local/client/types.js.map +1 -1
- package/dist/adapters/websocket/client/transport.d.ts.map +1 -1
- package/dist/adapters/websocket/client/transport.js +4 -12
- package/dist/adapters/websocket/client/transport.js.map +1 -1
- package/dist/browser.d.ts +6 -3
- package/dist/browser.d.ts.map +1 -1
- package/dist/browser.js +5 -1
- package/dist/browser.js.map +1 -1
- package/dist/client/client.d.ts +6 -0
- package/dist/client/client.d.ts.map +1 -1
- package/dist/client/client.js +3 -1
- package/dist/client/client.js.map +1 -1
- package/dist/client/errors/factory.d.ts.map +1 -1
- package/dist/client/errors/factory.js +2 -3
- package/dist/client/errors/factory.js.map +1 -1
- package/dist/client/middleware/cache.d.ts.map +1 -1
- package/dist/client/middleware/cache.js +2 -1
- package/dist/client/middleware/cache.js.map +1 -1
- package/dist/events/publisher.d.ts +25 -2
- package/dist/events/publisher.d.ts.map +1 -1
- package/dist/events/publisher.js +42 -9
- package/dist/events/publisher.js.map +1 -1
- package/dist/node.d.ts +6 -0
- package/dist/node.d.ts.map +1 -1
- package/dist/node.js +6 -0
- package/dist/node.js.map +1 -1
- package/dist/procedures/collection/changes.d.ts +3 -3
- package/dist/procedures/collection/changes.d.ts.map +1 -1
- package/dist/procedures/collection/changes.js +4 -4
- package/dist/procedures/collection/changes.js.map +1 -1
- package/dist/procedures/collection/index.d.ts +2 -0
- package/dist/procedures/collection/index.d.ts.map +1 -1
- package/dist/procedures/collection/index.js +2 -0
- package/dist/procedures/collection/index.js.map +1 -1
- package/dist/procedures/collection/partition.d.ts +86 -0
- package/dist/procedures/collection/partition.d.ts.map +1 -0
- package/dist/procedures/collection/partition.js +100 -0
- package/dist/procedures/collection/partition.js.map +1 -0
- package/dist/procedures/collection/procedures.d.ts +4 -4
- package/dist/procedures/collection/procedures.d.ts.map +1 -1
- package/dist/procedures/collection/procedures.js +55 -55
- package/dist/procedures/collection/procedures.js.map +1 -1
- package/dist/procedures/index.d.ts +4 -2
- package/dist/procedures/index.d.ts.map +1 -1
- package/dist/procedures/index.js +3 -1
- package/dist/procedures/index.js.map +1 -1
- package/dist/procedures/invoke.d.ts +17 -2
- package/dist/procedures/invoke.d.ts.map +1 -1
- package/dist/procedures/invoke.js +30 -1
- package/dist/procedures/invoke.js.map +1 -1
- package/dist/procedures/narrowing.d.ts +93 -0
- package/dist/procedures/narrowing.d.ts.map +1 -0
- package/dist/procedures/narrowing.js +328 -0
- package/dist/procedures/narrowing.js.map +1 -0
- package/dist/procedures/types.d.ts +19 -0
- package/dist/procedures/types.d.ts.map +1 -1
- package/dist/procedures/types.js.map +1 -1
- package/dist/server/index.d.ts +2 -2
- package/dist/server/index.d.ts.map +1 -1
- package/dist/server/index.js +1 -1
- package/dist/server/index.js.map +1 -1
- package/dist/server/method.d.ts +8 -2
- package/dist/server/method.d.ts.map +1 -1
- package/dist/server/method.js +16 -5
- package/dist/server/method.js.map +1 -1
- package/dist/server/procedure-server.d.ts +47 -1
- package/dist/server/procedure-server.d.ts.map +1 -1
- package/dist/server/procedure-server.js +89 -12
- package/dist/server/procedure-server.js.map +1 -1
- package/dist/server/types.d.ts +16 -0
- package/dist/server/types.d.ts.map +1 -1
- package/dist/server/types.js +2 -1
- package/dist/server/types.js.map +1 -1
- package/package.json +3 -3
- package/src/adapters/http/server/addressing.ts +118 -0
- package/src/adapters/http/server/cors.ts +3 -0
- package/src/adapters/http/server/handler.ts +57 -23
- package/src/adapters/http/server/strategies.ts +7 -0
- package/src/adapters/http/server/transport.ts +8 -3
- package/src/adapters/http/server/types.ts +8 -0
- package/src/adapters/http/shared/strategies.ts +25 -7
- package/src/adapters/local/client/types.ts +3 -1
- package/src/adapters/websocket/client/transport.ts +4 -12
- package/src/browser.ts +12 -1
- package/src/client/client.ts +8 -1
- package/src/client/errors/factory.ts +2 -3
- package/src/client/middleware/cache.ts +2 -1
- package/src/events/publisher.ts +50 -11
- package/src/node.ts +7 -0
- package/src/procedures/collection/changes.ts +4 -4
- package/src/procedures/collection/index.ts +4 -0
- package/src/procedures/collection/partition.ts +145 -0
- package/src/procedures/collection/procedures.ts +72 -73
- package/src/procedures/index.ts +9 -0
- package/src/procedures/invoke.ts +42 -2
- package/src/procedures/narrowing.ts +357 -0
- package/src/procedures/types.ts +23 -0
- package/src/server/index.ts +2 -1
- package/src/server/method.ts +17 -5
- package/src/server/procedure-server.ts +106 -13
- package/src/server/types.ts +19 -1
|
@@ -0,0 +1,357 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The narrowing key of the wire (roadmap CLI-30, `documentation/WIRE-PROTOCOL.md`).
|
|
3
|
+
*
|
|
4
|
+
* A call can ask for a narrower capability than its principal holds: a lower clearance, fewer
|
|
5
|
+
* effects or a shorter time. A narrower capability is always safe, for the same reason as the
|
|
6
|
+
* deadline (`procedure-timeout`). Thus each face carries the key. HTTP has the header
|
|
7
|
+
* `Procedure-Narrowing`, and WebSocket has the metadata of a frame. MCP has the `_meta` of a
|
|
8
|
+
* request, and an invocation in the process has the option `narrow`.
|
|
9
|
+
*
|
|
10
|
+
* A request can have more than one source of narrowing, for example the header of a gateway and
|
|
11
|
+
* the key of `X-Metadata` of the client. No source replaces another: the value of the key
|
|
12
|
+
* becomes a list of the sources (`addNarrowing`). The invocation of the host applies all the
|
|
13
|
+
* sources. The clearance is the meet of the clearance order, the effects are the intersection,
|
|
14
|
+
* and the deadline is the earliest. Thus each source can only narrow, in each sequence.
|
|
15
|
+
*
|
|
16
|
+
* A valid part that asks for more has no effect: the meet removes it. A part that is not valid
|
|
17
|
+
* refuses the call, because the server does not know what that source asks for. Thus a typo of a
|
|
18
|
+
* gateway does not let the call keep a wider capability. Examples are a text that is not JSON, an
|
|
19
|
+
* unknown field, a time that is not a number and a clearance outside the order. The notes of
|
|
20
|
+
* these parts go to the debug reports.
|
|
21
|
+
*
|
|
22
|
+
* This module imports no Node code.
|
|
23
|
+
*/
|
|
24
|
+
|
|
25
|
+
import type { Capability, CapabilityRequest, ClearanceOf, ClearanceOrder, PermittedEffects } from "./types.js";
|
|
26
|
+
import { UNLIMITED_CAPABILITY, narrowCapability } from "./capability.js";
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* The metadata key of the narrowing of a call. Its value is a `CapabilityRequest` as an object or
|
|
30
|
+
* as JSON text, or a list of them: each item is one source.
|
|
31
|
+
*/
|
|
32
|
+
export const NARROWING_METADATA_KEY = "procedure-narrowing";
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* The HTTP header of the narrowing of a call: the JSON of a `CapabilityRequest`, raw or
|
|
36
|
+
* URI-encoded. The header and the metadata key have the same name.
|
|
37
|
+
*/
|
|
38
|
+
export const NARROWING_HEADER = "Procedure-Narrowing";
|
|
39
|
+
|
|
40
|
+
/** A part of a narrowing that has no effect or that refuses the call, and the reason. */
|
|
41
|
+
export interface NarrowingNote {
|
|
42
|
+
/** The part: a whole source, its clearance, its effects or its time. */
|
|
43
|
+
readonly field: "narrowing" | "clearance" | "effects" | "timeoutMs";
|
|
44
|
+
/**
|
|
45
|
+
* - `"widens"`: the part is valid and asks for more than the call holds, so it has no effect.
|
|
46
|
+
* - `"refused"`: the part is not valid, so the server refuses the call with `code`.
|
|
47
|
+
*/
|
|
48
|
+
readonly kind: "widens" | "refused";
|
|
49
|
+
/**
|
|
50
|
+
* The code of a refusal. `INVALID_REQUEST`: the part is not well formed, for example a text that
|
|
51
|
+
* is not JSON, an unknown field or a time that is not a number. `FORBIDDEN` (the reason
|
|
52
|
+
* `clearance`): the clearance is not a value of the order, or the clearances have no meet.
|
|
53
|
+
*/
|
|
54
|
+
readonly code?: "INVALID_REQUEST" | "FORBIDDEN" | undefined;
|
|
55
|
+
/** The reason, as a sentence. */
|
|
56
|
+
readonly message: string;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/** The sources of narrowing from the wire, and the notes of the parts that the decoder did not take. */
|
|
60
|
+
export interface WireNarrowing {
|
|
61
|
+
/** The sources that the decoder can read, in their sequence. Each one can only narrow. */
|
|
62
|
+
readonly narrowings: readonly CapabilityRequest<unknown>[];
|
|
63
|
+
readonly notes: readonly NarrowingNote[];
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/** A capability after a narrowing, and the notes of the parts that had no effect. */
|
|
67
|
+
export interface NarrowedCapability<C = ClearanceOf> {
|
|
68
|
+
/** The narrowed capability. When the narrowing refuses the call, it is the capability that the call held. */
|
|
69
|
+
readonly capability: Capability<C>;
|
|
70
|
+
readonly notes: readonly NarrowingNote[];
|
|
71
|
+
/** The note that refuses the call, or undefined when the call can go on. */
|
|
72
|
+
readonly refusal?: NarrowingNote | undefined;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/** The fields of a `CapabilityRequest`. */
|
|
76
|
+
const FIELDS: ReadonlySet<string> = new Set(["clearance", "effects", "timeoutMs"]);
|
|
77
|
+
|
|
78
|
+
/** The fields of `PermittedEffects`. */
|
|
79
|
+
const EFFECT_FIELDS: ReadonlySet<string> = new Set(["reads", "writes", "destructive"]);
|
|
80
|
+
|
|
81
|
+
/** The depth of the lists and the texts in one value of the key: a list of texts of lists. */
|
|
82
|
+
const MAX_DEPTH = 4;
|
|
83
|
+
|
|
84
|
+
/** The value of `parseText` for a text that is not JSON. */
|
|
85
|
+
const UNREADABLE: unique symbol = Symbol("unreadable");
|
|
86
|
+
|
|
87
|
+
/** True for an object that is not an array and not null. */
|
|
88
|
+
function isRecord(value: unknown): value is Record<string, unknown> {
|
|
89
|
+
return value !== null && typeof value === "object" && !Array.isArray(value);
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/** True for a list of resource names. */
|
|
93
|
+
function isNameList(value: unknown): value is readonly string[] {
|
|
94
|
+
return Array.isArray(value) && value.every((name) => typeof name === "string");
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/** True for a time of a narrowing: a finite number of milliseconds, zero or more. */
|
|
98
|
+
function isTime(value: unknown): value is number {
|
|
99
|
+
return typeof value === "number" && Number.isFinite(value) && value >= 0;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/** The own field of a record, or undefined. A field of the prototype (`__proto__`) does not count. */
|
|
103
|
+
function own(record: Record<string, unknown>, key: string): unknown {
|
|
104
|
+
return Object.hasOwn(record, key) ? record[key] : undefined;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/** A short text of a value for a note. */
|
|
108
|
+
function describe(value: unknown): string {
|
|
109
|
+
try {
|
|
110
|
+
const text = JSON.stringify(value) ?? String(value);
|
|
111
|
+
return text.length > 80 ? `${text.slice(0, 77)}...` : text;
|
|
112
|
+
} catch {
|
|
113
|
+
return String(value);
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
/** The JSON value of a text, or `UNREADABLE`. */
|
|
118
|
+
function parseJson(text: string): unknown {
|
|
119
|
+
try {
|
|
120
|
+
return JSON.parse(text) as unknown;
|
|
121
|
+
} catch {
|
|
122
|
+
return UNREADABLE;
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
/**
|
|
127
|
+
* The value of a text: its JSON, or the JSON of its URI-decoded form. Two header values that a
|
|
128
|
+
* server joined with ", " are two JSON objects with a comma between them: the function reads them
|
|
129
|
+
* as a list.
|
|
130
|
+
*/
|
|
131
|
+
function parseText(text: string): unknown {
|
|
132
|
+
let decoded: string | undefined;
|
|
133
|
+
try {
|
|
134
|
+
decoded = decodeURIComponent(text);
|
|
135
|
+
} catch {
|
|
136
|
+
decoded = undefined;
|
|
137
|
+
}
|
|
138
|
+
for (const candidate of decoded === undefined || decoded === text ? [text] : [text, decoded]) {
|
|
139
|
+
for (const json of [candidate, `[${candidate}]`]) {
|
|
140
|
+
const value = parseJson(json);
|
|
141
|
+
if (value !== UNREADABLE) return value;
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
return UNREADABLE;
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
/** The permitted effects of a narrowing from the wire, or undefined. Each part that is not valid gets a note. */
|
|
148
|
+
function effectsFromWire(value: unknown, notes: NarrowingNote[]): PermittedEffects | undefined {
|
|
149
|
+
if (!isRecord(value)) {
|
|
150
|
+
notes.push({ field: "effects", kind: "refused", code: "INVALID_REQUEST", message: `The effects of the narrowing are not an object: ${describe(value)}` });
|
|
151
|
+
return undefined;
|
|
152
|
+
}
|
|
153
|
+
const result: { reads?: readonly string[] | "any"; writes?: readonly string[] | "any"; destructive?: boolean } = {};
|
|
154
|
+
for (const kind of ["reads", "writes"] as const) {
|
|
155
|
+
const permit = own(value, kind);
|
|
156
|
+
if (permit === undefined) continue;
|
|
157
|
+
if (permit === "any") result[kind] = "any";
|
|
158
|
+
else if (isNameList(permit)) result[kind] = Object.freeze([...permit]);
|
|
159
|
+
else notes.push({ field: "effects", kind: "refused", code: "INVALID_REQUEST", message: `The ${kind} of the narrowing are not "any" or a list of names: ${describe(permit)}` });
|
|
160
|
+
}
|
|
161
|
+
const destructive = own(value, "destructive");
|
|
162
|
+
if (typeof destructive === "boolean") result.destructive = destructive;
|
|
163
|
+
else if (destructive !== undefined) notes.push({ field: "effects", kind: "refused", code: "INVALID_REQUEST", message: `The destructive flag of the narrowing is not true or false: ${describe(destructive)}` });
|
|
164
|
+
for (const key of Object.keys(value)) {
|
|
165
|
+
if (!EFFECT_FIELDS.has(key)) notes.push({ field: "effects", kind: "refused", code: "INVALID_REQUEST", message: `The effects of the narrowing have an unknown field: ${describe(key)}` });
|
|
166
|
+
}
|
|
167
|
+
return Object.keys(result).length > 0 ? (Object.freeze(result) as PermittedEffects) : undefined;
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
/** The source of one object, or undefined when it asks for nothing that the decoder can read. */
|
|
171
|
+
function sourceOf(record: Record<string, unknown>, notes: NarrowingNote[]): CapabilityRequest<unknown> | undefined {
|
|
172
|
+
const narrowing: CapabilityRequest<unknown> = {};
|
|
173
|
+
const clearance = own(record, "clearance");
|
|
174
|
+
if (clearance === null) notes.push({ field: "clearance", kind: "refused", code: "INVALID_REQUEST", message: "The clearance of the narrowing is null" });
|
|
175
|
+
else if (clearance !== undefined) narrowing.clearance = clearance;
|
|
176
|
+
const effects = own(record, "effects");
|
|
177
|
+
if (effects !== undefined) {
|
|
178
|
+
const permitted = effectsFromWire(effects, notes);
|
|
179
|
+
if (permitted) narrowing.effects = permitted;
|
|
180
|
+
}
|
|
181
|
+
const timeoutMs = own(record, "timeoutMs");
|
|
182
|
+
if (isTime(timeoutMs)) narrowing.timeoutMs = timeoutMs;
|
|
183
|
+
else if (timeoutMs !== undefined) notes.push({ field: "timeoutMs", kind: "refused", code: "INVALID_REQUEST", message: `The time of the narrowing is not a number of milliseconds: ${describe(timeoutMs)}` });
|
|
184
|
+
for (const key of Object.keys(record)) {
|
|
185
|
+
if (!FIELDS.has(key)) notes.push({ field: "narrowing", kind: "refused", code: "INVALID_REQUEST", message: `The narrowing has an unknown field: ${describe(key)}` });
|
|
186
|
+
}
|
|
187
|
+
return Object.keys(narrowing).length > 0 ? narrowing : undefined;
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
/** This function reads the sources of one value of the key into `sources`. */
|
|
191
|
+
function collect(value: unknown, depth: number, sources: CapabilityRequest<unknown>[], notes: NarrowingNote[]): void {
|
|
192
|
+
if (value === undefined || value === null || value === "") return;
|
|
193
|
+
if (depth > MAX_DEPTH) {
|
|
194
|
+
notes.push({ field: "narrowing", kind: "refused", code: "INVALID_REQUEST", message: "The narrowing has too many nested lists or texts" });
|
|
195
|
+
return;
|
|
196
|
+
}
|
|
197
|
+
if (typeof value === "string") {
|
|
198
|
+
const parsed = parseText(value);
|
|
199
|
+
if (parsed === UNREADABLE) notes.push({ field: "narrowing", kind: "refused", code: "INVALID_REQUEST", message: `The narrowing is not JSON: ${describe(value)}` });
|
|
200
|
+
else collect(parsed, depth + 1, sources, notes);
|
|
201
|
+
return;
|
|
202
|
+
}
|
|
203
|
+
if (Array.isArray(value)) {
|
|
204
|
+
for (const item of value) collect(item, depth + 1, sources, notes);
|
|
205
|
+
return;
|
|
206
|
+
}
|
|
207
|
+
if (!isRecord(value)) {
|
|
208
|
+
notes.push({ field: "narrowing", kind: "refused", code: "INVALID_REQUEST", message: `The narrowing is not a JSON object: ${describe(value)}` });
|
|
209
|
+
return;
|
|
210
|
+
}
|
|
211
|
+
const source = sourceOf(value, notes);
|
|
212
|
+
if (source) sources.push(source);
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
/**
|
|
216
|
+
* This function reads the narrowing key of a call (CLI-30). The value is a `CapabilityRequest` as
|
|
217
|
+
* an object or as the JSON text of a header, or a list of them. It reads only the own
|
|
218
|
+
* fields `clearance`, `effects` and `timeoutMs`. A part that is not valid, and a source that is
|
|
219
|
+
* not a JSON object, get a note of the kind `"refused"`: the caller refuses the call. An absent
|
|
220
|
+
* value asks for nothing and gets no note.
|
|
221
|
+
*/
|
|
222
|
+
export function narrowingOf(value: unknown): WireNarrowing {
|
|
223
|
+
const narrowings: CapabilityRequest<unknown>[] = [];
|
|
224
|
+
const notes: NarrowingNote[] = [];
|
|
225
|
+
collect(value, 0, narrowings, notes);
|
|
226
|
+
return { narrowings, notes };
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
/**
|
|
230
|
+
* This function adds a source to the value of the narrowing key. The result is a list of the
|
|
231
|
+
* sources, so no source replaces another. A face calls it for each source of a request: a header,
|
|
232
|
+
* a key of `X-Metadata`, the `_meta` of MCP.
|
|
233
|
+
*/
|
|
234
|
+
export function addNarrowing(current: unknown, source: unknown): unknown {
|
|
235
|
+
if (source === undefined) return current;
|
|
236
|
+
if (current === undefined) return source;
|
|
237
|
+
return [...(Array.isArray(current) ? current : [current]), ...(Array.isArray(source) ? source : [source])];
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
/**
|
|
241
|
+
* The clearance that a source can apply, or undefined. A value outside the order gets a note, and a
|
|
242
|
+
* value above the held clearance gets a note too.
|
|
243
|
+
*/
|
|
244
|
+
function clearanceOf<C>(held: C | undefined, asked: C, order: ClearanceOrder<C> | undefined, notes: NarrowingNote[]): C | undefined {
|
|
245
|
+
if (!order) return asked;
|
|
246
|
+
try {
|
|
247
|
+
// A value outside the order throws here: a ladder throws for a value that it does not have
|
|
248
|
+
order.leq(asked, asked);
|
|
249
|
+
if (held !== undefined && !Object.is(held, asked) && !order.leq(asked, held)) {
|
|
250
|
+
notes.push({ field: "clearance", kind: "widens", message: `The clearance ${describe(asked)} is not at or below the clearance of the call (${describe(held)}). The part above it has no effect.` });
|
|
251
|
+
}
|
|
252
|
+
return asked;
|
|
253
|
+
} catch {
|
|
254
|
+
notes.push({ field: "clearance", kind: "refused", code: "FORBIDDEN", message: `The clearance ${describe(asked)} is not a value of the clearance order of the server` });
|
|
255
|
+
return undefined;
|
|
256
|
+
}
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
/** The result of `meetOf` when the clearances have no meet. */
|
|
260
|
+
const NO_MEET: unique symbol = Symbol("no meet");
|
|
261
|
+
|
|
262
|
+
/** The meet of the held clearance and the clearances of the sources, or `NO_MEET` with a note. */
|
|
263
|
+
function meetOf<C>(held: C | undefined, asked: readonly C[], order: ClearanceOrder<C> | undefined, notes: NarrowingNote[]): C | undefined | typeof NO_MEET {
|
|
264
|
+
const values = held === undefined ? [...asked] : [held, ...asked];
|
|
265
|
+
if (values.length === 0) return undefined;
|
|
266
|
+
if (!order) {
|
|
267
|
+
// Without an order, two clearances meet only when they are the same value
|
|
268
|
+
const distinct = values.filter((value, index) => values.findIndex((other) => Object.is(other, value)) === index);
|
|
269
|
+
if (distinct.length === 1) return distinct[0];
|
|
270
|
+
notes.push({ field: "clearance", kind: "refused", code: "FORBIDDEN", message: `The server has no clearance order, so it cannot meet the clearances ${distinct.map(describe).join(" and ")}` });
|
|
271
|
+
return NO_MEET;
|
|
272
|
+
}
|
|
273
|
+
try {
|
|
274
|
+
return values.reduce((met, value) => order.meet(met, value));
|
|
275
|
+
} catch (error) {
|
|
276
|
+
notes.push({ field: "clearance", kind: "refused", code: "FORBIDDEN", message: `The clearance order cannot meet the clearances: ${error instanceof Error ? error.message : String(error)}` });
|
|
277
|
+
return NO_MEET;
|
|
278
|
+
}
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
/** This function adds a note for each part of the asked effects that the held effects do not permit. */
|
|
282
|
+
function noteWiderEffects(held: PermittedEffects | undefined, asked: PermittedEffects, notes: NarrowingNote[]): void {
|
|
283
|
+
for (const kind of ["reads", "writes"] as const) {
|
|
284
|
+
const permit = held?.[kind];
|
|
285
|
+
const wanted = asked[kind];
|
|
286
|
+
if (wanted === undefined || permit === undefined || permit === "any") continue;
|
|
287
|
+
const extra = wanted === "any" ? ["any"] : wanted.filter((name) => !permit.includes(name));
|
|
288
|
+
if (extra.length > 0) {
|
|
289
|
+
const holds = permit.length > 0 ? permit.join(", ") : "nothing";
|
|
290
|
+
notes.push({ field: "effects", kind: "widens", message: `The narrowing asks for the ${kind} ${extra.join(", ")}, and the call holds ${holds}. That part has no effect.` });
|
|
291
|
+
}
|
|
292
|
+
}
|
|
293
|
+
if (held?.destructive === false && asked.destructive === true) {
|
|
294
|
+
notes.push({ field: "effects", kind: "widens", message: "The narrowing permits destructive procedures, and the call does not. That part has no effect." });
|
|
295
|
+
}
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
/**
|
|
299
|
+
* This function narrows a capability with one or more sources that can come from outside the host
|
|
300
|
+
* (CLI-30). The clearance is the meet of the held clearance and of each source, the effects are the
|
|
301
|
+
* intersection, and the deadline is the earliest. Thus the result does not depend on the sequence
|
|
302
|
+
* of the sources, and each source can only make it narrower.
|
|
303
|
+
*
|
|
304
|
+
* The function does not throw. A valid part that asks for more than `held` gets a note and has no
|
|
305
|
+
* effect. A part that is not valid gives `refusal`: a time that is not a number, a clearance
|
|
306
|
+
* outside the order, or clearances without a meet (two different values and no order). Then the
|
|
307
|
+
* caller refuses the call.
|
|
308
|
+
*/
|
|
309
|
+
export function narrowWithin<C>(
|
|
310
|
+
held: Capability<C> | undefined,
|
|
311
|
+
asked: CapabilityRequest<unknown> | readonly CapabilityRequest<unknown>[] | undefined,
|
|
312
|
+
order?: ClearanceOrder<C>,
|
|
313
|
+
now: number = Date.now()
|
|
314
|
+
): NarrowedCapability<C> {
|
|
315
|
+
const base = held ?? (UNLIMITED_CAPABILITY as Capability<C>);
|
|
316
|
+
const sources: readonly CapabilityRequest<unknown>[] = asked === undefined ? [] : Array.isArray(asked) ? asked : [asked as CapabilityRequest<unknown>];
|
|
317
|
+
if (sources.length === 0) return { capability: base, notes: [] };
|
|
318
|
+
const notes: NarrowingNote[] = [];
|
|
319
|
+
const clearances: C[] = [];
|
|
320
|
+
let capability = base;
|
|
321
|
+
for (const source of sources) {
|
|
322
|
+
if (source.clearance !== undefined) {
|
|
323
|
+
const clearance = clearanceOf(base.clearance, source.clearance as C, order, notes);
|
|
324
|
+
if (clearance !== undefined) clearances.push(clearance);
|
|
325
|
+
}
|
|
326
|
+
const part: CapabilityRequest<C> = {};
|
|
327
|
+
if (source.effects !== undefined) {
|
|
328
|
+
noteWiderEffects(base.effects, source.effects, notes);
|
|
329
|
+
part.effects = source.effects;
|
|
330
|
+
}
|
|
331
|
+
if (source.timeoutMs !== undefined) {
|
|
332
|
+
if (!isTime(source.timeoutMs)) {
|
|
333
|
+
notes.push({ field: "timeoutMs", kind: "refused", code: "INVALID_REQUEST", message: `The time of the narrowing is not a number of milliseconds: ${describe(source.timeoutMs)}` });
|
|
334
|
+
} else {
|
|
335
|
+
if (base.deadline !== undefined && now + source.timeoutMs > base.deadline) {
|
|
336
|
+
notes.push({ field: "timeoutMs", kind: "widens", message: `The narrowing asks for ${source.timeoutMs} ms, and the deadline of the call is earlier. That part has no effect.` });
|
|
337
|
+
}
|
|
338
|
+
part.timeoutMs = source.timeoutMs;
|
|
339
|
+
}
|
|
340
|
+
}
|
|
341
|
+
// Without a clearance, the narrowing of the effects and of the time does not throw
|
|
342
|
+
capability = narrowCapability(capability, part, order, now);
|
|
343
|
+
}
|
|
344
|
+
const met = meetOf(base.clearance, clearances, order, notes);
|
|
345
|
+
const refused = notes.find((note) => note.kind === "refused");
|
|
346
|
+
if (met === NO_MEET || refused) return { capability: base, notes, refusal: refused };
|
|
347
|
+
if (met !== undefined) {
|
|
348
|
+
try {
|
|
349
|
+
capability = narrowCapability(capability, { clearance: met }, order, now);
|
|
350
|
+
} catch (error) {
|
|
351
|
+
const refusal: NarrowingNote = { field: "clearance", kind: "refused", code: "FORBIDDEN", message: `The clearance order cannot meet the clearances: ${error instanceof Error ? error.message : String(error)}` };
|
|
352
|
+
notes.push(refusal);
|
|
353
|
+
return { capability: base, notes, refusal };
|
|
354
|
+
}
|
|
355
|
+
}
|
|
356
|
+
return { capability, notes };
|
|
357
|
+
}
|
package/src/procedures/types.ts
CHANGED
|
@@ -259,6 +259,22 @@ export interface RepositoryProvider {
|
|
|
259
259
|
*/
|
|
260
260
|
getStorage<T>(collection: string): CollectionStorage<T>;
|
|
261
261
|
|
|
262
|
+
/**
|
|
263
|
+
* The storage of one partition of a collection (roadmap CLI-17), for example the items of one
|
|
264
|
+
* tenant. A repository without this method has no partitions: the collection procedures then
|
|
265
|
+
* refuse a call with a partition, and never give it the storage of all partitions.
|
|
266
|
+
*/
|
|
267
|
+
getPartitionStorage?<T>(collection: string, partition: string): CollectionStorage<T>;
|
|
268
|
+
|
|
269
|
+
/** True when the collection has partitions (roadmap CLI-17). Then a call resolves its partition with `resolvePartition`. */
|
|
270
|
+
hasPartitions?(collection: string): boolean;
|
|
271
|
+
|
|
272
|
+
/**
|
|
273
|
+
* The partition of a call to a partitioned collection (roadmap CLI-17): from the context (the
|
|
274
|
+
* tenant of `ctx.principal`) and the partition that the call asks for. `null` refuses the call.
|
|
275
|
+
*/
|
|
276
|
+
resolvePartition?(collection: string, ctx: ProcedureContext, requested: string | undefined): string | undefined | null | Promise<string | undefined | null>;
|
|
277
|
+
|
|
262
278
|
/**
|
|
263
279
|
* Check if a collection exists.
|
|
264
280
|
*
|
|
@@ -383,6 +399,13 @@ export interface ProcedureContext {
|
|
|
383
399
|
*/
|
|
384
400
|
caller?: AnyProcedure | undefined;
|
|
385
401
|
|
|
402
|
+
/**
|
|
403
|
+
* The absolute path of the root of the files, for a procedure that a host confines to it
|
|
404
|
+
* (roadmap R33). `confineToRoot()` of `@procedures/fs` sets it for each procedure with rules. A
|
|
405
|
+
* procedure that starts a program uses it to keep the program in the root. Absent without a root.
|
|
406
|
+
*/
|
|
407
|
+
fsRoot?: string | undefined;
|
|
408
|
+
|
|
386
409
|
/**
|
|
387
410
|
* The principal of the call: who sends it. The server sets it from its authentication (the
|
|
388
411
|
* token, the request hook `principalOf`, `authenticate`). The nested calls get the same
|
package/src/server/index.ts
CHANGED
|
@@ -39,7 +39,7 @@ export type { ServerOptions } from "./server.js";
|
|
|
39
39
|
|
|
40
40
|
export { ProcedureServer, createProcedureServer, STREAM_METADATA_KEY, isUnboundedStream } from "./procedure-server.js";
|
|
41
41
|
export type { ProcedureServerOptions, StorageConfig, StopOptions, UseStorageOptions } from "./procedure-server.js";
|
|
42
|
-
export { pathToMethod, methodToPath } from "./method.js";
|
|
42
|
+
export { pathToMethod, methodToPath, methodName } from "./method.js";
|
|
43
43
|
export {
|
|
44
44
|
HandlerNotFoundError,
|
|
45
45
|
ServerError,
|
|
@@ -51,6 +51,7 @@ export {
|
|
|
51
51
|
type PrincipalOf,
|
|
52
52
|
type RequestIdentity,
|
|
53
53
|
type AmbientCredentials,
|
|
54
|
+
type ServerDebugReport,
|
|
54
55
|
type ServerHandler,
|
|
55
56
|
type ServerMiddleware,
|
|
56
57
|
type ServerContext,
|
package/src/server/method.ts
CHANGED
|
@@ -14,20 +14,32 @@ import type { Method } from "../client/types.js";
|
|
|
14
14
|
import type { ProcedurePath } from "../procedures/types.js";
|
|
15
15
|
|
|
16
16
|
/**
|
|
17
|
-
* This function gives the transport method of a procedure path.
|
|
18
|
-
* segments,
|
|
17
|
+
* This function gives the transport method of a procedure path. The last segment is the
|
|
18
|
+
* operation, and the other segments, joined with dots, are the service. A path of one segment,
|
|
19
|
+
* for example `["morning"]`, has the empty service (CLI-28). An empty path throws.
|
|
19
20
|
*/
|
|
20
21
|
export function pathToMethod(path: readonly string[]): Method {
|
|
21
|
-
if (path.length
|
|
22
|
-
throw new Error(
|
|
22
|
+
if (path.length === 0) {
|
|
23
|
+
throw new Error("Invalid procedure path: it needs one segment or more");
|
|
23
24
|
}
|
|
24
25
|
return { service: path.slice(0, -1).join("."), operation: path[path.length - 1]! };
|
|
25
26
|
}
|
|
26
27
|
|
|
28
|
+
/**
|
|
29
|
+
* The name of a method with dots: `docker.compose.up`, or only the operation for the empty
|
|
30
|
+
* service of a path of one segment (`morning`).
|
|
31
|
+
*/
|
|
32
|
+
export function methodName(method: Pick<Method, "service" | "operation">): string {
|
|
33
|
+
return method.service === "" ? method.operation : `${method.service}.${method.operation}`;
|
|
34
|
+
}
|
|
35
|
+
|
|
27
36
|
/**
|
|
28
37
|
* This function gives the procedure path of a transport method. It accepts both encodings:
|
|
29
38
|
* `{ service: "a.b", operation: "c" }` and `{ service: "a", operation: "b.c" }` give `["a", "b", "c"]`.
|
|
30
39
|
*/
|
|
31
40
|
export function methodToPath(method: Pick<Method, "service" | "operation">): ProcedurePath {
|
|
32
|
-
|
|
41
|
+
// The empty service of a path of one segment has no segment. Each other empty segment stays, so
|
|
42
|
+
// a method with ".." or a stray dot names no procedure: the registry has no empty segment (CLI-28)
|
|
43
|
+
const service = method.service === "" ? [] : method.service.split(".");
|
|
44
|
+
return [...service, ...method.operation.split(".")];
|
|
33
45
|
}
|
|
@@ -18,7 +18,8 @@ import { pathToKey } from "../procedures/types.js";
|
|
|
18
18
|
import { ProcedureRegistry, PROCEDURE_REGISTRY } from "../procedures/registry.js";
|
|
19
19
|
import { createCollectionProcedures } from "../procedures/collection/procedures.js";
|
|
20
20
|
import { InvocationError, invokeProcedure, type AdmitItem, type Interceptor, type Invocation } from "../procedures/invoke.js";
|
|
21
|
-
import type { Capability, ClearanceOrder, ResourcesOf } from "../procedures/types.js";
|
|
21
|
+
import type { Capability, ClearanceOrder, ProcedureContext, ResourcesOf } from "../procedures/types.js";
|
|
22
|
+
import type { CollectionPartitions } from "../procedures/collection/partition.js";
|
|
22
23
|
import { TIMEOUT_METADATA_KEY, effectsOf, joinNarrowing, narrowCapability } from "../procedures/capability.js";
|
|
23
24
|
import { IdempotencyCache, idempotencyKeyOf, isRepeatable, type IdempotencyOptions } from "../procedures/idempotency.js";
|
|
24
25
|
import { optionalMs, type Duration } from "../duration.js";
|
|
@@ -29,6 +30,8 @@ import { errorCodeOf } from "../protocol.js";
|
|
|
29
30
|
import { EventPublisher, type EventPublisherOptions } from "../events/publisher.js";
|
|
30
31
|
import { getGlobalEventBus } from "../events/bus.js";
|
|
31
32
|
import type { CollectionProceduresOptions } from "../procedures/collection/procedures.js";
|
|
33
|
+
import type { NarrowingNote } from "../procedures/narrowing.js";
|
|
34
|
+
import type { ServerDebugReport } from "./types.js";
|
|
32
35
|
|
|
33
36
|
// =============================================================================
|
|
34
37
|
// Types
|
|
@@ -165,6 +168,15 @@ export interface ProcedureServerOptions extends ServerOptions {
|
|
|
165
168
|
* `stop()` ends each subscription first, with `UNAVAILABLE`.
|
|
166
169
|
*/
|
|
167
170
|
events?: EventPublisherOptions | EventPublisher | undefined;
|
|
171
|
+
|
|
172
|
+
/**
|
|
173
|
+
* The debug reports of the server. `true` writes each report with `console.debug`, and a
|
|
174
|
+
* function gets each report. The server reports each part of the narrowing of a call that has
|
|
175
|
+
* no effect (CLI-30). Such a part asks for more than the call holds, or it is not valid. The
|
|
176
|
+
* call goes on without that part. The server also reports a source that refuses the call.
|
|
177
|
+
* @default false
|
|
178
|
+
*/
|
|
179
|
+
debug?: boolean | ((report: ServerDebugReport) => void) | undefined;
|
|
168
180
|
}
|
|
169
181
|
|
|
170
182
|
/**
|
|
@@ -316,6 +328,8 @@ export function errorResponse(id: string, error: unknown): ServerResponse<unknow
|
|
|
316
328
|
export class ProcedureServer extends Server implements RepositoryProvider {
|
|
317
329
|
private readonly procedureRegistry: ProcedureRegistry;
|
|
318
330
|
private readonly storages = new Map<string, CollectionStorage<unknown>>();
|
|
331
|
+
/** The partitions of each partitioned collection: their storages and their resolver (roadmap CLI-17) */
|
|
332
|
+
private readonly partitions = new Map<string, CollectionPartitions<unknown>>();
|
|
319
333
|
/** For each storage: the calls in flight that took it, and the closes that wait for them */
|
|
320
334
|
private readonly storageUses = new Map<CollectionStorage<unknown>, { calls: number; waiters: Array<() => void> }>();
|
|
321
335
|
/** The clock of the wait of `useStorage` */
|
|
@@ -358,6 +372,8 @@ export class ProcedureServer extends Server implements RepositoryProvider {
|
|
|
358
372
|
* read it: they trust such a request only from a named origin (CSRF).
|
|
359
373
|
*/
|
|
360
374
|
readonly ambientCredentials: AmbientCredentials | undefined;
|
|
375
|
+
/** The receiver of the debug reports, or undefined when the option `debug` is off. */
|
|
376
|
+
private readonly debugReport: ((report: ServerDebugReport) => void) | undefined;
|
|
361
377
|
|
|
362
378
|
constructor(options: ProcedureServerOptions = {}) {
|
|
363
379
|
super(options);
|
|
@@ -379,6 +395,12 @@ export class ProcedureServer extends Server implements RepositoryProvider {
|
|
|
379
395
|
this.authenticate = options.authenticate;
|
|
380
396
|
const ambient = options.ambientCredentials ?? (this.principalOf ? "always" : this.authenticate ? "cookies" : undefined);
|
|
381
397
|
this.ambientCredentials = ambient === false ? undefined : ambient;
|
|
398
|
+
this.debugReport =
|
|
399
|
+
typeof options.debug === "function"
|
|
400
|
+
? options.debug
|
|
401
|
+
: options.debug === true
|
|
402
|
+
? (report) => console.debug(`[ProcedureServer] ${report.topic} of ${report.requestId}: ${report.message}`)
|
|
403
|
+
: undefined;
|
|
382
404
|
// The remote events: the server pins events.subscribe, as it pins host.health (EVENTS.md)
|
|
383
405
|
this.publisher = options.events instanceof EventPublisher ? options.events : options.events ? new EventPublisher(options.events) : undefined;
|
|
384
406
|
if (this.publisher) this.registerProcedure(this.publisher.procedure);
|
|
@@ -421,7 +443,59 @@ export class ProcedureServer extends Server implements RepositoryProvider {
|
|
|
421
443
|
* @param collection - Collection name
|
|
422
444
|
*/
|
|
423
445
|
hasCollection(collection: string): boolean {
|
|
424
|
-
return this.storages.has(collection);
|
|
446
|
+
return this.storages.has(collection) || this.partitions.has(collection);
|
|
447
|
+
}
|
|
448
|
+
|
|
449
|
+
/**
|
|
450
|
+
* The storage of one partition of a collection (roadmap CLI-17).
|
|
451
|
+
*
|
|
452
|
+
* @throws Error if the collection has no partitions
|
|
453
|
+
*/
|
|
454
|
+
getPartitionStorage<T>(collection: string, partition: string): CollectionStorage<T> {
|
|
455
|
+
const partitions = this.partitions.get(collection);
|
|
456
|
+
if (!partitions) {
|
|
457
|
+
throw new Error(`Collection has no partitions: ${collection}`);
|
|
458
|
+
}
|
|
459
|
+
return partitions.storageOf(partition) as CollectionStorage<T>;
|
|
460
|
+
}
|
|
461
|
+
|
|
462
|
+
/** True when the collection has partitions (roadmap CLI-17). */
|
|
463
|
+
hasPartitions(collection: string): boolean {
|
|
464
|
+
return this.partitions.has(collection);
|
|
465
|
+
}
|
|
466
|
+
|
|
467
|
+
/**
|
|
468
|
+
* The partition of a call to a partitioned collection (roadmap CLI-17), from the resolver of
|
|
469
|
+
* `usePartitions`. `null` refuses the call.
|
|
470
|
+
*
|
|
471
|
+
* @throws Error if the collection has no partitions
|
|
472
|
+
*/
|
|
473
|
+
resolvePartition(collection: string, ctx: ProcedureContext, requested: string | undefined): string | undefined | null | Promise<string | undefined | null> {
|
|
474
|
+
const partitions = this.partitions.get(collection);
|
|
475
|
+
if (!partitions) {
|
|
476
|
+
throw new Error(`Collection has no partitions: ${collection}`);
|
|
477
|
+
}
|
|
478
|
+
return partitions.resolve(ctx, requested);
|
|
479
|
+
}
|
|
480
|
+
|
|
481
|
+
/**
|
|
482
|
+
* Configure the partitions of a collection (roadmap CLI-17). `resolve` gives the partition of
|
|
483
|
+
* each call from its context (the tenant of `ctx.principal`) and the partition that the call asks
|
|
484
|
+
* for: the collection procedures and the subscriptions of `EventPublisher` use it. `storageOf`
|
|
485
|
+
* gives the storage of a partition: give the same storage for the same partition each time.
|
|
486
|
+
*
|
|
487
|
+
* @param name - Collection name, without a dot
|
|
488
|
+
* @param partitions - The storage of each partition, and the resolver
|
|
489
|
+
* @returns this (for chaining)
|
|
490
|
+
* @throws Error if the name has a dot
|
|
491
|
+
*/
|
|
492
|
+
usePartitions<T>(name: string, partitions: CollectionPartitions<T>): this {
|
|
493
|
+
// The topic `collections.<name>.<partition>` names the partition after the first dot
|
|
494
|
+
if (name.includes(".")) {
|
|
495
|
+
throw new Error(`A collection with partitions cannot have a dot in its name: ${name}`);
|
|
496
|
+
}
|
|
497
|
+
this.partitions.set(name, partitions as CollectionPartitions<unknown>);
|
|
498
|
+
return this;
|
|
425
499
|
}
|
|
426
500
|
|
|
427
501
|
// ===========================================================================
|
|
@@ -503,17 +577,20 @@ export class ProcedureServer extends Server implements RepositoryProvider {
|
|
|
503
577
|
private callRepository(): { repository: RepositoryProvider; release: () => void } {
|
|
504
578
|
const taken = new Set<CollectionStorage<unknown>>();
|
|
505
579
|
let released = false;
|
|
580
|
+
const take = <T>(storage: CollectionStorage<unknown>): CollectionStorage<T> => {
|
|
581
|
+
if (!released && !taken.has(storage)) {
|
|
582
|
+
taken.add(storage);
|
|
583
|
+
const use = this.storageUses.get(storage) ?? { calls: 0, waiters: [] };
|
|
584
|
+
use.calls++;
|
|
585
|
+
this.storageUses.set(storage, use);
|
|
586
|
+
}
|
|
587
|
+
return storage as CollectionStorage<T>;
|
|
588
|
+
};
|
|
506
589
|
const repository: RepositoryProvider = {
|
|
507
|
-
getStorage: <T>(collection: string): CollectionStorage<T> =>
|
|
508
|
-
|
|
509
|
-
|
|
510
|
-
|
|
511
|
-
const use = this.storageUses.get(storage) ?? { calls: 0, waiters: [] };
|
|
512
|
-
use.calls++;
|
|
513
|
-
this.storageUses.set(storage, use);
|
|
514
|
-
}
|
|
515
|
-
return storage as CollectionStorage<T>;
|
|
516
|
-
},
|
|
590
|
+
getStorage: <T>(collection: string): CollectionStorage<T> => take<T>(this.getStorage<unknown>(collection)),
|
|
591
|
+
getPartitionStorage: <T>(collection: string, partition: string): CollectionStorage<T> => take<T>(this.getPartitionStorage<unknown>(collection, partition)),
|
|
592
|
+
hasPartitions: (collection: string): boolean => this.hasPartitions(collection),
|
|
593
|
+
resolvePartition: (collection: string, ctx: ProcedureContext, requested: string | undefined) => this.resolvePartition(collection, ctx, requested),
|
|
517
594
|
hasCollection: (collection: string): boolean => this.hasCollection(collection),
|
|
518
595
|
};
|
|
519
596
|
const release = (): void => {
|
|
@@ -564,7 +641,7 @@ export class ProcedureServer extends Server implements RepositoryProvider {
|
|
|
564
641
|
if (!procedure.handler) {
|
|
565
642
|
throw new Error(`Procedure at ${pathToKey(procedure.path)} has no handler`);
|
|
566
643
|
}
|
|
567
|
-
// The path must make a method: this throws for
|
|
644
|
+
// The path must make a method: this throws for an empty path. One segment or more is valid (CLI-28)
|
|
568
645
|
pathToMethod(procedure.path);
|
|
569
646
|
this.pinnedProcedures.set(pathToKey(procedure.path), procedure);
|
|
570
647
|
}
|
|
@@ -784,6 +861,9 @@ export class ProcedureServer extends Server implements RepositoryProvider {
|
|
|
784
861
|
* The capability of a request. It comes from the server, never from the request. The remaining
|
|
785
862
|
* time of the caller and the narrowing of the request can only make it narrower
|
|
786
863
|
* (CAPABILITIES-2026-10, decision 9). Two different clearances without an order throw.
|
|
864
|
+
*
|
|
865
|
+
* The narrowing key of the call (`procedure-narrowing`, CLI-30) is not part of it. The
|
|
866
|
+
* invocation applies the key for each face, after the narrowing of the request hook.
|
|
787
867
|
*/
|
|
788
868
|
private capabilityOf(request: ServerRequest<unknown>): Capability | undefined {
|
|
789
869
|
const { capability, clearanceOrder } = this.capabilityOptions;
|
|
@@ -791,6 +871,17 @@ export class ProcedureServer extends Server implements RepositoryProvider {
|
|
|
791
871
|
return request.narrowing ? narrowCapability(base, request.narrowing, clearanceOrder) : base;
|
|
792
872
|
}
|
|
793
873
|
|
|
874
|
+
/** The debug reports of the notes of the narrowing of a request (CLI-30), or undefined when `debug` is off. */
|
|
875
|
+
private narrowingReports(request: ServerRequest<unknown>): ((notes: readonly NarrowingNote[]) => void) | undefined {
|
|
876
|
+
const report = this.debugReport;
|
|
877
|
+
if (!report) return undefined;
|
|
878
|
+
return (notes) => {
|
|
879
|
+
for (const note of notes) {
|
|
880
|
+
report({ topic: "narrowing", requestId: request.id, method: request.method, message: note.message, detail: { field: note.field, kind: note.kind, ...(note.code ? { code: note.code } : {}) } });
|
|
881
|
+
}
|
|
882
|
+
};
|
|
883
|
+
}
|
|
884
|
+
|
|
794
885
|
/** Run a procedure for a request with a signal. */
|
|
795
886
|
private async run(procedure: AnyProcedure, request: ServerRequest<unknown>, signal: AbortSignal | undefined): Promise<ServerResponse<unknown>> {
|
|
796
887
|
// The call uses the storages that it takes until its end: the end of a stream, for a stream
|
|
@@ -822,6 +913,8 @@ export class ProcedureServer extends Server implements RepositoryProvider {
|
|
|
822
913
|
clearanceOrder,
|
|
823
914
|
resources,
|
|
824
915
|
admitItem,
|
|
916
|
+
// The parts of the narrowing key without effect go to the debug reports (CLI-30)
|
|
917
|
+
onNarrowingNotes: this.narrowingReports(request),
|
|
825
918
|
// The principal of the authentication: ctx.principal, and the nested calls (R18)
|
|
826
919
|
...(request.principal !== undefined ? { principal: request.principal } : {}),
|
|
827
920
|
// The bus of the remote events is the bus of the procedures (EVENTS.md)
|