@bsv/dpp-profiles 0.3.0-beta.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 (96) hide show
  1. package/LICENSE +58 -0
  2. package/README.md +64 -0
  3. package/dist/applicability.d.ts +96 -0
  4. package/dist/applicability.d.ts.map +1 -0
  5. package/dist/applicability.js +181 -0
  6. package/dist/applicability.js.map +1 -0
  7. package/dist/combinations.d.ts +30 -0
  8. package/dist/combinations.d.ts.map +1 -0
  9. package/dist/combinations.js +103 -0
  10. package/dist/combinations.js.map +1 -0
  11. package/dist/evidence-shapes.d.ts +198 -0
  12. package/dist/evidence-shapes.d.ts.map +1 -0
  13. package/dist/evidence-shapes.js +123 -0
  14. package/dist/evidence-shapes.js.map +1 -0
  15. package/dist/gs1-epc-binary.d.ts +81 -0
  16. package/dist/gs1-epc-binary.d.ts.map +1 -0
  17. package/dist/gs1-epc-binary.js +252 -0
  18. package/dist/gs1-epc-binary.js.map +1 -0
  19. package/dist/gs1-resolution.d.ts +392 -0
  20. package/dist/gs1-resolution.d.ts.map +1 -0
  21. package/dist/gs1-resolution.js +696 -0
  22. package/dist/gs1-resolution.js.map +1 -0
  23. package/dist/identifiers.d.ts +57 -0
  24. package/dist/identifiers.d.ts.map +1 -0
  25. package/dist/identifiers.js +117 -0
  26. package/dist/identifiers.js.map +1 -0
  27. package/dist/index.d.ts +235 -0
  28. package/dist/index.d.ts.map +1 -0
  29. package/dist/index.js +124 -0
  30. package/dist/index.js.map +1 -0
  31. package/dist/manifest-v2.d.ts +87 -0
  32. package/dist/manifest-v2.d.ts.map +1 -0
  33. package/dist/manifest-v2.js +31 -0
  34. package/dist/manifest-v2.js.map +1 -0
  35. package/dist/mapping.d.ts +84 -0
  36. package/dist/mapping.d.ts.map +1 -0
  37. package/dist/mapping.js +88 -0
  38. package/dist/mapping.js.map +1 -0
  39. package/dist/projections.d.ts +233 -0
  40. package/dist/projections.d.ts.map +1 -0
  41. package/dist/projections.js +466 -0
  42. package/dist/projections.js.map +1 -0
  43. package/frozen.json +42 -0
  44. package/generated/consumer/battery-v2.json +2248 -0
  45. package/generated/consumer/battery-v3.json +4354 -0
  46. package/generated/consumer/general-v1.json +9 -0
  47. package/generated/consumer/general-v2.json +714 -0
  48. package/generated/consumer/textile-v1.json +101 -0
  49. package/generated/consumer/textile-v2.json +1033 -0
  50. package/generated/consumer/textile-v3.json +2011 -0
  51. package/generated/index.json +86 -0
  52. package/generated/mapping/battery@2.md +116 -0
  53. package/generated/mapping/battery@3.md +130 -0
  54. package/generated/mapping/general@2.md +50 -0
  55. package/generated/mapping/textile@2.md +61 -0
  56. package/generated/mapping/textile@3.md +73 -0
  57. package/generated/payload-schema/battery@2.public.schema.json +609 -0
  58. package/generated/payload-schema/battery@2.restricted.schema.json +377 -0
  59. package/generated/payload-schema/battery@3.public.schema.json +1364 -0
  60. package/generated/payload-schema/battery@3.restricted.schema.json +1097 -0
  61. package/generated/payload-schema/general@1.public.schema.json +37 -0
  62. package/generated/payload-schema/general@1.restricted.schema.json +9 -0
  63. package/generated/payload-schema/general@2.public.schema.json +352 -0
  64. package/generated/payload-schema/general@2.restricted.schema.json +52 -0
  65. package/generated/payload-schema/textile@1.public.schema.json +82 -0
  66. package/generated/payload-schema/textile@1.restricted.schema.json +22 -0
  67. package/generated/payload-schema/textile@2.public.schema.json +464 -0
  68. package/generated/payload-schema/textile@2.restricted.schema.json +78 -0
  69. package/generated/payload-schema/textile@3.public.schema.json +1299 -0
  70. package/generated/payload-schema/textile@3.restricted.schema.json +78 -0
  71. package/manifests/battery@2.json +4047 -0
  72. package/manifests/battery@3.json +6164 -0
  73. package/manifests/exchange/untp-0.7.0-jose@1.json +191 -0
  74. package/manifests/exchange/vc-di-ecdsa-rdfc-2019@1.json +74 -0
  75. package/manifests/exchange/vsc-draft-compat@0.1.0.json +64 -0
  76. package/manifests/general@1.json +148 -0
  77. package/manifests/general@2.json +1266 -0
  78. package/manifests/interoperability/epcis-json@1.json +69 -0
  79. package/manifests/interoperability/epcis-vsc@1.json +64 -0
  80. package/manifests/interoperability/gs1-digital-link@1.json +71 -0
  81. package/manifests/operator/federated-operators@1.json +54 -0
  82. package/manifests/operator/single-operator@1.json +52 -0
  83. package/manifests/textile@1.json +383 -0
  84. package/manifests/textile@2.json +1682 -0
  85. package/manifests/textile@3.json +2763 -0
  86. package/package.json +59 -0
  87. package/schemas/exchange-profile.schema.json +134 -0
  88. package/schemas/gs1/NOTICE.md +11 -0
  89. package/schemas/gs1/resolver-description-file-schema-1.2.0.json +124 -0
  90. package/schemas/gs1/resolver-linkset-context-1.2.1.jsonld +39 -0
  91. package/schemas/gs1/resolver-linkset-schema-1.2.1.json +96 -0
  92. package/schemas/interoperability-profile.schema.json +347 -0
  93. package/schemas/operator-profile.schema.json +108 -0
  94. package/schemas/product-identity.schema.json +45 -0
  95. package/schemas/profile-manifest-v2.schema.json +1087 -0
  96. package/schemas/profile-manifest.schema.json +291 -0
@@ -0,0 +1,696 @@
1
+ /**
2
+ * GS1 resolution and discovery, pure (`spec/gs1-discovery.md`): the
3
+ * uncompressed Digital Link grammar for primary key 01, decompression of the
4
+ * EPC binary form, key-tuple equivalence, resolution records and the linksets
5
+ * a GS1-Conformant Resolver 1.2.1 builds from them, the selection a
6
+ * discovery client makes over a linkset, and the resolver description file.
7
+ * Nothing here touches the network, a store or a request object; the hosted
8
+ * resolver route and the discovery client are the callers, and every rule of
9
+ * theirs that can be stated without I/O is stated here so two hosts agree.
10
+ *
11
+ * Two boundaries hold throughout. A key tuple names an identified entity and
12
+ * nothing more: equivalence between two spellings of one key is not
13
+ * authority to bind a signed credential to a passport, and a link inherited
14
+ * from the model level does not become an item fact by being served under a
15
+ * serialised request. And the legacy parser in identifiers.ts is untouched:
16
+ * it answers historical records exactly as it always has, and this module is
17
+ * the current grammar beside it, never a reinterpretation of it.
18
+ */
19
+ import { gs1CheckDigit, gtin14, isDemonstrationGtin, isValidGtin } from './identifiers.js';
20
+ import { GS1_CHARACTER_SET_82, decodeEpcCompressionString } from './gs1-epc-binary.js';
21
+ /* ------------------------------------------------------------------------ */
22
+ /* Vocabulary */
23
+ /* ------------------------------------------------------------------------ */
24
+ /**
25
+ * The GS1 Web vocabulary namespace the resolver standard expands `gs1:` to
26
+ * (GS1-Conformant Resolver 1.2.1 §2.14). The published linkset context maps
27
+ * the same prefix to `http://gs1.org/voc/`; `normaliseLinkType` treats the
28
+ * three spellings as one so a linkset from another resolver is read
29
+ * correctly and this module always writes the standard's own.
30
+ */
31
+ export const GS1_VOCABULARY = 'https://ref.gs1.org/voc/';
32
+ const GS1_VOCABULARY_ALIASES = ['https://ref.gs1.org/voc/', 'https://gs1.org/voc/', 'http://gs1.org/voc/'];
33
+ /** The GS1 link types this profile uses, as full IRIs; a linkset key is always the full IRI. */
34
+ export const GS1_LINK_TYPES = {
35
+ defaultLink: `${GS1_VOCABULARY}defaultLink`,
36
+ defaultLinkMulti: `${GS1_VOCABULARY}defaultLinkMulti`,
37
+ pip: `${GS1_VOCABULARY}pip`,
38
+ sustainabilityInfo: `${GS1_VOCABULARY}sustainabilityInfo`,
39
+ certificationInfo: `${GS1_VOCABULARY}certificationInfo`,
40
+ epcis: `${GS1_VOCABULARY}epcis`,
41
+ traceability: `${GS1_VOCABULARY}traceability`,
42
+ recallStatus: `${GS1_VOCABULARY}recallStatus`,
43
+ handledBy: `${GS1_VOCABULARY}handledBy`,
44
+ };
45
+ /**
46
+ * The namespace of the link types this standard owns, and the one relation it
47
+ * defines: a link to a passport's portable evidence package
48
+ * (`spec/portable-evidence.md`), for which the GS1 vocabulary has no term.
49
+ * The pinned linkset schema admits a link relation key only as a bare
50
+ * lower-case token or as an `http(s)` IRI of letters, digits, dots and
51
+ * slashes, so the relation is spelled without a hyphen under the programme's
52
+ * public host and versioned in its path; the standard's usual
53
+ * `bsv-blockchain.github.io` base cannot be a link relation under that schema.
54
+ */
55
+ export const DPP_LINK_NAMESPACE = 'https://dpp.bsvb.net/link/';
56
+ export const DPP_EVIDENCE_PACKAGE_LINK_TYPE = `${DPP_LINK_NAMESPACE}evidencePackage/1`;
57
+ /** The media types and relations of the resolver standard §2.9 and §2.10. */
58
+ export const LINKSET_MEDIA_TYPE = 'application/linkset+json';
59
+ export const LINKSET_CONTEXT_URI = 'https://ref.gs1.org/standards/resolver/1.2.1/linkset-context';
60
+ export const JSON_LD_CONTEXT_RELATION = 'http://www.w3.org/ns/json-ld#context';
61
+ /** The one primary key this profile hosts, and its three key qualifiers in the order the URI syntax fixes. */
62
+ export const SUPPORTED_PRIMARY_KEY = '01';
63
+ export const GTIN_QUALIFIER_ORDER = ['22', '10', '21'];
64
+ /** Primary key AIs the URI syntax defines; every one but 01 is refused as unsupported rather than misread. */
65
+ const PRIMARY_KEY_AIS = new Set(['00', '01', '253', '255', '401', '402', '414', '415', '417', '8003', '8004', '8006', '8010', '8013', '8017', '8018']);
66
+ /** The third-party serialised extension: a GTIN qualifier this profile does not host. */
67
+ const TPX_AI = '235';
68
+ /** The regex the pinned linkset schema applies to an IRI link relation key. */
69
+ const SCHEMA_IRI_KEY = /^https?:\/\/[a-zA-Z0-9./]+$/;
70
+ const CHARACTER_SET_39 = /^[#\-/0-9A-Z]+$/;
71
+ const MEDIA_TYPE = /^[\w!#$&^.+-]+\/[\w!#$&^.+-]+$/;
72
+ const LANGUAGE_TAG = /^[A-Za-z]{2,3}(-[A-Za-z0-9]{2,8})*$/;
73
+ /** A resolver root without its trailing slashes, scanned rather than matched so an adversarial run of slashes costs linear time. */
74
+ function stripTrailingSlashes(value) {
75
+ let end = value.length;
76
+ while (end > 0 && value[end - 1] === '/')
77
+ end--;
78
+ return value.slice(0, end);
79
+ }
80
+ const invalid = (reason, detail) => ({ kind: 'invalid', reason, detail });
81
+ /**
82
+ * What this module knows of the data attributes a GTIN URI may carry in its
83
+ * query, with certainty: the six-digit dates, the fixed six-digit measures of
84
+ * AIs 31nn to 36nn, the variable counts, the internal and company-internal
85
+ * text AIs, and the thirteen-digit location numbers with their check digit.
86
+ * An AI outside this table is carried with `validated: false`, never
87
+ * refused for being unknown, because new AIs are introduced faster than a
88
+ * library is revised (GS1-Conformant Resolver 1.2.1 §2.4).
89
+ */
90
+ function knownAttributeProblem(ai, value) {
91
+ const date = () => {
92
+ if (!/^\d{6}$/.test(value))
93
+ return 'six digits YYMMDD';
94
+ const month = Number(value.slice(2, 4));
95
+ const day = Number(value.slice(4, 6));
96
+ if (month < 1 || month > 12 || day > 31)
97
+ return 'a real month and day (day 00 permitted)';
98
+ return undefined;
99
+ };
100
+ if (['11', '13', '15', '16', '17'].includes(ai))
101
+ return date();
102
+ if (/^3[1-6]\d{2}$/.test(ai))
103
+ return /^\d{6}$/.test(value) ? undefined : 'six digits';
104
+ if (ai === '30' || ai === '37')
105
+ return /^\d{1,8}$/.test(value) ? undefined : 'up to eight digits';
106
+ if (ai === '8005')
107
+ return /^\d{6}$/.test(value) ? undefined : 'six digits';
108
+ if (ai === '90')
109
+ return value.length <= 30 ? undefined : 'up to thirty characters';
110
+ if (/^9[1-9]$/.test(ai))
111
+ return value.length <= 90 ? undefined : 'up to ninety characters';
112
+ if (/^41[0-7]$/.test(ai))
113
+ return /^\d{13}$/.test(value) && gs1CheckDigit(value.slice(0, 12)) === Number(value[12]) ? undefined : 'thirteen digits with a valid check digit';
114
+ return false;
115
+ }
116
+ function parseLinkType(raw) {
117
+ if (raw === 'linkset' || raw === 'all')
118
+ return { raw, linkset: true };
119
+ const curie = /^gs1:([A-Za-z][A-Za-z0-9]*)$/.exec(raw);
120
+ if (curie != null)
121
+ return { raw, iri: `${GS1_VOCABULARY}${curie[1]}` };
122
+ if (/^https?:\/\//.test(raw))
123
+ return { raw, iri: normaliseLinkType(raw) };
124
+ return { raw };
125
+ }
126
+ /**
127
+ * Read a GS1 Digital Link URI in the grammar this profile hosts: primary key
128
+ * 01, its key qualifiers 22, 10 and 21 in that order, data attributes in the
129
+ * query, `linkType` beside them, and the special compressed form of the EPC
130
+ * binary standard. Refusals are answers, not exceptions, each with the
131
+ * standard's reason: a resolver turns them into 400 and a client into a
132
+ * finding. One trailing slash is tolerated (resolver standard §2.13).
133
+ *
134
+ * A URI this reads is syntactically a GS1 Digital Link URI and nothing more:
135
+ * the check digit is reported, not enforced, because a historical record may
136
+ * carry a GTIN that fails it, and allocation authority is never inferred.
137
+ */
138
+ export function parseGs1DigitalLinkUri(uri) {
139
+ let url;
140
+ try {
141
+ url = new URL(uri);
142
+ }
143
+ catch {
144
+ return invalid('not-a-url', 'the value does not parse as a URL');
145
+ }
146
+ if (url.protocol !== 'https:' && url.protocol !== 'http:')
147
+ return invalid('scheme', 'a Digital Link URI uses http or https');
148
+ const rawSegments = url.pathname.replace(/\/$/, '').split('/').slice(1);
149
+ const segments = [];
150
+ for (const raw of rawSegments) {
151
+ try {
152
+ segments.push(decodeURIComponent(raw));
153
+ }
154
+ catch {
155
+ return invalid('percent-encoding', `the path segment ${raw} is not valid percent-encoding`);
156
+ }
157
+ }
158
+ const at = segments.findIndex((segment) => /^\d{2,4}$/.test(segment));
159
+ if (at === -1) {
160
+ const last = segments.at(-1) ?? '';
161
+ if (/^(eh|ex)[A-Za-z0-9_-]*$/.test(last)) {
162
+ return {
163
+ kind: 'compressed',
164
+ origin: url.origin,
165
+ host: url.host,
166
+ pathStem: segments.slice(0, -1).map(encodeURIComponent).join('/'),
167
+ prefix: last.slice(0, 2),
168
+ compressionString: last,
169
+ query: url.search,
170
+ };
171
+ }
172
+ return invalid('no-primary-key', 'no path segment is a GS1 application identifier; the general compressed form is not decoded');
173
+ }
174
+ const primaryAi = segments[at];
175
+ if (primaryAi !== SUPPORTED_PRIMARY_KEY) {
176
+ return PRIMARY_KEY_AIS.has(primaryAi)
177
+ ? invalid('unsupported-primary-key', `primary key ${primaryAi} is not hosted; this profile resolves 01 only`)
178
+ : invalid('no-primary-key', `${primaryAi} is not a primary key application identifier`);
179
+ }
180
+ const gtinRaw = segments[at + 1];
181
+ if (gtinRaw == null || !/^(\d{8}|\d{12}|\d{13}|\d{14})$/.test(gtinRaw))
182
+ return invalid('primary-key-not-a-gtin', 'application identifier 01 carries 8, 12, 13 or 14 digits');
183
+ const gtin = gtin14(gtinRaw);
184
+ const rest = segments.slice(at + 2);
185
+ if (rest.length % 2 !== 0)
186
+ return invalid('trailing-segment', 'a path segment follows the last application identifier without a value');
187
+ const qualifiers = [];
188
+ let lastRank = -1;
189
+ for (let index = 0; index < rest.length; index += 2) {
190
+ const ai = rest[index];
191
+ const value = rest[index + 1];
192
+ const rank = GTIN_QUALIFIER_ORDER.indexOf(ai);
193
+ if (rank === -1) {
194
+ return ai === TPX_AI
195
+ ? invalid('qualifier-unsupported', 'application identifier 235 (third-party serialised extension) is not hosted by this profile')
196
+ : invalid('qualifier-unsupported', `${ai} is not a key qualifier of a GTIN`);
197
+ }
198
+ if (rank === lastRank)
199
+ return invalid('qualifier-repeated', `application identifier ${ai} appears twice`);
200
+ if (rank < lastRank)
201
+ return invalid('qualifier-order', `application identifier ${ai} must precede ${rest[index - 2]}: the order is 22, 10, 21`);
202
+ if (value.length === 0 || value.length > 20 || !GS1_CHARACTER_SET_82.test(value))
203
+ return invalid('qualifier-value', `application identifier ${ai} carries 1 to 20 characters of the GS1 encodable set 82`);
204
+ qualifiers.push({ ai, value });
205
+ lastRank = rank;
206
+ }
207
+ const dataAttributes = [];
208
+ const otherQuery = [];
209
+ let linkType;
210
+ for (const [key, value] of url.searchParams) {
211
+ if (key === 'linkType') {
212
+ linkType = parseLinkType(value);
213
+ continue;
214
+ }
215
+ if (/^\d{2,4}$/.test(key)) {
216
+ if (key === SUPPORTED_PRIMARY_KEY || GTIN_QUALIFIER_ORDER.includes(key) || key === TPX_AI || PRIMARY_KEY_AIS.has(key)) {
217
+ return invalid('key-in-query', `application identifier ${key} is a key or key qualifier and belongs in the path`);
218
+ }
219
+ if (value.length === 0 || !(GS1_CHARACTER_SET_82.test(value) || CHARACTER_SET_39.test(value)))
220
+ return invalid('attribute-value', `application identifier ${key} carries a value outside the GS1 character sets`);
221
+ const problem = knownAttributeProblem(key, value);
222
+ if (typeof problem === 'string')
223
+ return invalid('attribute-value', `application identifier ${key} requires ${problem}`);
224
+ dataAttributes.push({ ai: key, value, validated: problem === undefined });
225
+ continue;
226
+ }
227
+ otherQuery.push([key, value]);
228
+ }
229
+ return {
230
+ kind: 'uncompressed',
231
+ origin: url.origin,
232
+ host: url.host,
233
+ pathStem: segments.slice(0, at).map(encodeURIComponent).join('/'),
234
+ primaryKey: { ai: '01', value: gtin, checkDigitValid: isValidGtin(gtinRaw), demonstration: isDemonstrationGtin(gtin) },
235
+ qualifiers,
236
+ dataAttributes,
237
+ ...(linkType == null ? {} : { linkType }),
238
+ otherQuery,
239
+ };
240
+ }
241
+ /**
242
+ * Turn a special compressed GS1 Digital Link URI into its uncompressed form
243
+ * (compression standard §4.2.1 and §4.2.2): the stem up to the compression
244
+ * string is kept, the EPC binary string after `eh` or `ex` is decoded under
245
+ * the Tag Data Standard, and the result is `${stem}/01/{gtin}/21/{serial}`.
246
+ * A query string is carried across unchanged. An uncompressed URI is refused
247
+ * as `not-compressed` rather than echoed, so a caller never mistakes a
248
+ * pass-through for a decoding.
249
+ */
250
+ export function decompressGs1DigitalLink(uri) {
251
+ const parsed = parseGs1DigitalLinkUri(uri);
252
+ if (parsed.kind !== 'compressed')
253
+ return { ok: false, reason: 'not-compressed', detail: parsed.kind === 'invalid' ? parsed.detail : 'the URI is already uncompressed' };
254
+ const decoded = decodeEpcCompressionString(parsed.compressionString);
255
+ if (!decoded.ok)
256
+ return { ok: false, reason: decoded.reason, detail: decoded.detail, ...(decoded.header == null ? {} : { header: decoded.header }) };
257
+ const key = { ai: '01', value: decoded.gtin, qualifiers: [{ ai: '21', value: decoded.serial }] };
258
+ return { ok: true, uri: `${uncompressedUri(`${parsed.origin}${parsed.pathStem === '' ? '' : `/${parsed.pathStem}`}`, key)}${parsed.query}`, key, epc: decoded };
259
+ }
260
+ /* ------------------------------------------------------------------------ */
261
+ /* Keys */
262
+ /* ------------------------------------------------------------------------ */
263
+ /** The key an uncompressed parse names, or one built from its parts with the qualifiers put in canonical order. */
264
+ export function gs1KeyOf(source) {
265
+ if ('kind' in source)
266
+ return { ai: '01', value: source.primaryKey.value, qualifiers: [...source.qualifiers] };
267
+ const qualifiers = [...(source.qualifiers ?? [])].sort((a, b) => GTIN_QUALIFIER_ORDER.indexOf(a.ai) - GTIN_QUALIFIER_ORDER.indexOf(b.ai));
268
+ return { ai: '01', value: gtin14(source.gtin), qualifiers };
269
+ }
270
+ /**
271
+ * The canonical spelling of a key tuple, `01:{gtin14}` followed by each
272
+ * qualifier as `ai:value` in the syntax's order, joined by `|`. Two Digital
273
+ * Link URIs under different hosts with the same tuple name the same entity;
274
+ * the same serial under a different GTIN does not. Equivalence is a statement
275
+ * about identifiers only: it is not authority to bind a signed credential to
276
+ * a passport, which needs the binding evidence `spec/gs1-discovery.md` §2
277
+ * names.
278
+ */
279
+ export function gs1KeyTuple(key) {
280
+ const resolved = 'kind' in key ? gs1KeyOf(key) : key;
281
+ return [`01:${resolved.value}`, ...resolved.qualifiers.map((q) => `${q.ai}:${q.value}`)].join('|');
282
+ }
283
+ export function sameGs1Key(a, b) {
284
+ return gs1KeyTuple(a) === gs1KeyTuple(b);
285
+ }
286
+ export function granularityOf(qualifiers) {
287
+ if (qualifiers.some((q) => q.ai === '21'))
288
+ return 'item';
289
+ if (qualifiers.some((q) => q.ai === '10'))
290
+ return 'lot';
291
+ if (qualifiers.some((q) => q.ai === '22'))
292
+ return 'variant';
293
+ return 'model';
294
+ }
295
+ /** The uncompressed Digital Link URI of a key under a resolver root (origin plus optional stem, no trailing slash). */
296
+ export function uncompressedUri(resolverRoot, key) {
297
+ const root = stripTrailingSlashes(resolverRoot);
298
+ return `${root}/01/${key.value}${key.qualifiers.map((q) => `/${q.ai}/${encodeURIComponent(q.value)}`).join('')}`;
299
+ }
300
+ /** The path of the legacy evidence JSON answered at the API origin, and its alias, for an item key; undefined for any other granularity. */
301
+ export function legacyEvidencePaths(key) {
302
+ const serial = key.qualifiers.find((q) => q.ai === '21');
303
+ if (serial == null || key.qualifiers.length !== 1)
304
+ return undefined;
305
+ const tail = `/01/${key.value}/21/${encodeURIComponent(serial.value)}`;
306
+ return { legacy: tail, alias: `/resolve/gs1${tail}` };
307
+ }
308
+ /**
309
+ * Every rule a record must meet before it is stored, so the resolver validates
310
+ * at ingestion and not at every request (resolver standard §2.4): a GTIN
311
+ * whose check digit holds, qualifiers in order with no repeat and no TPX, a
312
+ * serial never registered beside a variant or a lot, at most one single
313
+ * default link with no optional attributes, and every link with an `https`
314
+ * target, a title, a relation the linkset schema can carry, and declared
315
+ * disclosure. An empty list means the record is admissible.
316
+ */
317
+ export function resolutionRecordProblems(record) {
318
+ const problems = [];
319
+ if (record.key.ai !== '01')
320
+ problems.push(`primary key ${String(record.key.ai)} is not hosted`);
321
+ if (!/^\d{14}$/.test(record.key.value) || !isValidGtin(record.key.value))
322
+ problems.push('the GTIN must be fourteen digits with a valid check digit');
323
+ let lastRank = -1;
324
+ const ais = new Set();
325
+ for (const q of record.qualifiers) {
326
+ const rank = GTIN_QUALIFIER_ORDER.indexOf(q.ai);
327
+ if (rank === -1)
328
+ problems.push(`${q.ai} is not a hosted key qualifier`);
329
+ else if (rank <= lastRank)
330
+ problems.push(`qualifier ${q.ai} is out of order or repeated`);
331
+ lastRank = Math.max(lastRank, rank);
332
+ ais.add(q.ai);
333
+ if (q.value.length === 0 || q.value.length > 20 || !GS1_CHARACTER_SET_82.test(q.value))
334
+ problems.push(`qualifier ${q.ai} carries 1 to 20 characters of the GS1 encodable set 82`);
335
+ }
336
+ if (ais.has('21') && (ais.has('22') || ais.has('10')))
337
+ problems.push('a link registered at the serial level carries neither a variant nor a lot (resolver standard section 2.5.10)');
338
+ if (record.configurationRevision === '')
339
+ problems.push('a configuration revision is required');
340
+ const defaults = record.links.filter((l) => l.default === 'single');
341
+ if (defaults.length > 1)
342
+ problems.push('at most one default link per identified entity');
343
+ for (const [index, link] of record.links.entries()) {
344
+ const where = `link ${index} (${link.rel})`;
345
+ if (!/^https:\/\/\S+$/.test(link.href))
346
+ problems.push(`${where}: the target must be an https URL`);
347
+ if (typeof link.title !== 'string' || link.title.trim() === '')
348
+ problems.push(`${where}: a title is required`);
349
+ if (!SCHEMA_IRI_KEY.test(link.rel))
350
+ problems.push(`${where}: the relation must be an http(s) IRI of letters, digits, dots and slashes so the linkset schema can carry it`);
351
+ if (link.rel === GS1_LINK_TYPES.defaultLink || link.rel === GS1_LINK_TYPES.defaultLinkMulti)
352
+ problems.push(`${where}: a default is flagged with default, and its relation is the descriptive type`);
353
+ if (link.default === 'single' && (link.type != null || link.hreflang != null || link.context != null))
354
+ problems.push(`${where}: the default link carries no optional attributes`);
355
+ if (link.type != null && !MEDIA_TYPE.test(link.type))
356
+ problems.push(`${where}: ${link.type} is not a media type`);
357
+ for (const tag of link.hreflang ?? [])
358
+ if (!LANGUAGE_TAG.test(tag))
359
+ problems.push(`${where}: ${tag} is not a language tag`);
360
+ if (link.hreflang != null && link.hreflang.length === 0)
361
+ problems.push(`${where}: hreflang is an array of at least one tag`);
362
+ if (link.context != null && link.context.length === 0)
363
+ problems.push(`${where}: context is an array of at least one value`);
364
+ if (link.disclosure !== 'public' && link.disclosure !== 'restricted')
365
+ problems.push(`${where}: disclosure must be public or restricted`);
366
+ }
367
+ return problems;
368
+ }
369
+ /** `text/html;q=0.9, application/json` to `['application/json', 'text/html']`: q-values honoured, zero excluded. */
370
+ export function parseAcceptHeader(header) {
371
+ return parseWeighted(header).map(([value]) => value.toLowerCase());
372
+ }
373
+ /** `fr-CH, fr;q=0.9, en;q=0.8` to `['fr-CH', 'fr', 'en']`. */
374
+ export function parseAcceptLanguage(header) {
375
+ return parseWeighted(header).map(([value]) => value);
376
+ }
377
+ function parseWeighted(header) {
378
+ if (header == null || header.trim() === '')
379
+ return [];
380
+ const entries = [];
381
+ header.split(',').forEach((part, index) => {
382
+ const [value, ...params] = part.trim().split(';').map((p) => p.trim());
383
+ if (value === '')
384
+ return;
385
+ let q = 1;
386
+ for (const param of params) {
387
+ const match = /^q=([01](?:\.\d{0,3})?)$/i.exec(param);
388
+ if (match != null)
389
+ q = Number(match[1]);
390
+ }
391
+ if (q > 0)
392
+ entries.push([value, q, index]);
393
+ });
394
+ return entries.sort((a, b) => b[1] - a[1] || a[2] - b[2]).map(([value, q]) => [value, q]);
395
+ }
396
+ /** Which of the six registration levels of §2.5.10 a request selects, most specific first. */
397
+ function levelsFor(key) {
398
+ const by = new Map(key.qualifiers.map((q) => [q.ai, q]));
399
+ const levels = [];
400
+ const serial = by.get('21');
401
+ const variant = by.get('22');
402
+ const lot = by.get('10');
403
+ if (serial != null)
404
+ levels.push([serial]);
405
+ if (variant != null && lot != null)
406
+ levels.push([variant, lot]);
407
+ if (lot != null)
408
+ levels.push([lot]);
409
+ if (variant != null)
410
+ levels.push([variant]);
411
+ levels.push([]);
412
+ return levels;
413
+ }
414
+ function sameQualifiers(a, b) {
415
+ return a.length === b.length && a.every((q, i) => q.ai === b[i].ai && q.value === b[i].value);
416
+ }
417
+ /**
418
+ * The records a request selects: the union the resolver standard §2.5.10
419
+ * fixes, most specific level first, so a link registered at the lot level is
420
+ * found under a serialised request and a link at the model level under every
421
+ * request. Each match keeps its own anchor and granularity.
422
+ */
423
+ export function matchRecords(records, key, resolverRoot) {
424
+ const matched = [];
425
+ for (const level of levelsFor(key)) {
426
+ for (const record of records) {
427
+ if (record.key.value !== key.value || !sameQualifiers(record.qualifiers, level))
428
+ continue;
429
+ const recordKey = { ai: '01', value: record.key.value, qualifiers: record.qualifiers };
430
+ matched.push({ record, anchor: uncompressedUri(resolverRoot, recordKey), granularity: granularityOf(record.qualifiers) });
431
+ }
432
+ }
433
+ return matched;
434
+ }
435
+ /**
436
+ * Media type against the client's ordered acceptable types: 3 for an exact
437
+ * type, 2 for a `type/*` range, 1 when the link declares no type or the
438
+ * client accepts anything, 0 for a mismatch; then the negated index of the
439
+ * earliest acceptable type the link satisfies, so the client's preference
440
+ * order decides between two exact matches.
441
+ */
442
+ function rankMediaType(link, accept) {
443
+ if (link.type == null || accept == null || accept.length === 0)
444
+ return [1, 0];
445
+ const type = link.type.toLowerCase();
446
+ const exact = accept.indexOf(type);
447
+ if (exact !== -1)
448
+ return [3, -exact];
449
+ const range = accept.indexOf(`${type.split('/')[0]}/*`);
450
+ if (range !== -1)
451
+ return [2, -range];
452
+ if (accept.includes('*/*'))
453
+ return [1, 0];
454
+ return [0, 0];
455
+ }
456
+ /**
457
+ * Language against the client's ordered preferences: the earliest preference
458
+ * a link satisfies decides, an exact tag beating a primary-subtag match only
459
+ * within that one preference. Three numbers: 2 match, 1 neutral (no
460
+ * hreflang, or no preference), 0 mismatch; then the negated preference index;
461
+ * then 1 for an exact tag and 0 for a primary-subtag match.
462
+ */
463
+ function rankLanguage(link, languages) {
464
+ if (link.hreflang == null || languages == null || languages.length === 0)
465
+ return [1, 0, 0];
466
+ const tags = link.hreflang.map((t) => t.toLowerCase());
467
+ for (const [index, wanted] of languages.entries()) {
468
+ const lower = wanted.toLowerCase();
469
+ if (lower === '*')
470
+ return [1, 0, 0];
471
+ if (tags.includes(lower))
472
+ return [2, -index, 1];
473
+ if (tags.some((t) => t.split('-')[0] === lower.split('-')[0]))
474
+ return [2, -index, 0];
475
+ }
476
+ return [0, 0, 0];
477
+ }
478
+ function rankContext(link, context) {
479
+ if (link.context == null || context == null || context.length === 0)
480
+ return 1;
481
+ return link.context.some((c) => context.includes(c)) ? 3 : 0;
482
+ }
483
+ function rank(link, request) {
484
+ const [mediaType, mediaPreference] = rankMediaType(link, request.accept);
485
+ const [language, preference, exact] = rankLanguage(link, request.acceptLanguage);
486
+ const context = rankContext(link, request.context);
487
+ return { mediaType, language, context, order: [mediaType, mediaPreference, language, preference, exact, context] };
488
+ }
489
+ function compareRanks(a, b) {
490
+ for (let i = 0; i < a.order.length; i++)
491
+ if (a.order[i] !== b.order[i])
492
+ return b.order[i] - a.order[i];
493
+ return 0;
494
+ }
495
+ /**
496
+ * The best match among candidates of one link type (resolver standard §2.6.3,
497
+ * media type before language before context): the unique best, or every
498
+ * candidate when the best is not unique, so the resolver answers 300 rather
499
+ * than choosing.
500
+ */
501
+ export function bestMatch(candidates, request) {
502
+ if (candidates.length === 0)
503
+ return undefined;
504
+ const ranked = candidates.map((link) => ({ link, rank: rank(link, request) })).sort((a, b) => compareRanks(a.rank, b.rank));
505
+ const top = ranked[0];
506
+ const tied = ranked.filter((r) => compareRanks(r.rank, top.rank) === 0);
507
+ return tied.length === 1 ? { unique: top.link } : { tie: tied.map((r) => r.link) };
508
+ }
509
+ /**
510
+ * Resolve a request against the records this resolver holds, exactly as the
511
+ * standard's flow chart does and without any I/O: which records the key
512
+ * selects, which links the audience may see, then the default, the requested
513
+ * type, the list, or the refusal. A restricted link is never returned to a
514
+ * public audience. The redirect target is the link's own `href`; forwarding
515
+ * the request's query string onto it (§2.12) is the route's step, because
516
+ * the query is the route's to read.
517
+ */
518
+ export function resolveLinks(records, request) {
519
+ const matched = matchRecords(records, request.key, request.resolverRoot);
520
+ if (matched.length === 0)
521
+ return { outcome: 'no-records', matched: [] };
522
+ const links = [];
523
+ for (const m of matched) {
524
+ for (const link of m.record.links) {
525
+ if (link.disclosure === 'restricted' && request.audience !== 'authorised')
526
+ continue;
527
+ links.push({ ...link, anchor: m.anchor, granularity: m.granularity, record: m.record });
528
+ }
529
+ }
530
+ if (request.linkType === 'linkset')
531
+ return { outcome: 'linkset', matched, links };
532
+ if (request.linkType == null) {
533
+ // The most specific level that registered a default answers, with its
534
+ // header-refined defaults tried first (§2.5.8, §2.6.1). A tie between
535
+ // refined defaults falls back to the single default, because the default
536
+ // exists so the resolver can always give a definite answer.
537
+ const single = links.find((l) => l.default === 'single');
538
+ if (single == null)
539
+ return { outcome: 'not-found', matched, requested: GS1_LINK_TYPES.defaultLink, links };
540
+ const multi = links.filter((l) => l.default === 'multi' && l.anchor === single.anchor);
541
+ const refined = bestMatch(multi, request);
542
+ if (refined != null && 'unique' in refined) {
543
+ const own = rank(refined.unique, request);
544
+ const matchesSomething = own.mediaType >= 2 || own.language >= 2 || own.context >= 2;
545
+ if (matchesSomething)
546
+ return { outcome: 'redirect', matched, link: refined.unique, links };
547
+ }
548
+ return { outcome: 'redirect', matched, link: single, links };
549
+ }
550
+ const wanted = normaliseLinkType(request.linkType);
551
+ const candidates = links.filter((l) => normaliseLinkType(l.rel) === wanted);
552
+ const chosen = bestMatch(candidates, request);
553
+ if (chosen == null)
554
+ return { outcome: 'not-found', matched, requested: wanted, links };
555
+ if ('unique' in chosen)
556
+ return { outcome: 'redirect', matched, link: chosen.unique, links };
557
+ return { outcome: 'multiple-choices', matched, candidates: chosen.tie, links };
558
+ }
559
+ /** One relation IRI for the three spellings of the GS1 vocabulary; anything else is returned as given. */
560
+ export function normaliseLinkType(iri) {
561
+ for (const alias of GS1_VOCABULARY_ALIASES) {
562
+ if (iri.startsWith(alias))
563
+ return `${GS1_VOCABULARY}${iri.slice(alias.length)}`;
564
+ }
565
+ return iri;
566
+ }
567
+ function linksetLink(link) {
568
+ return {
569
+ href: link.href,
570
+ title: link.title,
571
+ ...(link.type == null ? {} : { type: link.type }),
572
+ ...(link.hreflang == null ? {} : { hreflang: [...link.hreflang] }),
573
+ ...(link.context == null ? {} : { context: [...link.context] }),
574
+ ...(link.forwardQuery === false ? { fwqs: false } : {}),
575
+ ...(link.disclosure === 'restricted' ? { public: false } : {}),
576
+ };
577
+ }
578
+ /**
579
+ * The linkset a resolver returns for a request (resolver standard §2.9 and
580
+ * §2.10): one link context object per matched record, most specific first,
581
+ * its anchor the uncompressed URI of that record's own identifier, every link
582
+ * under its descriptive relation and a default additionally under
583
+ * `gs1:defaultLink` or `gs1:defaultLinkMulti`. Links a public audience may
584
+ * not see are absent, not blanked; a restricted link shown to an authorised
585
+ * audience says `public: false`. The document validates against the pinned
586
+ * linkset schema.
587
+ */
588
+ export function linksetDocument(records, request) {
589
+ const resolved = resolveLinks(records, { ...request, linkType: 'linkset' });
590
+ if (resolved.outcome !== 'linkset')
591
+ return undefined;
592
+ const entries = [];
593
+ for (const m of resolved.matched) {
594
+ const entry = { anchor: m.anchor, ...(m.record.description == null ? {} : { description: m.record.description }) };
595
+ const push = (rel, link) => {
596
+ const list = entry[rel] ?? [];
597
+ list.push(link);
598
+ entry[rel] = list;
599
+ };
600
+ for (const link of resolved.links) {
601
+ if (link.record !== m.record)
602
+ continue;
603
+ const shaped = linksetLink(link);
604
+ push(link.rel, shaped);
605
+ if (link.default === 'single')
606
+ push(GS1_LINK_TYPES.defaultLink, shaped);
607
+ if (link.default === 'multi')
608
+ push(GS1_LINK_TYPES.defaultLinkMulti, shaped);
609
+ }
610
+ entries.push(entry);
611
+ }
612
+ return { linkset: entries };
613
+ }
614
+ /**
615
+ * A discovery client's choice over a linkset another resolver returned: the
616
+ * links under the requested relation (a full IRI or a `gs1:` CURIE) across
617
+ * every link context object, the best match by media type, language and
618
+ * context, and the anchor's own scope beside the result, so a link found at
619
+ * the model level under an item request is handed back as model
620
+ * information. An anchor that is not a Digital Link URI this module reads
621
+ * has scope `unknown`; it is not guessed.
622
+ */
623
+ export function selectLink(linkset, selection) {
624
+ const wanted = normaliseLinkType(parseLinkType(selection.rel).iri ?? selection.rel);
625
+ const candidates = [];
626
+ const available = new Set();
627
+ for (const entry of linkset.linkset ?? []) {
628
+ if (typeof entry?.anchor !== 'string')
629
+ continue;
630
+ for (const [rel, value] of Object.entries(entry)) {
631
+ if (rel === 'anchor' || rel === 'description' || rel === 'itemDescription' || !Array.isArray(value))
632
+ continue;
633
+ available.add(normaliseLinkType(rel));
634
+ if (normaliseLinkType(rel) !== wanted)
635
+ continue;
636
+ for (const link of value) {
637
+ if (typeof link?.href !== 'string')
638
+ continue;
639
+ candidates.push({ link, anchor: entry.anchor });
640
+ }
641
+ }
642
+ }
643
+ if (candidates.length === 0)
644
+ return { outcome: 'not-found', available: [...available].sort() };
645
+ const request = {
646
+ accept: selection.mediaType == null ? undefined : [selection.mediaType.toLowerCase()],
647
+ acceptLanguage: selection.language == null ? undefined : [selection.language],
648
+ context: selection.context == null ? undefined : [selection.context],
649
+ };
650
+ const ranked = candidates
651
+ .map((c) => ({ ...c, rank: rank({ rel: wanted, href: c.link.href, title: c.link.title, type: c.link.type, hreflang: c.link.hreflang, context: c.link.context, disclosure: 'public' }, request) }))
652
+ .sort((a, b) => compareRanks(a.rank, b.rank));
653
+ const tied = ranked.filter((r) => compareRanks(r.rank, ranked[0].rank) === 0);
654
+ if (tied.length > 1)
655
+ return { outcome: 'ambiguous', candidates: tied.map(({ link, anchor }) => ({ link, anchor })) };
656
+ const best = tied[0];
657
+ const parsed = parseGs1DigitalLinkUri(best.anchor);
658
+ const scope = parsed.kind === 'uncompressed' ? { key: gs1KeyOf(parsed), granularity: granularityOf(parsed.qualifiers) } : { granularity: 'unknown' };
659
+ return { outcome: 'selected', link: best.link, anchor: best.anchor, scope };
660
+ }
661
+ /**
662
+ * The `/.well-known/gs1resolver` document (resolver standard §3) for the one
663
+ * scope this profile hosts: primary key 01 with every qualifier, the GS1
664
+ * vocabulary and this standard's own link namespace, `linkset` never the
665
+ * default, and the pinned GS1 context for JSON linksets. It is published only
666
+ * by an origin that actually provides the resolver contract; the legacy API
667
+ * origin does not describe itself as one.
668
+ */
669
+ export function describeResolver(config) {
670
+ let root;
671
+ try {
672
+ root = new URL(config.resolverRoot);
673
+ }
674
+ catch {
675
+ throw new Error('resolverRoot must be an absolute URL');
676
+ }
677
+ if (root.protocol !== 'https:')
678
+ throw new Error('a resolver root is served over TLS');
679
+ if (root.search !== '' || root.hash !== '')
680
+ throw new Error('a resolver root carries no query or fragment');
681
+ return {
682
+ ...(config.name == null ? {} : { name: config.name }),
683
+ resolverRoot: stripTrailingSlashes(config.resolverRoot),
684
+ supportedPrimaryKeys: ['01'],
685
+ supportedLinkType: [
686
+ { namespace: GS1_VOCABULARY, prefix: 'gs1:' },
687
+ { namespace: DPP_LINK_NAMESPACE, prefix: 'dpp:' },
688
+ ],
689
+ linkTypeDefaultCanBeLinkset: false,
690
+ ...(config.supportedContextValuesEnumerated == null ? {} : { supportedContextValuesEnumerated: [...config.supportedContextValuesEnumerated] }),
691
+ ...(config.contact == null ? {} : { contact: structuredClone(config.contact) }),
692
+ ...(config.extensionProfile == null ? {} : { extensionProfile: config.extensionProfile }),
693
+ jsonLdContextLocation: LINKSET_CONTEXT_URI,
694
+ };
695
+ }
696
+ //# sourceMappingURL=gs1-resolution.js.map