@tracepass/dpp-identifiers 0.9.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,200 @@
1
+ /**
2
+ * Operator and facility identifier validation per EN 18219.
3
+ *
4
+ * validateOperatorIdentifier — EN 18219 schemes 6–9:
5
+ * iso6523: icd 4 digits; special ICDs validated:
6
+ * 0199 (LEI) — 20 chars [A-Z0-9]{18}[0-9]{2}, ISO 7064 MOD 97-10 check.
7
+ * 0088 (GLN) — 13 digits, GS1 mod-10 check digit.
8
+ * 0060 (DUNS) — 9 digits (no check digit).
9
+ * others — non-empty value.
10
+ * gln: 13 digits, GS1 mod-10 check digit (via gs1-utils).
11
+ * did: W3C DID Core syntax (method [a-z0-9]+, method-specific-id valid).
12
+ * doi: prefix 10.<registrant>/<suffix>, accepts doi:/https://doi.org/ prefixes.
13
+ *
14
+ * validateFacilityIdentifier — EN 18219 scheme 7 (GLN only; no other heading
15
+ * names facilities). The optional `extension` field identifies a sub-location
16
+ * (GS1 SGLN extension component).
17
+ */
18
+ import { validateGln } from "@tracepass/gs1-utils";
19
+ import { normalizeDoi } from "./product.js";
20
+ // ── ISO 7064 MOD 97-10 check for LEI ─────────────────────────────────────────
21
+ /**
22
+ * Verify an LEI's ISO 7064 MOD 97-10 check digits.
23
+ *
24
+ * LEI format: [A-Z0-9]{18}[0-9]{2}
25
+ * Conversion: each letter → its ordinal (A=10, B=11, …, Z=35); each digit stays.
26
+ * The concatenated number mod 97 must equal 1.
27
+ *
28
+ * Processing in digit-by-digit chunks avoids BigInt or a full string-to-number
29
+ * conversion (a 20-char LEI expands to up to 34 decimal digits).
30
+ */
31
+ function verifyLeiCheckDigits(lei) {
32
+ // Build the full digit string (letters → 2-digit ordinals, digits → as-is)
33
+ const digits = lei
34
+ .split("")
35
+ .map((c) => {
36
+ const code = c.charCodeAt(0);
37
+ // A–Z (65–90) → 10–35; 0–9 (48–57) → 0–9
38
+ return code >= 65 ? String(code - 55) : c;
39
+ })
40
+ .join("");
41
+ // Compute the large number mod 97 by iterating one digit at a time
42
+ let remainder = 0;
43
+ for (let i = 0; i < digits.length; i++) {
44
+ remainder = (remainder * 10 + Number(digits[i])) % 97;
45
+ }
46
+ return remainder === 1;
47
+ }
48
+ // ── DID validation (shared with product.ts but not exported there) ────────────
49
+ function isValidDidSegment(seg) {
50
+ if (!seg)
51
+ return false;
52
+ let i = 0;
53
+ while (i < seg.length) {
54
+ const c = seg[i];
55
+ if (/[A-Za-z0-9._\-]/.test(c)) {
56
+ i++;
57
+ }
58
+ else if (c === "%" &&
59
+ i + 2 < seg.length &&
60
+ /^[0-9A-Fa-f]{2}$/.test(seg.slice(i + 1, i + 3))) {
61
+ i += 3;
62
+ }
63
+ else {
64
+ return false;
65
+ }
66
+ }
67
+ return true;
68
+ }
69
+ function validateDidString(did) {
70
+ const errors = [];
71
+ if (!did.startsWith("did:")) {
72
+ errors.push("DID must start with 'did:'");
73
+ return errors;
74
+ }
75
+ const rest = did.slice(4);
76
+ const firstColon = rest.indexOf(":");
77
+ if (firstColon <= 0) {
78
+ errors.push("DID must have a method and a method-specific-id separated by ':'");
79
+ return errors;
80
+ }
81
+ const method = rest.slice(0, firstColon);
82
+ const methodSpecificId = rest.slice(firstColon + 1);
83
+ if (!/^[a-z0-9]+$/.test(method)) {
84
+ errors.push(`DID method '${method}' must match [a-z0-9]+`);
85
+ }
86
+ if (!methodSpecificId) {
87
+ errors.push("DID method-specific-id is empty");
88
+ return errors;
89
+ }
90
+ if (methodSpecificId.endsWith(":")) {
91
+ errors.push("DID method-specific-id must not end with ':'");
92
+ }
93
+ for (const seg of methodSpecificId.split(":")) {
94
+ if (!isValidDidSegment(seg)) {
95
+ errors.push(`DID method-specific-id segment '${seg}' contains invalid characters`);
96
+ }
97
+ }
98
+ return errors;
99
+ }
100
+ // ── iso6523 ───────────────────────────────────────────────────────────────────
101
+ function validateIso6523(icd, value) {
102
+ const errors = [];
103
+ if (!/^\d{4}$/.test(icd)) {
104
+ errors.push("iso6523 icd must be exactly 4 digits");
105
+ }
106
+ if (!value) {
107
+ errors.push("iso6523 value must not be empty");
108
+ return { ok: false, errors };
109
+ }
110
+ // ICD-specific rules
111
+ if (icd === "0199") {
112
+ // LEI (ISO 17442): [A-Z0-9]{18}[0-9]{2}, ISO 7064 MOD 97-10 check
113
+ if (!/^[A-Z0-9]{18}[0-9]{2}$/.test(value)) {
114
+ errors.push("LEI (icd 0199) must be 20 characters: 18 upper-case alphanumerics followed by 2 digits");
115
+ }
116
+ else if (!verifyLeiCheckDigits(value)) {
117
+ errors.push("LEI (icd 0199) check digits fail ISO 7064 MOD 97-10 verification");
118
+ }
119
+ }
120
+ else if (icd === "0088") {
121
+ // GLN (ISO/IEC 15418 GS1 AI 414/417): 13 digits, mod-10 check
122
+ if (!validateGln(value)) {
123
+ errors.push("GLN (icd 0088) must be a valid 13-digit GLN with a correct mod-10 check digit");
124
+ }
125
+ }
126
+ else if (icd === "0060") {
127
+ // DUNS: 9 digits (no check digit defined in the public spec)
128
+ if (!/^\d{9}$/.test(value)) {
129
+ errors.push("DUNS (icd 0060) must be exactly 9 digits");
130
+ }
131
+ }
132
+ // All other ICDs: non-empty value is sufficient (already checked above).
133
+ if (errors.length > 0)
134
+ return { ok: false, errors };
135
+ return { ok: true, value: { scheme: "iso6523", icd, value } };
136
+ }
137
+ // ── Public API ────────────────────────────────────────────────────────────────
138
+ /**
139
+ * Validate a `OperatorIdentifier` against EN 18219 rules.
140
+ * Returns the normalised identifier on success or an error list on failure.
141
+ */
142
+ export function validateOperatorIdentifier(id) {
143
+ switch (id.scheme) {
144
+ case "iso6523":
145
+ return validateIso6523(id.icd, id.value);
146
+ case "gln": {
147
+ if (!validateGln(id.gln)) {
148
+ return {
149
+ ok: false,
150
+ errors: ["gln must be a valid 13-digit GLN with a correct mod-10 check digit"],
151
+ };
152
+ }
153
+ return { ok: true, value: id };
154
+ }
155
+ case "did": {
156
+ const didErrors = validateDidString(id.did);
157
+ if (didErrors.length > 0)
158
+ return { ok: false, errors: didErrors };
159
+ return { ok: true, value: id };
160
+ }
161
+ case "doi": {
162
+ const bare = normalizeDoi(id.doi);
163
+ if (!bare) {
164
+ return {
165
+ ok: false,
166
+ errors: [
167
+ `doi '${id.doi}' must match 10.<4–9 registrant digits>/<suffix> per the DOI Handbook`,
168
+ ],
169
+ };
170
+ }
171
+ return { ok: true, value: { ...id, doi: bare } };
172
+ }
173
+ default: {
174
+ // Exhaustiveness guard
175
+ const _exhaustive = id;
176
+ void _exhaustive;
177
+ return { ok: false, errors: ["unknown operator identifier scheme"] };
178
+ }
179
+ }
180
+ }
181
+ /**
182
+ * Validate a `FacilityIdentifier` against EN 18219 rules.
183
+ * EN 18219 scheme 7 names GLN as the only facility scheme.
184
+ *
185
+ * The optional `extension` field is a sub-location identifier following the
186
+ * GS1 SGLN extension component convention (non-empty when present).
187
+ */
188
+ export function validateFacilityIdentifier(id) {
189
+ const errors = [];
190
+ if (!validateGln(id.gln)) {
191
+ errors.push("gln must be a valid 13-digit GLN with a correct mod-10 check digit");
192
+ }
193
+ if (id.extension !== undefined && !id.extension) {
194
+ errors.push("extension, when present, must not be empty");
195
+ }
196
+ if (errors.length > 0)
197
+ return { ok: false, errors };
198
+ return { ok: true, value: id };
199
+ }
200
+ //# sourceMappingURL=operator.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"operator.js","sourceRoot":"","sources":["../src/operator.ts"],"names":[],"mappings":"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"}
@@ -0,0 +1,30 @@
1
+ /**
2
+ * Helpers that operate on a Passport object and its identifier fields.
3
+ *
4
+ * `resolveProductIdentifier` — read the canonical product identifier from a
5
+ * passport, handling the `identifier`/`gs1` precedence without the caller
6
+ * having to repeat it. Precedence (highest first):
7
+ * 1. `passport.identifier` — the scheme-tagged EN 18219 field (new primary).
8
+ * 2. `passport.gs1` — the legacy GS1-only field, lifted into a `Gs1Identifier`.
9
+ * 3. `undefined` — no identifier is set.
10
+ */
11
+ import type { Passport, ProductIdentifier } from "@tracepass/dpp-types";
12
+ /**
13
+ * Return the canonical `ProductIdentifier` for a passport.
14
+ *
15
+ * Prefers the scheme-tagged `identifier` field (EN 18219). Falls back to
16
+ * lifting the deprecated `gs1` field into a `Gs1Identifier`. Returns
17
+ * `undefined` when neither is set.
18
+ *
19
+ * Use this instead of reading `passport.identifier ?? liftGs1(passport.gs1)`
20
+ * directly — it centralises the precedence rule so callers stay correct when
21
+ * new fallback logic is added.
22
+ *
23
+ * @example
24
+ * ```ts
25
+ * const id = resolveProductIdentifier(passport);
26
+ * if (!id) throw new Error("Passport has no product identifier");
27
+ * ```
28
+ */
29
+ export declare function resolveProductIdentifier(passport: Passport): ProductIdentifier | undefined;
30
+ //# sourceMappingURL=passport.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"passport.d.ts","sourceRoot":"","sources":["../src/passport.ts"],"names":[],"mappings":"AAAA;;;;;;;;;GASG;AAEH,OAAO,KAAK,EAAE,QAAQ,EAAE,iBAAiB,EAAiB,MAAM,sBAAsB,CAAC;AAEvF;;;;;;;;;;;;;;;;GAgBG;AACH,wBAAgB,wBAAwB,CAAC,QAAQ,EAAE,QAAQ,GAAG,iBAAiB,GAAG,SAAS,CAkB1F"}
@@ -0,0 +1,46 @@
1
+ /**
2
+ * Helpers that operate on a Passport object and its identifier fields.
3
+ *
4
+ * `resolveProductIdentifier` — read the canonical product identifier from a
5
+ * passport, handling the `identifier`/`gs1` precedence without the caller
6
+ * having to repeat it. Precedence (highest first):
7
+ * 1. `passport.identifier` — the scheme-tagged EN 18219 field (new primary).
8
+ * 2. `passport.gs1` — the legacy GS1-only field, lifted into a `Gs1Identifier`.
9
+ * 3. `undefined` — no identifier is set.
10
+ */
11
+ /**
12
+ * Return the canonical `ProductIdentifier` for a passport.
13
+ *
14
+ * Prefers the scheme-tagged `identifier` field (EN 18219). Falls back to
15
+ * lifting the deprecated `gs1` field into a `Gs1Identifier`. Returns
16
+ * `undefined` when neither is set.
17
+ *
18
+ * Use this instead of reading `passport.identifier ?? liftGs1(passport.gs1)`
19
+ * directly — it centralises the precedence rule so callers stay correct when
20
+ * new fallback logic is added.
21
+ *
22
+ * @example
23
+ * ```ts
24
+ * const id = resolveProductIdentifier(passport);
25
+ * if (!id) throw new Error("Passport has no product identifier");
26
+ * ```
27
+ */
28
+ export function resolveProductIdentifier(passport) {
29
+ // 1. New scheme-tagged field (EN 18219) — always preferred.
30
+ if (passport.identifier)
31
+ return passport.identifier;
32
+ // 2. Legacy GS1-only field — lifted into the ProductIdentifier union.
33
+ if (passport.gs1) {
34
+ const lifted = {
35
+ scheme: "gs1",
36
+ gtin: passport.gs1.gtin,
37
+ serialNumber: passport.gs1.serialNumber,
38
+ };
39
+ if (passport.gs1.digitalLinkUri !== undefined) {
40
+ lifted.digitalLinkUri = passport.gs1.digitalLinkUri;
41
+ }
42
+ return lifted;
43
+ }
44
+ return undefined;
45
+ }
46
+ //# sourceMappingURL=passport.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"passport.js","sourceRoot":"","sources":["../src/passport.ts"],"names":[],"mappings":"AAAA;;;;;;;;;GASG;AAIH;;;;;;;;;;;;;;;;GAgBG;AACH,MAAM,UAAU,wBAAwB,CAAC,QAAkB;IACzD,4DAA4D;IAC5D,IAAI,QAAQ,CAAC,UAAU;QAAE,OAAO,QAAQ,CAAC,UAAU,CAAC;IAEpD,sEAAsE;IACtE,IAAI,QAAQ,CAAC,GAAG,EAAE,CAAC;QACjB,MAAM,MAAM,GAAkB;YAC5B,MAAM,EAAE,KAAK;YACb,IAAI,EAAE,QAAQ,CAAC,GAAG,CAAC,IAAI;YACvB,YAAY,EAAE,QAAQ,CAAC,GAAG,CAAC,YAAY;SACxC,CAAC;QACF,IAAI,QAAQ,CAAC,GAAG,CAAC,cAAc,KAAK,SAAS,EAAE,CAAC;YAC9C,MAAM,CAAC,cAAc,GAAG,QAAQ,CAAC,GAAG,CAAC,cAAc,CAAC;QACtD,CAAC;QACD,OAAO,MAAM,CAAC;IAChB,CAAC;IAED,OAAO,SAAS,CAAC;AACnB,CAAC"}
@@ -0,0 +1,66 @@
1
+ /**
2
+ * Product identifier validation per EN 18219.
3
+ *
4
+ * `validateProductIdentifier` accepts any of the five product schemes and
5
+ * returns a normalised value on success or a list of errors on failure.
6
+ *
7
+ * Scheme constraints:
8
+ * - gs1: GTIN-8/12/13/14 + non-empty serial; GTIN normalised to 14 digits.
9
+ * - iso15459: issuingAgencyCode 1–3 upper-case alphanumerics, first char a letter;
10
+ * non-empty primaryId.
11
+ * - iec61406: https URL, ASCII-only, RFC 3986-valid.
12
+ * - did: W3C DID Core ABNF — method [a-z0-9]+, method-specific-id valid chars.
13
+ * - doi: prefix 10.<registrant>/<suffix>; accepts doi:/https://doi.org/ prefixes
14
+ * and normalises to bare form.
15
+ *
16
+ * Cross-cutting (EN 18219 clause 4.3.2): all identifier fields must use
17
+ * ISO/IEC 646 characters (printable ASCII, codepoints 0x20–0x7E only).
18
+ *
19
+ * Category restriction: `category: "battery"` allows only gs1 and iso15459
20
+ * (Battery Regulation Art. 77(3) requires ISO/IEC 15459 until a delegated
21
+ * act replaces it with EN 18219).
22
+ */
23
+ import type { ProductIdentifier } from "@tracepass/dpp-types";
24
+ /** Validation result — either a normalised identifier or an error list. */
25
+ export type ValidationResult<T> = {
26
+ ok: true;
27
+ value: T;
28
+ } | {
29
+ ok: false;
30
+ errors: string[];
31
+ };
32
+ /** Options for {@link validateProductIdentifier}. */
33
+ export interface ValidateProductOptions {
34
+ /**
35
+ * DPP category. Pass `"battery"` to restrict to ISO/IEC 15459-based schemes
36
+ * (gs1 and iso15459), which Battery Regulation Art. 77(3) requires.
37
+ */
38
+ category?: string;
39
+ }
40
+ /**
41
+ * Normalise a DOI to bare form (`10.<registrant>/<suffix>`).
42
+ *
43
+ * Accepts the following prefixes (case-insensitive):
44
+ * - `doi:10.…`
45
+ * - `https://doi.org/10.…` (canonical resolver)
46
+ * - `http://doi.org/10.…` (legacy http form)
47
+ * - `https://dx.doi.org/10.…` / `http://dx.doi.org/10.…` (legacy dx proxy)
48
+ *
49
+ * Returns `null` when:
50
+ * - the input contains non-ASCII characters (EN 18219 clause 4.3.2 / ISO/IEC 646)
51
+ * - the bare form does not match DOI Handbook syntax (`10.<4–9 digits>/<suffix>`)
52
+ */
53
+ export declare function normalizeDoi(input: string): string | null;
54
+ /**
55
+ * Validate a `ProductIdentifier` against EN 18219 rules.
56
+ *
57
+ * On success returns `{ ok: true, value }` with the normalised identifier
58
+ * (gtin padded to 14 digits; doi stripped of prefix and lowercased).
59
+ * On failure returns `{ ok: false, errors }` listing every problem found.
60
+ *
61
+ * Pass `{ category: "battery" }` to apply the additional Battery Regulation
62
+ * Art. 77(3) restriction: only `gs1` and `iso15459` are accepted, because
63
+ * DID, DOI and IEC 61406 are not ISO/IEC 15459 identifiers.
64
+ */
65
+ export declare function validateProductIdentifier(id: ProductIdentifier, options?: ValidateProductOptions): ValidationResult<ProductIdentifier>;
66
+ //# sourceMappingURL=product.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"product.d.ts","sourceRoot":"","sources":["../src/product.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;GAqBG;AAEH,OAAO,KAAK,EACV,iBAAiB,EAMlB,MAAM,sBAAsB,CAAC;AAG9B,2EAA2E;AAC3E,MAAM,MAAM,gBAAgB,CAAC,CAAC,IAC1B;IAAE,EAAE,EAAE,IAAI,CAAC;IAAC,KAAK,EAAE,CAAC,CAAA;CAAE,GACtB;IAAE,EAAE,EAAE,KAAK,CAAC;IAAC,MAAM,EAAE,MAAM,EAAE,CAAA;CAAE,CAAC;AAEpC,qDAAqD;AACrD,MAAM,WAAW,sBAAsB;IACrC;;;OAGG;IACH,QAAQ,CAAC,EAAE,MAAM,CAAC;CACnB;AAgGD;;;;;;;;;;;;GAYG;AACH,wBAAgB,YAAY,CAAC,KAAK,EAAE,MAAM,GAAG,MAAM,GAAG,IAAI,CAkBzD;AAgJD;;;;;;;;;;GAUG;AACH,wBAAgB,yBAAyB,CACvC,EAAE,EAAE,iBAAiB,EACrB,OAAO,GAAE,sBAA2B,GACnC,gBAAgB,CAAC,iBAAiB,CAAC,CA2BrC"}
@@ -0,0 +1,300 @@
1
+ /**
2
+ * Product identifier validation per EN 18219.
3
+ *
4
+ * `validateProductIdentifier` accepts any of the five product schemes and
5
+ * returns a normalised value on success or a list of errors on failure.
6
+ *
7
+ * Scheme constraints:
8
+ * - gs1: GTIN-8/12/13/14 + non-empty serial; GTIN normalised to 14 digits.
9
+ * - iso15459: issuingAgencyCode 1–3 upper-case alphanumerics, first char a letter;
10
+ * non-empty primaryId.
11
+ * - iec61406: https URL, ASCII-only, RFC 3986-valid.
12
+ * - did: W3C DID Core ABNF — method [a-z0-9]+, method-specific-id valid chars.
13
+ * - doi: prefix 10.<registrant>/<suffix>; accepts doi:/https://doi.org/ prefixes
14
+ * and normalises to bare form.
15
+ *
16
+ * Cross-cutting (EN 18219 clause 4.3.2): all identifier fields must use
17
+ * ISO/IEC 646 characters (printable ASCII, codepoints 0x20–0x7E only).
18
+ *
19
+ * Category restriction: `category: "battery"` allows only gs1 and iso15459
20
+ * (Battery Regulation Art. 77(3) requires ISO/IEC 15459 until a delegated
21
+ * act replaces it with EN 18219).
22
+ */
23
+ import { normalizeGtin } from "@tracepass/gs1-utils";
24
+ // ── Shared helpers ────────────────────────────────────────────────────────────
25
+ /**
26
+ * True when every character in `s` is a printable ASCII character (0x20–0x7E).
27
+ * EN 18219 clause 4.3.2 requires ISO/IEC 646 characters for all identifier fields.
28
+ */
29
+ function isAsciiPrintable(s) {
30
+ for (let i = 0; i < s.length; i++) {
31
+ const code = s.charCodeAt(i);
32
+ if (code < 0x20 || code > 0x7e)
33
+ return false;
34
+ }
35
+ return true;
36
+ }
37
+ /**
38
+ * True when `s` is RFC 3986-valid. Uses the platform-global `URL` constructor
39
+ * (available in Node ≥ 18, browsers and Deno) as a lightweight proxy — it
40
+ * throws on obvious syntax errors. The check is intentionally permissive:
41
+ * percent-encoded bytes are accepted so long as the `URL` constructor accepts them.
42
+ */
43
+ function isRfc3986Valid(s) {
44
+ try {
45
+ new URL(s);
46
+ return true;
47
+ }
48
+ catch {
49
+ return false;
50
+ }
51
+ }
52
+ // ── DID validation ────────────────────────────────────────────────────────────
53
+ /**
54
+ * True when `seg` is a valid DID method-specific-id segment.
55
+ * Per W3C DID Core: idchar = ALPHA / DIGIT / "." / "-" / "_" / pct-encoded.
56
+ */
57
+ function isValidDidSegment(seg) {
58
+ if (!seg)
59
+ return false;
60
+ let i = 0;
61
+ while (i < seg.length) {
62
+ const c = seg[i];
63
+ if (/[A-Za-z0-9._\-]/.test(c)) {
64
+ i++;
65
+ }
66
+ else if (c === "%" &&
67
+ i + 2 < seg.length &&
68
+ /^[0-9A-Fa-f]{2}$/.test(seg.slice(i + 1, i + 3))) {
69
+ i += 3;
70
+ }
71
+ else {
72
+ return false;
73
+ }
74
+ }
75
+ return true;
76
+ }
77
+ function validateDidString(did) {
78
+ const errors = [];
79
+ if (!did.startsWith("did:")) {
80
+ errors.push("DID must start with 'did:'");
81
+ return errors;
82
+ }
83
+ const rest = did.slice(4);
84
+ const firstColon = rest.indexOf(":");
85
+ if (firstColon <= 0) {
86
+ errors.push("DID must have a method and a method-specific-id separated by ':'");
87
+ return errors;
88
+ }
89
+ const method = rest.slice(0, firstColon);
90
+ const methodSpecificId = rest.slice(firstColon + 1);
91
+ if (!/^[a-z0-9]+$/.test(method)) {
92
+ errors.push(`DID method '${method}' must match [a-z0-9]+ (all lower-case, no hyphens)`);
93
+ }
94
+ if (!methodSpecificId) {
95
+ errors.push("DID method-specific-id is empty");
96
+ return errors;
97
+ }
98
+ if (methodSpecificId.endsWith(":")) {
99
+ errors.push("DID method-specific-id must not end with ':'");
100
+ }
101
+ const segments = methodSpecificId.split(":");
102
+ for (const seg of segments) {
103
+ if (!isValidDidSegment(seg)) {
104
+ errors.push(`DID method-specific-id segment '${seg}' contains invalid characters ` +
105
+ "(allowed: ALPHA, DIGIT, '.', '-', '_', pct-encoded)");
106
+ }
107
+ }
108
+ return errors;
109
+ }
110
+ // ── DOI normalisation ─────────────────────────────────────────────────────────
111
+ /**
112
+ * Normalise a DOI to bare form (`10.<registrant>/<suffix>`).
113
+ *
114
+ * Accepts the following prefixes (case-insensitive):
115
+ * - `doi:10.…`
116
+ * - `https://doi.org/10.…` (canonical resolver)
117
+ * - `http://doi.org/10.…` (legacy http form)
118
+ * - `https://dx.doi.org/10.…` / `http://dx.doi.org/10.…` (legacy dx proxy)
119
+ *
120
+ * Returns `null` when:
121
+ * - the input contains non-ASCII characters (EN 18219 clause 4.3.2 / ISO/IEC 646)
122
+ * - the bare form does not match DOI Handbook syntax (`10.<4–9 digits>/<suffix>`)
123
+ */
124
+ export function normalizeDoi(input) {
125
+ // EN 18219 clause 4.3.2: all identifier content must be ISO/IEC 646 (printable ASCII)
126
+ if (!isAsciiPrintable(input))
127
+ return null;
128
+ let bare = input.trim();
129
+ // Strip known prefixes (case-insensitive per the DOI Handbook).
130
+ // The (dx.)? alternative covers the legacy dx.doi.org proxy.
131
+ if (/^https?:\/\/(dx\.)?doi\.org\//i.test(bare)) {
132
+ bare = bare.replace(/^https?:\/\/(dx\.)?doi\.org\//i, "");
133
+ }
134
+ else if (/^doi:/i.test(bare)) {
135
+ bare = bare.replace(/^doi:/i, "");
136
+ }
137
+ // DOI Handbook syntax: prefix = 10.<4-9 digits>, suffix = at least one non-space char.
138
+ // Case-insensitive per the DOI Handbook; stored as lower-case.
139
+ if (!/^10\.\d{4,9}\/\S+$/i.test(bare))
140
+ return null;
141
+ return bare.toLowerCase();
142
+ }
143
+ // ── Per-scheme validators ─────────────────────────────────────────────────────
144
+ function validateGs1(id) {
145
+ const errors = [];
146
+ const gtin14 = normalizeGtin(id.gtin);
147
+ if (!gtin14) {
148
+ errors.push(`gtin '${id.gtin}' is not a valid GTIN-8/12/13/14 (must be all digits with a correct mod-10 check digit)`);
149
+ }
150
+ if (!id.serialNumber) {
151
+ errors.push("serialNumber must not be empty");
152
+ }
153
+ else if (!isAsciiPrintable(id.serialNumber)) {
154
+ errors.push("serialNumber must contain only printable ASCII characters (ISO/IEC 646)");
155
+ }
156
+ if (id.digitalLinkUri !== undefined && !isAsciiPrintable(id.digitalLinkUri)) {
157
+ errors.push("digitalLinkUri must contain only printable ASCII characters");
158
+ }
159
+ if (errors.length > 0)
160
+ return { ok: false, errors };
161
+ return {
162
+ ok: true,
163
+ value: {
164
+ ...id,
165
+ gtin: gtin14, // eslint-disable-line @typescript-eslint/no-non-null-assertion
166
+ },
167
+ };
168
+ }
169
+ function validateIso15459(id) {
170
+ const errors = [];
171
+ const code = id.issuingAgencyCode;
172
+ if (!code || code.length < 1 || code.length > 3) {
173
+ errors.push("issuingAgencyCode must be 1–3 characters");
174
+ }
175
+ else if (!/^[A-Z0-9]{1,3}$/.test(code)) {
176
+ errors.push("issuingAgencyCode must be upper-case letters and/or digits only");
177
+ }
178
+ else if (!/^[A-Z]/.test(code)) {
179
+ // GS1 holds the all-digit issuing agency code range (ISO/IEC 15459-2
180
+ // code allocation rule — unverified against the current register).
181
+ errors.push("issuingAgencyCode first character must be a letter " +
182
+ "(GS1 holds the all-digit range per the ISO/IEC 15459-2 code allocation)");
183
+ }
184
+ if (!id.primaryId) {
185
+ errors.push("primaryId must not be empty");
186
+ }
187
+ else if (!isAsciiPrintable(id.primaryId)) {
188
+ errors.push("primaryId must contain only printable ASCII characters (ISO/IEC 646)");
189
+ }
190
+ if (id.serial !== undefined) {
191
+ if (!id.serial) {
192
+ errors.push("serial, when present, must not be empty");
193
+ }
194
+ else if (!isAsciiPrintable(id.serial)) {
195
+ errors.push("serial must contain only printable ASCII characters (ISO/IEC 646)");
196
+ }
197
+ }
198
+ if (!id.raw) {
199
+ errors.push("raw must not be empty");
200
+ }
201
+ else if (!isAsciiPrintable(id.raw)) {
202
+ errors.push("raw must contain only printable ASCII characters (ISO/IEC 646)");
203
+ }
204
+ if (errors.length > 0)
205
+ return { ok: false, errors };
206
+ return { ok: true, value: id };
207
+ }
208
+ function validateIec61406(id) {
209
+ const errors = [];
210
+ if (!id.uri) {
211
+ errors.push("uri must not be empty");
212
+ }
213
+ else {
214
+ if (!id.uri.startsWith("https://")) {
215
+ errors.push("IEC 61406 uri must be an https URL");
216
+ }
217
+ if (!isAsciiPrintable(id.uri)) {
218
+ errors.push("uri must contain only printable ASCII characters (ISO/IEC 646)");
219
+ }
220
+ if (!isRfc3986Valid(id.uri)) {
221
+ errors.push("uri must be a valid RFC 3986 URL");
222
+ }
223
+ }
224
+ if (errors.length > 0)
225
+ return { ok: false, errors };
226
+ return { ok: true, value: id };
227
+ }
228
+ function validateDid(id) {
229
+ const errors = [];
230
+ if (!isAsciiPrintable(id.did)) {
231
+ errors.push("did must contain only printable ASCII characters (ISO/IEC 646)");
232
+ }
233
+ const didErrors = validateDidString(id.did);
234
+ errors.push(...didErrors);
235
+ // Verify method field matches the method in the DID string
236
+ if (didErrors.length === 0) {
237
+ const methodInDid = id.did.slice(4, id.did.indexOf(":", 4));
238
+ if (id.method !== methodInDid) {
239
+ errors.push(`method field '${id.method}' does not match the method in the did string '${methodInDid}'`);
240
+ }
241
+ }
242
+ if (errors.length > 0)
243
+ return { ok: false, errors };
244
+ return { ok: true, value: id };
245
+ }
246
+ function validateDoiId(id) {
247
+ const errors = [];
248
+ if (!isAsciiPrintable(id.doi)) {
249
+ errors.push("doi must contain only printable ASCII characters (ISO/IEC 646)");
250
+ }
251
+ const bare = normalizeDoi(id.doi);
252
+ if (!bare) {
253
+ errors.push(`doi '${id.doi}' must match 10.<4–9 registrant digits>/<suffix> per the DOI Handbook`);
254
+ }
255
+ if (errors.length > 0)
256
+ return { ok: false, errors };
257
+ return { ok: true, value: { ...id, doi: bare } }; // eslint-disable-line @typescript-eslint/no-non-null-assertion
258
+ }
259
+ // ── Battery category restriction ──────────────────────────────────────────────
260
+ const BATTERY_ALLOWED_SCHEMES = new Set(["gs1", "iso15459"]);
261
+ // ── Public API ────────────────────────────────────────────────────────────────
262
+ /**
263
+ * Validate a `ProductIdentifier` against EN 18219 rules.
264
+ *
265
+ * On success returns `{ ok: true, value }` with the normalised identifier
266
+ * (gtin padded to 14 digits; doi stripped of prefix and lowercased).
267
+ * On failure returns `{ ok: false, errors }` listing every problem found.
268
+ *
269
+ * Pass `{ category: "battery" }` to apply the additional Battery Regulation
270
+ * Art. 77(3) restriction: only `gs1` and `iso15459` are accepted, because
271
+ * DID, DOI and IEC 61406 are not ISO/IEC 15459 identifiers.
272
+ */
273
+ export function validateProductIdentifier(id, options = {}) {
274
+ // Battery Regulation Art. 77(3) restriction
275
+ if (options.category === "battery" && !BATTERY_ALLOWED_SCHEMES.has(id.scheme)) {
276
+ return {
277
+ ok: false,
278
+ errors: [
279
+ `scheme '${id.scheme}' is not permitted for battery passports — ` +
280
+ "Battery Regulation Art. 77(3) requires an ISO/IEC 15459 identifier " +
281
+ "(gs1 or iso15459); DID, DOI and IEC 61406 are not ISO/IEC 15459 identifiers.",
282
+ ],
283
+ };
284
+ }
285
+ switch (id.scheme) {
286
+ case "gs1": return validateGs1(id);
287
+ case "iso15459": return validateIso15459(id);
288
+ case "iec61406": return validateIec61406(id);
289
+ case "did": return validateDid(id);
290
+ case "doi": return validateDoiId(id);
291
+ default: {
292
+ // Exhaustiveness guard — a new scheme added to the union without a
293
+ // validator here becomes a compile error on the line below.
294
+ const _exhaustive = id;
295
+ void _exhaustive;
296
+ return { ok: false, errors: ["unknown scheme"] };
297
+ }
298
+ }
299
+ }
300
+ //# sourceMappingURL=product.js.map