attenu-guard 0.13.0 → 0.13.2
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 +49 -0
- package/dist/cjs/audit.d.ts +15 -1
- package/dist/cjs/audit.d.ts.map +1 -1
- package/dist/cjs/audit.js +26 -3
- package/dist/cjs/audit.js.map +1 -1
- package/dist/cjs/authority.d.ts +12 -6
- package/dist/cjs/authority.d.ts.map +1 -1
- package/dist/cjs/authority.js +51 -15
- package/dist/cjs/authority.js.map +1 -1
- package/dist/cjs/ceilings.d.ts +65 -13
- package/dist/cjs/ceilings.d.ts.map +1 -1
- package/dist/cjs/ceilings.js +393 -70
- package/dist/cjs/ceilings.js.map +1 -1
- package/dist/cjs/cli.d.ts.map +1 -1
- package/dist/cjs/cli.js +11 -4
- package/dist/cjs/cli.js.map +1 -1
- package/dist/cjs/display.d.ts +3 -2
- package/dist/cjs/display.d.ts.map +1 -1
- package/dist/cjs/display.js +4 -3
- package/dist/cjs/display.js.map +1 -1
- package/dist/cjs/evidence.d.ts +35 -9
- package/dist/cjs/evidence.d.ts.map +1 -1
- package/dist/cjs/evidence.js +333 -39
- package/dist/cjs/evidence.js.map +1 -1
- package/dist/cjs/guard.d.ts.map +1 -1
- package/dist/cjs/guard.js +7 -3
- package/dist/cjs/guard.js.map +1 -1
- package/dist/cjs/index.d.ts +1 -1
- package/dist/cjs/index.d.ts.map +1 -1
- package/dist/cjs/index.js +3 -1
- package/dist/cjs/index.js.map +1 -1
- package/dist/cjs/version.d.ts +1 -1
- package/dist/cjs/version.js +1 -1
- package/dist/cjs/wire.d.ts.map +1 -1
- package/dist/cjs/wire.js +3 -2
- package/dist/cjs/wire.js.map +1 -1
- package/dist/esm/audit.d.ts +15 -1
- package/dist/esm/audit.d.ts.map +1 -1
- package/dist/esm/audit.js +26 -4
- package/dist/esm/audit.js.map +1 -1
- package/dist/esm/authority.d.ts +12 -6
- package/dist/esm/authority.d.ts.map +1 -1
- package/dist/esm/authority.js +51 -15
- package/dist/esm/authority.js.map +1 -1
- package/dist/esm/ceilings.d.ts +65 -13
- package/dist/esm/ceilings.d.ts.map +1 -1
- package/dist/esm/ceilings.js +390 -70
- package/dist/esm/ceilings.js.map +1 -1
- package/dist/esm/cli.d.ts.map +1 -1
- package/dist/esm/cli.js +12 -5
- package/dist/esm/cli.js.map +1 -1
- package/dist/esm/display.d.ts +3 -2
- package/dist/esm/display.d.ts.map +1 -1
- package/dist/esm/display.js +4 -3
- package/dist/esm/display.js.map +1 -1
- package/dist/esm/evidence.d.ts +35 -9
- package/dist/esm/evidence.d.ts.map +1 -1
- package/dist/esm/evidence.js +335 -41
- package/dist/esm/evidence.js.map +1 -1
- package/dist/esm/guard.d.ts.map +1 -1
- package/dist/esm/guard.js +8 -4
- package/dist/esm/guard.js.map +1 -1
- package/dist/esm/index.d.ts +1 -1
- package/dist/esm/index.d.ts.map +1 -1
- package/dist/esm/index.js +1 -1
- package/dist/esm/index.js.map +1 -1
- package/dist/esm/version.d.ts +1 -1
- package/dist/esm/version.js +1 -1
- package/dist/esm/wire.d.ts.map +1 -1
- package/dist/esm/wire.js +3 -2
- package/dist/esm/wire.js.map +1 -1
- package/package.json +1 -1
package/dist/esm/ceilings.js
CHANGED
|
@@ -14,7 +14,7 @@
|
|
|
14
14
|
* always denies. There is no code path by which an unrecognised bound is
|
|
15
15
|
* silently dropped.
|
|
16
16
|
*/
|
|
17
|
-
import { MAX_SAFE_INTEGER, compareCodePoints, pyNumber, toPlain } from "./canonical.js";
|
|
17
|
+
import { MAX_SAFE_INTEGER, RawNumber, canonicalJson, compareCodePoints, pyNumber, toPlain, } from "./canonical.js";
|
|
18
18
|
import { escaped, pyRepr, pyStr, shown, shownText } from "./display.js";
|
|
19
19
|
import { Decision, Reason, ReasonCode } from "./reasons.js";
|
|
20
20
|
/**
|
|
@@ -32,6 +32,12 @@ function validateSafeNumber(key, value) {
|
|
|
32
32
|
"binary64 signing surface (RFC 8785)");
|
|
33
33
|
}
|
|
34
34
|
}
|
|
35
|
+
/**
|
|
36
|
+
* The scope grammar the draft defines: lowercase dot-separated segments, `*` only as the whole last
|
|
37
|
+
* segment. `Authority` validates its scopes against it, and a scoped `CallLimit`'s `appliesTo`
|
|
38
|
+
* follows it too.
|
|
39
|
+
*/
|
|
40
|
+
export const SCOPE_RE = /^[a-z][a-z0-9_-]*(?:\.[a-z][a-z0-9_-]*)*\.(?:[a-z][a-z0-9_-]*|\*)$/;
|
|
35
41
|
// Ordered enum for egress: index 0 is the strictest. A value outside this
|
|
36
42
|
// vocabulary is treated as maximally permissive-requested (worst case), so a
|
|
37
43
|
// garbage or unknown egress value fails closed rather than silently passing.
|
|
@@ -40,6 +46,93 @@ function egressRankOf(value) {
|
|
|
40
46
|
const i = EGRESS_ORDER.indexOf(value);
|
|
41
47
|
return i === -1 ? EGRESS_ORDER.length : i;
|
|
42
48
|
}
|
|
49
|
+
/** The JSON kinds a ceiling compares, as `jsonKind` names them. */
|
|
50
|
+
const NUMBER = ["a number"];
|
|
51
|
+
const STRING = ["a string"];
|
|
52
|
+
const SCALAR = ["a string", "a number", "a boolean"];
|
|
53
|
+
/**
|
|
54
|
+
* `value`'s JSON type as a refusal names it: null, a boolean, a number, a string, an array or an
|
|
55
|
+
* object, or "a value that is not JSON" for anything else passed in-process (a bigint). A number
|
|
56
|
+
* this library's `parseJson` read, a `RawNumber`, is a number. The Python implementation names the
|
|
57
|
+
* same values the same way.
|
|
58
|
+
*/
|
|
59
|
+
export function jsonKind(value) {
|
|
60
|
+
if (value === null)
|
|
61
|
+
return "null";
|
|
62
|
+
if (typeof value === "boolean")
|
|
63
|
+
return "a boolean";
|
|
64
|
+
if (typeof value === "number" || value instanceof RawNumber)
|
|
65
|
+
return "a number";
|
|
66
|
+
if (typeof value === "string")
|
|
67
|
+
return "a string";
|
|
68
|
+
if (Array.isArray(value))
|
|
69
|
+
return "an array";
|
|
70
|
+
if (typeof value === "object")
|
|
71
|
+
return "an object";
|
|
72
|
+
return "a value that is not JSON";
|
|
73
|
+
}
|
|
74
|
+
/** `value`, or the number it holds when `parseJson` read it as a `RawNumber`. */
|
|
75
|
+
function plain(value) {
|
|
76
|
+
return value instanceof RawNumber ? value.value : value;
|
|
77
|
+
}
|
|
78
|
+
/**
|
|
79
|
+
* `jsonKind(value)` when a ceiling that compares `accepted` cannot compare `value`, or `null`.
|
|
80
|
+
*
|
|
81
|
+
* Every built-in ceiling refuses such a request value rather than coercing it (attenu-ops#110):
|
|
82
|
+
* `"50"`, `[50]` and `true` passed a row cap through `<=`, `["/tmp/x"]` passed the prefix "/tmp/" as
|
|
83
|
+
* the text "/tmp/x", and `true` passed the prefix "t" as "true".
|
|
84
|
+
*/
|
|
85
|
+
function wrongKind(value, accepted) {
|
|
86
|
+
const kind = jsonKind(value);
|
|
87
|
+
return accepted.includes(kind) ? null : kind;
|
|
88
|
+
}
|
|
89
|
+
/**
|
|
90
|
+
* A denial's message: none when the value had the right type, as before; for one of the wrong type,
|
|
91
|
+
* `<kind> cannot be compared with <against>; refused`, one wording per ceiling kind, and the Python
|
|
92
|
+
* implementation's too.
|
|
93
|
+
*/
|
|
94
|
+
function refusal(kind, against) {
|
|
95
|
+
return kind === null ? "" : `${kind} cannot be compared with ${against}; refused`;
|
|
96
|
+
}
|
|
97
|
+
/**
|
|
98
|
+
* The error for a constraint member of the wrong type, `<member> of constraint <key> is <kind>, not
|
|
99
|
+
* <expected>`, with the key as Python's repr prints it: the Python implementation's text, where it
|
|
100
|
+
* raises ValueError. A member the constraint does not carry is `absent`.
|
|
101
|
+
*
|
|
102
|
+
* A bound of the wrong type is malformed (attenu-ops#110), on every path: the draft makes "max" a
|
|
103
|
+
* number and "prefix" a string, an egress rank outside none < internal < any ranked above "any" and
|
|
104
|
+
* admitted every request, and a `field` or `applies_to` that is not a string named a different
|
|
105
|
+
* context field in each implementation. A token carrying one is refused as malformed, and a bundle
|
|
106
|
+
* reports the authority unreadable.
|
|
107
|
+
*/
|
|
108
|
+
function malformed(key, member, value, expected, shownAs) {
|
|
109
|
+
const kind = value === undefined ? "absent" : (shownAs ?? jsonKind(value));
|
|
110
|
+
return new TypeError(`${member} of constraint ${pyRepr(key)} is ${kind}, not ${expected}`);
|
|
111
|
+
}
|
|
112
|
+
/**
|
|
113
|
+
* A constraint's key is a string: it names the dimension ceilings pair by and, unless `field` says
|
|
114
|
+
* otherwise, the context field the ceiling reads. A number, null or a boolean key was read as
|
|
115
|
+
* String(key) here, so 5 and "5" were one dimension, and an absent key loaded and read the field
|
|
116
|
+
* "undefined"; the Python implementation read a field no JSON context carries. Every constraint is
|
|
117
|
+
* refused without one, an unknown type included, in the Python implementation's words.
|
|
118
|
+
*/
|
|
119
|
+
function checkKey(key) {
|
|
120
|
+
if (typeof key !== "string") {
|
|
121
|
+
throw new TypeError(`key of a constraint is ${key === undefined ? "absent" : jsonKind(key)}, not a string`);
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
/** A `max` is a JSON number. */
|
|
125
|
+
function checkMax(key, value) {
|
|
126
|
+
if (value === undefined || jsonKind(value) !== "a number")
|
|
127
|
+
throw malformed(key, "max", value, "a number");
|
|
128
|
+
}
|
|
129
|
+
/** A `prefix`, `field` or `applies_to` is a string; `field` and `applies_to` may be null, unset. */
|
|
130
|
+
function checkString(key, member, value, optional = false) {
|
|
131
|
+
if (optional && (value === null || value === undefined))
|
|
132
|
+
return;
|
|
133
|
+
if (typeof value !== "string")
|
|
134
|
+
throw malformed(key, member, value, "a string");
|
|
135
|
+
}
|
|
43
136
|
/**
|
|
44
137
|
* The request-context field a ceiling reads. Prefers an explicit `ctxField`,
|
|
45
138
|
* then the caller-keyed `field`, then the ceiling's own `key`.
|
|
@@ -51,11 +144,23 @@ export function ctxFieldOf(ceiling) {
|
|
|
51
144
|
return ceiling.field;
|
|
52
145
|
return ceiling.key;
|
|
53
146
|
}
|
|
54
|
-
/**
|
|
147
|
+
/**
|
|
148
|
+
* Python's `type(a) is type(b)`: whether two ceilings are of one class. Ceilings pair by key, and
|
|
149
|
+
* two of different classes under one key are not comparable: neither narrows the other, so an
|
|
150
|
+
* authority holding one is not narrower than an authority holding the other (attenu-ops#110).
|
|
151
|
+
*/
|
|
152
|
+
export function sameType(a, b) {
|
|
153
|
+
return Object.getPrototypeOf(a) === Object.getPrototypeOf(b);
|
|
154
|
+
}
|
|
155
|
+
/**
|
|
156
|
+
* Uniform human-readable rendering of any ceiling. Never parsed back. A ceiling without its own
|
|
157
|
+
* `describe`, such as one this build does not define, prints as the Python implementation prints
|
|
158
|
+
* it: its key as the wire carried it, `=`, and its wire form as Python prints a dict.
|
|
159
|
+
*/
|
|
55
160
|
export function describe(ceiling) {
|
|
56
161
|
if (typeof ceiling.describe === "function")
|
|
57
162
|
return ceiling.describe();
|
|
58
|
-
return `${ceiling
|
|
163
|
+
return `${pyStr(wireKeyOf(ceiling))}=${pyRepr(ceiling.toWire())}`;
|
|
59
164
|
}
|
|
60
165
|
/**
|
|
61
166
|
* A ceiling's key as the wire carried it, which is what a finding names. A ceiling this build does
|
|
@@ -70,16 +175,22 @@ export function wireKeyOf(ceiling) {
|
|
|
70
175
|
* printed through `shown` (display.ts), so the finding stays on one line.
|
|
71
176
|
*
|
|
72
177
|
* `describe` itself is left alone, so dashboards and `Authority.describe()` print a region called
|
|
73
|
-
* "São Paulo" as it is, and for values in the bare set the two agree character for character
|
|
74
|
-
*
|
|
75
|
-
*
|
|
76
|
-
*
|
|
178
|
+
* "São Paulo" as it is, and for values in the bare set the two agree character for character, except
|
|
179
|
+
* that an allow-list's or a deny-list's string members are printed in their escaped JSON form, quoted,
|
|
180
|
+
* so the string "1" and the number 1 read differently. A ceiling this build does not define prints the
|
|
181
|
+
* Python implementation's description of it — its key, `=`, and the wire object as Python prints a
|
|
182
|
+
* dict — as it is when that is printable ASCII, spaces included, and as escaped JSON otherwise. Same
|
|
183
|
+
* text as the Python implementation's finding.
|
|
77
184
|
*/
|
|
78
185
|
export function describeInFinding(ceiling) {
|
|
79
186
|
// A bound the wire supplied: a number prints as `describe` prints it; anything else is shown.
|
|
80
187
|
const bound = (v) => (typeof v === "number" ? pyNumber(v) : shown(v));
|
|
81
|
-
|
|
82
|
-
|
|
188
|
+
// The typed members in wire order, as `describe` lists them, except that a string member is printed
|
|
189
|
+
// in its escaped JSON form, quoted, so a finding tells the string "1" from the number 1
|
|
190
|
+
// (attenu-ops#110). That form is ASCII, the Python implementation's on every Python version; its
|
|
191
|
+
// repr printed a printable non-ASCII character as it is. Every other member's text is bare.
|
|
192
|
+
const members = (values) => inWireOrder(values)
|
|
193
|
+
.map((v) => (typeof v === "string" ? escaped(v) : shownText(strOf(v), v)))
|
|
83
194
|
.join(", ");
|
|
84
195
|
// By exact class, as the Python implementation matches by exact type: a subclass of a built-in
|
|
85
196
|
// describes itself, and is printed the way any other ceiling this build does not define is.
|
|
@@ -98,8 +209,7 @@ export function describeInFinding(ceiling) {
|
|
|
98
209
|
return `${shown(ceiling.key)} not in [${members(ceiling.notOneOf)}]`;
|
|
99
210
|
if (kind === Prefix)
|
|
100
211
|
return `${shown(ceiling.key)} startswith ${shown(ceiling.prefix)}`;
|
|
101
|
-
const
|
|
102
|
-
const text = typeof ceiling.describe === "function" ? ceiling.describe() : `${pyStr(key)}=${pyRepr(ceiling.toWire())}`;
|
|
212
|
+
const text = describe(ceiling);
|
|
103
213
|
return /^[ -~]*$/.test(text) ? text : escaped(text);
|
|
104
214
|
}
|
|
105
215
|
/**
|
|
@@ -111,8 +221,136 @@ export function describeInFinding(ceiling) {
|
|
|
111
221
|
export function isMetered(ceiling) {
|
|
112
222
|
return Boolean(ceiling.metered) || String(ceiling.key).startsWith("max_");
|
|
113
223
|
}
|
|
224
|
+
/**
|
|
225
|
+
* What a refusal calls a `one_of` / `not_one_of` that is not a list of members, or `null` for one
|
|
226
|
+
* that is: a JSON array, or in-process any other iterable of members (a Set).
|
|
227
|
+
*
|
|
228
|
+
* The draft defines both as an array. null used to become the empty list, a string its
|
|
229
|
+
* characters, and an object threw a TypeError in its own words; an absent list read as an empty
|
|
230
|
+
* one, so an absent deny-list bounded nothing. Each is malformed now, as in the Python
|
|
231
|
+
* implementation: a token carrying one is refused as malformed, and a bundle reports the authority
|
|
232
|
+
* unreadable.
|
|
233
|
+
*/
|
|
234
|
+
function notAnArray(values) {
|
|
235
|
+
if (values === undefined)
|
|
236
|
+
return "absent";
|
|
237
|
+
if (values === null)
|
|
238
|
+
return "null";
|
|
239
|
+
if (typeof values === "boolean")
|
|
240
|
+
return "a boolean";
|
|
241
|
+
if (typeof values === "number")
|
|
242
|
+
return "a number";
|
|
243
|
+
if (typeof values === "string")
|
|
244
|
+
return "a string";
|
|
245
|
+
if (Array.isArray(values))
|
|
246
|
+
return null;
|
|
247
|
+
if (values instanceof Map)
|
|
248
|
+
return "an object";
|
|
249
|
+
if (typeof values === "object" && typeof values[Symbol.iterator] === "function")
|
|
250
|
+
return null;
|
|
251
|
+
if (typeof values === "object")
|
|
252
|
+
return "an object";
|
|
253
|
+
return "a value that is not an array";
|
|
254
|
+
}
|
|
255
|
+
/**
|
|
256
|
+
* `values` as the members of an `Allow` or a `Deny`, a number `parseJson` read as the number it
|
|
257
|
+
* holds. Throws a TypeError naming the list and the key when `values` is not a list of members
|
|
258
|
+
* (`notAnArray`); the Python implementation raises ValueError with the same text.
|
|
259
|
+
*/
|
|
260
|
+
function memberList(key, listName, values) {
|
|
261
|
+
const kind = notAnArray(values);
|
|
262
|
+
if (kind !== null)
|
|
263
|
+
throw new TypeError(`${listName} of constraint ${pyRepr(key)} is ${kind}, not an array`);
|
|
264
|
+
return Array.from(values, (v) => plain(v));
|
|
265
|
+
}
|
|
266
|
+
/**
|
|
267
|
+
* The context's own value for `field`, or `undefined`. A plain object inherits `constructor`,
|
|
268
|
+
* `toString` and the rest from Object.prototype, and a field named like one of them is absent unless
|
|
269
|
+
* the context holds it, as `ctx.get(field)` reads it in the Python implementation.
|
|
270
|
+
*/
|
|
271
|
+
export function ownValue(ctx, field) {
|
|
272
|
+
return Object.prototype.hasOwnProperty.call(ctx, field) ? ctx[field] : undefined;
|
|
273
|
+
}
|
|
274
|
+
/**
|
|
275
|
+
* Where two members print alike ("1" and 1), JSON type decides their order: null, boolean, number,
|
|
276
|
+
* string, then anything else.
|
|
277
|
+
*/
|
|
278
|
+
function kindRank(value) {
|
|
279
|
+
if (value === null)
|
|
280
|
+
return 0;
|
|
281
|
+
if (typeof value === "boolean")
|
|
282
|
+
return 1;
|
|
283
|
+
if (typeof value === "number")
|
|
284
|
+
return 2;
|
|
285
|
+
if (typeof value === "string")
|
|
286
|
+
return 3;
|
|
287
|
+
return 4;
|
|
288
|
+
}
|
|
289
|
+
/**
|
|
290
|
+
* `values` as the wire form, a denial's `limit` and `describe()` list them: sorted by `strOf`, then
|
|
291
|
+
* by JSON type (`kindRank`). That is a total order on distinct members, so an equal member set
|
|
292
|
+
* re-emits the same bytes whatever order it arrived in, in both implementations; ties used to keep
|
|
293
|
+
* their arrival order.
|
|
294
|
+
*/
|
|
114
295
|
function sortByStr(values) {
|
|
115
|
-
return Array.from(values).sort((a, b) => compareCodePoints(strOf(a), strOf(b)));
|
|
296
|
+
return Array.from(values).sort((a, b) => compareCodePoints(strOf(a), strOf(b)) || kindRank(a) - kindRank(b));
|
|
297
|
+
}
|
|
298
|
+
/**
|
|
299
|
+
* The members of an `Allow` or a `Deny`: a set that cannot change once built, as the Python
|
|
300
|
+
* implementation's member set has no mutators. `add`, `delete` and `clear` throw a TypeError, and the
|
|
301
|
+
* members live in a private Set no Set method can reach, so the wire order computed once
|
|
302
|
+
* (`inWireOrder`) cannot go stale: a plain Set a caller could still add to let `permits()` decide by
|
|
303
|
+
* members the wire did not list (attenu-ops#110).
|
|
304
|
+
*/
|
|
305
|
+
class Members {
|
|
306
|
+
#values;
|
|
307
|
+
constructor(values) {
|
|
308
|
+
this.#values = new Set(values);
|
|
309
|
+
}
|
|
310
|
+
get size() {
|
|
311
|
+
return this.#values.size;
|
|
312
|
+
}
|
|
313
|
+
has(value) {
|
|
314
|
+
return this.#values.has(value);
|
|
315
|
+
}
|
|
316
|
+
forEach(callback, thisArg) {
|
|
317
|
+
this.#values.forEach((value) => callback.call(thisArg, value, value, this));
|
|
318
|
+
}
|
|
319
|
+
entries() {
|
|
320
|
+
return this.#values.entries();
|
|
321
|
+
}
|
|
322
|
+
keys() {
|
|
323
|
+
return this.#values.keys();
|
|
324
|
+
}
|
|
325
|
+
values() {
|
|
326
|
+
return this.#values.values();
|
|
327
|
+
}
|
|
328
|
+
[Symbol.iterator]() {
|
|
329
|
+
return this.#values.values();
|
|
330
|
+
}
|
|
331
|
+
add(_value) {
|
|
332
|
+
throw new TypeError("the members of an allow-list or a deny-list are read-only");
|
|
333
|
+
}
|
|
334
|
+
delete(_value) {
|
|
335
|
+
throw new TypeError("the members of an allow-list or a deny-list are read-only");
|
|
336
|
+
}
|
|
337
|
+
clear() {
|
|
338
|
+
throw new TypeError("the members of an allow-list or a deny-list are read-only");
|
|
339
|
+
}
|
|
340
|
+
}
|
|
341
|
+
/**
|
|
342
|
+
* The wire order (`sortByStr`) of an `Allow`'s or a `Deny`'s members, computed once per member set: a
|
|
343
|
+
* denial, `toWire()` and `describe()` each sorted the whole set again. The members cannot change
|
|
344
|
+
* (`Members`); each caller gets its own copy of the order.
|
|
345
|
+
*/
|
|
346
|
+
const wireOrders = new WeakMap();
|
|
347
|
+
function inWireOrder(members) {
|
|
348
|
+
let order = wireOrders.get(members);
|
|
349
|
+
if (order === undefined) {
|
|
350
|
+
order = sortByStr(members);
|
|
351
|
+
wireOrders.set(members, order);
|
|
352
|
+
}
|
|
353
|
+
return [...order];
|
|
116
354
|
}
|
|
117
355
|
function strOf(value) {
|
|
118
356
|
if (typeof value === "number")
|
|
@@ -131,21 +369,26 @@ function strOf(value) {
|
|
|
131
369
|
// =========================================================================
|
|
132
370
|
/** Per-call cap on rows read or returned. Context field: "rows". */
|
|
133
371
|
export class RowLimit {
|
|
134
|
-
maxRows;
|
|
135
372
|
key = "max_rows";
|
|
136
373
|
ctxField = "rows";
|
|
374
|
+
maxRows;
|
|
137
375
|
constructor(maxRows) {
|
|
138
|
-
|
|
139
|
-
|
|
376
|
+
checkMax("max_rows", maxRows);
|
|
377
|
+
this.maxRows = plain(maxRows);
|
|
378
|
+
validateSafeNumber("max_rows", this.maxRows);
|
|
140
379
|
}
|
|
141
380
|
permits(ctx) {
|
|
142
|
-
const n = ctx
|
|
143
|
-
if (n === undefined || n === null
|
|
381
|
+
const n = ownValue(ctx, "rows");
|
|
382
|
+
if (n === undefined || n === null)
|
|
383
|
+
return Decision.allow();
|
|
384
|
+
const kind = wrongKind(n, NUMBER);
|
|
385
|
+
if (kind === null && plain(n) <= this.maxRows)
|
|
144
386
|
return Decision.allow();
|
|
145
387
|
return Decision.deny(new Reason(ReasonCode.CEILING_EXCEEDED, {
|
|
146
388
|
constraint: this.key,
|
|
147
389
|
limit: this.maxRows,
|
|
148
390
|
requested: n,
|
|
391
|
+
message: refusal(kind, "a maximum"),
|
|
149
392
|
}));
|
|
150
393
|
}
|
|
151
394
|
describe() {
|
|
@@ -155,7 +398,7 @@ export class RowLimit {
|
|
|
155
398
|
return new RowLimit(Math.min(this.maxRows, other.maxRows));
|
|
156
399
|
}
|
|
157
400
|
subsumes(other) {
|
|
158
|
-
return this.maxRows >= other.maxRows;
|
|
401
|
+
return sameType(this, other) && this.maxRows >= other.maxRows;
|
|
159
402
|
}
|
|
160
403
|
toWire() {
|
|
161
404
|
return { key: this.key, max: this.maxRows };
|
|
@@ -166,21 +409,26 @@ export class RowLimit {
|
|
|
166
409
|
}
|
|
167
410
|
/** Per-call cap on spend, currency-agnostic. Context field: "spend". */
|
|
168
411
|
export class SpendCap {
|
|
169
|
-
maxSpend;
|
|
170
412
|
key = "max_spend";
|
|
171
413
|
ctxField = "spend";
|
|
414
|
+
maxSpend;
|
|
172
415
|
constructor(maxSpend) {
|
|
173
|
-
|
|
174
|
-
|
|
416
|
+
checkMax("max_spend", maxSpend);
|
|
417
|
+
this.maxSpend = plain(maxSpend);
|
|
418
|
+
validateSafeNumber("max_spend", this.maxSpend);
|
|
175
419
|
}
|
|
176
420
|
permits(ctx) {
|
|
177
|
-
const n = ctx
|
|
178
|
-
if (n === undefined || n === null
|
|
421
|
+
const n = ownValue(ctx, "spend");
|
|
422
|
+
if (n === undefined || n === null)
|
|
423
|
+
return Decision.allow();
|
|
424
|
+
const kind = wrongKind(n, NUMBER);
|
|
425
|
+
if (kind === null && plain(n) <= this.maxSpend)
|
|
179
426
|
return Decision.allow();
|
|
180
427
|
return Decision.deny(new Reason(ReasonCode.CEILING_EXCEEDED, {
|
|
181
428
|
constraint: this.key,
|
|
182
429
|
limit: this.maxSpend,
|
|
183
430
|
requested: n,
|
|
431
|
+
message: refusal(kind, "a maximum"),
|
|
184
432
|
}));
|
|
185
433
|
}
|
|
186
434
|
describe() {
|
|
@@ -190,7 +438,7 @@ export class SpendCap {
|
|
|
190
438
|
return new SpendCap(Math.min(this.maxSpend, other.maxSpend));
|
|
191
439
|
}
|
|
192
440
|
subsumes(other) {
|
|
193
|
-
return this.maxSpend >= other.maxSpend;
|
|
441
|
+
return sameType(this, other) && this.maxSpend >= other.maxSpend;
|
|
194
442
|
}
|
|
195
443
|
toWire() {
|
|
196
444
|
return { key: this.key, max: this.maxSpend };
|
|
@@ -212,15 +460,22 @@ export class SpendCap {
|
|
|
212
460
|
* does not; an explicit `calls` in the context still wins.
|
|
213
461
|
*/
|
|
214
462
|
export class CallLimit {
|
|
215
|
-
maxCalls;
|
|
216
463
|
appliesTo;
|
|
217
464
|
key;
|
|
218
465
|
ctxField;
|
|
466
|
+
maxCalls;
|
|
219
467
|
constructor(maxCalls, appliesTo = null) {
|
|
220
|
-
this.maxCalls = maxCalls;
|
|
221
468
|
this.appliesTo = appliesTo;
|
|
222
|
-
|
|
469
|
+
checkString("max_calls", "applies_to", appliesTo, true);
|
|
470
|
+
// A pattern no scope matches ("*", "crm", "CRM.READ") applied to no call, so the limit bounded
|
|
471
|
+
// nothing. It follows the scope grammar: an exact scope, or a terminal `.*` wildcard.
|
|
472
|
+
if (appliesTo !== null && appliesTo !== undefined && !SCOPE_RE.test(appliesTo)) {
|
|
473
|
+
throw malformed("max_calls", "applies_to", appliesTo, "a scope", pyRepr(appliesTo));
|
|
474
|
+
}
|
|
223
475
|
this.key = appliesTo ? `max_calls[${appliesTo}]` : "max_calls";
|
|
476
|
+
checkMax(this.key, maxCalls);
|
|
477
|
+
this.maxCalls = plain(maxCalls);
|
|
478
|
+
validateSafeNumber("max_calls", this.maxCalls);
|
|
224
479
|
this.ctxField = appliesTo ? `calls[${appliesTo}]` : "calls";
|
|
225
480
|
}
|
|
226
481
|
/** What this ceiling counts: the pattern it applies to, or every call. */
|
|
@@ -234,15 +489,19 @@ export class CallLimit {
|
|
|
234
489
|
return held === scope || (held.endsWith(".*") && String(scope).startsWith(held.slice(0, -1)));
|
|
235
490
|
}
|
|
236
491
|
permits(ctx) {
|
|
237
|
-
if (!this.appliesToScope(ctx
|
|
492
|
+
if (!this.appliesToScope(ownValue(ctx, "_scope")))
|
|
238
493
|
return Decision.allow();
|
|
239
|
-
const n = ctx
|
|
240
|
-
if (n === undefined || n === null
|
|
494
|
+
const n = ownValue(ctx, this.ctxField);
|
|
495
|
+
if (n === undefined || n === null)
|
|
496
|
+
return Decision.allow();
|
|
497
|
+
const kind = wrongKind(n, NUMBER);
|
|
498
|
+
if (kind === null && plain(n) <= this.maxCalls)
|
|
241
499
|
return Decision.allow();
|
|
242
500
|
return Decision.deny(new Reason(ReasonCode.CEILING_EXCEEDED, {
|
|
243
501
|
constraint: this.key,
|
|
244
502
|
limit: this.maxCalls,
|
|
245
503
|
requested: n,
|
|
504
|
+
message: refusal(kind, "a maximum"),
|
|
246
505
|
}));
|
|
247
506
|
}
|
|
248
507
|
describe() {
|
|
@@ -252,7 +511,7 @@ export class CallLimit {
|
|
|
252
511
|
return new CallLimit(Math.min(this.maxCalls, other.maxCalls), this.appliesTo);
|
|
253
512
|
}
|
|
254
513
|
subsumes(other) {
|
|
255
|
-
return this.maxCalls >= other.maxCalls;
|
|
514
|
+
return sameType(this, other) && this.maxCalls >= other.maxCalls;
|
|
256
515
|
}
|
|
257
516
|
toWire() {
|
|
258
517
|
if (!this.appliesTo)
|
|
@@ -270,16 +529,23 @@ export class EgressRank {
|
|
|
270
529
|
ctxField = "egress";
|
|
271
530
|
constructor(level) {
|
|
272
531
|
this.level = level;
|
|
532
|
+
// A rank outside the vocabulary ranked above "any", so the ceiling admitted every request.
|
|
533
|
+
if (typeof level !== "string" || !EGRESS_ORDER.includes(level)) {
|
|
534
|
+
throw malformed("egress", "rank", level, "'none', 'internal' or 'any'", typeof level === "string" ? pyRepr(level) : undefined);
|
|
535
|
+
}
|
|
273
536
|
}
|
|
274
537
|
permits(ctx) {
|
|
275
|
-
const val = ctx
|
|
276
|
-
if (val === undefined || val === null
|
|
538
|
+
const val = ownValue(ctx, "egress");
|
|
539
|
+
if (val === undefined || val === null)
|
|
540
|
+
return Decision.allow();
|
|
541
|
+
const kind = wrongKind(val, STRING);
|
|
542
|
+
if (kind === null && egressRankOf(val) <= egressRankOf(this.level))
|
|
277
543
|
return Decision.allow();
|
|
278
|
-
}
|
|
279
544
|
return Decision.deny(new Reason(ReasonCode.CEILING_EXCEEDED, {
|
|
280
545
|
constraint: this.key,
|
|
281
546
|
limit: this.level,
|
|
282
547
|
requested: val,
|
|
548
|
+
message: refusal(kind, "an egress rank"),
|
|
283
549
|
}));
|
|
284
550
|
}
|
|
285
551
|
describe() {
|
|
@@ -290,7 +556,7 @@ export class EgressRank {
|
|
|
290
556
|
return new EgressRank(egressRankOf(this.level) <= egressRankOf(o.level) ? this.level : o.level);
|
|
291
557
|
}
|
|
292
558
|
subsumes(other) {
|
|
293
|
-
return egressRankOf(this.level) >= egressRankOf(other.level);
|
|
559
|
+
return sameType(this, other) && egressRankOf(this.level) >= egressRankOf(other.level);
|
|
294
560
|
}
|
|
295
561
|
toWire() {
|
|
296
562
|
return { key: this.key, rank: this.level };
|
|
@@ -304,7 +570,11 @@ export class EgressRank {
|
|
|
304
570
|
// `key` here IS a caller choice, so it cannot double as the wire discriminator;
|
|
305
571
|
// these carry an explicit "type" on the wire.
|
|
306
572
|
// =========================================================================
|
|
307
|
-
/**
|
|
573
|
+
/**
|
|
574
|
+
* Membership allow-list: the context value MUST be one of `oneOf`. A member is its JSON type plus
|
|
575
|
+
* its value, as a Set holds it: `[1]` admits 1 and refuses `true` and `"1"`. A context value that
|
|
576
|
+
* is not a JSON scalar is refused (`wrongKind`).
|
|
577
|
+
*/
|
|
308
578
|
export class Allow {
|
|
309
579
|
key;
|
|
310
580
|
field;
|
|
@@ -312,42 +582,54 @@ export class Allow {
|
|
|
312
582
|
constructor(key, oneOf, field = null) {
|
|
313
583
|
this.key = key;
|
|
314
584
|
this.field = field;
|
|
315
|
-
|
|
585
|
+
checkKey(key);
|
|
586
|
+
this.oneOf = new Members(memberList(key, "one_of", oneOf));
|
|
587
|
+
checkString(key, "field", field, true);
|
|
316
588
|
}
|
|
317
589
|
ctxKey() {
|
|
318
590
|
return this.field ?? this.key;
|
|
319
591
|
}
|
|
320
592
|
permits(ctx) {
|
|
321
|
-
const val = ctx
|
|
322
|
-
if (val === undefined || val === null
|
|
593
|
+
const val = ownValue(ctx, this.ctxKey());
|
|
594
|
+
if (val === undefined || val === null)
|
|
595
|
+
return Decision.allow();
|
|
596
|
+
const kind = wrongKind(val, SCALAR);
|
|
597
|
+
if (kind === null && this.oneOf.has(plain(val)))
|
|
323
598
|
return Decision.allow();
|
|
324
599
|
return Decision.deny(new Reason(ReasonCode.CEILING_EXCEEDED, {
|
|
325
600
|
constraint: this.key,
|
|
326
|
-
limit:
|
|
601
|
+
limit: inWireOrder(this.oneOf),
|
|
327
602
|
requested: val,
|
|
603
|
+
message: refusal(kind, "one_of members"),
|
|
328
604
|
}));
|
|
329
605
|
}
|
|
330
606
|
describe() {
|
|
331
|
-
return `${this.key} in [${
|
|
607
|
+
return `${this.key} in [${inWireOrder(this.oneOf).map(strOf).join(", ")}]`;
|
|
332
608
|
}
|
|
333
609
|
narrow(other) {
|
|
334
610
|
const o = other;
|
|
335
611
|
return new Allow(this.key, Array.from(this.oneOf).filter((v) => o.oneOf.has(v)), this.field);
|
|
336
612
|
}
|
|
337
613
|
subsumes(other) {
|
|
338
|
-
return Array.from(other.oneOf).every((v) => this.oneOf.has(v));
|
|
614
|
+
return sameType(this, other) && Array.from(other.oneOf).every((v) => this.oneOf.has(v));
|
|
339
615
|
}
|
|
340
616
|
toWire() {
|
|
341
|
-
const d = { key: this.key, type: "allow", one_of:
|
|
617
|
+
const d = { key: this.key, type: "allow", one_of: inWireOrder(this.oneOf) };
|
|
342
618
|
if (this.field !== null && this.field !== this.key)
|
|
343
619
|
d["field"] = this.field;
|
|
344
620
|
return d;
|
|
345
621
|
}
|
|
346
622
|
static fromWire(d) {
|
|
347
|
-
|
|
623
|
+
// Anything but an array, an absent one_of included, is refused by the constructor.
|
|
624
|
+
return new Allow(d["key"], d["one_of"], d["field"] ?? null);
|
|
348
625
|
}
|
|
349
626
|
}
|
|
350
|
-
/**
|
|
627
|
+
/**
|
|
628
|
+
* Membership deny-list: the context value MUST NOT be one of `notOneOf`. A member is its JSON type
|
|
629
|
+
* plus its value, as a Set holds it: `[1]` refuses 1 and not `true` or `"1"`. A context value that
|
|
630
|
+
* is not a JSON scalar is refused as well (`wrongKind`): a deny-list never waves through a value it
|
|
631
|
+
* cannot compare, since waving `["rm"]` through because it is not the string "rm" would fail open.
|
|
632
|
+
*/
|
|
351
633
|
export class Deny {
|
|
352
634
|
key;
|
|
353
635
|
field;
|
|
@@ -355,23 +637,29 @@ export class Deny {
|
|
|
355
637
|
constructor(key, notOneOf, field = null) {
|
|
356
638
|
this.key = key;
|
|
357
639
|
this.field = field;
|
|
358
|
-
|
|
640
|
+
checkKey(key);
|
|
641
|
+
this.notOneOf = new Members(memberList(key, "not_one_of", notOneOf));
|
|
642
|
+
checkString(key, "field", field, true);
|
|
359
643
|
}
|
|
360
644
|
ctxKey() {
|
|
361
645
|
return this.field ?? this.key;
|
|
362
646
|
}
|
|
363
647
|
permits(ctx) {
|
|
364
|
-
const val = ctx
|
|
365
|
-
if (val === undefined || val === null
|
|
648
|
+
const val = ownValue(ctx, this.ctxKey());
|
|
649
|
+
if (val === undefined || val === null)
|
|
650
|
+
return Decision.allow();
|
|
651
|
+
const kind = wrongKind(val, SCALAR);
|
|
652
|
+
if (kind === null && !this.notOneOf.has(plain(val)))
|
|
366
653
|
return Decision.allow();
|
|
367
654
|
return Decision.deny(new Reason(ReasonCode.CEILING_EXCEEDED, {
|
|
368
655
|
constraint: this.key,
|
|
369
|
-
limit:
|
|
656
|
+
limit: inWireOrder(this.notOneOf),
|
|
370
657
|
requested: val,
|
|
658
|
+
message: refusal(kind, "not_one_of members"),
|
|
371
659
|
}));
|
|
372
660
|
}
|
|
373
661
|
describe() {
|
|
374
|
-
return `${this.key} not in [${
|
|
662
|
+
return `${this.key} not in [${inWireOrder(this.notOneOf).map(strOf).join(", ")}]`;
|
|
375
663
|
}
|
|
376
664
|
narrow(other) {
|
|
377
665
|
const o = other;
|
|
@@ -380,20 +668,21 @@ export class Deny {
|
|
|
380
668
|
subsumes(other) {
|
|
381
669
|
// `this` admits a superset of `other`'s admitted set iff it forbids a
|
|
382
670
|
// subset of what `other` forbids.
|
|
383
|
-
return Array.from(this.notOneOf).every((v) => other.notOneOf.has(v));
|
|
671
|
+
return sameType(this, other) && Array.from(this.notOneOf).every((v) => other.notOneOf.has(v));
|
|
384
672
|
}
|
|
385
673
|
toWire() {
|
|
386
674
|
const d = {
|
|
387
675
|
key: this.key,
|
|
388
676
|
type: "deny",
|
|
389
|
-
not_one_of:
|
|
677
|
+
not_one_of: inWireOrder(this.notOneOf),
|
|
390
678
|
};
|
|
391
679
|
if (this.field !== null && this.field !== this.key)
|
|
392
680
|
d["field"] = this.field;
|
|
393
681
|
return d;
|
|
394
682
|
}
|
|
395
683
|
static fromWire(d) {
|
|
396
|
-
|
|
684
|
+
// Anything but an array, an absent not_one_of included, is refused by the constructor.
|
|
685
|
+
return new Deny(d["key"], d["not_one_of"], d["field"] ?? null);
|
|
397
686
|
}
|
|
398
687
|
}
|
|
399
688
|
/** String-prefix bound: the context value MUST start with `prefix`. */
|
|
@@ -405,19 +694,25 @@ export class Prefix {
|
|
|
405
694
|
this.key = key;
|
|
406
695
|
this.prefix = prefix;
|
|
407
696
|
this.field = field;
|
|
697
|
+
checkKey(key);
|
|
698
|
+
checkString(key, "prefix", prefix);
|
|
699
|
+
checkString(key, "field", field, true);
|
|
408
700
|
}
|
|
409
701
|
ctxKey() {
|
|
410
702
|
return this.field ?? this.key;
|
|
411
703
|
}
|
|
412
704
|
permits(ctx) {
|
|
413
|
-
const val = ctx
|
|
414
|
-
if (val === undefined || val === null
|
|
705
|
+
const val = ownValue(ctx, this.ctxKey());
|
|
706
|
+
if (val === undefined || val === null)
|
|
707
|
+
return Decision.allow();
|
|
708
|
+
const kind = wrongKind(val, STRING);
|
|
709
|
+
if (kind === null && val.startsWith(this.prefix))
|
|
415
710
|
return Decision.allow();
|
|
416
|
-
}
|
|
417
711
|
return Decision.deny(new Reason(ReasonCode.CEILING_EXCEEDED, {
|
|
418
712
|
constraint: this.key,
|
|
419
713
|
limit: this.prefix,
|
|
420
714
|
requested: val,
|
|
715
|
+
message: refusal(kind, "a prefix"),
|
|
421
716
|
}));
|
|
422
717
|
}
|
|
423
718
|
describe() {
|
|
@@ -439,7 +734,7 @@ export class Prefix {
|
|
|
439
734
|
return new Prefix(this.key, `${this.prefix}\u0000${o.prefix}`, this.field);
|
|
440
735
|
}
|
|
441
736
|
subsumes(other) {
|
|
442
|
-
return other.prefix.startsWith(this.prefix);
|
|
737
|
+
return sameType(this, other) && other.prefix.startsWith(this.prefix);
|
|
443
738
|
}
|
|
444
739
|
toWire() {
|
|
445
740
|
const d = { key: this.key, type: "prefix", prefix: this.prefix };
|
|
@@ -464,10 +759,15 @@ export function registerCeiling(key, cls) {
|
|
|
464
759
|
* Fail-closed placeholder for a wire constraint this build does not recognise.
|
|
465
760
|
*
|
|
466
761
|
* `permits` always denies. `narrow` stays an unknown ceiling, so it can never
|
|
467
|
-
* resolve to something more permissive than "deny everything"
|
|
762
|
+
* resolve to something more permissive than "deny everything"; `Authority.meet`
|
|
763
|
+
* keeps a parent's against a request of any other type, so the child inherits
|
|
764
|
+
* it. `subsumes` is
|
|
468
765
|
* true only against an identical unknown ceiling — just enough reflexivity for
|
|
469
|
-
* `isNarrowerThan(self)`.
|
|
470
|
-
*
|
|
766
|
+
* `isNarrowerThan(self)`. Identical means the same RFC 8785 bytes, which is
|
|
767
|
+
* equality as JSON: `true` is not 1, `1.0` is 1, and key order is no difference
|
|
768
|
+
* at any depth; a value RFC 8785 cannot write is identical to nothing. `toWire`
|
|
769
|
+
* preserves the original bytes losslessly, so a chain that merely forwards
|
|
770
|
+
* constraints can still do so.
|
|
471
771
|
*/
|
|
472
772
|
export class UnknownCeiling {
|
|
473
773
|
raw;
|
|
@@ -477,17 +777,28 @@ export class UnknownCeiling {
|
|
|
477
777
|
this.key = key === null || key === undefined ? "" : String(key);
|
|
478
778
|
}
|
|
479
779
|
permits(_ctx) {
|
|
780
|
+
// In the Python implementation's words, and with the key as the wire carried it (null, a
|
|
781
|
+
// number), so a deny entry is the same bytes from both implementations.
|
|
782
|
+
const key = wireKeyOf(this);
|
|
480
783
|
return Decision.deny(new Reason(ReasonCode.UNKNOWN_CONSTRAINT, {
|
|
481
|
-
constraint:
|
|
482
|
-
message: `unrecognised constraint type for key=${
|
|
784
|
+
constraint: key,
|
|
785
|
+
message: `unrecognised constraint type for key=${pyRepr(key)}; fail-closed`,
|
|
483
786
|
}));
|
|
484
787
|
}
|
|
485
788
|
narrow(_other) {
|
|
486
789
|
return this;
|
|
487
790
|
}
|
|
488
791
|
subsumes(other) {
|
|
489
|
-
|
|
490
|
-
|
|
792
|
+
// Compared as RFC 8785 bytes, as the Python implementation compares them. JSON text with only
|
|
793
|
+
// the top-level keys sorted told `{"v":{"a":1,"b":2}}` from `{"v":{"b":2,"a":1}}` (attenu-ops#110).
|
|
794
|
+
if (!(other instanceof UnknownCeiling))
|
|
795
|
+
return false;
|
|
796
|
+
try {
|
|
797
|
+
return canonicalJson(other.raw) === canonicalJson(this.raw);
|
|
798
|
+
}
|
|
799
|
+
catch {
|
|
800
|
+
return false;
|
|
801
|
+
}
|
|
491
802
|
}
|
|
492
803
|
toWire() {
|
|
493
804
|
return { ...this.raw };
|
|
@@ -496,19 +807,28 @@ export class UnknownCeiling {
|
|
|
496
807
|
return new UnknownCeiling(d["key"] ?? null, { ...d });
|
|
497
808
|
}
|
|
498
809
|
}
|
|
499
|
-
function canonicalPairs(d) {
|
|
500
|
-
return Object.keys(d)
|
|
501
|
-
.sort(compareCodePoints)
|
|
502
|
-
.map((k) => [k, d[k]]);
|
|
503
|
-
}
|
|
504
810
|
/**
|
|
505
811
|
* Reconstruct a Ceiling from its wire form. Routes on "type" when present (it
|
|
506
812
|
* disambiguates the generic ceilings), else on "key". An unrecognised
|
|
507
813
|
* discriminator fails closed via `UnknownCeiling`.
|
|
814
|
+
*
|
|
815
|
+
* A constraint is a JSON object with a string `key`, and a `type` that, when present, is a string;
|
|
816
|
+
* anything else is malformed (attenu-ops#110), in the Python implementation's words too: `a
|
|
817
|
+
* constraint is a string, not an object`, `type of constraint 'max_rows' is null, not a string`.
|
|
818
|
+
* A constraint that is not an object loaded as an unknown constraint here, null aside, and a null
|
|
819
|
+
* `type` was read as absent, so the constraint was routed by its key: `{"key": "allow", "type":
|
|
820
|
+
* null, ...}` loaded as an allow-list, where the Python implementation loaded an unknown constraint.
|
|
508
821
|
*/
|
|
509
822
|
export function ceilingFromWire(wire) {
|
|
510
823
|
const d = toPlain(wire);
|
|
511
|
-
|
|
824
|
+
if (d === null || typeof d !== "object" || Array.isArray(d)) {
|
|
825
|
+
throw new TypeError(`a constraint is ${jsonKind(d)}, not an object`);
|
|
826
|
+
}
|
|
827
|
+
checkKey(d["key"]);
|
|
828
|
+
const type = d["type"];
|
|
829
|
+
if (type !== undefined && typeof type !== "string")
|
|
830
|
+
throw malformed(d["key"], "type", type, "a string");
|
|
831
|
+
const discriminator = type ?? d["key"];
|
|
512
832
|
const cls = typeof discriminator === "string" ? REGISTRY.get(discriminator) : undefined;
|
|
513
833
|
if (cls === undefined)
|
|
514
834
|
return UnknownCeiling.fromWire(d);
|