@blamejs/core 0.18.42 → 0.18.44

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.
@@ -0,0 +1,480 @@
1
+ // SPDX-License-Identifier: Apache-2.0
2
+ // Copyright (c) blamejs contributors
3
+ "use strict";
4
+ /**
5
+ * @module b.guardCountry
6
+ * @nav Guards
7
+ * @title Guard Country
8
+ *
9
+ * @intro
10
+ * ISO 3166-1 alpha-2 country-code guard. Accepts the 249
11
+ * officially assigned codes and refuses everything else, from a
12
+ * bundled table. KIND="identifier" - the gate consumes
13
+ * `ctx.identifier` (or `ctx.country` / `ctx.countryCode`).
14
+ *
15
+ * The framework already routes on these codes: compliance
16
+ * postures are keyed by jurisdiction, and data-residency, tax
17
+ * nexus, geo-restriction and DSR-jurisdiction decisions all turn
18
+ * on one. A two-letter string that looks like a country but is
19
+ * not one produces a wrong answer at every one of those, wearing
20
+ * the shape of a right one.
21
+ *
22
+ * Threat catalog: not-a-code (`UK` - the United Kingdom is `GB`);
23
+ * user-assigned codes (`AA`, `QM`-`QZ`, `XA`-`XZ`, `ZZ`, which
24
+ * includes CLDR's unknown-region sentinel and `XK` for Kosovo);
25
+ * exceptionally reserved codes (`EU`, `EZ`, `UN`, `AC`, `CP`,
26
+ * `CQ`, `DG`, `EA`, `IC`, `TA`) that name unions, organisations
27
+ * and territories rather than countries; formerly used codes
28
+ * (`AN`, `BU`, `CS`, `DD`, `FX`, `NT`, `SU`, `TP`, `YD`, `YU`,
29
+ * `ZR`) that a legacy dataset still carries; BIDI / zero-width /
30
+ * C0-control / null-byte universal-refuse; and homoglyph or
31
+ * fullwidth spellings that render as a valid code.
32
+ *
33
+ * The answer comes from the bundled table and never from `Intl`.
34
+ * A `small-icu` or `no-icu` Node build has no
35
+ * `Intl.DisplayNames`, so the widespread hand-rolled version -
36
+ * which asks `DisplayNames` whether a code echoes back and
37
+ * returns true from its `catch` - accepts every two-letter string
38
+ * on those runtimes. That is a deployment-shaped behaviour change
39
+ * with no signal.
40
+ *
41
+ * Profiles: `strict` / `balanced` / `permissive`. Compliance
42
+ * postures: `hipaa` / `pci-dss` / `gdpr` / `soc2`.
43
+ *
44
+ * @card
45
+ * ISO 3166-1 alpha-2 country-code guard, answered from a bundled table.
46
+ */
47
+
48
+ var gateContract = require("./gate-contract");
49
+ var codepointClass = require("./codepoint-class");
50
+ var C = require("./constants");
51
+ var { GuardCountryError } = require("./framework-error");
52
+
53
+ void GuardCountryError;
54
+
55
+ // ---- Code tables ----------------------------------------------------------
56
+ //
57
+ // The 249 officially assigned ISO 3166-1 alpha-2 codes.
58
+ //
59
+ // Derived, not recalled. Two independent machine-readable sources were read
60
+ // and diffed, and they agree on exactly this set:
61
+ //
62
+ // - the IANA language-subtag-registry (File-Date 2026-08-08): its 261 live
63
+ // two-letter `Type: region` subtags, minus the twelve named in
64
+ // USER_ASSIGNED / EXCEPTIONALLY_RESERVED below;
65
+ // - Unicode CLDR common/validity/region.xml: its 257 `idStatus='regular'`
66
+ // entries, minus the eight non-country codes CLDR keeps there because it
67
+ // formats them (AC, CP, CQ, DG, EA, IC, TA and XK).
68
+ //
69
+ // Neither source carries ISO assignment status on its own - in the IANA
70
+ // registry, AC (exceptionally reserved) and GB (officially assigned) are
71
+ // byte-identical record shapes - which is why the exclusions are enumerated
72
+ // here rather than filtered on a status field that does not exist. Both
73
+ // derivations land on 249, the published count of officially assigned
74
+ // alpha-2 codes.
75
+ //
76
+ // Refreshing: re-derive from both sources and diff before editing. A code
77
+ // added to one source and not the other is a signal, not a merge conflict.
78
+ var ASSIGNED_CODES = Object.freeze([
79
+ "AD", "AE", "AF", "AG", "AI", "AL", "AM", "AO", "AQ", "AR", "AS", "AT",
80
+ "AU", "AW", "AX", "AZ", "BA", "BB", "BD", "BE", "BF", "BG", "BH", "BI",
81
+ "BJ", "BL", "BM", "BN", "BO", "BQ", "BR", "BS", "BT", "BV", "BW", "BY",
82
+ "BZ", "CA", "CC", "CD", "CF", "CG", "CH", "CI", "CK", "CL", "CM", "CN",
83
+ "CO", "CR", "CU", "CV", "CW", "CX", "CY", "CZ", "DE", "DJ", "DK", "DM",
84
+ "DO", "DZ", "EC", "EE", "EG", "EH", "ER", "ES", "ET", "FI", "FJ", "FK",
85
+ "FM", "FO", "FR", "GA", "GB", "GD", "GE", "GF", "GG", "GH", "GI", "GL",
86
+ "GM", "GN", "GP", "GQ", "GR", "GS", "GT", "GU", "GW", "GY", "HK", "HM",
87
+ "HN", "HR", "HT", "HU", "ID", "IE", "IL", "IM", "IN", "IO", "IQ", "IR",
88
+ "IS", "IT", "JE", "JM", "JO", "JP", "KE", "KG", "KH", "KI", "KM", "KN",
89
+ "KP", "KR", "KW", "KY", "KZ", "LA", "LB", "LC", "LI", "LK", "LR", "LS",
90
+ "LT", "LU", "LV", "LY", "MA", "MC", "MD", "ME", "MF", "MG", "MH", "MK",
91
+ "ML", "MM", "MN", "MO", "MP", "MQ", "MR", "MS", "MT", "MU", "MV", "MW",
92
+ "MX", "MY", "MZ", "NA", "NC", "NE", "NF", "NG", "NI", "NL", "NO", "NP",
93
+ "NR", "NU", "NZ", "OM", "PA", "PE", "PF", "PG", "PH", "PK", "PL", "PM",
94
+ "PN", "PR", "PS", "PT", "PW", "PY", "QA", "RE", "RO", "RS", "RU", "RW",
95
+ "SA", "SB", "SC", "SD", "SE", "SG", "SH", "SI", "SJ", "SK", "SL", "SM",
96
+ "SN", "SO", "SR", "SS", "ST", "SV", "SX", "SY", "SZ", "TC", "TD", "TF",
97
+ "TG", "TH", "TJ", "TK", "TL", "TM", "TN", "TO", "TR", "TT", "TV", "TW",
98
+ "TZ", "UA", "UG", "UM", "US", "UY", "UZ", "VA", "VC", "VE", "VG", "VI",
99
+ "VN", "VU", "WF", "WS", "YE", "YT", "ZA", "ZM", "ZW",
100
+ ]);
101
+
102
+ var ASSIGNED = new Set(ASSIGNED_CODES);
103
+
104
+ // ISO 3166-1 exceptionally reserved: names an entity that is not a country,
105
+ // or a territory that already resolves to one. Present in the IANA registry
106
+ // because BCP 47 needs them for locale data, absent from ISO's assigned list.
107
+ var EXCEPTIONALLY_RESERVED = Object.freeze({
108
+ AC: "Ascension Island",
109
+ CP: "Clipperton Island",
110
+ CQ: "Sark",
111
+ DG: "Diego Garcia",
112
+ EA: "Ceuta and Melilla",
113
+ EU: "European Union",
114
+ EZ: "Eurozone",
115
+ IC: "Canary Islands",
116
+ TA: "Tristan da Cunha",
117
+ UN: "United Nations",
118
+ });
119
+
120
+ // Codes the IANA language-subtag-registry marks Deprecated. Where the record
121
+ // carries a Preferred-Value it is reproduced verbatim; where it does not, the
122
+ // value here is null and no successor is stated. NT (Neutral Zone), AN
123
+ // (Netherlands Antilles), CS (Serbia and Montenegro) and YU (Yugoslavia)
124
+ // dissolved into several successors, so the registry names none and neither
125
+ // does this guard.
126
+ var FORMERLY_USED = Object.freeze({
127
+ AN: null, // Netherlands Antilles
128
+ BU: "MM", // Burma
129
+ CS: null, // Serbia and Montenegro
130
+ DD: "DE", // German Democratic Republic
131
+ FX: "FR", // Metropolitan France
132
+ NT: null, // Neutral Zone
133
+ SU: null, // Union of Soviet Socialist Republics
134
+ TP: "TL", // East Timor
135
+ YD: "YE", // Democratic Yemen
136
+ YU: null, // Yugoslavia
137
+ ZR: "CD", // Zaire
138
+ });
139
+
140
+ // UK is the single most common wrong answer: it is the ccTLD and the everyday
141
+ // abbreviation, and it is not an ISO 3166-1 code at all.
142
+ var COMMON_MISTAKES = Object.freeze({ UK: "GB" });
143
+
144
+ // ---- Profile presets ------------------------------------------------------
145
+
146
+ var PROFILES = Object.freeze({
147
+ "strict": {
148
+ ...gateContract.CHAR_THREATS_REJECT_ALL,
149
+ reservedPolicy: "reject", // reject | audit | allow
150
+ userAssignedPolicy: "reject", // reject | audit | allow
151
+ formerlyUsedPolicy: "reject", // reject | audit | allow
152
+ maxBytes: C.BYTES.bytes(16),
153
+ maxRuntimeMs: C.TIME.seconds(2),
154
+ },
155
+ "balanced": {
156
+ ...gateContract.CHAR_THREATS_REJECT_ALL,
157
+ // A formerly-used code is a data-age signal, not an attack: an operator
158
+ // migrating records written before 1993 wants to see them, not to have
159
+ // the import refused row by row. A user-assigned or reserved code still
160
+ // refuses - those are answers a routing decision must never act on.
161
+ reservedPolicy: "reject",
162
+ userAssignedPolicy: "reject",
163
+ formerlyUsedPolicy: "audit",
164
+ maxBytes: C.BYTES.bytes(16),
165
+ maxRuntimeMs: C.TIME.seconds(2),
166
+ },
167
+ "permissive": {
168
+ ...gateContract.CHAR_THREATS_REJECT_ALL,
169
+ reservedPolicy: "audit",
170
+ userAssignedPolicy: "audit",
171
+ formerlyUsedPolicy: "audit",
172
+ maxBytes: C.BYTES.bytes(16),
173
+ maxRuntimeMs: C.TIME.seconds(2),
174
+ },
175
+ });
176
+
177
+ var DEFAULTS = gateContract.strictDefaults(PROFILES);
178
+
179
+ var COMPLIANCE_POSTURES = gateContract.compliancePostures(PROFILES, { base: 32 });
180
+
181
+ // ---- Detection ------------------------------------------------------------
182
+
183
+ // ISO 3166-1 user-assigned codes: AA, QM-QZ, XA-XZ and ZZ. These are reserved
184
+ // for private use, so two systems can both use one and mean different things.
185
+ // CLDR spends ZZ on "unknown region" and XK on Kosovo; neither is a promise
186
+ // any other system keeps.
187
+ function _isUserAssigned(code) {
188
+ if (code === "AA" || code === "ZZ") return true;
189
+ var first = code.charCodeAt(0);
190
+ var second = code.charCodeAt(1);
191
+ // Q M..Z
192
+ if (first === 0x51 && second >= 0x4D && second <= 0x5A) return true;
193
+ // X A..Z
194
+ if (first === 0x58) return true;
195
+ return false;
196
+ }
197
+
198
+ // Exactly two ASCII letters, in either case. Deliberately not a character
199
+ // class over "letters": a fullwidth U+FF35 or a Cyrillic U+0413 renders as a
200
+ // Latin letter and must not pass.
201
+ function _isTwoAsciiLetters(input) {
202
+ if (input.length !== 2) return false;
203
+ for (var i = 0; i < 2; i += 1) {
204
+ if (!codepointClass.isAsciiLetter(input.charCodeAt(i))) return false;
205
+ }
206
+ return true;
207
+ }
208
+
209
+ function _upper(input) {
210
+ var out = "";
211
+ for (var i = 0; i < input.length; i += 1) {
212
+ var cc = input.charCodeAt(i);
213
+ out += (cc >= 0x61 && cc <= 0x7A) ? String.fromCharCode(cc - 32) : input.charAt(i);
214
+ }
215
+ return out;
216
+ }
217
+
218
+ var POLICY_VALUES = ["reject", "audit", "allow"];
219
+
220
+ // These decide whether a code is refused, so a value outside the enum is
221
+ // config-time input to throw on, not something to interpret. Read leniently, a
222
+ // typo took the "not reject" branch and turned a refusal into an audit entry:
223
+ // `{ reservedPolicy: "rejcet" }` served `EU` and reported ok.
224
+ //
225
+ // Declared as `enumOpts` so the check runs where options are RESOLVED. Putting
226
+ // it on the detection path would have left gate() and resolveOpts() accepting
227
+ // the typo at boot and only failing once requests arrived, which is the
228
+ // opposite of what a config-time refusal is for.
229
+ var POLICY_ENUM = {
230
+ reservedPolicy: POLICY_VALUES,
231
+ userAssignedPolicy: POLICY_VALUES,
232
+ formerlyUsedPolicy: POLICY_VALUES,
233
+ };
234
+
235
+ function _severity(policy) { return policy === "reject" ? "high" : "warn"; }
236
+
237
+ // `allow` means the operator has decided this class of code is fine here - a
238
+ // residency router that deliberately accepts `EU`, say. Emitting a warn finding
239
+ // anyway made `allow` and `audit` the same setting, because any finding at all
240
+ // dispositions the gate to audit-only. The rest of the family skips the emit
241
+ // when the policy says allow; this says the same thing once for all three
242
+ // classes rather than at each of their emit sites.
243
+ function _allowed(policy) { return policy === "allow"; }
244
+
245
+ function _detectIssues(input, opts) {
246
+ var pre = gateContract.detectStringInput(input, opts, {
247
+ name: "country", cap: { bytes: opts.maxBytes },
248
+ });
249
+ if (pre.done) return pre.issues;
250
+ var issues = pre.issues;
251
+
252
+ if (!_isTwoAsciiLetters(input)) {
253
+ issues.push({
254
+ kind: "country-shape", severity: "high",
255
+ ruleId: "country.country-shape",
256
+ snippet: "input is not two ASCII letters, so it cannot be an " +
257
+ "ISO 3166-1 alpha-2 code",
258
+ });
259
+ return issues;
260
+ }
261
+
262
+ var code = _upper(input);
263
+ if (ASSIGNED.has(code)) return issues;
264
+
265
+ // Not assigned. Say WHICH non-assignment it is, but only where a record can
266
+ // be pointed at. ISO also reserves codes this guard has no machine-readable
267
+ // source for (the indeterminately reserved set), so an unrecognised code is
268
+ // reported as not-assigned rather than claimed to be unassigned - the
269
+ // second is a statement about ISO that this table cannot support.
270
+ if (Object.prototype.hasOwnProperty.call(COMMON_MISTAKES, code)) {
271
+ issues.push({
272
+ kind: "country-not-assigned", severity: "high",
273
+ ruleId: "country.country-not-assigned",
274
+ snippet: "`" + code + "` is not an ISO 3166-1 alpha-2 code; the code " +
275
+ "for that country is `" + COMMON_MISTAKES[code] + "`",
276
+ });
277
+ return issues;
278
+ }
279
+
280
+ if (_isUserAssigned(code)) {
281
+ if (_allowed(opts.userAssignedPolicy)) return issues;
282
+ issues.push({
283
+ kind: "country-user-assigned", severity: _severity(opts.userAssignedPolicy),
284
+ ruleId: "country.country-user-assigned",
285
+ snippet: "`" + code + "` is in an ISO 3166-1 user-assigned range " +
286
+ "(AA, QM-QZ, XA-XZ, ZZ) - reserved for private use, so it " +
287
+ "means whatever the system that wrote it decided",
288
+ });
289
+ return issues;
290
+ }
291
+
292
+ if (Object.prototype.hasOwnProperty.call(EXCEPTIONALLY_RESERVED, code)) {
293
+ if (_allowed(opts.reservedPolicy)) return issues;
294
+ issues.push({
295
+ kind: "country-exceptionally-reserved", severity: _severity(opts.reservedPolicy),
296
+ ruleId: "country.country-exceptionally-reserved",
297
+ snippet: "`" + code + "` is exceptionally reserved for " +
298
+ EXCEPTIONALLY_RESERVED[code] + ", which is not a country",
299
+ });
300
+ return issues;
301
+ }
302
+
303
+ if (Object.prototype.hasOwnProperty.call(FORMERLY_USED, code)) {
304
+ if (_allowed(opts.formerlyUsedPolicy)) return issues;
305
+ var successor = FORMERLY_USED[code];
306
+ issues.push({
307
+ kind: "country-formerly-used", severity: _severity(opts.formerlyUsedPolicy),
308
+ ruleId: "country.country-formerly-used",
309
+ snippet: "`" + code + "` is a formerly used code, withdrawn from " +
310
+ "ISO 3166-1" + (successor
311
+ ? "; the registry records it as replaced by `" + successor + "`"
312
+ : "; no single successor is recorded"),
313
+ });
314
+ return issues;
315
+ }
316
+
317
+ issues.push({
318
+ kind: "country-not-assigned", severity: "high",
319
+ ruleId: "country.country-not-assigned",
320
+ snippet: "`" + code + "` is not an officially assigned ISO 3166-1 " +
321
+ "alpha-2 code",
322
+ });
323
+ return issues;
324
+ }
325
+
326
+ /**
327
+ * @primitive b.guardCountry.validate
328
+ * @signature b.guardCountry.validate(input, opts?)
329
+ * @since 0.18.44
330
+ * @status stable
331
+ * @compliance hipaa, pci-dss, gdpr, soc2
332
+ * @related b.guardCountry.sanitize, b.guardCountry.isValid, b.guardCountry.gate
333
+ *
334
+ * Inspect a country code against the resolved profile and return
335
+ * `{ ok, issues }`. Each issue carries `kind` / `severity`
336
+ * (`critical` | `high` | `medium` | `low`) / `ruleId` / `snippet`.
337
+ * Non-string input returns a single `country.bad-input` issue
338
+ * rather than throwing - callers that prefer an exception use
339
+ * `b.guardCountry.sanitize`.
340
+ *
341
+ * Issue kinds name only what a record can be pointed at:
342
+ * `country-user-assigned`, `country-exceptionally-reserved`,
343
+ * `country-formerly-used`, and `country-not-assigned` for anything
344
+ * else well-formed. There is deliberately no "unassigned" kind -
345
+ * ISO reserves codes this table has no source for, so the stronger
346
+ * claim would be unsupported.
347
+ *
348
+ * @opts
349
+ * profile: "strict"|"balanced"|"permissive",
350
+ * compliancePosture: "hipaa"|"pci-dss"|"gdpr"|"soc2",
351
+ * bidiPolicy: "reject"|"strip"|"audit"|"allow",
352
+ * controlPolicy: "reject"|"strip"|"allow",
353
+ * nullBytePolicy: "reject"|"strip"|"allow",
354
+ * zeroWidthPolicy: "reject"|"strip"|"allow",
355
+ * reservedPolicy: "reject"|"audit"|"allow",
356
+ * userAssignedPolicy: "reject"|"audit"|"allow",
357
+ * formerlyUsedPolicy: "reject"|"audit"|"allow",
358
+ * maxBytes: number,
359
+ *
360
+ * @example
361
+ * b.guardCountry.validate("GB", { profile: "strict" }).ok; // -> true
362
+ *
363
+ * var uk = b.guardCountry.validate("UK", { profile: "strict" });
364
+ * uk.ok; // -> false
365
+ * uk.issues[0].ruleId; // -> "country.country-not-assigned"
366
+ */
367
+ // validate is assembled by gateContract.defineGuard from `detect`
368
+ // (_detectIssues) above.
369
+
370
+ /**
371
+ * @primitive b.guardCountry.sanitize
372
+ * @signature b.guardCountry.sanitize(input, opts?)
373
+ * @since 0.18.44
374
+ * @status stable
375
+ * @related b.guardCountry.validate, b.guardCountry.isValid
376
+ *
377
+ * Normalize a country code to its canonical uppercase form and
378
+ * return it. Throws `GuardCountryError` when any `critical` or
379
+ * `high` issue fires - a malformed shape, or a code the resolved
380
+ * profile refuses. Use `validate` to inspect issues without
381
+ * throwing.
382
+ *
383
+ * @opts
384
+ * profile: "strict"|"balanced"|"permissive",
385
+ * compliancePosture: "hipaa"|"pci-dss"|"gdpr"|"soc2",
386
+ * ...: same shape as b.guardCountry.validate opts,
387
+ *
388
+ * @example
389
+ * b.guardCountry.sanitize("gb", { profile: "strict" }); // -> "GB"
390
+ *
391
+ * try {
392
+ * b.guardCountry.sanitize("ZZ", { profile: "strict" });
393
+ * } catch (e) {
394
+ * e.code; // -> "country.country-user-assigned"
395
+ * }
396
+ */
397
+ function _sanitizeTransform(input) {
398
+ return _upper(input);
399
+ }
400
+
401
+ /**
402
+ * @primitive b.guardCountry.isValid
403
+ * @signature b.guardCountry.isValid(input)
404
+ * @since 0.18.44
405
+ * @status stable
406
+ * @related b.guardCountry.validate, b.guardCountry.sanitize
407
+ *
408
+ * Whether `input` is an officially assigned ISO 3166-1 alpha-2
409
+ * code, as a boolean. Case-insensitive; never throws, so a
410
+ * non-string is `false` rather than an exception.
411
+ *
412
+ * Takes NO profile, deliberately. The profiles differ in how they
413
+ * DISPOSE of a non-assigned code — `strict` refuses it, `balanced`
414
+ * and `permissive` downgrade some findings to a warning — and none
415
+ * of them changes whether ISO assigned the code. A predicate that
416
+ * accepted a profile would answer `true` for `EU`, `ZZ` or `SU`
417
+ * under `permissive`, which is exactly the reserved-and-sentinel
418
+ * value a residency or jurisdiction decision must never act on.
419
+ *
420
+ * Reach for `validate(input, { profile })` when the
421
+ * profile-dependent disposition is what you want, and for the
422
+ * reason a code was refused.
423
+ *
424
+ * @example
425
+ * b.guardCountry.isValid("gb"); // -> true
426
+ * b.guardCountry.isValid("UK"); // -> false
427
+ * b.guardCountry.isValid("ZZ"); // -> false
428
+ * b.guardCountry.isValid(null); // -> false
429
+ */
430
+ // Hostile: ZZ - the CLDR unknown-region sentinel, refused at strict because a
431
+ // routing decision must never act on "we do not know".
432
+ var INTEGRATION_FIXTURES = gateContract.identifierFixtures("GB", "ZZ");
433
+
434
+ // Assembled from the gate-contract guard factory: error class, registry
435
+ // exports (NAME / KIND / INTEGRATION_FIXTURES), buildProfile /
436
+ // compliancePosture / loadRulePack wiring, plus validate / sanitize. The
437
+ // gate is the factory default - serve -> audit-only -> refuse - reading
438
+ // ctx.identifier || ctx.country || ctx.countryCode via ctxFields.
439
+ //
440
+ // The factory call is the module.exports initializer rather than a variable
441
+ // the file exports afterwards: the wiki's source-doc parser detects a
442
+ // guard-family factory only in that position, and a guard it cannot see gets
443
+ // no generated ABI page.
444
+ module.exports = gateContract.defineGuard({
445
+ name: "country",
446
+ kind: "identifier",
447
+ errorClass: GuardCountryError,
448
+ profiles: PROFILES,
449
+ defaults: DEFAULTS,
450
+ postures: COMPLIANCE_POSTURES,
451
+ integrationFixtures: INTEGRATION_FIXTURES,
452
+ detect: _detectIssues,
453
+ sanitizeTransform: _sanitizeTransform,
454
+ intOpts: ["maxBytes"],
455
+ enumOpts: POLICY_ENUM,
456
+ ctxFields: ["identifier", "country", "countryCode"],
457
+ extra: {
458
+ // The assigned set, exposed so a consumer can populate a picker from the
459
+ // same table the guard answers from rather than shipping a second one.
460
+ ASSIGNED_CODES: ASSIGNED_CODES,
461
+ // Answers membership in the assigned table directly, and takes NO opts.
462
+ //
463
+ // Forwarding a profile here was wrong: the profiles differ only in how
464
+ // they DISPOSE of a non-assigned code — strict refuses, balanced and
465
+ // permissive downgrade some findings to `warn`, which leaves
466
+ // validate().ok true. A predicate built on that reported `EU`, `ZZ`, `SU`
467
+ // and `AN` as valid under permissive, contradicting its own documented
468
+ // promise and feeding reserved and sentinel values into exactly the
469
+ // residency and jurisdiction routing this guard exists to protect.
470
+ //
471
+ // "Is this an officially assigned code" is one question with one answer,
472
+ // so it is answered from the table rather than from a policy that was
473
+ // never about assignment. A caller who wants the profile-dependent
474
+ // disposition calls validate.
475
+ isValid: function isValid(input) {
476
+ if (typeof input !== "string" || !_isTwoAsciiLetters(input)) return false;
477
+ return ASSIGNED.has(_upper(input));
478
+ },
479
+ },
480
+ });
package/lib/guard-csv.js CHANGED
@@ -1428,6 +1428,11 @@ module.exports = gateContract.defineGuard({
1428
1428
  sanitizeTransform: _stripIssues,
1429
1429
  sanitizeSeverities: [],
1430
1430
  sanitizeAmplificationCap: "sanitizeAmplificationCap",
1431
+ // The options that are genuinely CAPS, where zero is not a setting. Declared
1432
+ // so a caller learns about `maxRows: 0` at the call that sets it rather than
1433
+ // from a parse that refuses every row. maxRuntimeMs is deliberately absent:
1434
+ // zero there means no runtime budget.
1435
+ intOpts: ["maxRows", "maxColumns", "maxCellBytes", "maxTotalBytes"],
1431
1436
  gate: gate,
1432
1437
  extra: {
1433
1438
  _gateDispositionForTest: _gateDispositionFor,
@@ -433,14 +433,10 @@ function _stripOuterQuotes(s) {
433
433
  return out;
434
434
  }
435
435
 
436
+ // Delegates to the guard's own resolver rather than repeating its binding, so
437
+ // every entry point below is held to this guard's cap list. See guard-archive.
436
438
  function _resolveOpts(opts) {
437
- return gateContract.resolveProfileAndPosture(opts, {
438
- profiles: PROFILES,
439
- compliancePostures: COMPLIANCE_POSTURES,
440
- defaults: DEFAULTS,
441
- errorClass: GuardEmailError,
442
- errCodePrefix: "email",
443
- });
439
+ return module.exports.resolveOpts(opts);
444
440
  }
445
441
 
446
442
  // ---- Address validation ----
@@ -1131,6 +1127,10 @@ module.exports = gateContract.defineGuard({
1131
1127
  integrationFixtures: INTEGRATION_FIXTURES,
1132
1128
  validate: validate,
1133
1129
  sanitize: sanitize,
1130
+ // Genuine caps, where zero is not a setting — see guard-csv. maxRuntimeMs is
1131
+ // deliberately absent: zero there means no runtime budget.
1132
+ intOpts: ["maxBytes", "maxLocalPartBytes", "maxDomainBytes",
1133
+ "maxAddressBytes", "maxHeaderLineBytes", "maxHeaders"],
1134
1134
  gate: gate,
1135
1135
  extra: {
1136
1136
  validateAddress: validateAddress,
@@ -211,14 +211,10 @@ var COMPLIANCE_POSTURES = gateContract.compliancePostures(PROFILES, { base: 256,
211
211
 
212
212
  // ---- Helpers ----
213
213
 
214
+ // Delegates to the guard's own resolver rather than repeating its binding, so
215
+ // every entry point below is held to this guard's cap list. See guard-archive.
214
216
  function _resolveOpts(opts) {
215
- return gateContract.resolveProfileAndPosture(opts, {
216
- profiles: PROFILES,
217
- compliancePostures: COMPLIANCE_POSTURES,
218
- defaults: DEFAULTS,
219
- errorClass: GuardFilenameError,
220
- errCodePrefix: "filename",
221
- });
217
+ return module.exports.resolveOpts(opts);
222
218
  }
223
219
 
224
220
  function _normalizeNFC(s) {
@@ -959,8 +955,11 @@ function gate(opts) {
959
955
  opts,
960
956
  async function (ctx) {
961
957
  // Filename-shape ctx — operator passes filename via ctx.filename.
962
- var name = ctx && (ctx.filename || ctx.name || "");
963
- if (!name) return { ok: true, action: "serve" };
958
+ // Read through the shared reader rather than an `||` chain: the chain
959
+ // cannot tell an ABSENT field from one present as "", and served both,
960
+ // while validate("") refuses an empty filename.
961
+ var name = gateContract.ctxValueFrom(ctx, ["filename", "name"]);
962
+ if (name === undefined || name === null) return { ok: true, action: "serve" };
964
963
  var rv = validate(name, opts);
965
964
  if (rv.issues.length === 0) return { ok: true, action: "serve" };
966
965
 
@@ -1314,6 +1313,9 @@ module.exports = gateContract.defineGuard({
1314
1313
  integrationFixtures: INTEGRATION_FIXTURES,
1315
1314
  validate: validate,
1316
1315
  sanitize: sanitize,
1316
+ // Genuine caps, where zero is not a setting — see guard-csv. maxRuntimeMs is
1317
+ // deliberately absent: zero there means no runtime budget.
1318
+ intOpts: ["maxBytes", "maxComponents"],
1317
1319
  gate: gate,
1318
1320
  extra: {
1319
1321
  WIN_RESERVED_NAMES: WIN_RESERVED_NAMES,
@@ -540,9 +540,7 @@ function _detectIssues(req, opts) {
540
540
  // generated sanitize AFTER resolve → detect → throw-on-refusal. GraphQL
541
541
  // request bundles can't be partially repaired; once detection passes with no
542
542
  // critical/high issue, the input is returned unchanged.
543
- function _sanitizeTransform(input) {
544
- return input;
545
- }
543
+ var _sanitizeTransform = gateContract.identitySanitize;
546
544
 
547
545
  /**
548
546
  * @primitive b.guardGraphql.gate
package/lib/guard-html.js CHANGED
@@ -283,14 +283,10 @@ var COMPLIANCE_POSTURES = gateContract.compliancePostures(PROFILES, { base: 256
283
283
 
284
284
  // ---- Internal helpers ----
285
285
 
286
+ // Delegates to the guard's own resolver rather than repeating its binding, so
287
+ // every entry point below is held to this guard's cap list. See guard-archive.
286
288
  function _resolveOpts(opts) {
287
- return gateContract.resolveProfileAndPosture(opts, {
288
- profiles: PROFILES,
289
- compliancePostures: COMPLIANCE_POSTURES,
290
- defaults: DEFAULTS,
291
- errorClass: GuardHtmlError,
292
- errCodePrefix: "html",
293
- });
289
+ return module.exports.resolveOpts(opts);
294
290
  }
295
291
 
296
292
  /**
@@ -996,6 +992,9 @@ module.exports = gateContract.defineGuard({
996
992
  integrationFixtures: INTEGRATION_FIXTURES,
997
993
  validate: validate,
998
994
  sanitize: sanitize,
995
+ // Genuine caps, where zero is not a setting — see guard-csv. maxRuntimeMs is
996
+ // deliberately absent: zero there means no runtime budget.
997
+ intOpts: ["maxBytes", "maxAttrValueBytes", "maxTagDepth", "maxAttrsPerTag"],
999
998
  gate: gate,
1000
999
  extra: {
1001
1000
  _gateDispositionForTest: _gateDispositionFor,
@@ -154,6 +154,13 @@ var PROFILES = Object.freeze({
154
154
 
155
155
  var DEFAULTS = gateContract.strictDefaults(PROFILES);
156
156
 
157
+ // The options that are genuinely CAPS, where zero is not a setting. Named once
158
+ // because this guard's gate binds its own resolver and must hold a caller to
159
+ // the same rules the generated validate() does. Everything else numeric is
160
+ // derived from DEFAULTS and held only to being a non-negative integer —
161
+ // maxRuntimeMs among them, where zero means no runtime budget.
162
+ var INT_OPTS = ["maxBytes", "maxWidth", "maxHeight", "maxFrames"];
163
+
157
164
  var COMPLIANCE_POSTURES = gateContract.compliancePostures(PROFILES, { base: 256 });
158
165
 
159
166
  function _bytesAt(buf, offset, sig) {
@@ -662,6 +669,13 @@ function gate(opts) {
662
669
  defaults: DEFAULTS,
663
670
  errorClass: GuardImageError,
664
671
  errCodePrefix: "image",
672
+ // This gate binds its own resolver, so it declares its own limits. Without
673
+ // them a malformed limit reaches the comparisons as NaN and disables the
674
+ // bound that validate() would have refused. Both lists, and the SAME cap
675
+ // list the module exports, so building a gate and calling validate hold a
676
+ // caller to identical rules.
677
+ intOpts: INT_OPTS,
678
+ nonNegativeOpts: gateContract.capKeysOf(DEFAULTS),
665
679
  });
666
680
  return gateContract.buildGuardGate(
667
681
  opts.name || "guardImage:" + (opts.profile || "default"),
@@ -743,7 +757,7 @@ module.exports = gateContract.defineGuard({
743
757
  // contract hands the bag straight to detect (which owns its own bad-input).
744
758
  detect: _detectIssues,
745
759
  sanitizeTransform: _sanitizeTransform,
746
- intOpts: ["maxBytes", "maxWidth", "maxHeight", "maxFrames"],
760
+ intOpts: INT_OPTS,
747
761
  gate: gate,
748
762
  extra: {
749
763
  inspectMagic: inspectMagic,
package/lib/guard-json.js CHANGED
@@ -388,14 +388,10 @@ var COMPLIANCE_POSTURES = gateContract.compliancePostures(PROFILES, { base: 256
388
388
 
389
389
  // ---- Helpers ----
390
390
 
391
+ // Delegates to the guard's own resolver rather than repeating its binding, so
392
+ // every entry point below is held to this guard's cap list. See guard-archive.
391
393
  function _resolveOpts(opts) {
392
- return gateContract.resolveProfileAndPosture(opts, {
393
- profiles: PROFILES,
394
- compliancePostures: COMPLIANCE_POSTURES,
395
- defaults: DEFAULTS,
396
- errorClass: GuardJsonError,
397
- errCodePrefix: "json",
398
- });
394
+ return module.exports.resolveOpts(opts);
399
395
  }
400
396
 
401
397
  function _isPollutionKey(key) {