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