@decionis/agent-safe-pipeline 0.4.0 → 0.5.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/README.md +29 -0
- package/dist/Index.d.ts +9 -0
- package/dist/Index.d.ts.map +1 -1
- package/dist/Index.js +9 -0
- package/dist/Index.js.map +1 -1
- package/dist/execution/ActionRegistry.d.ts +12 -0
- package/dist/execution/ActionRegistry.d.ts.map +1 -1
- package/dist/execution/ActionRegistry.js +14 -0
- package/dist/execution/ActionRegistry.js.map +1 -1
- package/dist/execution/GovernedExecutionRunner.d.ts +14 -0
- package/dist/execution/GovernedExecutionRunner.d.ts.map +1 -1
- package/dist/execution/GovernedExecutionRunner.js +46 -1
- package/dist/execution/GovernedExecutionRunner.js.map +1 -1
- package/dist/execution/NativeExecutor.d.ts +20 -1
- package/dist/execution/NativeExecutor.d.ts.map +1 -1
- package/dist/execution/NativeExecutor.js.map +1 -1
- package/dist/intent/CanonicalIntentHasher.d.ts.map +1 -1
- package/dist/intent/CanonicalIntentHasher.js +11 -0
- package/dist/intent/CanonicalIntentHasher.js.map +1 -1
- package/dist/intent/ExecutionIntent.d.ts +55 -4
- package/dist/intent/ExecutionIntent.d.ts.map +1 -1
- package/dist/intent/ExecutionIntent.js +40 -0
- package/dist/intent/ExecutionIntent.js.map +1 -1
- package/dist/intent/ExecutionSignals.d.ts +9 -1
- package/dist/intent/ExecutionSignals.d.ts.map +1 -1
- package/dist/intent/ExecutionSignals.js +9 -0
- package/dist/intent/ExecutionSignals.js.map +1 -1
- package/dist/intent/IntentBindingSchema.d.ts +6 -0
- package/dist/intent/IntentBindingSchema.d.ts.map +1 -1
- package/dist/intent/IntentBindingSchema.js +10 -0
- package/dist/intent/IntentBindingSchema.js.map +1 -1
- package/dist/intent/IntentCapture.d.ts.map +1 -1
- package/dist/intent/IntentCapture.js +18 -4
- package/dist/intent/IntentCapture.js.map +1 -1
- package/dist/intent/TargetPrecondition.d.ts +36 -0
- package/dist/intent/TargetPrecondition.d.ts.map +1 -0
- package/dist/intent/TargetPrecondition.js +57 -0
- package/dist/intent/TargetPrecondition.js.map +1 -0
- package/dist/manifest/ActionManifest.d.ts +112 -0
- package/dist/manifest/ActionManifest.d.ts.map +1 -0
- package/dist/manifest/ActionManifest.js +182 -0
- package/dist/manifest/ActionManifest.js.map +1 -0
- package/dist/plan/GovernedPlan.d.ts +109 -0
- package/dist/plan/GovernedPlan.d.ts.map +1 -0
- package/dist/plan/GovernedPlan.js +187 -0
- package/dist/plan/GovernedPlan.js.map +1 -0
- package/dist/plan/PlanState.d.ts +94 -0
- package/dist/plan/PlanState.d.ts.map +1 -0
- package/dist/plan/PlanState.js +240 -0
- package/dist/plan/PlanState.js.map +1 -0
- package/dist/plan/PlanStepReference.d.ts +16 -0
- package/dist/plan/PlanStepReference.d.ts.map +1 -0
- package/dist/plan/PlanStepReference.js +25 -0
- package/dist/plan/PlanStepReference.js.map +1 -0
- package/dist/profile/ApiProfile.d.ts +354 -0
- package/dist/profile/ApiProfile.d.ts.map +1 -0
- package/dist/profile/ApiProfile.js +755 -0
- package/dist/profile/ApiProfile.js.map +1 -0
- package/dist/profile/ApiProfiles.d.ts +16 -0
- package/dist/profile/ApiProfiles.d.ts.map +1 -0
- package/dist/profile/ApiProfiles.js +29 -0
- package/dist/profile/ApiProfiles.js.map +1 -0
- package/dist/profile/BuiltInApiProfiles.d.ts +9 -0
- package/dist/profile/BuiltInApiProfiles.d.ts.map +1 -0
- package/dist/profile/BuiltInApiProfiles.js +494 -0
- package/dist/profile/BuiltInApiProfiles.js.map +1 -0
- package/dist/profile/McpToolProfile.d.ts +129 -0
- package/dist/profile/McpToolProfile.d.ts.map +1 -0
- package/dist/profile/McpToolProfile.js +270 -0
- package/dist/profile/McpToolProfile.js.map +1 -0
- package/dist/testing/Index.d.ts +3 -1
- package/dist/testing/Index.d.ts.map +1 -1
- package/dist/testing/Index.js +2 -0
- package/dist/testing/Index.js.map +1 -1
- package/dist/testing/LocalAssumptionBreaches.d.ts +96 -0
- package/dist/testing/LocalAssumptionBreaches.d.ts.map +1 -0
- package/dist/testing/LocalAssumptionBreaches.js +109 -0
- package/dist/testing/LocalAssumptionBreaches.js.map +1 -0
- package/dist/testing/LocalAuthority.d.ts +158 -5
- package/dist/testing/LocalAuthority.d.ts.map +1 -1
- package/dist/testing/LocalAuthority.js +302 -18
- package/dist/testing/LocalAuthority.js.map +1 -1
- package/dist/testing/LocalPlans.d.ts +43 -0
- package/dist/testing/LocalPlans.d.ts.map +1 -0
- package/dist/testing/LocalPlans.js +124 -0
- package/dist/testing/LocalPlans.js.map +1 -0
- package/package.json +5 -4
|
@@ -0,0 +1,755 @@
|
|
|
1
|
+
import { z } from "zod";
|
|
2
|
+
import { TargetPreconditionSchema } from "../intent/TargetPrecondition.js";
|
|
3
|
+
/**
|
|
4
|
+
* `agent-safe.profile.api/1`: a platform API described as data, so that every
|
|
5
|
+
* component that turns one of its calls into an intent turns it into the same
|
|
6
|
+
* one. A profile maps method + path (and, where an API overloads a path, an
|
|
7
|
+
* exact query value) to an action type, and names the facts a decision reads
|
|
8
|
+
* by JSON pointer into the call. The gateway reads it to govern an agent's
|
|
9
|
+
* call; a native executor's `prepare` reads it to describe the call it is
|
|
10
|
+
* about to make. One mapping, no code coupling between them.
|
|
11
|
+
*
|
|
12
|
+
* An operation may also say where the subject sits (the customer a call is
|
|
13
|
+
* for): in the call, and, for a read, in each item of its answer. A component
|
|
14
|
+
* that pins a session to one subject checks a read against it, and states the
|
|
15
|
+
* subject to the authority for a write. A read is an operation only for that:
|
|
16
|
+
* it is checked, never decided.
|
|
17
|
+
*
|
|
18
|
+
* A write may also name the read that states its target's version (an ETag,
|
|
19
|
+
* a row version), and where that read's answer states it: a component that
|
|
20
|
+
* binds the target's state reads it once before the authority is asked, and
|
|
21
|
+
* the version rides into the intent as its precondition.
|
|
22
|
+
*/
|
|
23
|
+
export const API_PROFILE_CONTRACT = "agent-safe.profile.api/1";
|
|
24
|
+
export const API_PROFILE_SCHEMA_ID = "https://github.com/decionis/agent-safe-pipeline/blob/master/spec/profiles/api/v1/schema.json";
|
|
25
|
+
/** The methods a profile governs: each operation of one is decided by the authority. */
|
|
26
|
+
export const API_PROFILE_METHODS = ["POST", "PUT", "PATCH", "DELETE"];
|
|
27
|
+
/** The one read method an operation may name: checked for its subject, never decided. */
|
|
28
|
+
export const API_PROFILE_READ_METHOD = "GET";
|
|
29
|
+
/**
|
|
30
|
+
* Fact names a profile may not use. The first group is what the gateway
|
|
31
|
+
* already states in an intent's context; the second is what the Decionis
|
|
32
|
+
* authority states itself, or reads from the request rather than the context,
|
|
33
|
+
* so a fact of that name would be shadowed or refused there.
|
|
34
|
+
*/
|
|
35
|
+
export const RESERVED_FACT_NAMES = [
|
|
36
|
+
"request_sha256",
|
|
37
|
+
"request_hmac_sha256",
|
|
38
|
+
"body_sha256",
|
|
39
|
+
"body_bytes",
|
|
40
|
+
"content_type",
|
|
41
|
+
"body_embedded",
|
|
42
|
+
"ingress",
|
|
43
|
+
"claimed_principal",
|
|
44
|
+
"api_profile",
|
|
45
|
+
"agent_safe",
|
|
46
|
+
"action_context",
|
|
47
|
+
"amount",
|
|
48
|
+
"risk_score",
|
|
49
|
+
"channel",
|
|
50
|
+
"objective_profile",
|
|
51
|
+
"mode",
|
|
52
|
+
"decision_band",
|
|
53
|
+
"transaction_type",
|
|
54
|
+
"workflow_key",
|
|
55
|
+
"vertical_pack",
|
|
56
|
+
"policy_version",
|
|
57
|
+
"org_id",
|
|
58
|
+
"decision_type",
|
|
59
|
+
"decision_domain",
|
|
60
|
+
"signals",
|
|
61
|
+
"signal_provenance",
|
|
62
|
+
"history",
|
|
63
|
+
"override",
|
|
64
|
+
"evidence",
|
|
65
|
+
"attachments",
|
|
66
|
+
"attachment_count",
|
|
67
|
+
"policy",
|
|
68
|
+
"context",
|
|
69
|
+
"intake",
|
|
70
|
+
"runtime_attribution",
|
|
71
|
+
"session",
|
|
72
|
+
];
|
|
73
|
+
/** The most elements one `*` pointer collects; more is not a fact, it is a payload. */
|
|
74
|
+
export const MAX_FACT_ELEMENTS = 100;
|
|
75
|
+
/** The longest string a fact carries; a longer value is not stated. */
|
|
76
|
+
export const MAX_FACT_STRING = 1000;
|
|
77
|
+
/** The longest subject: an identifier, never a payload. */
|
|
78
|
+
export const MAX_SUBJECT_LENGTH = 200;
|
|
79
|
+
const PLACEHOLDER = /\{([A-Z]\w{0,63})\}/gi;
|
|
80
|
+
const TEMPLATE = /^(\/[^/?#%\\\s]+)+$/;
|
|
81
|
+
const POINTER = /^\/(path|query|body)(\/.*)?$/;
|
|
82
|
+
const RESPONSE_POINTER = /^\/body(\/.*)?$/;
|
|
83
|
+
/** Into a read's answer: its JSON body, or one response header by its lower-case name. */
|
|
84
|
+
const STATE_POINTER = /^\/(body(\/.*)?|headers\/[a-z0-9][a-z0-9-]{0,63})$/;
|
|
85
|
+
const name = z.string().regex(/^[a-z][a-z0-9_]{0,63}$/);
|
|
86
|
+
const projectionText = z.string().trim().min(1).max(120);
|
|
87
|
+
export const ApiPolicyProjectionSchema = z.strictObject({
|
|
88
|
+
decision_type: projectionText,
|
|
89
|
+
transaction_type: projectionText.nullable(),
|
|
90
|
+
workflow_key: projectionText.nullable(),
|
|
91
|
+
channel: projectionText.nullable(),
|
|
92
|
+
});
|
|
93
|
+
/** A query parameter's name as a subject rule names it: `$filter` as well as `sku`. */
|
|
94
|
+
const queryName = z.string().regex(/^\$?[a-z][\w.-]{0,63}$/i);
|
|
95
|
+
/**
|
|
96
|
+
* Where an operation's subject sits, and what else a call or an answer may
|
|
97
|
+
* carry around it:
|
|
98
|
+
*
|
|
99
|
+
* - `request`, a pointer into the call like a fact's, with no `*`;
|
|
100
|
+
* - `response`, a pointer into a successful answer's JSON body, where one `*`
|
|
101
|
+
* reaches every item of a list;
|
|
102
|
+
* - `query`, for a read, the one query parameter that must state the subject,
|
|
103
|
+
* exactly as `template` writes it with `{subject}` in place of the
|
|
104
|
+
* session's subject (`customerNumber eq '{subject}'`);
|
|
105
|
+
* - `parameters`, for a read, every other query parameter it may carry;
|
|
106
|
+
* - `envelope`, beside a `response` pointer through `*`, the keys the object
|
|
107
|
+
* holding the list may carry besides the list.
|
|
108
|
+
*/
|
|
109
|
+
export const ApiSubjectSchema = z.strictObject({
|
|
110
|
+
request: z.string().max(300).regex(POINTER).optional(),
|
|
111
|
+
response: z.string().max(300).regex(RESPONSE_POINTER).optional(),
|
|
112
|
+
query: z
|
|
113
|
+
.strictObject({
|
|
114
|
+
name: queryName,
|
|
115
|
+
template: z.string().min(1).max(300),
|
|
116
|
+
})
|
|
117
|
+
.optional(),
|
|
118
|
+
parameters: z.array(queryName).max(32).optional(),
|
|
119
|
+
envelope: z.array(z.string().min(1).max(100)).max(32).optional(),
|
|
120
|
+
});
|
|
121
|
+
const operationId = z
|
|
122
|
+
.string()
|
|
123
|
+
.regex(/^[a-z][A-Za-z0-9]*(\.[a-z][A-Za-z0-9]*)*$/)
|
|
124
|
+
.max(120);
|
|
125
|
+
/**
|
|
126
|
+
* Where a write's target states its version: `read`, a read in the same
|
|
127
|
+
* profile whose path placeholders the write's path also has, so the read can
|
|
128
|
+
* be made from the write's own call; `version`, a pointer into the read's
|
|
129
|
+
* answer, `{ body, headers }`, with no `*`; and `state`, where the same answer
|
|
130
|
+
* states the target's state, when a transition rule reads one.
|
|
131
|
+
*/
|
|
132
|
+
export const ApiPreconditionSchema = z.strictObject({
|
|
133
|
+
read: operationId,
|
|
134
|
+
version: z.string().max(300).regex(STATE_POINTER),
|
|
135
|
+
state: z.string().max(300).regex(STATE_POINTER).optional(),
|
|
136
|
+
});
|
|
137
|
+
export const ApiOperationSchema = z.strictObject({
|
|
138
|
+
/** The operation's name in the profile, unique, dotted lower camel case. */
|
|
139
|
+
id: operationId,
|
|
140
|
+
method: z.enum([...API_PROFILE_METHODS, API_PROFILE_READ_METHOD]),
|
|
141
|
+
/** A path template: literal text and `{name}` placeholders, each within one segment. */
|
|
142
|
+
path: z.string().max(500).regex(TEMPLATE),
|
|
143
|
+
/** Exact query values the call must carry to be this operation (an API that overloads a path). */
|
|
144
|
+
query: z.record(z.string().regex(/^[a-z][\w.-]{0,63}$/), z.string().max(200)).optional(),
|
|
145
|
+
/** The intent's action type. */
|
|
146
|
+
action: z
|
|
147
|
+
.string()
|
|
148
|
+
.max(120)
|
|
149
|
+
.regex(/^[a-z][a-z0-9._:-]*$/),
|
|
150
|
+
summary: z.string().trim().min(1).max(300),
|
|
151
|
+
/** Fact name → RFC 6901 pointer into `{ path, query, body }`; one `*` segment collects an array. */
|
|
152
|
+
facts: z.record(name, z.string().max(300).regex(POINTER)),
|
|
153
|
+
/** The policy path the authority decides the call under, when the profile states one. */
|
|
154
|
+
policy_projection: ApiPolicyProjectionSchema.nullable(),
|
|
155
|
+
/** Where the call's subject sits; required of a read, optional for a write. */
|
|
156
|
+
subject: ApiSubjectSchema.optional(),
|
|
157
|
+
/** For a write, the read that states its target's version. */
|
|
158
|
+
precondition: ApiPreconditionSchema.optional(),
|
|
159
|
+
});
|
|
160
|
+
export const ApiProfileDefinitionSchema = z.strictObject({
|
|
161
|
+
profile: z.literal(API_PROFILE_CONTRACT),
|
|
162
|
+
/** `<platform>/<api>/<version>`, the profile's directory under `profiles/`. */
|
|
163
|
+
id: z
|
|
164
|
+
.string()
|
|
165
|
+
.regex(/^[a-z][a-z0-9]*\/[a-z][a-z0-9]*\/v\d+(\.\d+)?$/)
|
|
166
|
+
.max(120),
|
|
167
|
+
platform: z
|
|
168
|
+
.string()
|
|
169
|
+
.regex(/^[a-z][a-z0-9-]*$/)
|
|
170
|
+
.max(80),
|
|
171
|
+
title: z.string().trim().min(1).max(200),
|
|
172
|
+
/** Path templates an operation's path may follow; `""` is the bare path. Tried in order. */
|
|
173
|
+
prefixes: z
|
|
174
|
+
.array(z.union([z.literal(""), z.string().max(300).regex(TEMPLATE)]))
|
|
175
|
+
.min(1)
|
|
176
|
+
.max(8),
|
|
177
|
+
operations: z.array(ApiOperationSchema).min(1).max(200),
|
|
178
|
+
});
|
|
179
|
+
/** Where a template states the subject. */
|
|
180
|
+
const SUBJECT_PLACEHOLDER = "{subject}";
|
|
181
|
+
export class ApiProfileError extends Error {
|
|
182
|
+
code;
|
|
183
|
+
detail;
|
|
184
|
+
constructor(code, detail) {
|
|
185
|
+
super(code);
|
|
186
|
+
this.code = code;
|
|
187
|
+
this.detail = detail;
|
|
188
|
+
this.name = "ApiProfileError";
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
/**
|
|
192
|
+
* One profile, validated and compiled. Matching is deterministic and total:
|
|
193
|
+
* the first operation, in profile order, whose method, path and query match
|
|
194
|
+
* is the call's operation; a call no operation matches is not described by
|
|
195
|
+
* the profile and the caller decides what that means.
|
|
196
|
+
*/
|
|
197
|
+
export class ApiProfile {
|
|
198
|
+
definition;
|
|
199
|
+
compiled;
|
|
200
|
+
constructor(definition) {
|
|
201
|
+
this.definition = definition;
|
|
202
|
+
const ids = new Set();
|
|
203
|
+
const signatures = new Set();
|
|
204
|
+
this.compiled = definition.operations.map((operation) => {
|
|
205
|
+
if (ids.has(operation.id))
|
|
206
|
+
throw new ApiProfileError("API_PROFILE_INVALID", `duplicate operation ${operation.id}`);
|
|
207
|
+
ids.add(operation.id);
|
|
208
|
+
const signature = `${operation.method} ${operation.path.replace(PLACEHOLDER, "{}")} ${JSON.stringify(Object.entries(operation.query ?? {}).sort(([a], [b]) => a.localeCompare(b)))}`;
|
|
209
|
+
if (signatures.has(signature))
|
|
210
|
+
throw new ApiProfileError("API_PROFILE_INVALID", `operation ${operation.id} repeats another operation's method, path and query`);
|
|
211
|
+
signatures.add(signature);
|
|
212
|
+
for (const fact of Object.keys(operation.facts)) {
|
|
213
|
+
if (RESERVED_FACT_NAMES.includes(fact))
|
|
214
|
+
throw new ApiProfileError("API_PROFILE_INVALID", `operation ${operation.id} names the reserved fact ${fact}`);
|
|
215
|
+
}
|
|
216
|
+
for (const pointer of Object.values(operation.facts))
|
|
217
|
+
ApiProfile.pointerSegments(pointer, operation.id);
|
|
218
|
+
ApiProfile.subjectRules(operation);
|
|
219
|
+
ApiProfile.preconditionRules(operation, definition.operations);
|
|
220
|
+
const matchers = [];
|
|
221
|
+
const placeholders = [];
|
|
222
|
+
for (const prefix of definition.prefixes) {
|
|
223
|
+
const { pattern, names } = ApiProfile.compile(`${prefix}${operation.path}`, operation.id);
|
|
224
|
+
matchers.push(pattern);
|
|
225
|
+
placeholders.push(names);
|
|
226
|
+
}
|
|
227
|
+
return {
|
|
228
|
+
definition: operation,
|
|
229
|
+
matchers,
|
|
230
|
+
loose: matchers.map((matcher) => new RegExp(matcher.source, "i")),
|
|
231
|
+
beneath: matchers.map((matcher) => new RegExp(`${matcher.source.slice(0, -1)}/`, "i")),
|
|
232
|
+
placeholders,
|
|
233
|
+
};
|
|
234
|
+
});
|
|
235
|
+
}
|
|
236
|
+
/** Validates a definition (closed schema, unique operations, pointers, reserved names) and compiles it. */
|
|
237
|
+
static parse(value) {
|
|
238
|
+
const parsed = ApiProfileDefinitionSchema.safeParse(value);
|
|
239
|
+
if (!parsed.success)
|
|
240
|
+
throw new ApiProfileError("API_PROFILE_INVALID", parsed.error.issues
|
|
241
|
+
.slice(0, 3)
|
|
242
|
+
.map((issue) => `${issue.path.join(".") || "(root)"}: ${issue.message}`)
|
|
243
|
+
.join("; "));
|
|
244
|
+
return new ApiProfile(parsed.data);
|
|
245
|
+
}
|
|
246
|
+
get id() {
|
|
247
|
+
return this.definition.id;
|
|
248
|
+
}
|
|
249
|
+
get platform() {
|
|
250
|
+
return this.definition.platform;
|
|
251
|
+
}
|
|
252
|
+
/**
|
|
253
|
+
* Every action type the profile's writes can produce, for a registry to be
|
|
254
|
+
* sealed over. A read's action names it in reports; it is never an intent.
|
|
255
|
+
*/
|
|
256
|
+
actions() {
|
|
257
|
+
return [
|
|
258
|
+
...new Set(this.definition.operations
|
|
259
|
+
.filter((operation) => !ApiProfile.isRead(operation))
|
|
260
|
+
.map((operation) => operation.action)),
|
|
261
|
+
];
|
|
262
|
+
}
|
|
263
|
+
/** Whether an operation is a read: checked for its subject, never decided. */
|
|
264
|
+
static isRead(operation) {
|
|
265
|
+
return operation.method === API_PROFILE_READ_METHOD;
|
|
266
|
+
}
|
|
267
|
+
/**
|
|
268
|
+
* A call against the session's subject, before it is sent: `MATCH`, or why
|
|
269
|
+
* not. A read that names the query parameters it may carry carries no
|
|
270
|
+
* other (`QUERY_PARAMETER_REFUSED`); a read that names its subject query
|
|
271
|
+
* states the session's subject there and nothing else, so a list can be
|
|
272
|
+
* asked only for the session's own records; and the subject at the
|
|
273
|
+
* operation's request pointer is the session's.
|
|
274
|
+
*/
|
|
275
|
+
checkRequest(match, query, subject) {
|
|
276
|
+
const definition = this.operation(match.operation);
|
|
277
|
+
const declared = definition?.subject;
|
|
278
|
+
if (declared?.parameters !== undefined) {
|
|
279
|
+
const allowed = new Set([
|
|
280
|
+
...declared.parameters,
|
|
281
|
+
...(declared.query === undefined ? [] : [declared.query.name]),
|
|
282
|
+
...Object.keys(definition?.query ?? {}),
|
|
283
|
+
]);
|
|
284
|
+
if (Object.keys(query).some((name) => !allowed.has(name)))
|
|
285
|
+
return "QUERY_PARAMETER_REFUSED";
|
|
286
|
+
}
|
|
287
|
+
if (declared?.query !== undefined) {
|
|
288
|
+
const stated = ApiProfile.checkQuery(declared.query.template, query[declared.query.name], subject);
|
|
289
|
+
if (stated !== "MATCH")
|
|
290
|
+
return stated;
|
|
291
|
+
}
|
|
292
|
+
if (match.subject === undefined)
|
|
293
|
+
return "MATCH";
|
|
294
|
+
if (match.subject === null)
|
|
295
|
+
return "SUBJECT_ABSENT";
|
|
296
|
+
return match.subject === subject ? "MATCH" : "SUBJECT_MISMATCH";
|
|
297
|
+
}
|
|
298
|
+
/**
|
|
299
|
+
* A subject query parameter against its template: absent is
|
|
300
|
+
* `SUBJECT_ABSENT`; given twice, or anything but the template with one
|
|
301
|
+
* literal in place of `{subject}`, is `QUERY_PARAMETER_REFUSED`, which is
|
|
302
|
+
* what a second predicate beside the subject's is; a literal naming
|
|
303
|
+
* another subject is `SUBJECT_MISMATCH`. A `'` inside the literal is
|
|
304
|
+
* written doubled, as an OData string literal writes it; a single one ends
|
|
305
|
+
* the literal, so it refuses the call.
|
|
306
|
+
*/
|
|
307
|
+
static checkQuery(template, value, subject) {
|
|
308
|
+
if (value === undefined)
|
|
309
|
+
return "SUBJECT_ABSENT";
|
|
310
|
+
if (typeof value !== "string")
|
|
311
|
+
return "QUERY_PARAMETER_REFUSED";
|
|
312
|
+
const at = template.indexOf(SUBJECT_PLACEHOLDER);
|
|
313
|
+
const prefix = template.slice(0, at);
|
|
314
|
+
const suffix = template.slice(at + SUBJECT_PLACEHOLDER.length);
|
|
315
|
+
if (value.length < prefix.length + suffix.length ||
|
|
316
|
+
!value.startsWith(prefix) ||
|
|
317
|
+
!value.endsWith(suffix)) {
|
|
318
|
+
return "QUERY_PARAMETER_REFUSED";
|
|
319
|
+
}
|
|
320
|
+
const literal = value.slice(prefix.length, value.length - suffix.length);
|
|
321
|
+
let named = "";
|
|
322
|
+
for (let index = 0; index < literal.length; index += 1) {
|
|
323
|
+
const character = literal[index];
|
|
324
|
+
if (character === "'") {
|
|
325
|
+
if (literal[index + 1] !== "'")
|
|
326
|
+
return "QUERY_PARAMETER_REFUSED";
|
|
327
|
+
index += 1;
|
|
328
|
+
}
|
|
329
|
+
named += character;
|
|
330
|
+
}
|
|
331
|
+
return named === subject ? "MATCH" : "SUBJECT_MISMATCH";
|
|
332
|
+
}
|
|
333
|
+
/**
|
|
334
|
+
* A successful answer, to a read or a write, against the session's
|
|
335
|
+
* subject, before any of it is relayed: every item the operation's response
|
|
336
|
+
* pointer reaches must name the subject. One item naming another is
|
|
337
|
+
* `RESPONSE_SUBJECT_MISMATCH`, one naming none `RESPONSE_SUBJECT_ABSENT`,
|
|
338
|
+
* and an answer the pointer cannot be walked into (not JSON, or no list
|
|
339
|
+
* where the `*` stands) `RESPONSE_UNREADABLE`. Beside a list, the object
|
|
340
|
+
* that holds it may carry only the keys the operation's `envelope` names,
|
|
341
|
+
* so a count or a summary about other subjects is `RESPONSE_KEY_REFUSED`.
|
|
342
|
+
* Nothing is filtered: a partial answer would look complete. An empty list
|
|
343
|
+
* names no one and matches. Null when the operation names no response
|
|
344
|
+
* subject, so there is nothing to check.
|
|
345
|
+
*/
|
|
346
|
+
checkResponse(operationId, body, subject) {
|
|
347
|
+
const pointer = this.operation(operationId)?.subject?.response;
|
|
348
|
+
if (pointer === undefined)
|
|
349
|
+
return null;
|
|
350
|
+
if (body === undefined)
|
|
351
|
+
return "RESPONSE_UNREADABLE";
|
|
352
|
+
const tokens = ApiProfile.pointerSegments(pointer, operationId);
|
|
353
|
+
const root = { body };
|
|
354
|
+
const wildcard = tokens.indexOf("*");
|
|
355
|
+
const items = wildcard === -1
|
|
356
|
+
? [ApiProfile.walk(root, tokens)]
|
|
357
|
+
: ApiProfile.walkList(root, tokens.slice(0, wildcard), tokens.slice(wildcard + 1));
|
|
358
|
+
if (items === null)
|
|
359
|
+
return "RESPONSE_UNREADABLE";
|
|
360
|
+
if (wildcard > 1) {
|
|
361
|
+
const holder = ApiProfile.walk(root, tokens.slice(0, wildcard - 1));
|
|
362
|
+
const list = tokens[wildcard - 1];
|
|
363
|
+
const envelope = this.operation(operationId)?.subject?.envelope ?? [];
|
|
364
|
+
if (holder !== null &&
|
|
365
|
+
typeof holder === "object" &&
|
|
366
|
+
!Array.isArray(holder) &&
|
|
367
|
+
Object.keys(holder).some((key) => key !== list && !envelope.includes(key))) {
|
|
368
|
+
return "RESPONSE_KEY_REFUSED";
|
|
369
|
+
}
|
|
370
|
+
}
|
|
371
|
+
let absent = false;
|
|
372
|
+
for (const item of items) {
|
|
373
|
+
const named = ApiProfile.subjectOf(item);
|
|
374
|
+
if (named === null)
|
|
375
|
+
absent = true;
|
|
376
|
+
else if (named !== subject)
|
|
377
|
+
return "RESPONSE_SUBJECT_MISMATCH";
|
|
378
|
+
}
|
|
379
|
+
return absent ? "RESPONSE_SUBJECT_ABSENT" : "MATCH";
|
|
380
|
+
}
|
|
381
|
+
operation(id) {
|
|
382
|
+
return this.definition.operations.find((operation) => operation.id === id) ?? null;
|
|
383
|
+
}
|
|
384
|
+
/**
|
|
385
|
+
* The operation a method, path and query select, before any body is read:
|
|
386
|
+
* what the gateway needs to decide whether to govern a call.
|
|
387
|
+
*/
|
|
388
|
+
operationFor(method, path, query = {}) {
|
|
389
|
+
return this.select(method, path, query)?.operation.definition ?? null;
|
|
390
|
+
}
|
|
391
|
+
/**
|
|
392
|
+
* Whether a call reads like one of this method's operations without
|
|
393
|
+
* matching it exactly: its path in another letter case, or with a literal
|
|
394
|
+
* percent-encoded. An upstream that folds case or decodes before it routes
|
|
395
|
+
* would serve it as that operation, so a component that checks reads
|
|
396
|
+
* refuses it rather than let it through unchecked. A path that does not
|
|
397
|
+
* decode resembles everything.
|
|
398
|
+
*/
|
|
399
|
+
resembles(method, path) {
|
|
400
|
+
let decoded;
|
|
401
|
+
try {
|
|
402
|
+
decoded = decodeURIComponent(path);
|
|
403
|
+
}
|
|
404
|
+
catch {
|
|
405
|
+
return true;
|
|
406
|
+
}
|
|
407
|
+
const upper = method.toUpperCase();
|
|
408
|
+
return this.compiled.some((operation) => operation.definition.method === upper &&
|
|
409
|
+
operation.loose.some((matcher) => matcher.test(path) || matcher.test(decoded)));
|
|
410
|
+
}
|
|
411
|
+
/**
|
|
412
|
+
* Whether a call's path goes on below one of this method's operations
|
|
413
|
+
* without being an operation itself, in any letter case: a count
|
|
414
|
+
* (`/customers/$count`), a navigation or a media stream beneath a record.
|
|
415
|
+
* What it answers is not what the operation's subject rule checks, so a
|
|
416
|
+
* component that checks reads refuses it.
|
|
417
|
+
*/
|
|
418
|
+
beneath(method, path) {
|
|
419
|
+
let decoded;
|
|
420
|
+
try {
|
|
421
|
+
decoded = decodeURIComponent(path);
|
|
422
|
+
}
|
|
423
|
+
catch {
|
|
424
|
+
return true;
|
|
425
|
+
}
|
|
426
|
+
const upper = method.toUpperCase();
|
|
427
|
+
return this.compiled.some((operation) => operation.definition.method === upper &&
|
|
428
|
+
operation.beneath.some((matcher) => matcher.test(path) || matcher.test(decoded)));
|
|
429
|
+
}
|
|
430
|
+
/** The operation, action type, facts and policy projection of one call; null when no operation matches. */
|
|
431
|
+
match(request) {
|
|
432
|
+
const selected = this.select(request.method, request.path, request.query ?? {});
|
|
433
|
+
if (selected === null)
|
|
434
|
+
return null;
|
|
435
|
+
const { operation, parameters } = selected;
|
|
436
|
+
const root = {
|
|
437
|
+
path: parameters,
|
|
438
|
+
query: ApiProfile.queryRoot(request.query ?? {}),
|
|
439
|
+
...(request.body === undefined ? {} : { body: request.body }),
|
|
440
|
+
};
|
|
441
|
+
const facts = {};
|
|
442
|
+
for (const [fact, pointer] of Object.entries(operation.definition.facts)) {
|
|
443
|
+
const value = ApiProfile.read(root, ApiProfile.pointerSegments(pointer, operation.definition.id));
|
|
444
|
+
if (value !== undefined)
|
|
445
|
+
facts[fact] = value;
|
|
446
|
+
}
|
|
447
|
+
const subjectPointer = operation.definition.subject?.request;
|
|
448
|
+
return {
|
|
449
|
+
profile: this.definition.id,
|
|
450
|
+
operation: operation.definition.id,
|
|
451
|
+
action: operation.definition.action,
|
|
452
|
+
facts,
|
|
453
|
+
policyProjection: operation.definition.policy_projection,
|
|
454
|
+
...(subjectPointer === undefined
|
|
455
|
+
? {}
|
|
456
|
+
: {
|
|
457
|
+
subject: ApiProfile.subjectOf(ApiProfile.walk(root, ApiProfile.pointerSegments(subjectPointer, operation.definition.id))),
|
|
458
|
+
}),
|
|
459
|
+
};
|
|
460
|
+
}
|
|
461
|
+
/**
|
|
462
|
+
* The read a write's precondition names, as the call to make: `GET`, the
|
|
463
|
+
* read's path under the prefix the write matched, each placeholder filled
|
|
464
|
+
* with the write's own text for it, exactly as the write's call wrote it.
|
|
465
|
+
* Null when the call matches no write, or its operation names no
|
|
466
|
+
* precondition. The component makes the read; the profile makes no call.
|
|
467
|
+
*/
|
|
468
|
+
stateRead(request) {
|
|
469
|
+
const selected = this.select(request.method, request.path, request.query ?? {});
|
|
470
|
+
const precondition = selected?.operation.definition.precondition;
|
|
471
|
+
if (selected === null || precondition === undefined)
|
|
472
|
+
return null;
|
|
473
|
+
const read = this.operation(precondition.read);
|
|
474
|
+
const template = `${this.definition.prefixes[selected.prefix]}${read.path}`;
|
|
475
|
+
return {
|
|
476
|
+
method: API_PROFILE_READ_METHOD,
|
|
477
|
+
path: template.replace(PLACEHOLDER, (_, name) => selected.raw[name]),
|
|
478
|
+
...(read.query === undefined ? {} : { query: { ...read.query } }),
|
|
479
|
+
};
|
|
480
|
+
}
|
|
481
|
+
/**
|
|
482
|
+
* The precondition a write's read answered: the version at the operation's
|
|
483
|
+
* pointer, a string or a finite number as a string, within the
|
|
484
|
+
* precondition's bounds; the state beside it when the operation names
|
|
485
|
+
* where; which read said so, as `<profile>#<read>`; and when. Null when the
|
|
486
|
+
* operation names no precondition, or the answer states no usable version:
|
|
487
|
+
* a component that binds the target's state refuses then, rather than bind
|
|
488
|
+
* none. A state the answer does not state is left out.
|
|
489
|
+
*/
|
|
490
|
+
precondition(operationId, answer, readAt) {
|
|
491
|
+
const declared = this.operation(operationId)?.precondition;
|
|
492
|
+
if (declared === undefined)
|
|
493
|
+
return null;
|
|
494
|
+
const root = {
|
|
495
|
+
headers: { ...answer.headers },
|
|
496
|
+
...(answer.body === undefined ? {} : { body: answer.body }),
|
|
497
|
+
};
|
|
498
|
+
const at = (pointer) => {
|
|
499
|
+
if (pointer === undefined)
|
|
500
|
+
return undefined;
|
|
501
|
+
const value = ApiProfile.walk(root, ApiProfile.pointerSegments(pointer, operationId));
|
|
502
|
+
if (typeof value === "string")
|
|
503
|
+
return value;
|
|
504
|
+
return typeof value === "number" && Number.isFinite(value) ? String(value) : undefined;
|
|
505
|
+
};
|
|
506
|
+
const state = at(declared.state);
|
|
507
|
+
const parsed = TargetPreconditionSchema.safeParse({
|
|
508
|
+
version: at(declared.version),
|
|
509
|
+
...(state === undefined ? {} : { state }),
|
|
510
|
+
source: `${this.definition.id}#${declared.read}`,
|
|
511
|
+
read_at: readAt.toISOString(),
|
|
512
|
+
});
|
|
513
|
+
return parsed.success ? parsed.data : null;
|
|
514
|
+
}
|
|
515
|
+
select(method, path, query) {
|
|
516
|
+
const upper = method.toUpperCase();
|
|
517
|
+
if (!path.startsWith("/") || /[?#]/.test(path))
|
|
518
|
+
return null;
|
|
519
|
+
for (const operation of this.compiled) {
|
|
520
|
+
if (operation.definition.method !== upper)
|
|
521
|
+
continue;
|
|
522
|
+
if (!ApiProfile.queryMatches(operation.definition.query ?? {}, query))
|
|
523
|
+
continue;
|
|
524
|
+
for (let index = 0; index < operation.matchers.length; index += 1) {
|
|
525
|
+
const found = operation.matchers[index].exec(path);
|
|
526
|
+
if (found === null)
|
|
527
|
+
continue;
|
|
528
|
+
const parameters = {};
|
|
529
|
+
const raw = {};
|
|
530
|
+
let decodable = true;
|
|
531
|
+
operation.placeholders[index].forEach((placeholder, position) => {
|
|
532
|
+
const text = found[position + 1] ?? "";
|
|
533
|
+
const decoded = ApiProfile.decode(text);
|
|
534
|
+
raw[placeholder] = text;
|
|
535
|
+
if (decoded === null)
|
|
536
|
+
decodable = false;
|
|
537
|
+
else
|
|
538
|
+
parameters[placeholder] = decoded;
|
|
539
|
+
});
|
|
540
|
+
if (decodable)
|
|
541
|
+
return { operation, parameters, prefix: index, raw };
|
|
542
|
+
}
|
|
543
|
+
}
|
|
544
|
+
return null;
|
|
545
|
+
}
|
|
546
|
+
/** A template as an anchored expression: placeholders take one segment's text, never a `/`. */
|
|
547
|
+
static compile(template, operation) {
|
|
548
|
+
const names = [];
|
|
549
|
+
let source = "";
|
|
550
|
+
let last = 0;
|
|
551
|
+
for (const found of template.matchAll(PLACEHOLDER)) {
|
|
552
|
+
const placeholder = found[1];
|
|
553
|
+
if (names.includes(placeholder))
|
|
554
|
+
throw new ApiProfileError("API_PROFILE_INVALID", `operation ${operation} repeats the placeholder ${placeholder}`);
|
|
555
|
+
names.push(placeholder);
|
|
556
|
+
source += ApiProfile.escape(template.slice(last, found.index)) + "([^/()]+)";
|
|
557
|
+
last = (found.index ?? 0) + found[0].length;
|
|
558
|
+
}
|
|
559
|
+
source += ApiProfile.escape(template.slice(last));
|
|
560
|
+
if (/[{}]/.test(template.replace(PLACEHOLDER, "")))
|
|
561
|
+
throw new ApiProfileError("API_PROFILE_INVALID", `operation ${operation} has a malformed placeholder`);
|
|
562
|
+
return { pattern: new RegExp(`^${source}$`), names };
|
|
563
|
+
}
|
|
564
|
+
static escape(text) {
|
|
565
|
+
return text.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
566
|
+
}
|
|
567
|
+
/** A placeholder's value: percent-decoded, OData quotes removed; null when it does not decode. */
|
|
568
|
+
static decode(raw) {
|
|
569
|
+
let value;
|
|
570
|
+
try {
|
|
571
|
+
value = decodeURIComponent(raw);
|
|
572
|
+
}
|
|
573
|
+
catch {
|
|
574
|
+
return null;
|
|
575
|
+
}
|
|
576
|
+
if (value.length >= 2 && value.startsWith("'") && value.endsWith("'"))
|
|
577
|
+
value = value.slice(1, -1);
|
|
578
|
+
if (value === "" || value === "." || value === ".." || value.includes("/"))
|
|
579
|
+
return null;
|
|
580
|
+
return value;
|
|
581
|
+
}
|
|
582
|
+
static queryMatches(expected, query) {
|
|
583
|
+
return Object.entries(expected).every(([key, value]) => {
|
|
584
|
+
const actual = query[key];
|
|
585
|
+
return typeof actual === "string" ? actual === value : false;
|
|
586
|
+
});
|
|
587
|
+
}
|
|
588
|
+
static queryRoot(query) {
|
|
589
|
+
const root = {};
|
|
590
|
+
for (const [key, value] of Object.entries(query)) {
|
|
591
|
+
if (key === "__proto__" || key === "constructor" || key === "prototype")
|
|
592
|
+
continue;
|
|
593
|
+
root[key] = typeof value === "string" ? value : [...value];
|
|
594
|
+
}
|
|
595
|
+
return root;
|
|
596
|
+
}
|
|
597
|
+
/**
|
|
598
|
+
* What a definition must also be about subjects: a subject names where it
|
|
599
|
+
* sits, in the call, its query or its answer; a read names one and states
|
|
600
|
+
* no fact or policy path, since it is never decided; only a read names a
|
|
601
|
+
* subject query or the parameters it may carry; a request subject is one
|
|
602
|
+
* value, so its pointer has no `*`; a subject query's template states the
|
|
603
|
+
* subject once; an envelope stands beside a list.
|
|
604
|
+
*/
|
|
605
|
+
static subjectRules(operation) {
|
|
606
|
+
const subject = operation.subject;
|
|
607
|
+
const refuse = (detail) => {
|
|
608
|
+
throw new ApiProfileError("API_PROFILE_INVALID", `operation ${operation.id} ${detail}`);
|
|
609
|
+
};
|
|
610
|
+
if (subject !== undefined &&
|
|
611
|
+
subject.request === undefined &&
|
|
612
|
+
subject.response === undefined &&
|
|
613
|
+
subject.query === undefined)
|
|
614
|
+
refuse("names an empty subject");
|
|
615
|
+
if (ApiProfile.isRead(operation)) {
|
|
616
|
+
if (subject === undefined)
|
|
617
|
+
refuse("is a read that names no subject");
|
|
618
|
+
if (Object.keys(operation.facts).length > 0 || operation.policy_projection !== null)
|
|
619
|
+
refuse("is a read: it states no fact and no policy projection");
|
|
620
|
+
}
|
|
621
|
+
else if (subject?.query !== undefined || subject?.parameters !== undefined) {
|
|
622
|
+
refuse("is a write: only a read names its query parameters");
|
|
623
|
+
}
|
|
624
|
+
if (subject?.request !== undefined) {
|
|
625
|
+
if (ApiProfile.pointerSegments(subject.request, operation.id).includes("*"))
|
|
626
|
+
refuse("names a request subject through *");
|
|
627
|
+
}
|
|
628
|
+
const response = subject?.response === undefined
|
|
629
|
+
? null
|
|
630
|
+
: ApiProfile.pointerSegments(subject.response, operation.id);
|
|
631
|
+
const template = subject?.query?.template;
|
|
632
|
+
if (template !== undefined && template.split(SUBJECT_PLACEHOLDER).length !== 2)
|
|
633
|
+
refuse("has a subject query template that does not state {subject} once");
|
|
634
|
+
if (subject?.envelope !== undefined && !(response ?? []).includes("*"))
|
|
635
|
+
refuse("names an envelope without a list to stand beside");
|
|
636
|
+
}
|
|
637
|
+
/**
|
|
638
|
+
* What a precondition must be: a write's, naming a read in the same
|
|
639
|
+
* profile, whose path placeholders the write's path all has, so the read
|
|
640
|
+
* is made from the write's own call; and pointers through no `*`, since a
|
|
641
|
+
* version is one value.
|
|
642
|
+
*/
|
|
643
|
+
static preconditionRules(operation, operations) {
|
|
644
|
+
const precondition = operation.precondition;
|
|
645
|
+
if (precondition === undefined)
|
|
646
|
+
return;
|
|
647
|
+
const refuse = (detail) => {
|
|
648
|
+
throw new ApiProfileError("API_PROFILE_INVALID", `operation ${operation.id} ${detail}`);
|
|
649
|
+
};
|
|
650
|
+
if (ApiProfile.isRead(operation))
|
|
651
|
+
refuse("is a read: only a write names a precondition");
|
|
652
|
+
const read = operations.find((candidate) => candidate.id === precondition.read);
|
|
653
|
+
if (read === undefined || !ApiProfile.isRead(read))
|
|
654
|
+
refuse(`names a precondition read ${precondition.read} that is not a read in the profile`);
|
|
655
|
+
const placeholders = (path) => [...path.matchAll(PLACEHOLDER)].map((found) => found[1]);
|
|
656
|
+
const own = placeholders(operation.path);
|
|
657
|
+
if (placeholders(read.path).some((name) => !own.includes(name)))
|
|
658
|
+
refuse(`names a precondition read whose path has a placeholder the write's has not`);
|
|
659
|
+
for (const pointer of [precondition.version, precondition.state]) {
|
|
660
|
+
if (pointer !== undefined && ApiProfile.pointerSegments(pointer, operation.id).includes("*"))
|
|
661
|
+
refuse("names a precondition pointer through *");
|
|
662
|
+
}
|
|
663
|
+
}
|
|
664
|
+
/** A subject as stated: a string or a finite number, as a string, within the bound; else null. */
|
|
665
|
+
static subjectOf(value) {
|
|
666
|
+
const text = typeof value === "string"
|
|
667
|
+
? value
|
|
668
|
+
: typeof value === "number" && Number.isFinite(value)
|
|
669
|
+
? String(value)
|
|
670
|
+
: null;
|
|
671
|
+
return text === null || text.length === 0 || text.length > MAX_SUBJECT_LENGTH ? null : text;
|
|
672
|
+
}
|
|
673
|
+
/** Each element's value through a `*`; null when there is no list where the `*` stands. */
|
|
674
|
+
static walkList(root, before, after) {
|
|
675
|
+
const list = ApiProfile.walk(root, before);
|
|
676
|
+
if (!Array.isArray(list))
|
|
677
|
+
return null;
|
|
678
|
+
return list.map((element) => ApiProfile.walk(element, after));
|
|
679
|
+
}
|
|
680
|
+
/** RFC 6901 reference tokens, unescaped; one `*` token at most. */
|
|
681
|
+
static pointerSegments(pointer, operation) {
|
|
682
|
+
const tokens = pointer
|
|
683
|
+
.slice(1)
|
|
684
|
+
.split("/")
|
|
685
|
+
.map((token) => token.replaceAll("~1", "/").replaceAll("~0", "~"));
|
|
686
|
+
if (tokens.filter((token) => token === "*").length > 1 || /~[^01]|~$/.test(pointer))
|
|
687
|
+
throw new ApiProfileError("API_PROFILE_INVALID", `operation ${operation} has an invalid fact pointer ${pointer}`);
|
|
688
|
+
return tokens;
|
|
689
|
+
}
|
|
690
|
+
/**
|
|
691
|
+
* The value at a pointer, when it is statable: a string (at most 1000
|
|
692
|
+
* characters), a finite number or a boolean; or, through one `*`, an array
|
|
693
|
+
* of up to 100 of them, every element statable. Anything else is absent.
|
|
694
|
+
*/
|
|
695
|
+
static read(root, tokens) {
|
|
696
|
+
const wildcard = tokens.indexOf("*");
|
|
697
|
+
if (wildcard === -1)
|
|
698
|
+
return ApiProfile.statable(ApiProfile.walk(root, tokens));
|
|
699
|
+
const list = ApiProfile.walk(root, tokens.slice(0, wildcard));
|
|
700
|
+
if (!Array.isArray(list) || list.length === 0 || list.length > MAX_FACT_ELEMENTS)
|
|
701
|
+
return undefined;
|
|
702
|
+
const values = [];
|
|
703
|
+
for (const element of list) {
|
|
704
|
+
const value = ApiProfile.statable(ApiProfile.walk(element, tokens.slice(wildcard + 1)));
|
|
705
|
+
if (value === undefined)
|
|
706
|
+
return undefined;
|
|
707
|
+
values.push(value);
|
|
708
|
+
}
|
|
709
|
+
return values;
|
|
710
|
+
}
|
|
711
|
+
static walk(value, tokens) {
|
|
712
|
+
let current = value;
|
|
713
|
+
for (const token of tokens) {
|
|
714
|
+
if (Array.isArray(current)) {
|
|
715
|
+
if (!/^(?:0|[1-9]\d{0,5})$/.test(token))
|
|
716
|
+
return undefined;
|
|
717
|
+
current = current[Number(token)];
|
|
718
|
+
}
|
|
719
|
+
else if (current !== null && typeof current === "object") {
|
|
720
|
+
current = Object.hasOwn(current, token) ? current[token] : undefined;
|
|
721
|
+
}
|
|
722
|
+
else {
|
|
723
|
+
return undefined;
|
|
724
|
+
}
|
|
725
|
+
if (current === undefined)
|
|
726
|
+
return undefined;
|
|
727
|
+
}
|
|
728
|
+
return current;
|
|
729
|
+
}
|
|
730
|
+
static statable(value) {
|
|
731
|
+
if (typeof value === "string")
|
|
732
|
+
return value.length <= MAX_FACT_STRING ? value : undefined;
|
|
733
|
+
if (typeof value === "number")
|
|
734
|
+
return Number.isFinite(value) ? value : undefined;
|
|
735
|
+
if (typeof value === "boolean")
|
|
736
|
+
return value;
|
|
737
|
+
return undefined;
|
|
738
|
+
}
|
|
739
|
+
}
|
|
740
|
+
/** The published JSON Schema of a profile definition, generated from the validator above. */
|
|
741
|
+
export function apiProfileJsonSchema() {
|
|
742
|
+
const generated = z.toJSONSchema(ApiProfileDefinitionSchema, {
|
|
743
|
+
target: "draft-2020-12",
|
|
744
|
+
io: "input",
|
|
745
|
+
});
|
|
746
|
+
const schema = { ...generated };
|
|
747
|
+
delete schema["$schema"];
|
|
748
|
+
return {
|
|
749
|
+
$schema: "https://json-schema.org/draft/2020-12/schema",
|
|
750
|
+
$id: API_PROFILE_SCHEMA_ID,
|
|
751
|
+
title: API_PROFILE_CONTRACT,
|
|
752
|
+
...schema,
|
|
753
|
+
};
|
|
754
|
+
}
|
|
755
|
+
//# sourceMappingURL=ApiProfile.js.map
|