@authorizedretailers/spec 0.1.1 → 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 (208) 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 -91
  17. package/dist/schemas.js +802 -181
  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 +300 -107
  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/{file/invalid/full-physical-only.json → list/invalid/list-physical.json} +16 -10
  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 +606 -158
  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/0.2/common.json +761 -0
  162. package/schemas/0.2/list.json +87 -0
  163. package/schemas/0.2/pointer.json +33 -0
  164. package/schemas/0.2/revocation-feed.json +55 -0
  165. package/schemas/0.2/verify-request.json +65 -0
  166. package/schemas/0.2/verify-response.json +284 -0
  167. package/spec/spec-v0.2.md +531 -0
  168. package/fixtures/file/invalid/full-all-mixed.json +0 -47
  169. package/fixtures/file/invalid/full-authorization-expires-after-file.json +0 -46
  170. package/fixtures/file/invalid/full-expires-before-issued.json +0 -46
  171. package/fixtures/file/invalid/full-expiry-over-180-days.json +0 -46
  172. package/fixtures/file/invalid/full-extra-property.json +0 -47
  173. package/fixtures/file/invalid/full-impossible-date.json +0 -46
  174. package/fixtures/file/invalid/full-lowercase-territory.json +0 -45
  175. package/fixtures/file/invalid/full-missing-file-expires.json +0 -45
  176. package/fixtures/file/invalid/full-missing-product-lines.json +0 -43
  177. package/fixtures/file/invalid/full-missing-scope.json +0 -37
  178. package/fixtures/file/invalid/full-missing-territories.json +0 -42
  179. package/fixtures/file/invalid/full-name-only-retailer.json +0 -30
  180. package/fixtures/file/invalid/full-no-channels.json +0 -31
  181. package/fixtures/file/invalid/full-non-utc-timestamp.json +0 -46
  182. package/fixtures/file/invalid/full-physical-missing-country.json +0 -50
  183. package/fixtures/file/invalid/full-web-missing-domain.json +0 -45
  184. package/fixtures/file/invalid/full-wrong-spec.json +0 -46
  185. package/fixtures/file/invalid/private-missing-verify.json +0 -8
  186. package/fixtures/file/invalid/private-with-authorizations.json +0 -45
  187. package/fixtures/file/invalid/unknown-form.json +0 -9
  188. package/fixtures/file/valid/full-fractional-seconds.json +0 -46
  189. package/fixtures/file/valid/full-max-expiry.json +0 -46
  190. package/fixtures/file/valid/full-no-authorizations.json +0 -11
  191. package/fixtures/file/valid/full-unsigned-handwritten.json +0 -46
  192. package/fixtures/file/valid/full-worldwide-physical-proposed.json +0 -82
  193. package/fixtures/file/valid/private.json +0 -9
  194. package/fixtures/verify-response/invalid/authorized-missing-authorization-id.json +0 -12
  195. package/fixtures/verify-response/invalid/missing-valid-until.json +0 -12
  196. package/fixtures/verify-response/invalid/observed-without-last-seen.json +0 -13
  197. package/fixtures/verify-response/invalid/unknown-status.json +0 -13
  198. package/fixtures/verify-response/invalid/unlisted-with-reason.json +0 -12
  199. package/fixtures/verify-response/invalid/valid-until-over-24h.json +0 -13
  200. package/fixtures/verify-response/valid/brand-unverified-no-reason.json +0 -11
  201. package/fixtures/verify-response/valid/brand-unverified-reason.json +0 -12
  202. /package/schemas/{common.json → 0.1/common.json} +0 -0
  203. /package/schemas/{file-full.json → 0.1/file-full.json} +0 -0
  204. /package/schemas/{file-pointer.json → 0.1/file-pointer.json} +0 -0
  205. /package/schemas/{file-private.json → 0.1/file-private.json} +0 -0
  206. /package/schemas/{file.json → 0.1/file.json} +0 -0
  207. /package/schemas/{verify-request.json → 0.1/verify-request.json} +0 -0
  208. /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,136 +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
- const ONLINE_CHANNEL_TYPES = new Set(["amazon", "walmart", "ebay", "web"]);
69
+ const CHANNEL_TYPES = new Set(["amazon", "walmart", "ebay", "web"]);
52
70
  /**
53
- * Section 6: every authorization MUST name at least one online channel identifier. The schema
54
- * enforces this too (`contains`), but its error is buried in generic output, so name it plainly.
55
- * Runs on unvalidated input, so it checks shapes defensively.
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.
56
73
  */
57
- function onlineChannelErrors(input) {
58
- const auths = input?.authorizations;
59
- if (!Array.isArray(auths))
60
- return [];
61
- const errors = [];
62
- auths.forEach((a, i) => {
63
- const channels = a?.channels;
64
- if (!Array.isArray(channels) || channels.length === 0)
65
- return;
66
- const online = channels.some((c) => ONLINE_CHANNEL_TYPES.has(c?.type));
67
- if (!online) {
68
- errors.push({
69
- code: "no_online_channel",
70
- path: `/authorizations/${i}/channels`,
71
- message: "an authorization MUST name at least one online channel identifier (amazon, walmart, ebay or web); a physical identifier alone does not authorize anything (section 6)",
72
- });
73
- }
74
- });
75
- return errors;
76
- }
77
- /** Rules the JSON Schema cannot express (sections 5 and 10). Assumes the file already passed the schema. */
78
- function fullFileRules(f) {
79
- const errors = [];
80
- const issued = ts(errors, "/issued", f.issued);
81
- const expires = ts(errors, "/expires", f.expires);
82
- if (issued !== null && expires !== null) {
83
- if (expires <= issued) {
84
- errors.push({ code: "expires_not_after_issued", path: "/expires", message: "file expires must be after issued" });
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)`);
85
97
  }
86
- else if (expires - issued > MAX_EXPIRY_MS) {
87
- errors.push({
88
- code: "file_expiry_exceeds_max",
89
- path: "/expires",
90
- message: "file expires more than 180 days after issued (section 10)",
91
- });
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)`);
92
103
  }
93
104
  }
94
- const ids = new Set();
95
- f.authorizations.forEach((a, i) => {
96
- const base = `/authorizations/${i}`;
97
- if (ids.has(a.id)) {
98
- 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
+ }
99
123
  }
100
- ids.add(a.id);
101
- const aExpires = ts(errors, `${base}/expires`, a.expires);
102
- if (aExpires !== null && expires !== null && aExpires > expires) {
103
- errors.push({
104
- code: "authorization_expires_after_file",
105
- path: `${base}/expires`,
106
- message: "an authorization's expires MUST NOT be later than the file's expires (section 5)",
107
- });
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}`);
108
130
  }
109
131
  });
110
- return errors;
111
- }
112
- function verifyResponseRules(r) {
113
- const errors = [];
114
- const checked = ts(errors, "/checked", r.checked);
115
- const validUntil = ts(errors, "/valid_until", r.valid_until);
116
- if ("expires" in r)
117
- ts(errors, "/expires", r.expires);
118
- for (const [i, s] of r.signals.entries())
119
- ts(errors, `/signals/${i}/observed`, s.observed);
120
- if (r.observed)
121
- ts(errors, "/observed/last_seen", r.observed.last_seen);
122
- if (checked !== null && validUntil !== null) {
123
- if (validUntil <= checked) {
124
- errors.push({ code: "valid_until_not_after_checked", path: "/valid_until", message: "valid_until must be after checked" });
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}`);
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)`);
174
+ });
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)");
125
193
  }
126
- else if (validUntil - checked > MAX_ANSWER_VALIDITY_MS) {
127
- errors.push({
128
- code: "valid_until_exceeds_max",
129
- path: "/valid_until",
130
- 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)`);
131
198
  });
132
199
  }
133
- }
134
- 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)");
135
217
  }
136
- /** Validates an authorized-retailers.json file of any form. */
137
- export function validateFile(input) {
138
- const isFull = input?.form === "full";
139
- const errors = [...(isFull ? onlineChannelErrors(input) : []), ...schemaErrors(file, input)];
140
- if (errors.length === 0 && input.form === "full") {
141
- 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
+ });
142
235
  }
143
- return done(errors, input);
236
+ productIdentifierRules(r, input);
144
237
  }
145
- export function validateFullFile(input) {
146
- const errors = [...onlineChannelErrors(input), ...schemaErrors(fileFull, input)];
147
- if (errors.length === 0)
148
- errors.push(...fullFileRules(input));
149
- 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
+ }
150
245
  }
151
- export function validatePointerFile(input) {
152
- 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);
153
267
  }
154
- export function validatePrivateFile(input) {
155
- 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);
156
291
  }
157
292
  /** Validates a canonical verify request. Call normalizeVerifyRequest on raw agent input first. */
158
293
  export function validateVerifyRequest(input) {
159
- 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);
160
313
  }
161
314
  export function validateVerifyResponse(input) {
162
- const errors = schemaErrors(verifyResponse, input);
163
- if (errors.length === 0)
164
- errors.push(...verifyResponseRules(input));
165
- 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);
166
341
  }
167
- /** 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. */
168
343
  export function validateScope(input) {
169
- 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);
170
349
  }
171
- /** 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. */
172
351
  export function validateChannel(input) {
173
- 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);
174
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
+ }