@authorizedretailers/spec 0.1.0 → 0.2.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.
Files changed (209) hide show
  1. package/README.md +66 -47
  2. package/dist/constants.d.ts +25 -6
  3. package/dist/constants.js +41 -7
  4. package/dist/identity.d.ts +29 -14
  5. package/dist/identity.js +79 -39
  6. package/dist/index.d.ts +2 -0
  7. package/dist/index.js +2 -0
  8. package/dist/jws.d.ts +36 -5
  9. package/dist/jws.js +48 -14
  10. package/dist/match.d.ts +21 -0
  11. package/dist/match.js +89 -0
  12. package/dist/product.d.ts +34 -0
  13. package/dist/product.js +75 -0
  14. package/dist/published.d.ts +14 -8
  15. package/dist/published.js +32 -10
  16. package/dist/schemas.d.ts +366 -88
  17. package/dist/schemas.js +802 -178
  18. package/dist/types.d.ts +121 -47
  19. package/dist/types.js +2 -2
  20. package/dist/validate.d.ts +24 -10
  21. package/dist/validate.js +302 -82
  22. package/fixtures/list/invalid/list-all-mixed.json +53 -0
  23. package/fixtures/list/invalid/list-amazon-missing-seller-id.json +51 -0
  24. package/fixtures/list/invalid/list-amazon-on-walmart-domain.json +52 -0
  25. package/fixtures/{file/invalid/full-attached-jws.json → list/invalid/list-attached-jws.json} +14 -12
  26. package/fixtures/list/invalid/list-bad-asin.json +102 -0
  27. package/fixtures/list/invalid/list-bad-gtin.json +102 -0
  28. package/fixtures/list/invalid/list-brand-exclusion-no-catalog.json +57 -0
  29. package/fixtures/list/invalid/list-duplicate-asin.json +102 -0
  30. package/fixtures/{file/invalid/full-duplicate-authorization-id.json → list/invalid/list-duplicate-authorization-id.json} +23 -18
  31. package/fixtures/list/invalid/list-duplicate-gtin.json +102 -0
  32. package/fixtures/list/invalid/list-duplicate-product-id.json +102 -0
  33. package/fixtures/{file/invalid/full-empty-territories.json → list/invalid/list-empty-territories.json} +17 -10
  34. package/fixtures/list/invalid/list-exclude-unknown-product.json +105 -0
  35. package/fixtures/list/invalid/list-expires-field.json +53 -0
  36. package/fixtures/list/invalid/list-extra-property.json +53 -0
  37. package/fixtures/list/invalid/list-generic-web-no-territories.json +39 -0
  38. package/fixtures/list/invalid/list-gtin-11-digits.json +102 -0
  39. package/fixtures/list/invalid/list-impossible-date.json +53 -0
  40. package/fixtures/list/invalid/list-lowercase-territory.json +52 -0
  41. package/fixtures/{file/invalid/full-signature-missing-kid.json → list/invalid/list-marketplace-field.json} +14 -11
  42. package/fixtures/list/invalid/list-marketplace-territory-mismatch.json +52 -0
  43. package/fixtures/list/invalid/list-missing-revocation-id.json +51 -0
  44. package/fixtures/list/invalid/list-missing-scope.json +44 -0
  45. package/fixtures/list/invalid/list-missing-stale-until.json +51 -0
  46. package/fixtures/list/invalid/list-name-only-retailer.json +36 -0
  47. package/fixtures/list/invalid/list-no-channels.json +37 -0
  48. package/fixtures/list/invalid/list-non-utc-timestamp.json +52 -0
  49. package/fixtures/list/invalid/list-not-public-with-authorizations.json +18 -0
  50. package/fixtures/list/invalid/list-not-public-with-catalog.json +18 -0
  51. package/fixtures/list/invalid/list-physical.json +43 -0
  52. package/fixtures/list/invalid/list-product-no-identifier.json +98 -0
  53. package/fixtures/list/invalid/list-scope-no-products.json +49 -0
  54. package/fixtures/{file/valid/full-signed.json → list/invalid/list-single-domain.json} +12 -12
  55. package/fixtures/list/invalid/list-stale-over-24-hours.json +52 -0
  56. package/fixtures/list/invalid/list-territory-uk.json +52 -0
  57. package/fixtures/{file/invalid/full-unknown-channel-type.json → list/invalid/list-unknown-channel-type.json} +18 -12
  58. package/fixtures/list/invalid/list-unknown-line.json +102 -0
  59. package/fixtures/list/invalid/list-unknown-product.json +105 -0
  60. package/fixtures/{file/invalid/full-missing-authorization-expires.json → list/invalid/list-unsigned.json} +12 -9
  61. package/fixtures/list/invalid/list-valid-over-15-minutes.json +52 -0
  62. package/fixtures/{file/invalid/full-web-domain-is-url.json → list/invalid/list-web-domain-is-url.json} +18 -12
  63. package/fixtures/list/invalid/list-web-on-marketplace-domain.json +52 -0
  64. package/fixtures/{file/invalid/full-wildcard-mixed.json → list/invalid/list-wildcard-mixed.json} +17 -10
  65. package/fixtures/list/valid/list-catalog-line-scope.json +102 -0
  66. package/fixtures/list/valid/list-cctld-web.json +39 -0
  67. package/fixtures/list/valid/list-fractional-seconds.json +52 -0
  68. package/fixtures/{file/invalid/full-amazon-missing-seller-id.json → list/valid/list-generic-web-territories.json} +15 -17
  69. package/fixtures/list/valid/list-marketplace-no-territories.json +40 -0
  70. package/fixtures/list/valid/list-no-authorizations.json +18 -0
  71. package/fixtures/list/valid/list-no-catalog.json +52 -0
  72. package/fixtures/list/valid/list-no-expires.json +52 -0
  73. package/fixtures/list/valid/list-not-public.json +17 -0
  74. package/fixtures/list/valid/list-product-scope.json +103 -0
  75. package/fixtures/list/valid/list-proposed-by.json +55 -0
  76. package/fixtures/list/valid/list-with-expires.json +53 -0
  77. package/fixtures/list/valid/list-worldwide.json +52 -0
  78. package/fixtures/manifest.json +609 -154
  79. package/fixtures/match/all-scope-excluded-line.json +194 -0
  80. package/fixtures/match/all-scope-not-in-catalog.json +194 -0
  81. package/fixtures/match/as-of-before-end-date.json +194 -0
  82. package/fixtures/match/brand-exclusion-beats-named-product.json +194 -0
  83. package/fixtures/match/duplicate-asin-authorized-seller.json +192 -0
  84. package/fixtures/match/duplicate-asin-unauthorized-seller.json +191 -0
  85. package/fixtures/match/expired-end-date.json +193 -0
  86. package/fixtures/match/marketplace-territory-default.json +186 -0
  87. package/fixtures/match/marketplace-territory-mismatch.json +187 -0
  88. package/fixtures/match/match-by-channel-key.json +186 -0
  89. package/fixtures/match/product-excluded-by-authorization.json +194 -0
  90. package/fixtures/match/product-line-request.json +186 -0
  91. package/fixtures/match/product-not-in-scope.json +194 -0
  92. package/fixtures/match/seller-authorized-by-gtin.json +194 -0
  93. package/fixtures/match/seller-authorized-for-line.json +194 -0
  94. package/fixtures/match/worldwide-web.json +186 -0
  95. package/fixtures/pointer/invalid/full-form.json +11 -0
  96. package/fixtures/pointer/invalid/pointer-domain-mismatch.json +10 -0
  97. package/fixtures/{file → pointer}/invalid/pointer-http-url.json +2 -1
  98. package/fixtures/pointer/invalid/pointer-missing-verify-token.json +9 -0
  99. package/fixtures/pointer/invalid/pointer-other-host.json +10 -0
  100. package/fixtures/{file/valid/pointer.json → pointer/invalid/pointer-wrong-spec.json} +1 -0
  101. package/fixtures/pointer/invalid/private-form.json +10 -0
  102. package/fixtures/pointer/invalid/unknown-form.json +10 -0
  103. package/fixtures/pointer/valid/pointer-fetched-via-www.json +10 -0
  104. package/fixtures/pointer/valid/pointer.json +10 -0
  105. package/fixtures/revocation-feed/invalid/feed-hashed-entry.json +17 -0
  106. package/fixtures/revocation-feed/invalid/feed-valid-over-15-minutes.json +17 -0
  107. package/fixtures/revocation-feed/valid/feed-empty.json +12 -0
  108. package/fixtures/revocation-feed/valid/feed.json +17 -0
  109. package/fixtures/verify-request/invalid/bad-as-of.json +9 -0
  110. package/fixtures/verify-request/invalid/bad-gtin.json +11 -0
  111. package/fixtures/verify-request/invalid/domain-with-scheme.json +2 -4
  112. package/fixtures/verify-request/{valid/web-no-product-line.json → invalid/generic-web-no-territory.json} +1 -2
  113. package/fixtures/verify-request/invalid/gtin-and-asin.json +12 -0
  114. package/fixtures/verify-request/{valid/amazon.json → invalid/marketplace-field.json} +1 -3
  115. package/fixtures/verify-request/invalid/missing-channel.json +1 -3
  116. package/fixtures/verify-request/invalid/physical-channel.json +1 -3
  117. package/fixtures/verify-request/invalid/retailer-name-only.json +0 -2
  118. package/fixtures/verify-request/invalid/territory-uk.json +9 -0
  119. package/fixtures/verify-request/invalid/wildcard-territory.json +2 -3
  120. package/fixtures/verify-request/valid/as-of-datetime.json +9 -0
  121. package/fixtures/verify-request/valid/full.json +13 -0
  122. package/fixtures/verify-request/valid/gtin.json +11 -0
  123. package/fixtures/verify-request/valid/minimal-marketplace.json +8 -0
  124. package/fixtures/verify-request/valid/web-cctld-no-territory.json +7 -0
  125. package/fixtures/verify-request/valid/web-generic-with-territory.json +9 -0
  126. package/fixtures/verify-response/invalid/authorized-missing-revocation-id.json +18 -0
  127. package/fixtures/verify-response/invalid/authorized-with-reason.json +20 -0
  128. package/fixtures/verify-response/invalid/brand-unverified.json +13 -0
  129. package/fixtures/verify-response/invalid/expired-without-expires.json +18 -0
  130. package/fixtures/verify-response/invalid/missing-observed.json +11 -5
  131. package/fixtures/verify-response/invalid/missing-signature.json +9 -3
  132. package/fixtures/verify-response/invalid/missing-stale-until.json +18 -0
  133. package/fixtures/verify-response/invalid/not-in-catalog-with-id.json +17 -0
  134. package/fixtures/verify-response/invalid/not-registered-with-unlisted-reason.json +13 -0
  135. package/fixtures/verify-response/invalid/stale-until-over-24-hours.json +19 -0
  136. package/fixtures/verify-response/invalid/unlisted-observed.json +5 -4
  137. package/fixtures/verify-response/invalid/unlisted-with-authorization-id.json +5 -4
  138. package/fixtures/verify-response/invalid/unlisted-with-not-registered-reason.json +13 -0
  139. package/fixtures/verify-response/invalid/unlisted-with-revocation-id.json +13 -0
  140. package/fixtures/verify-response/invalid/valid-until-before-checked.json +11 -5
  141. package/fixtures/verify-response/invalid/valid-until-over-15-minutes.json +19 -0
  142. package/fixtures/verify-response/invalid/with-stale-flag.json +20 -0
  143. package/fixtures/verify-response/invalid/with-tier.json +11 -5
  144. package/fixtures/verify-response/valid/authorized-no-product.json +14 -0
  145. package/fixtures/verify-response/valid/authorized-not-in-catalog.json +22 -0
  146. package/fixtures/verify-response/valid/authorized-observed.json +11 -5
  147. package/fixtures/verify-response/valid/authorized-private-product.json +17 -0
  148. package/fixtures/verify-response/valid/authorized-with-end-date.json +20 -0
  149. package/fixtures/verify-response/valid/authorized.json +11 -5
  150. package/fixtures/verify-response/valid/disputed.json +10 -5
  151. package/fixtures/verify-response/valid/expired.json +12 -6
  152. package/fixtures/verify-response/valid/not-registered-no-reason.json +12 -0
  153. package/fixtures/verify-response/valid/not-registered-reason.json +13 -0
  154. package/fixtures/verify-response/valid/unlisted-product-excluded.json +18 -0
  155. package/fixtures/verify-response/valid/unlisted-product-not-in-catalog.json +16 -0
  156. package/fixtures/verify-response/valid/unlisted-territory-mismatch.json +13 -0
  157. package/fixtures/verify-response/valid/unlisted.json +5 -4
  158. package/fixtures/verify-response/valid/with-as-of.json +20 -0
  159. package/fixtures/verify-response/valid/with-signal.json +11 -5
  160. package/package.json +5 -4
  161. package/schemas/{common.json → 0.1/common.json} +3 -2
  162. package/schemas/0.2/common.json +761 -0
  163. package/schemas/0.2/list.json +87 -0
  164. package/schemas/0.2/pointer.json +33 -0
  165. package/schemas/0.2/revocation-feed.json +55 -0
  166. package/schemas/0.2/verify-request.json +65 -0
  167. package/schemas/0.2/verify-response.json +284 -0
  168. package/spec/spec-v0.1.md +11 -3
  169. package/spec/spec-v0.2.md +531 -0
  170. package/fixtures/file/invalid/full-all-mixed.json +0 -47
  171. package/fixtures/file/invalid/full-authorization-expires-after-file.json +0 -46
  172. package/fixtures/file/invalid/full-expires-before-issued.json +0 -46
  173. package/fixtures/file/invalid/full-expiry-over-180-days.json +0 -46
  174. package/fixtures/file/invalid/full-extra-property.json +0 -47
  175. package/fixtures/file/invalid/full-impossible-date.json +0 -46
  176. package/fixtures/file/invalid/full-lowercase-territory.json +0 -45
  177. package/fixtures/file/invalid/full-missing-file-expires.json +0 -45
  178. package/fixtures/file/invalid/full-missing-product-lines.json +0 -43
  179. package/fixtures/file/invalid/full-missing-scope.json +0 -37
  180. package/fixtures/file/invalid/full-missing-territories.json +0 -42
  181. package/fixtures/file/invalid/full-name-only-retailer.json +0 -30
  182. package/fixtures/file/invalid/full-no-channels.json +0 -31
  183. package/fixtures/file/invalid/full-non-utc-timestamp.json +0 -46
  184. package/fixtures/file/invalid/full-physical-missing-country.json +0 -50
  185. package/fixtures/file/invalid/full-web-missing-domain.json +0 -45
  186. package/fixtures/file/invalid/full-wrong-spec.json +0 -46
  187. package/fixtures/file/invalid/private-missing-verify.json +0 -8
  188. package/fixtures/file/invalid/private-with-authorizations.json +0 -45
  189. package/fixtures/file/invalid/unknown-form.json +0 -9
  190. package/fixtures/file/valid/full-fractional-seconds.json +0 -46
  191. package/fixtures/file/valid/full-max-expiry.json +0 -46
  192. package/fixtures/file/valid/full-no-authorizations.json +0 -11
  193. package/fixtures/file/valid/full-unsigned-handwritten.json +0 -46
  194. package/fixtures/file/valid/full-worldwide-physical-proposed.json +0 -82
  195. package/fixtures/file/valid/private.json +0 -9
  196. package/fixtures/verify-response/invalid/authorized-missing-authorization-id.json +0 -12
  197. package/fixtures/verify-response/invalid/missing-valid-until.json +0 -12
  198. package/fixtures/verify-response/invalid/observed-without-last-seen.json +0 -13
  199. package/fixtures/verify-response/invalid/unknown-status.json +0 -13
  200. package/fixtures/verify-response/invalid/unlisted-with-reason.json +0 -12
  201. package/fixtures/verify-response/invalid/valid-until-over-24h.json +0 -13
  202. package/fixtures/verify-response/valid/brand-unverified-no-reason.json +0 -11
  203. package/fixtures/verify-response/valid/brand-unverified-reason.json +0 -12
  204. /package/schemas/{file-full.json → 0.1/file-full.json} +0 -0
  205. /package/schemas/{file-pointer.json → 0.1/file-pointer.json} +0 -0
  206. /package/schemas/{file-private.json → 0.1/file-private.json} +0 -0
  207. /package/schemas/{file.json → 0.1/file.json} +0 -0
  208. /package/schemas/{verify-request.json → 0.1/verify-request.json} +0 -0
  209. /package/schemas/{verify-response.json → 0.1/verify-response.json} +0 -0
package/dist/validate.js CHANGED
@@ -1,10 +1,14 @@
1
1
  import { Validator } from "@cfworker/json-schema";
2
- import { common, file, fileFull, filePointer, filePrivate, verifyRequest, verifyResponse } from "./schemas.js";
3
- import { MAX_ANSWER_VALIDITY_MS, MAX_EXPIRY_MS } from "./constants.js";
2
+ import { common, list, pointer, revocationFeed, verifyRequest, verifyResponse } from "./schemas.js";
3
+ import { MAX_STALE_MS, MAX_VALIDITY_MS, MARKETPLACE_DOMAINS } from "./constants.js";
4
+ import { isCountryCode, isMarketplaceDomain, isRecord, normalizeDomain, territoryForDomain } from "./identity.js";
5
+ import { isValidAsin, isValidGtin, toGtin14 } from "./product.js";
4
6
  import { parseTimestamp } from "./timestamp.js";
5
- const ALL_SCHEMAS = [common, fileFull, filePointer, filePrivate, file, verifyRequest, verifyResponse];
6
- const scopeSchema = { $id: "https://authorizedretailers.ai/schemas/0.1/scope.json", $ref: "common.json#/$defs/scope" };
7
- const channelSchema = { $id: "https://authorizedretailers.ai/schemas/0.1/channel.json", $ref: "common.json#/$defs/channel" };
7
+ const V = "https://authorizedretailers.ai/schemas/0.2";
8
+ const ALL_SCHEMAS = [common, pointer, list, verifyRequest, verifyResponse, revocationFeed];
9
+ const scopeSchema = { $id: `${V}/scope.json`, $ref: "common.json#/$defs/scope" };
10
+ const channelSchema = { $id: `${V}/channel.json`, $ref: "common.json#/$defs/channel" };
11
+ const productSchema = { $id: `${V}/product.json`, $ref: "common.json#/$defs/product" };
8
12
  function makeValidator(root) {
9
13
  const v = new Validator(root, "2020-12", false);
10
14
  for (const s of ALL_SCHEMAS)
@@ -39,109 +43,325 @@ function dedupe(errors) {
39
43
  return true;
40
44
  });
41
45
  }
42
- function done(errors, value) {
43
- return errors.length === 0 ? { valid: true, value: value, errors: [] } : { valid: false, errors };
46
+ class Report {
47
+ errors = [];
48
+ warnings = [];
49
+ /** Paths a specific error already explains; schema errors under them only repeat it. */
50
+ claimed = [];
51
+ error(code, path, message) {
52
+ this.errors.push({ code, path, message });
53
+ }
54
+ warn(code, path, message) {
55
+ this.warnings.push({ code, path, message });
56
+ }
57
+ done(value) {
58
+ return this.errors.length === 0
59
+ ? { valid: true, value: value, errors: [], warnings: this.warnings }
60
+ : { valid: false, errors: this.errors, warnings: this.warnings };
61
+ }
44
62
  }
45
- function ts(errors, path, value) {
63
+ function ts(r, path, value) {
46
64
  const ms = parseTimestamp(value);
47
65
  if (ms === null)
48
- errors.push({ code: "invalid_timestamp", path, message: `not a valid UTC timestamp: ${value}` });
66
+ r.error("invalid_timestamp", path, `not a valid UTC timestamp: ${value}`);
49
67
  return ms;
50
68
  }
51
- /** Rules the JSON Schema cannot express (sections 5 and 10). Assumes the file already passed the schema. */
52
- function fullFileRules(f) {
53
- const errors = [];
54
- const issued = ts(errors, "/issued", f.issued);
55
- const expires = ts(errors, "/expires", f.expires);
56
- if (issued !== null && expires !== null) {
57
- if (expires <= issued) {
58
- errors.push({ code: "expires_not_after_issued", path: "/expires", message: "file expires must be after issued" });
69
+ const CHANNEL_TYPES = new Set(["amazon", "walmart", "ebay", "web"]);
70
+ /**
71
+ * Section 6 rules on one channel that the generic schema output would bury: removed fields and
72
+ * types, and marketplace domains that don't match the type. Runs on unvalidated input.
73
+ */
74
+ function channelRules(r, path, input) {
75
+ if (!isRecord(input))
76
+ return;
77
+ const before = r.errors.length;
78
+ channelChecks(r, path, input);
79
+ if (r.errors.length > before)
80
+ r.claimed.push(path);
81
+ }
82
+ function channelChecks(r, path, input) {
83
+ const type = input.type;
84
+ if (typeof type === "string" && !CHANNEL_TYPES.has(type)) {
85
+ r.error("unknown_channel_type", path, `unknown channel type "${type}"; v0.2 has amazon, walmart, ebay and web (section 6)`);
86
+ return;
87
+ }
88
+ if ("marketplace" in input) {
89
+ r.error("marketplace_field_removed", `${path}/marketplace`, 'the "marketplace" field was removed in v0.2: identify the marketplace by "domain", e.g. "amazon.com" (section 6)');
90
+ }
91
+ if (typeof input.domain !== "string")
92
+ return;
93
+ const domain = normalizeDomain(input.domain);
94
+ if (type === "web") {
95
+ if (isMarketplaceDomain(domain)) {
96
+ r.error("marketplace_domain_mismatch", `${path}/domain`, `${domain} is a marketplace domain; list the seller as a ${MARKETPLACE_DOMAINS[domain].type} channel (section 6)`);
59
97
  }
60
- else if (expires - issued > MAX_EXPIRY_MS) {
61
- errors.push({
62
- code: "file_expiry_exceeds_max",
63
- path: "/expires",
64
- message: "file expires more than 180 days after issued (section 10)",
65
- });
98
+ }
99
+ else if (typeof type === "string") {
100
+ const market = MARKETPLACE_DOMAINS[domain];
101
+ if (!market || market.type !== type) {
102
+ r.error("marketplace_domain_mismatch", `${path}/domain`, `${domain} is not one of the ${type} marketplace domains (section 6)`);
66
103
  }
67
104
  }
68
- const ids = new Set();
69
- f.authorizations.forEach((a, i) => {
70
- const base = `/authorizations/${i}`;
71
- if (ids.has(a.id)) {
72
- errors.push({ code: "duplicate_authorization_id", path: `${base}/id`, message: `duplicate authorization id ${a.id}` });
105
+ }
106
+ /** Territory codes must be ISO 3166-1 alpha-2 (UK is GB). The schema enforces this too; name it plainly. */
107
+ function countryRule(r, path, value) {
108
+ if (typeof value === "string" && value !== "*" && !isCountryCode(value)) {
109
+ r.error("invalid_country", path, `${value} is not an ISO 3166-1 alpha-2 code${value.toUpperCase() === "UK" ? " (the United Kingdom is GB)" : ""}`);
110
+ }
111
+ }
112
+ /** Section 6.2 territory rules for one authorization. Assumes the list passed the schema. */
113
+ function territoryRules(r, base, channels, scope) {
114
+ const listed = scope.territories;
115
+ const worldwide = listed?.[0] === "*";
116
+ channels.forEach((c, j) => {
117
+ const own = territoryForDomain(c.domain);
118
+ const path = `${base}/channels/${j}`;
119
+ if (c.type !== "web") {
120
+ if (listed && !worldwide && own !== null && !listed.includes(own)) {
121
+ r.error("territory_mismatch", `${base}/scope/territories`, `${c.domain} is in ${own}, which scope.territories leaves out (section 6.2)`);
122
+ }
73
123
  }
74
- ids.add(a.id);
75
- const aExpires = ts(errors, `${base}/expires`, a.expires);
76
- if (aExpires !== null && expires !== null && aExpires > expires) {
77
- errors.push({
78
- code: "authorization_expires_after_file",
79
- path: `${base}/expires`,
80
- message: "an authorization's expires MUST NOT be later than the file's expires (section 5)",
81
- });
124
+ else if (own === null) {
125
+ if (!listed)
126
+ r.error("territories_required", path, `${c.domain} is a generic domain, so the authorization must list scope.territories (section 6.2)`);
127
+ }
128
+ else if (listed && !worldwide && !listed.includes(own)) {
129
+ r.warn("territory_differs_from_domain", `${base}/scope/territories`, `${c.domain} is a ${own} domain but the territories listed leave out ${own}`);
130
+ }
131
+ });
132
+ }
133
+ /** Section 3.1 catalog rules. Assumes the list passed the schema. */
134
+ function catalogRules(r, l) {
135
+ const catalog = l.catalog ?? [];
136
+ const ids = new Set();
137
+ const lines = new Set();
138
+ const identifiers = new Map();
139
+ catalog.forEach((p, i) => {
140
+ const base = `/catalog/${i}`;
141
+ if (ids.has(p.id))
142
+ r.error("duplicate_product_id", `${base}/id`, `duplicate product id ${p.id}`);
143
+ ids.add(p.id);
144
+ if (p.product_line)
145
+ lines.add(p.product_line);
146
+ const claim = (key, path, shown) => {
147
+ const other = identifiers.get(key);
148
+ if (other !== undefined && other !== p.id)
149
+ r.error("duplicate_identifier", path, `${shown} is on products ${other} and ${p.id}`);
150
+ identifiers.set(key, p.id);
151
+ };
152
+ if (p.gtin !== undefined) {
153
+ if (!isValidGtin(p.gtin))
154
+ r.error("invalid_gtin", `${base}/gtin`, `${p.gtin} is not a valid GTIN (bad length or check digit)`);
155
+ else
156
+ claim(`gtin:${toGtin14(p.gtin)}`, `${base}/gtin`, `GTIN ${p.gtin}`);
82
157
  }
158
+ p.asins?.forEach((a, j) => claim(`asin:${a}`, `${base}/asins/${j}`, `ASIN ${a}`));
159
+ });
160
+ const hasCatalog = catalog.length > 0;
161
+ const checkLines = (names, path) => names?.forEach((n, k) => {
162
+ if (n === "all")
163
+ return;
164
+ if (hasCatalog && !lines.has(n))
165
+ r.error("unknown_product_line", `${path}/${k}`, `product line "${n}" is not in the catalog (section 3.1)`);
166
+ });
167
+ const checkExclusionLines = (names, path) => names?.forEach((n, k) => {
168
+ if (!lines.has(n))
169
+ r.error("unknown_product_line", `${path}/${k}`, `excluded product line "${n}" is not in the catalog (section 3.1)`);
170
+ });
171
+ const checkProducts = (list, path) => list?.forEach((id, k) => {
172
+ if (!ids.has(id))
173
+ r.error("unknown_product", `${path}/${k}`, `product ${id} is not in the catalog (section 3.1)`);
83
174
  });
84
- return errors;
85
- }
86
- function verifyResponseRules(r) {
87
- const errors = [];
88
- const checked = ts(errors, "/checked", r.checked);
89
- const validUntil = ts(errors, "/valid_until", r.valid_until);
90
- if ("expires" in r)
91
- ts(errors, "/expires", r.expires);
92
- for (const [i, s] of r.signals.entries())
93
- ts(errors, `/signals/${i}/observed`, s.observed);
94
- if (r.observed)
95
- ts(errors, "/observed/last_seen", r.observed.last_seen);
96
- if (checked !== null && validUntil !== null) {
97
- if (validUntil <= checked) {
98
- errors.push({ code: "valid_until_not_after_checked", path: "/valid_until", message: "valid_until must be after checked" });
175
+ checkExclusionLines(l.exclusions?.product_lines, "/exclusions/product_lines");
176
+ checkProducts(l.exclusions?.products, "/exclusions/products");
177
+ l.authorizations.forEach((a, i) => {
178
+ const base = `/authorizations/${i}/scope`;
179
+ checkLines(a.scope.product_lines, `${base}/product_lines`);
180
+ checkExclusionLines(a.scope.exclude_product_lines, `${base}/exclude_product_lines`);
181
+ checkProducts(a.scope.products, `${base}/products`);
182
+ checkProducts(a.scope.exclude_products, `${base}/exclude_products`);
183
+ });
184
+ }
185
+ /** Section 3.1: each product has a gtin or asins. Named plainly, since the schema's anyOf error is vague. */
186
+ function productIdentifierRules(r, input) {
187
+ const catalog = isRecord(input) ? input.catalog : undefined;
188
+ if (!Array.isArray(catalog))
189
+ return;
190
+ catalog.forEach((p, i) => {
191
+ if (isRecord(p) && !("gtin" in p) && !("asins" in p)) {
192
+ r.error("product_without_identifier", `/catalog/${i}`, "every product needs a gtin or at least one ASIN (section 3.1)");
99
193
  }
100
- else if (validUntil - checked > MAX_ANSWER_VALIDITY_MS) {
101
- errors.push({
102
- code: "valid_until_exceeds_max",
103
- path: "/valid_until",
104
- message: "valid_until MUST be no later than 24 hours after checked (section 9)",
194
+ if (isRecord(p) && Array.isArray(p.asins)) {
195
+ p.asins.forEach((a, j) => {
196
+ if (typeof a === "string" && !isValidAsin(a))
197
+ r.error("invalid_asin", `/catalog/${i}/asins/${j}`, `${a} is not an ASIN (10 uppercase letters or digits)`);
105
198
  });
106
199
  }
107
- }
108
- return errors;
200
+ });
201
+ }
202
+ /** Section 10: valid_until within 15 minutes of issue, stale_until within 24 hours and not before valid_until. */
203
+ function freshnessRules(r, issuedAt, issuedPath, doc) {
204
+ const issued = ts(r, issuedPath, issuedAt);
205
+ const validUntil = ts(r, "/valid_until", doc.valid_until);
206
+ const staleUntil = ts(r, "/stale_until", doc.stale_until);
207
+ if (issued === null || validUntil === null || staleUntil === null)
208
+ return;
209
+ if (validUntil <= issued)
210
+ r.error("valid_until_not_after_issued", "/valid_until", `valid_until must be after ${issuedPath.slice(1)}`);
211
+ else if (validUntil - issued > MAX_VALIDITY_MS)
212
+ r.error("valid_until_exceeds_max", "/valid_until", "valid_until MUST be no later than 15 minutes after issue (section 10.1)");
213
+ if (staleUntil < validUntil)
214
+ r.error("stale_until_before_valid_until", "/stale_until", "stale_until must not be before valid_until");
215
+ else if (staleUntil - issued > MAX_STALE_MS)
216
+ r.error("stale_until_exceeds_max", "/stale_until", "stale_until MUST be no later than 24 hours after issue (section 10.2)");
109
217
  }
110
- /** Validates an authorized-retailers.json file of any form. */
111
- export function validateFile(input) {
112
- const errors = schemaErrors(file, input);
113
- if (errors.length === 0 && input.form === "full") {
114
- errors.push(...fullFileRules(input));
218
+ /** Walks the channels in an unvalidated list or request, applying channelRules and countryRule. */
219
+ function preSchemaChecks(r, input) {
220
+ if (!isRecord(input))
221
+ return;
222
+ if (isRecord(input.channel))
223
+ channelRules(r, "/channel", input.channel);
224
+ countryRule(r, "/territory", input.territory);
225
+ if (Array.isArray(input.authorizations)) {
226
+ input.authorizations.forEach((a, i) => {
227
+ if (!isRecord(a))
228
+ return;
229
+ if (Array.isArray(a.channels))
230
+ a.channels.forEach((c, j) => channelRules(r, `/authorizations/${i}/channels/${j}`, c));
231
+ if (isRecord(a.scope) && Array.isArray(a.scope.territories)) {
232
+ a.scope.territories.forEach((t, k) => countryRule(r, `/authorizations/${i}/scope/territories/${k}`, t));
233
+ }
234
+ });
115
235
  }
116
- return done(errors, input);
236
+ productIdentifierRules(r, input);
117
237
  }
118
- export function validateFullFile(input) {
119
- const errors = schemaErrors(fileFull, input);
120
- if (errors.length === 0)
121
- errors.push(...fullFileRules(input));
122
- return done(errors, input);
238
+ /** Keeps the specific errors and drops schema errors at or under the same paths, which only repeat them. */
239
+ function merge(r, schema) {
240
+ const specific = [...r.errors.map((e) => e.path), ...r.claimed];
241
+ for (const e of schema) {
242
+ if (!specific.some((p) => e.path === p || e.path.startsWith(`${p}/`) || p.startsWith(`${e.path}/`)))
243
+ r.errors.push(e);
244
+ }
123
245
  }
124
- export function validatePointerFile(input) {
125
- return done(schemaErrors(filePointer, input), input);
246
+ /**
247
+ * Validates a pointer file (section 5.1). Pass the domain it was fetched from (the domain first
248
+ * asked for, before any redirects) to check that the pointer vouches for it.
249
+ */
250
+ export function validateFile(input, fetchedFrom) {
251
+ const r = new Report();
252
+ const form = isRecord(input) ? input.form : undefined;
253
+ if (form !== "pointer") {
254
+ r.error("form_not_supported", "/form", `form ${JSON.stringify(form)} is not supported; v0.2 has only "pointer" (section 5.1)`);
255
+ return r.done(input);
256
+ }
257
+ const list = input.list;
258
+ if (typeof list === "string" && /^https:\/\//.test(list) && !list.startsWith("https://authorizedretailers.ai/")) {
259
+ r.error("list_not_on_registry", "/list", "the list MUST be an https://authorizedretailers.ai/ URL; a pointer to any other host is no file (section 5.1)");
260
+ }
261
+ const brand = input.brand;
262
+ if (fetchedFrom !== undefined && typeof brand?.domain === "string" && normalizeDomain(brand.domain) !== normalizeDomain(fetchedFrom)) {
263
+ r.error("brand_domain_mismatch", "/brand/domain", `the pointer names ${brand.domain} but was fetched from ${fetchedFrom}; a pointer vouches only for its own domain (section 5.1)`);
264
+ }
265
+ merge(r, schemaErrors(pointer, input));
266
+ return r.done(input);
126
267
  }
127
- export function validatePrivateFile(input) {
128
- return done(schemaErrors(filePrivate, input), input);
268
+ /** Validates a signed list or not-public response (section 5.2). Does not check the signature: see checkPublishedList. */
269
+ export function validateList(input) {
270
+ const r = new Report();
271
+ preSchemaChecks(r, input);
272
+ merge(r, schemaErrors(list, input));
273
+ if (r.errors.length === 0) {
274
+ const l = input;
275
+ freshnessRules(r, l.issued, "/issued", l);
276
+ if (l.public) {
277
+ const ids = new Set();
278
+ l.authorizations.forEach((a, i) => {
279
+ const base = `/authorizations/${i}`;
280
+ if (ids.has(a.id))
281
+ r.error("duplicate_authorization_id", `${base}/id`, `duplicate authorization id ${a.id}`);
282
+ ids.add(a.id);
283
+ if (a.expires !== undefined)
284
+ ts(r, `${base}/expires`, a.expires);
285
+ territoryRules(r, base, a.channels, a.scope);
286
+ });
287
+ catalogRules(r, l);
288
+ }
289
+ }
290
+ return r.done(input);
129
291
  }
130
292
  /** Validates a canonical verify request. Call normalizeVerifyRequest on raw agent input first. */
131
293
  export function validateVerifyRequest(input) {
132
- return done(schemaErrors(verifyRequest, input), input);
294
+ const r = new Report();
295
+ preSchemaChecks(r, input);
296
+ const product = isRecord(input) && isRecord(input.product) ? input.product : undefined;
297
+ if (typeof product?.gtin === "string" && /^(\d{8}|\d{12,14})$/.test(product.gtin) && !isValidGtin(product.gtin)) {
298
+ r.error("invalid_gtin", "/product/gtin", `${product.gtin} has a bad check digit`);
299
+ }
300
+ if (typeof product?.asin === "string" && !isValidAsin(product.asin)) {
301
+ r.error("invalid_asin", "/product/asin", `${product.asin} is not an ASIN (10 uppercase letters or digits)`);
302
+ }
303
+ merge(r, schemaErrors(verifyRequest, input));
304
+ if (r.errors.length === 0) {
305
+ const req = input;
306
+ if (req.territory === undefined && territoryForDomain(req.channel.domain) === null) {
307
+ r.error("territory_required", "/territory", `${req.channel.domain} is a generic domain, so the request must name a territory (section 6.2)`);
308
+ }
309
+ if (req.as_of !== undefined && !/^\d{4}-\d{2}-\d{2}$/.test(req.as_of))
310
+ ts(r, "/as_of", req.as_of);
311
+ }
312
+ return r.done(input);
133
313
  }
134
314
  export function validateVerifyResponse(input) {
135
- const errors = schemaErrors(verifyResponse, input);
136
- if (errors.length === 0)
137
- errors.push(...verifyResponseRules(input));
138
- return done(errors, input);
315
+ const r = new Report();
316
+ merge(r, schemaErrors(verifyResponse, input));
317
+ if (r.errors.length === 0) {
318
+ const res = input;
319
+ freshnessRules(r, res.checked, "/checked", res);
320
+ if ("expires" in res && res.expires !== undefined)
321
+ ts(r, "/expires", res.expires);
322
+ if (res.as_of !== undefined)
323
+ ts(r, "/as_of", res.as_of);
324
+ for (const [i, s] of res.signals.entries())
325
+ ts(r, `/signals/${i}/observed`, s.observed);
326
+ if (res.observed)
327
+ ts(r, "/observed/last_seen", res.observed.last_seen);
328
+ }
329
+ return r.done(input);
330
+ }
331
+ /** Validates a revocation feed (section 10.3). Does not check the signature: see checkRevocationFeed. */
332
+ export function validateRevocationFeed(input) {
333
+ const r = new Report();
334
+ merge(r, schemaErrors(revocationFeed, input));
335
+ if (r.errors.length === 0) {
336
+ const f = input;
337
+ freshnessRules(r, f.issued, "/issued", f);
338
+ f.entries.forEach((e, i) => ts(r, `/entries/${i}/revoked`, e.revoked));
339
+ }
340
+ return r.done(input);
139
341
  }
140
- /** Validates one authorization scope (§3), e.g. before a registry stores it. */
342
+ /** Validates one authorization scope (section 3), e.g. before the registry stores it. */
141
343
  export function validateScope(input) {
142
- return done(schemaErrors(scopeSchema, input), input);
344
+ const r = new Report();
345
+ if (isRecord(input) && Array.isArray(input.territories))
346
+ input.territories.forEach((t, k) => countryRule(r, `/territories/${k}`, t));
347
+ merge(r, schemaErrors(scopeSchema, input));
348
+ return r.done(input);
143
349
  }
144
- /** Validates one channel identifier (§6), e.g. before a registry stores it. */
350
+ /** Validates one channel identifier (section 6), e.g. before the registry stores it. */
145
351
  export function validateChannel(input) {
146
- return done(schemaErrors(channelSchema, input), input);
352
+ const r = new Report();
353
+ channelRules(r, "", input);
354
+ merge(r, schemaErrors(channelSchema, input));
355
+ return r.done(input);
356
+ }
357
+ /** Validates one catalog product (section 3.1), e.g. a row from a brand's CSV import. */
358
+ export function validateProduct(input) {
359
+ const r = new Report();
360
+ productIdentifierRules(r, { catalog: [input] });
361
+ r.errors.forEach((e) => (e.path = e.path.replace(/^\/catalog\/0/, "")));
362
+ if (isRecord(input) && typeof input.gtin === "string" && /^(\d{8}|\d{12,14})$/.test(input.gtin) && !isValidGtin(input.gtin)) {
363
+ r.error("invalid_gtin", "/gtin", `${input.gtin} has a bad check digit`);
364
+ }
365
+ merge(r, schemaErrors(productSchema, input));
366
+ return r.done(input);
147
367
  }
@@ -0,0 +1,53 @@
1
+ {
2
+ "spec": "authorized-retailers/0.2",
3
+ "brand": {
4
+ "name": "Example Brand",
5
+ "domains": [
6
+ "brand.example"
7
+ ]
8
+ },
9
+ "public": true,
10
+ "issued": "2026-09-28T14:00:00Z",
11
+ "valid_until": "2026-09-28T14:15:00Z",
12
+ "stale_until": "2026-09-29T14:00:00Z",
13
+ "authorizations": [
14
+ {
15
+ "id": "auth_01J9X2",
16
+ "revocation_id": "rv_Q2l8dX4bT0aN5kPw",
17
+ "retailer": {
18
+ "name": "Example Retail LLC",
19
+ "entity_id": "ent_7Q4M"
20
+ },
21
+ "channels": [
22
+ {
23
+ "type": "amazon",
24
+ "domain": "amazon.com",
25
+ "seller_id": "A1B2C3D4E5F6G7"
26
+ },
27
+ {
28
+ "type": "walmart",
29
+ "domain": "walmart.com",
30
+ "seller_id": "101234567"
31
+ },
32
+ {
33
+ "type": "web",
34
+ "domain": "retailer.example"
35
+ }
36
+ ],
37
+ "scope": {
38
+ "territories": [
39
+ "US"
40
+ ],
41
+ "product_lines": [
42
+ "all",
43
+ "Running"
44
+ ]
45
+ },
46
+ "proposed_by": null
47
+ }
48
+ ],
49
+ "signature": {
50
+ "kid": "ar-20260928-6e37",
51
+ "jws": "eyJhbGciOiJFZERTQSIsImtpZCI6ImFyLTIwMjYwOTI4LTZlMzcifQ..c2lnbmF0dXJlLXBsYWNlaG9sZGVy"
52
+ }
53
+ }
@@ -0,0 +1,51 @@
1
+ {
2
+ "spec": "authorized-retailers/0.2",
3
+ "brand": {
4
+ "name": "Example Brand",
5
+ "domains": [
6
+ "brand.example"
7
+ ]
8
+ },
9
+ "public": true,
10
+ "issued": "2026-09-28T14:00:00Z",
11
+ "valid_until": "2026-09-28T14:15:00Z",
12
+ "stale_until": "2026-09-29T14:00:00Z",
13
+ "authorizations": [
14
+ {
15
+ "id": "auth_01J9X2",
16
+ "revocation_id": "rv_Q2l8dX4bT0aN5kPw",
17
+ "retailer": {
18
+ "name": "Example Retail LLC",
19
+ "entity_id": "ent_7Q4M"
20
+ },
21
+ "channels": [
22
+ {
23
+ "type": "amazon",
24
+ "domain": "amazon.com"
25
+ },
26
+ {
27
+ "type": "walmart",
28
+ "domain": "walmart.com",
29
+ "seller_id": "101234567"
30
+ },
31
+ {
32
+ "type": "web",
33
+ "domain": "retailer.example"
34
+ }
35
+ ],
36
+ "scope": {
37
+ "territories": [
38
+ "US"
39
+ ],
40
+ "product_lines": [
41
+ "all"
42
+ ]
43
+ },
44
+ "proposed_by": null
45
+ }
46
+ ],
47
+ "signature": {
48
+ "kid": "ar-20260928-6e37",
49
+ "jws": "eyJhbGciOiJFZERTQSIsImtpZCI6ImFyLTIwMjYwOTI4LTZlMzcifQ..c2lnbmF0dXJlLXBsYWNlaG9sZGVy"
50
+ }
51
+ }
@@ -0,0 +1,52 @@
1
+ {
2
+ "spec": "authorized-retailers/0.2",
3
+ "brand": {
4
+ "name": "Example Brand",
5
+ "domains": [
6
+ "brand.example"
7
+ ]
8
+ },
9
+ "public": true,
10
+ "issued": "2026-09-28T14:00:00Z",
11
+ "valid_until": "2026-09-28T14:15:00Z",
12
+ "stale_until": "2026-09-29T14:00:00Z",
13
+ "authorizations": [
14
+ {
15
+ "id": "auth_01J9X2",
16
+ "revocation_id": "rv_Q2l8dX4bT0aN5kPw",
17
+ "retailer": {
18
+ "name": "Example Retail LLC",
19
+ "entity_id": "ent_7Q4M"
20
+ },
21
+ "channels": [
22
+ {
23
+ "type": "amazon",
24
+ "domain": "walmart.com",
25
+ "seller_id": "A1B2C3D4E5F6G7"
26
+ },
27
+ {
28
+ "type": "walmart",
29
+ "domain": "walmart.com",
30
+ "seller_id": "101234567"
31
+ },
32
+ {
33
+ "type": "web",
34
+ "domain": "retailer.example"
35
+ }
36
+ ],
37
+ "scope": {
38
+ "territories": [
39
+ "US"
40
+ ],
41
+ "product_lines": [
42
+ "all"
43
+ ]
44
+ },
45
+ "proposed_by": null
46
+ }
47
+ ],
48
+ "signature": {
49
+ "kid": "ar-20260928-6e37",
50
+ "jws": "eyJhbGciOiJFZERTQSIsImtpZCI6ImFyLTIwMjYwOTI4LTZlMzcifQ..c2lnbmF0dXJlLXBsYWNlaG9sZGVy"
51
+ }
52
+ }
@@ -1,15 +1,19 @@
1
1
  {
2
- "spec": "authorized-retailers/0.1",
3
- "form": "full",
2
+ "spec": "authorized-retailers/0.2",
4
3
  "brand": {
5
4
  "name": "Example Brand",
6
- "domain": "brand.example"
5
+ "domains": [
6
+ "brand.example"
7
+ ]
7
8
  },
8
- "issued": "2026-09-23T00:00:00Z",
9
- "expires": "2026-12-22T00:00:00Z",
9
+ "public": true,
10
+ "issued": "2026-09-28T14:00:00Z",
11
+ "valid_until": "2026-09-28T14:15:00Z",
12
+ "stale_until": "2026-09-29T14:00:00Z",
10
13
  "authorizations": [
11
14
  {
12
15
  "id": "auth_01J9X2",
16
+ "revocation_id": "rv_Q2l8dX4bT0aN5kPw",
13
17
  "retailer": {
14
18
  "name": "Example Retail LLC",
15
19
  "entity_id": "ent_7Q4M"
@@ -17,12 +21,12 @@
17
21
  "channels": [
18
22
  {
19
23
  "type": "amazon",
20
- "marketplace": "US",
24
+ "domain": "amazon.com",
21
25
  "seller_id": "A1B2C3D4E5F6G7"
22
26
  },
23
27
  {
24
28
  "type": "walmart",
25
- "marketplace": "US",
29
+ "domain": "walmart.com",
26
30
  "seller_id": "101234567"
27
31
  },
28
32
  {
@@ -32,19 +36,17 @@
32
36
  ],
33
37
  "scope": {
34
38
  "territories": [
35
- "US",
36
- "CA"
39
+ "US"
37
40
  ],
38
41
  "product_lines": [
39
42
  "all"
40
43
  ]
41
44
  },
42
- "proposed_by": null,
43
- "expires": "2026-12-22T00:00:00Z"
45
+ "proposed_by": null
44
46
  }
45
47
  ],
46
48
  "signature": {
47
- "kid": "ar-2026-09",
49
+ "kid": "ar-20260928-6e37",
48
50
  "jws": "aGVhZA.cGF5bG9hZA.c2ln"
49
51
  }
50
52
  }