@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.
- package/README.md +66 -47
- package/dist/constants.d.ts +25 -6
- package/dist/constants.js +41 -7
- package/dist/identity.d.ts +29 -14
- package/dist/identity.js +79 -39
- package/dist/index.d.ts +2 -0
- package/dist/index.js +2 -0
- package/dist/jws.d.ts +36 -5
- package/dist/jws.js +48 -14
- package/dist/match.d.ts +21 -0
- package/dist/match.js +89 -0
- package/dist/product.d.ts +34 -0
- package/dist/product.js +75 -0
- package/dist/published.d.ts +14 -8
- package/dist/published.js +32 -10
- package/dist/schemas.d.ts +366 -91
- package/dist/schemas.js +802 -181
- package/dist/types.d.ts +121 -47
- package/dist/types.js +2 -2
- package/dist/validate.d.ts +24 -10
- package/dist/validate.js +300 -107
- package/fixtures/list/invalid/list-all-mixed.json +53 -0
- package/fixtures/list/invalid/list-amazon-missing-seller-id.json +51 -0
- package/fixtures/list/invalid/list-amazon-on-walmart-domain.json +52 -0
- package/fixtures/{file/invalid/full-attached-jws.json → list/invalid/list-attached-jws.json} +14 -12
- package/fixtures/list/invalid/list-bad-asin.json +102 -0
- package/fixtures/list/invalid/list-bad-gtin.json +102 -0
- package/fixtures/list/invalid/list-brand-exclusion-no-catalog.json +57 -0
- package/fixtures/list/invalid/list-duplicate-asin.json +102 -0
- package/fixtures/{file/invalid/full-duplicate-authorization-id.json → list/invalid/list-duplicate-authorization-id.json} +23 -18
- package/fixtures/list/invalid/list-duplicate-gtin.json +102 -0
- package/fixtures/list/invalid/list-duplicate-product-id.json +102 -0
- package/fixtures/{file/invalid/full-empty-territories.json → list/invalid/list-empty-territories.json} +17 -10
- package/fixtures/list/invalid/list-exclude-unknown-product.json +105 -0
- package/fixtures/list/invalid/list-expires-field.json +53 -0
- package/fixtures/list/invalid/list-extra-property.json +53 -0
- package/fixtures/list/invalid/list-generic-web-no-territories.json +39 -0
- package/fixtures/list/invalid/list-gtin-11-digits.json +102 -0
- package/fixtures/list/invalid/list-impossible-date.json +53 -0
- package/fixtures/list/invalid/list-lowercase-territory.json +52 -0
- package/fixtures/{file/invalid/full-signature-missing-kid.json → list/invalid/list-marketplace-field.json} +14 -11
- package/fixtures/list/invalid/list-marketplace-territory-mismatch.json +52 -0
- package/fixtures/list/invalid/list-missing-revocation-id.json +51 -0
- package/fixtures/list/invalid/list-missing-scope.json +44 -0
- package/fixtures/list/invalid/list-missing-stale-until.json +51 -0
- package/fixtures/list/invalid/list-name-only-retailer.json +36 -0
- package/fixtures/list/invalid/list-no-channels.json +37 -0
- package/fixtures/list/invalid/list-non-utc-timestamp.json +52 -0
- package/fixtures/list/invalid/list-not-public-with-authorizations.json +18 -0
- package/fixtures/list/invalid/list-not-public-with-catalog.json +18 -0
- package/fixtures/{file/invalid/full-physical-only.json → list/invalid/list-physical.json} +16 -10
- package/fixtures/list/invalid/list-product-no-identifier.json +98 -0
- package/fixtures/list/invalid/list-scope-no-products.json +49 -0
- package/fixtures/{file/valid/full-signed.json → list/invalid/list-single-domain.json} +12 -12
- package/fixtures/list/invalid/list-stale-over-24-hours.json +52 -0
- package/fixtures/list/invalid/list-territory-uk.json +52 -0
- package/fixtures/{file/invalid/full-unknown-channel-type.json → list/invalid/list-unknown-channel-type.json} +18 -12
- package/fixtures/list/invalid/list-unknown-line.json +102 -0
- package/fixtures/list/invalid/list-unknown-product.json +105 -0
- package/fixtures/{file/invalid/full-missing-authorization-expires.json → list/invalid/list-unsigned.json} +12 -9
- package/fixtures/list/invalid/list-valid-over-15-minutes.json +52 -0
- package/fixtures/{file/invalid/full-web-domain-is-url.json → list/invalid/list-web-domain-is-url.json} +18 -12
- package/fixtures/list/invalid/list-web-on-marketplace-domain.json +52 -0
- package/fixtures/{file/invalid/full-wildcard-mixed.json → list/invalid/list-wildcard-mixed.json} +17 -10
- package/fixtures/list/valid/list-catalog-line-scope.json +102 -0
- package/fixtures/list/valid/list-cctld-web.json +39 -0
- package/fixtures/list/valid/list-fractional-seconds.json +52 -0
- package/fixtures/{file/invalid/full-amazon-missing-seller-id.json → list/valid/list-generic-web-territories.json} +15 -17
- package/fixtures/list/valid/list-marketplace-no-territories.json +40 -0
- package/fixtures/list/valid/list-no-authorizations.json +18 -0
- package/fixtures/list/valid/list-no-catalog.json +52 -0
- package/fixtures/list/valid/list-no-expires.json +52 -0
- package/fixtures/list/valid/list-not-public.json +17 -0
- package/fixtures/list/valid/list-product-scope.json +103 -0
- package/fixtures/list/valid/list-proposed-by.json +55 -0
- package/fixtures/list/valid/list-with-expires.json +53 -0
- package/fixtures/list/valid/list-worldwide.json +52 -0
- package/fixtures/manifest.json +606 -158
- package/fixtures/match/all-scope-excluded-line.json +194 -0
- package/fixtures/match/all-scope-not-in-catalog.json +194 -0
- package/fixtures/match/as-of-before-end-date.json +194 -0
- package/fixtures/match/brand-exclusion-beats-named-product.json +194 -0
- package/fixtures/match/duplicate-asin-authorized-seller.json +192 -0
- package/fixtures/match/duplicate-asin-unauthorized-seller.json +191 -0
- package/fixtures/match/expired-end-date.json +193 -0
- package/fixtures/match/marketplace-territory-default.json +186 -0
- package/fixtures/match/marketplace-territory-mismatch.json +187 -0
- package/fixtures/match/match-by-channel-key.json +186 -0
- package/fixtures/match/product-excluded-by-authorization.json +194 -0
- package/fixtures/match/product-line-request.json +186 -0
- package/fixtures/match/product-not-in-scope.json +194 -0
- package/fixtures/match/seller-authorized-by-gtin.json +194 -0
- package/fixtures/match/seller-authorized-for-line.json +194 -0
- package/fixtures/match/worldwide-web.json +186 -0
- package/fixtures/pointer/invalid/full-form.json +11 -0
- package/fixtures/pointer/invalid/pointer-domain-mismatch.json +10 -0
- package/fixtures/{file → pointer}/invalid/pointer-http-url.json +2 -1
- package/fixtures/pointer/invalid/pointer-missing-verify-token.json +9 -0
- package/fixtures/pointer/invalid/pointer-other-host.json +10 -0
- package/fixtures/{file/valid/pointer.json → pointer/invalid/pointer-wrong-spec.json} +1 -0
- package/fixtures/pointer/invalid/private-form.json +10 -0
- package/fixtures/pointer/invalid/unknown-form.json +10 -0
- package/fixtures/pointer/valid/pointer-fetched-via-www.json +10 -0
- package/fixtures/pointer/valid/pointer.json +10 -0
- package/fixtures/revocation-feed/invalid/feed-hashed-entry.json +17 -0
- package/fixtures/revocation-feed/invalid/feed-valid-over-15-minutes.json +17 -0
- package/fixtures/revocation-feed/valid/feed-empty.json +12 -0
- package/fixtures/revocation-feed/valid/feed.json +17 -0
- package/fixtures/verify-request/invalid/bad-as-of.json +9 -0
- package/fixtures/verify-request/invalid/bad-gtin.json +11 -0
- package/fixtures/verify-request/invalid/domain-with-scheme.json +2 -4
- package/fixtures/verify-request/{valid/web-no-product-line.json → invalid/generic-web-no-territory.json} +1 -2
- package/fixtures/verify-request/invalid/gtin-and-asin.json +12 -0
- package/fixtures/verify-request/{valid/amazon.json → invalid/marketplace-field.json} +1 -3
- package/fixtures/verify-request/invalid/missing-channel.json +1 -3
- package/fixtures/verify-request/invalid/physical-channel.json +1 -3
- package/fixtures/verify-request/invalid/retailer-name-only.json +0 -2
- package/fixtures/verify-request/invalid/territory-uk.json +9 -0
- package/fixtures/verify-request/invalid/wildcard-territory.json +2 -3
- package/fixtures/verify-request/valid/as-of-datetime.json +9 -0
- package/fixtures/verify-request/valid/full.json +13 -0
- package/fixtures/verify-request/valid/gtin.json +11 -0
- package/fixtures/verify-request/valid/minimal-marketplace.json +8 -0
- package/fixtures/verify-request/valid/web-cctld-no-territory.json +7 -0
- package/fixtures/verify-request/valid/web-generic-with-territory.json +9 -0
- package/fixtures/verify-response/invalid/authorized-missing-revocation-id.json +18 -0
- package/fixtures/verify-response/invalid/authorized-with-reason.json +20 -0
- package/fixtures/verify-response/invalid/brand-unverified.json +13 -0
- package/fixtures/verify-response/invalid/expired-without-expires.json +18 -0
- package/fixtures/verify-response/invalid/missing-observed.json +11 -5
- package/fixtures/verify-response/invalid/missing-signature.json +9 -3
- package/fixtures/verify-response/invalid/missing-stale-until.json +18 -0
- package/fixtures/verify-response/invalid/not-in-catalog-with-id.json +17 -0
- package/fixtures/verify-response/invalid/not-registered-with-unlisted-reason.json +13 -0
- package/fixtures/verify-response/invalid/stale-until-over-24-hours.json +19 -0
- package/fixtures/verify-response/invalid/unlisted-observed.json +5 -4
- package/fixtures/verify-response/invalid/unlisted-with-authorization-id.json +5 -4
- package/fixtures/verify-response/invalid/unlisted-with-not-registered-reason.json +13 -0
- package/fixtures/verify-response/invalid/unlisted-with-revocation-id.json +13 -0
- package/fixtures/verify-response/invalid/valid-until-before-checked.json +11 -5
- package/fixtures/verify-response/invalid/valid-until-over-15-minutes.json +19 -0
- package/fixtures/verify-response/invalid/with-stale-flag.json +20 -0
- package/fixtures/verify-response/invalid/with-tier.json +11 -5
- package/fixtures/verify-response/valid/authorized-no-product.json +14 -0
- package/fixtures/verify-response/valid/authorized-not-in-catalog.json +22 -0
- package/fixtures/verify-response/valid/authorized-observed.json +11 -5
- package/fixtures/verify-response/valid/authorized-private-product.json +17 -0
- package/fixtures/verify-response/valid/authorized-with-end-date.json +20 -0
- package/fixtures/verify-response/valid/authorized.json +11 -5
- package/fixtures/verify-response/valid/disputed.json +10 -5
- package/fixtures/verify-response/valid/expired.json +12 -6
- package/fixtures/verify-response/valid/not-registered-no-reason.json +12 -0
- package/fixtures/verify-response/valid/not-registered-reason.json +13 -0
- package/fixtures/verify-response/valid/unlisted-product-excluded.json +18 -0
- package/fixtures/verify-response/valid/unlisted-product-not-in-catalog.json +16 -0
- package/fixtures/verify-response/valid/unlisted-territory-mismatch.json +13 -0
- package/fixtures/verify-response/valid/unlisted.json +5 -4
- package/fixtures/verify-response/valid/with-as-of.json +20 -0
- package/fixtures/verify-response/valid/with-signal.json +11 -5
- package/package.json +5 -4
- package/schemas/0.2/common.json +761 -0
- package/schemas/0.2/list.json +87 -0
- package/schemas/0.2/pointer.json +33 -0
- package/schemas/0.2/revocation-feed.json +55 -0
- package/schemas/0.2/verify-request.json +65 -0
- package/schemas/0.2/verify-response.json +284 -0
- package/spec/spec-v0.2.md +531 -0
- package/fixtures/file/invalid/full-all-mixed.json +0 -47
- package/fixtures/file/invalid/full-authorization-expires-after-file.json +0 -46
- package/fixtures/file/invalid/full-expires-before-issued.json +0 -46
- package/fixtures/file/invalid/full-expiry-over-180-days.json +0 -46
- package/fixtures/file/invalid/full-extra-property.json +0 -47
- package/fixtures/file/invalid/full-impossible-date.json +0 -46
- package/fixtures/file/invalid/full-lowercase-territory.json +0 -45
- package/fixtures/file/invalid/full-missing-file-expires.json +0 -45
- package/fixtures/file/invalid/full-missing-product-lines.json +0 -43
- package/fixtures/file/invalid/full-missing-scope.json +0 -37
- package/fixtures/file/invalid/full-missing-territories.json +0 -42
- package/fixtures/file/invalid/full-name-only-retailer.json +0 -30
- package/fixtures/file/invalid/full-no-channels.json +0 -31
- package/fixtures/file/invalid/full-non-utc-timestamp.json +0 -46
- package/fixtures/file/invalid/full-physical-missing-country.json +0 -50
- package/fixtures/file/invalid/full-web-missing-domain.json +0 -45
- package/fixtures/file/invalid/full-wrong-spec.json +0 -46
- package/fixtures/file/invalid/private-missing-verify.json +0 -8
- package/fixtures/file/invalid/private-with-authorizations.json +0 -45
- package/fixtures/file/invalid/unknown-form.json +0 -9
- package/fixtures/file/valid/full-fractional-seconds.json +0 -46
- package/fixtures/file/valid/full-max-expiry.json +0 -46
- package/fixtures/file/valid/full-no-authorizations.json +0 -11
- package/fixtures/file/valid/full-unsigned-handwritten.json +0 -46
- package/fixtures/file/valid/full-worldwide-physical-proposed.json +0 -82
- package/fixtures/file/valid/private.json +0 -9
- package/fixtures/verify-response/invalid/authorized-missing-authorization-id.json +0 -12
- package/fixtures/verify-response/invalid/missing-valid-until.json +0 -12
- package/fixtures/verify-response/invalid/observed-without-last-seen.json +0 -13
- package/fixtures/verify-response/invalid/unknown-status.json +0 -13
- package/fixtures/verify-response/invalid/unlisted-with-reason.json +0 -12
- package/fixtures/verify-response/invalid/valid-until-over-24h.json +0 -13
- package/fixtures/verify-response/valid/brand-unverified-no-reason.json +0 -11
- package/fixtures/verify-response/valid/brand-unverified-reason.json +0 -12
- /package/schemas/{common.json → 0.1/common.json} +0 -0
- /package/schemas/{file-full.json → 0.1/file-full.json} +0 -0
- /package/schemas/{file-pointer.json → 0.1/file-pointer.json} +0 -0
- /package/schemas/{file-private.json → 0.1/file-private.json} +0 -0
- /package/schemas/{file.json → 0.1/file.json} +0 -0
- /package/schemas/{verify-request.json → 0.1/verify-request.json} +0 -0
- /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 (
|
|
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 (
|
|
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
|
-
* -
|
|
48
|
-
*
|
|
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
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
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
|
-
|
|
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
|
}
|
package/dist/match.d.ts
ADDED
|
@@ -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
|
+
};
|
package/dist/product.js
ADDED
|
@@ -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
|
+
}
|
package/dist/published.d.ts
CHANGED
|
@@ -1,15 +1,21 @@
|
|
|
1
|
-
import { type Jwks, type VerifyFailure } from "./jws.js";
|
|
2
|
-
import type {
|
|
3
|
-
export type
|
|
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
|
|
12
|
-
*
|
|
13
|
-
* doesn't count: treat it as no
|
|
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
|
|
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
|
|
2
|
-
//
|
|
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
|
|
7
|
-
*
|
|
8
|
-
* doesn't count: treat it as no
|
|
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
|
|
11
|
-
|
|
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(
|
|
14
|
-
|
|
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
|
}
|