@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/jws.js CHANGED
@@ -40,12 +40,16 @@ export async function signDetached(privateKey, kid, payload) {
40
40
  return `${header}..${base64url(sig)}`;
41
41
  }
42
42
  /**
43
- * Reference verifier for agents (§8, §9, §12). Checks, in order:
43
+ * Reference verifier for agents (sections 8, 9, 10, 12). Checks, in order:
44
44
  * - the signature is a well-formed detached EdDSA JWS whose header kid matches signature.kid;
45
- * - the kid is in the current key set: signatures from keys no longer in the set are rejected (§8 MUST);
45
+ * - the kid is in the current key set: signatures from keys no longer in the set are rejected (section 8 MUST);
46
46
  * - the signature verifies over the canonical document;
47
- * - a verify answer is not past its valid_until (§9, §12 MUST), and a list is not past its expires (§5).
48
- * Callers SHOULD refetch the key set once on "unknown_kid" before giving up (§8).
47
+ * - the document is not past its valid_until (section 10.1 MUST). With allowStale and a revocation
48
+ * feed, a list or answer up to its stale_until is accepted as { valid: true, stale: true }, unless
49
+ * the feed lists the answer's revocation_id (section 10.3). A stale list's revoked authorizations
50
+ * are dropped by validAuthorizations(list, now, feed), not here.
51
+ * - a revocation feed is usable up to its own stale_until, and is flagged stale after valid_until.
52
+ * Callers SHOULD refetch the key set once on "unknown_kid" before giving up (section 8).
49
53
  */
50
54
  export async function verifyDocument(doc, jwks, opts = {}) {
51
55
  const now = opts.now ?? Date.now();
@@ -76,15 +80,45 @@ export async function verifyDocument(doc, jwks, opts = {}) {
76
80
  if (!ok)
77
81
  return { valid: false, reason: "bad_signature" };
78
82
  const d = doc;
79
- if (typeof d.valid_until === "string") {
80
- const until = parseTimestamp(d.valid_until);
81
- if (until === null || now > until)
82
- return { valid: false, reason: "answer_expired" };
83
- }
84
- else if (d.form === "full" && typeof d.expires === "string") {
85
- const expires = parseTimestamp(d.expires);
86
- if (expires === null || now >= expires)
87
- return { valid: false, reason: "list_expired" };
83
+ const until = typeof d.valid_until === "string" ? parseTimestamp(d.valid_until) : null;
84
+ const staleUntil = typeof d.stale_until === "string" ? parseTimestamp(d.stale_until) : null;
85
+ const isFeed = d.feed === "revocations";
86
+ const expired = isFeed ? "feed_expired" : typeof d.public === "boolean" ? "list_expired" : "answer_expired";
87
+ if (until === null)
88
+ return { valid: false, reason: expired };
89
+ if (now <= until)
90
+ return { valid: true, kid: sig.kid };
91
+ // Past valid_until. A feed stays usable to its own stale_until: it is what makes stale answers safe.
92
+ if (staleUntil === null || now > staleUntil)
93
+ return { valid: false, reason: expired };
94
+ if (isFeed)
95
+ return { valid: true, kid: sig.kid, stale: true };
96
+ if (!opts.allowStale)
97
+ return { valid: false, reason: expired };
98
+ if (!opts.revocationFeed)
99
+ return { valid: false, reason: "revocation_feed_required" };
100
+ const feed = await verifyDocument(opts.revocationFeed, jwks, { now });
101
+ if (!feed.valid || opts.revocationFeed.feed !== "revocations")
102
+ return { valid: false, reason: "revocation_feed_invalid" };
103
+ if (typeof d.revocation_id === "string" && isRevoked(opts.revocationFeed, d.revocation_id))
104
+ return { valid: false, reason: "revoked" };
105
+ return { valid: true, kid: sig.kid, stale: true };
106
+ }
107
+ /** Whether a revocation feed lists this revocation_id (section 10.3). Doesn't check the feed's signature. */
108
+ export function isRevoked(feed, revocationId) {
109
+ return feed.entries.some((e) => e.revocation_id === revocationId);
110
+ }
111
+ /**
112
+ * Section 10.3: checks a revocation feed's signature and freshness (usable to its stale_until) and
113
+ * returns a lookup for revocation IDs. Makes no network calls: the caller fetches the feed.
114
+ */
115
+ export async function checkRevocationFeed(feed, jwks, opts = {}) {
116
+ if (typeof feed !== "object" || feed === null || feed.feed !== "revocations" || !Array.isArray(feed.entries)) {
117
+ return { valid: false, reason: "not_a_feed" };
88
118
  }
89
- return { valid: true, kid: sig.kid };
119
+ const check = await verifyDocument(feed, jwks, opts);
120
+ if (!check.valid)
121
+ return check;
122
+ const ids = new Set(feed.entries.map((e) => e.revocation_id));
123
+ return { valid: true, kid: check.kid, stale: check.stale === true, revoked: (id) => ids.has(id) };
90
124
  }
@@ -0,0 +1,21 @@
1
+ import type { Authorization, ProductResult, PublicList, UnlistedReason, VerifyRequest } from "./types.js";
2
+ export interface MatchResult {
3
+ status: "authorized" | "unlisted" | "expired";
4
+ reason?: UnlistedReason;
5
+ /** The authorization behind an authorized or expired answer. */
6
+ authorization?: Authorization;
7
+ /** Present when the request named a product (section 9). */
8
+ product?: ProductResult;
9
+ /** Signal types to attach to the answer, e.g. "product_not_in_catalog" (section 9.2 rule 5). */
10
+ signals: string[];
11
+ }
12
+ /** An as_of value (ISO date or UTC date-time) as epoch ms; a bare date means the start of that day, UTC. */
13
+ export declare function parseAsOf(value: string): number | null;
14
+ /**
15
+ * Matches a validated verify request against a brand's public list (sections 6.2, 9.1, 9.2).
16
+ * Seller first: channel, seller ID, territory and date. Then product line or product, only among the
17
+ * authorizations that cover the seller, so a product never makes an uncovered seller authorized.
18
+ * Does not know about registration, disputes or observation: the registry adds those.
19
+ * Throws if the territory can't be worked out (a generic-domain web store with no territory).
20
+ */
21
+ export declare function matchRequest(list: PublicList, request: VerifyRequest, now?: number): MatchResult;
package/dist/match.js ADDED
@@ -0,0 +1,89 @@
1
+ // Section 9: answering a verify request from a public list. The registry and agents holding a
2
+ // cached public list use the same function, so they reach the same answer.
3
+ import { channelKey, coversTerritory, territoryForDomain } from "./identity.js";
4
+ import { productCovered, productLineCovered } from "./product.js";
5
+ import { parseTimestamp } from "./timestamp.js";
6
+ /** An as_of value (ISO date or UTC date-time) as epoch ms; a bare date means the start of that day, UTC. */
7
+ export function parseAsOf(value) {
8
+ return parseTimestamp(/^\d{4}-\d{2}-\d{2}$/.test(value) ? `${value}T00:00:00Z` : value);
9
+ }
10
+ function expiresBy(a, at) {
11
+ if (a.expires === undefined)
12
+ return false;
13
+ const exp = parseTimestamp(a.expires);
14
+ return exp === null || at >= exp;
15
+ }
16
+ /**
17
+ * Matches a validated verify request against a brand's public list (sections 6.2, 9.1, 9.2).
18
+ * Seller first: channel, seller ID, territory and date. Then product line or product, only among the
19
+ * authorizations that cover the seller, so a product never makes an uncovered seller authorized.
20
+ * Does not know about registration, disputes or observation: the registry adds those.
21
+ * Throws if the territory can't be worked out (a generic-domain web store with no territory).
22
+ */
23
+ export function matchRequest(list, request, now = Date.now()) {
24
+ const at = request.as_of !== undefined ? parseAsOf(request.as_of) : now;
25
+ if (at === null)
26
+ throw new TypeError(`invalid as_of: ${request.as_of}`);
27
+ const territory = request.territory ?? territoryForDomain(request.channel.domain);
28
+ if (territory === null)
29
+ throw new TypeError("territory is required for a web store on a generic domain (section 6.2)");
30
+ const key = channelKey(request.channel);
31
+ const named = list.authorizations
32
+ .map((a) => ({ a, channel: a.channels.find((c) => channelKey(c) === key) }))
33
+ .filter((x) => x.channel !== undefined);
34
+ const inTerritory = named.filter(({ a, channel }) => coversTerritory(a, channel, territory));
35
+ const live = inTerritory.filter(({ a }) => !expiresBy(a, at)).map(({ a }) => a);
36
+ if (live.length === 0) {
37
+ const ended = inTerritory.filter(({ a }) => expiresBy(a, at)).map(({ a }) => a);
38
+ if (ended.length > 0) {
39
+ const latest = ended.reduce((x, y) => (parseTimestamp(y.expires) > parseTimestamp(x.expires) ? y : x));
40
+ return { status: "expired", authorization: latest, signals: [], ...productOnly(list, request) };
41
+ }
42
+ // Section 6.2: a marketplace domain fixes the territory. Say so when the seller is listed there.
43
+ const marketplaceElsewhere = named.some(({ a, channel }) => channel.type !== "web" && !expiresBy(a, at) && territoryForDomain(channel.domain) !== territory);
44
+ return {
45
+ status: "unlisted",
46
+ ...(marketplaceElsewhere ? { reason: "territory_mismatch" } : {}),
47
+ signals: [],
48
+ ...productOnly(list, request),
49
+ };
50
+ }
51
+ let candidates = live;
52
+ if (request.product_line !== undefined) {
53
+ candidates = candidates.filter((a) => productLineCovered(a, request.product_line, list.exclusions));
54
+ if (candidates.length === 0)
55
+ return { status: "unlisted", signals: [], ...productOnly(list, request) };
56
+ }
57
+ if (request.product === undefined)
58
+ return { status: "authorized", authorization: candidates[0], signals: [] };
59
+ const results = candidates.map((a) => ({ a, r: productCovered(list, a, request.product) }));
60
+ const hit = results.find(({ r }) => r.coverage === "covered");
61
+ const first = results[0].r;
62
+ const product = first.in_catalog
63
+ ? { in_catalog: true, id: first.product.id, ...(first.product.product_line ? { product_line: first.product.product_line } : {}) }
64
+ : { in_catalog: false };
65
+ if (hit) {
66
+ return {
67
+ status: "authorized",
68
+ authorization: hit.a,
69
+ product,
70
+ signals: hit.r.in_catalog ? [] : ["product_not_in_catalog"],
71
+ };
72
+ }
73
+ const reason = !first.in_catalog
74
+ ? "product_not_in_catalog"
75
+ : results.some(({ r }) => r.coverage === "excluded")
76
+ ? "product_excluded"
77
+ : "product_not_in_scope";
78
+ return { status: "unlisted", reason, product, signals: [] };
79
+ }
80
+ /** The product part of an answer that was decided before the product was considered. */
81
+ function productOnly(list, request) {
82
+ if (request.product === undefined)
83
+ return {};
84
+ const probe = productCovered(list, { scope: {} }, request.product);
85
+ if (!probe.in_catalog)
86
+ return { product: { in_catalog: false } };
87
+ const p = probe.product;
88
+ return { product: { in_catalog: true, id: p.id, ...(p.product_line ? { product_line: p.product_line } : {}) } };
89
+ }
@@ -0,0 +1,34 @@
1
+ import type { Authorization, Exclusions, Product, ProductRef, PublicList } from "./types.js";
2
+ /** Whether a GTIN (8, 12, 13 or 14 digits) has a valid GS1 check digit. */
3
+ export declare function isValidGtin(gtin: string): boolean;
4
+ /** GTIN-14 form used for matching: left-padded with zeros. Throws on an invalid GTIN. */
5
+ export declare function toGtin14(gtin: string): string;
6
+ /** An ASIN: 10 uppercase letters or digits. */
7
+ export declare function isValidAsin(asin: string): boolean;
8
+ /** The catalog product a GTIN or ASIN refers to, or undefined when it isn't in the catalog. */
9
+ export declare function findProduct(catalog: Product[] | undefined, ref: ProductRef): Product | undefined;
10
+ export type Coverage = "covered" | "excluded" | "not_in_scope";
11
+ /**
12
+ * Section 3.1: whether an authorization covers a catalog product. Exclusions (the authorization's
13
+ * and the brand's) always win over inclusions.
14
+ */
15
+ export declare function productCoverage(authorization: Authorization, product: Product, brandExclusions?: Exclusions): Coverage;
16
+ /** Whether the authorization is scoped to every product (product_lines ["all"]). */
17
+ export declare function coversAllProducts(authorization: Authorization): boolean;
18
+ /**
19
+ * Section 9: whether an authorization covers a whole product line, for requests that ask with
20
+ * product_line. The line is covered when the scope is ["all"] or names it, and nobody excludes it.
21
+ */
22
+ export declare function productLineCovered(authorization: Authorization, line: string, brandExclusions?: Exclusions): boolean;
23
+ /**
24
+ * Section 9.2, for one authorization: covered, excluded, not in scope, or (for a product the catalog
25
+ * doesn't hold) whether the authorization's ["all"] scope still covers it.
26
+ */
27
+ export declare function productCovered(list: Pick<PublicList, "catalog" | "exclusions">, authorization: Authorization, product: ProductRef): {
28
+ in_catalog: true;
29
+ product: Product;
30
+ coverage: Coverage;
31
+ } | {
32
+ in_catalog: false;
33
+ coverage: "covered" | "not_in_catalog";
34
+ };
@@ -0,0 +1,75 @@
1
+ // Section 3.1: product identifiers and catalog lookups.
2
+ /** Whether a GTIN (8, 12, 13 or 14 digits) has a valid GS1 check digit. */
3
+ export function isValidGtin(gtin) {
4
+ if (!/^(\d{8}|\d{12,14})$/.test(gtin))
5
+ return false;
6
+ const digits = [...gtin].map(Number);
7
+ const check = digits.pop();
8
+ // Weights alternate 3, 1, 3, ... from the digit next to the check digit.
9
+ const sum = digits.reverse().reduce((s, d, i) => s + d * (i % 2 === 0 ? 3 : 1), 0);
10
+ return (10 - (sum % 10)) % 10 === check;
11
+ }
12
+ /** GTIN-14 form used for matching: left-padded with zeros. Throws on an invalid GTIN. */
13
+ export function toGtin14(gtin) {
14
+ if (!isValidGtin(gtin))
15
+ throw new Error(`invalid GTIN: ${gtin}`);
16
+ return gtin.padStart(14, "0");
17
+ }
18
+ /** An ASIN: 10 uppercase letters or digits. */
19
+ export function isValidAsin(asin) {
20
+ return /^[A-Z0-9]{10}$/.test(asin);
21
+ }
22
+ /** The catalog product a GTIN or ASIN refers to, or undefined when it isn't in the catalog. */
23
+ export function findProduct(catalog, ref) {
24
+ if (!catalog)
25
+ return undefined;
26
+ if ("gtin" in ref) {
27
+ if (!isValidGtin(ref.gtin))
28
+ return undefined;
29
+ const want = toGtin14(ref.gtin);
30
+ return catalog.find((p) => p.gtin !== undefined && isValidGtin(p.gtin) && toGtin14(p.gtin) === want);
31
+ }
32
+ const asin = ref.asin.toUpperCase();
33
+ return catalog.find((p) => p.asins?.includes(asin));
34
+ }
35
+ /**
36
+ * Section 3.1: whether an authorization covers a catalog product. Exclusions (the authorization's
37
+ * and the brand's) always win over inclusions.
38
+ */
39
+ export function productCoverage(authorization, product, brandExclusions) {
40
+ const s = authorization.scope;
41
+ const line = product.product_line;
42
+ const excluded = (line !== undefined && (s.exclude_product_lines?.includes(line) || brandExclusions?.product_lines?.includes(line))) ||
43
+ s.exclude_products?.includes(product.id) ||
44
+ brandExclusions?.products?.includes(product.id);
45
+ if (excluded)
46
+ return "excluded";
47
+ const included = s.product_lines?.[0] === "all" ||
48
+ (line !== undefined && s.product_lines?.includes(line)) ||
49
+ s.products?.includes(product.id);
50
+ return included ? "covered" : "not_in_scope";
51
+ }
52
+ /** Whether the authorization is scoped to every product (product_lines ["all"]). */
53
+ export function coversAllProducts(authorization) {
54
+ return authorization.scope.product_lines?.[0] === "all";
55
+ }
56
+ /**
57
+ * Section 9: whether an authorization covers a whole product line, for requests that ask with
58
+ * product_line. The line is covered when the scope is ["all"] or names it, and nobody excludes it.
59
+ */
60
+ export function productLineCovered(authorization, line, brandExclusions) {
61
+ const s = authorization.scope;
62
+ if (s.exclude_product_lines?.includes(line) || brandExclusions?.product_lines?.includes(line))
63
+ return false;
64
+ return s.product_lines?.[0] === "all" || (s.product_lines?.includes(line) ?? false);
65
+ }
66
+ /**
67
+ * Section 9.2, for one authorization: covered, excluded, not in scope, or (for a product the catalog
68
+ * doesn't hold) whether the authorization's ["all"] scope still covers it.
69
+ */
70
+ export function productCovered(list, authorization, product) {
71
+ const found = findProduct(list.catalog, product);
72
+ if (found)
73
+ return { in_catalog: true, product: found, coverage: productCoverage(authorization, found, list.exclusions) };
74
+ return { in_catalog: false, coverage: coversAllProducts(authorization) ? "covered" : "not_in_catalog" };
75
+ }
@@ -1,15 +1,21 @@
1
- import { type Jwks, type VerifyFailure } from "./jws.js";
2
- import type { AuthorizedRetailersFile } from "./types.js";
3
- export type PublishedFileCheck = {
1
+ import { type Jwks, type VerifyFailure, type VerifyOptions } from "./jws.js";
2
+ import type { Authorization, PublicList, RevocationFeed } from "./types.js";
3
+ export type PublishedListCheck = {
4
4
  counts: true;
5
5
  kid: string;
6
+ stale?: true;
6
7
  } | {
7
8
  counts: false;
8
- reason: VerifyFailure | "domain_mismatch";
9
+ reason: VerifyFailure | "domain_mismatch" | "not_a_list";
9
10
  };
10
11
  /**
11
- * Whether a published file counts, given the domain it was fetched from and the registry's keys.
12
- * An unsigned file, a bad signature, an expired list, or a file naming another brand's domain
13
- * doesn't count: treat it as no file at all.
12
+ * Whether a fetched list counts for `brandDomain`, given the registry's keys. An unsigned list, a
13
+ * bad signature, a list past its valid_until (or stale_until, with allowStale) or a list that
14
+ * doesn't name the domain doesn't count: treat it as no list at all.
14
15
  */
15
- export declare function checkPublishedFile(fetchedFrom: string, file: AuthorizedRetailersFile, jwks: Jwks, now?: number): Promise<PublishedFileCheck>;
16
+ export declare function checkPublishedList(brandDomain: string, list: unknown, jwks: Jwks, opts?: VerifyOptions): Promise<PublishedListCheck>;
17
+ /**
18
+ * The authorizations a reader may rely on at `now`: those without an end date or not yet past it.
19
+ * With a revocation feed (for a stale list, section 10.3), revoked authorizations are dropped too.
20
+ */
21
+ export declare function validAuthorizations(list: PublicList, now?: number, revocationFeed?: RevocationFeed): Authorization[];
package/dist/published.js CHANGED
@@ -1,15 +1,37 @@
1
- // Section 5: which published files count. Only a file signed by a registry, and only for the domain
2
- // it was fetched from. Indexes and agents reading brands' files directly both apply this.
1
+ // Section 5.2: which signed lists count. Only a list signed with the registry's keys, and only for a
2
+ // brand domain the list names. Agents reading lists directly or from a mirror apply this.
3
3
  import { normalizeDomain } from "./identity.js";
4
- import { verifyDocument } from "./jws.js";
4
+ import { isRevoked, verifyDocument } from "./jws.js";
5
+ import { parseTimestamp } from "./timestamp.js";
5
6
  /**
6
- * Whether a published file counts, given the domain it was fetched from and the registry's keys.
7
- * An unsigned file, a bad signature, an expired list, or a file naming another brand's domain
8
- * doesn't count: treat it as no file at all.
7
+ * Whether a fetched list counts for `brandDomain`, given the registry's keys. An unsigned list, a
8
+ * bad signature, a list past its valid_until (or stale_until, with allowStale) or a list that
9
+ * doesn't name the domain doesn't count: treat it as no list at all.
9
10
  */
10
- export async function checkPublishedFile(fetchedFrom, file, jwks, now = Date.now()) {
11
- if (normalizeDomain(file.brand.domain) !== normalizeDomain(fetchedFrom))
11
+ export async function checkPublishedList(brandDomain, list, jwks, opts = {}) {
12
+ const domains = list?.brand?.domains;
13
+ if (typeof list !== "object" || list === null || !Array.isArray(domains) || typeof list.public !== "boolean") {
14
+ return { counts: false, reason: "not_a_list" };
15
+ }
16
+ const want = normalizeDomain(brandDomain);
17
+ if (!domains.some((d) => typeof d === "string" && normalizeDomain(d) === want))
12
18
  return { counts: false, reason: "domain_mismatch" };
13
- const sig = await verifyDocument(file, jwks, { now });
14
- return sig.valid ? { counts: true, kid: sig.kid } : { counts: false, reason: sig.reason };
19
+ const sig = await verifyDocument(list, jwks, opts);
20
+ if (!sig.valid)
21
+ return { counts: false, reason: sig.reason };
22
+ return sig.stale ? { counts: true, kid: sig.kid, stale: true } : { counts: true, kid: sig.kid };
23
+ }
24
+ /**
25
+ * The authorizations a reader may rely on at `now`: those without an end date or not yet past it.
26
+ * With a revocation feed (for a stale list, section 10.3), revoked authorizations are dropped too.
27
+ */
28
+ export function validAuthorizations(list, now = Date.now(), revocationFeed) {
29
+ return list.authorizations.filter((a) => {
30
+ if (revocationFeed && isRevoked(revocationFeed, a.revocation_id))
31
+ return false;
32
+ if (a.expires === undefined)
33
+ return true;
34
+ const exp = parseTimestamp(a.expires);
35
+ return exp !== null && now < exp;
36
+ });
15
37
  }