From 70b559c7ab86adb6f024b8e375fe68042ba01974 Mon Sep 17 00:00:00 2001 From: Andrew Lisowski Date: Mon, 4 May 2026 22:54:43 -0700 Subject: [PATCH 1/7] implement --- .oxlintrc.json | 1 + .../fyi/atstore/auth/thirdPartyReviews.json | 32 + .../fyi/atstore/directory/getListing.json | 117 ++ .../fyi/atstore/directory/resolveListing.json | 37 + .../fyi/atstore/directory/searchListings.json | 103 ++ .../fyi/atstore/reviews/listForListing.json | 75 + .../fyi/atstore/reviews/submitReview.json | 61 + lexicons/fyi/atstore/server/describe.json | 44 + package.json | 2 +- src/components/SiteFooter.tsx | 3 +- .../api-directory-listings.functions.ts | 11 + src/lexicons/generated/bundle.ts | 1270 +++++++++++++---- src/lib/atproto/nsids.ts | 17 + src/routeTree.gen.ts | 43 + .../_header-layout.developers.atproto.tsx | 210 +++ src/routes/xrpc.$nsid.tsx | 15 + .../atstore-xrpc-handler.server.ts | 691 +++++++++ 17 files changed, 2473 insertions(+), 259 deletions(-) create mode 100644 lexicons/fyi/atstore/auth/thirdPartyReviews.json create mode 100644 lexicons/fyi/atstore/directory/getListing.json create mode 100644 lexicons/fyi/atstore/directory/resolveListing.json create mode 100644 lexicons/fyi/atstore/directory/searchListings.json create mode 100644 lexicons/fyi/atstore/reviews/listForListing.json create mode 100644 lexicons/fyi/atstore/reviews/submitReview.json create mode 100644 lexicons/fyi/atstore/server/describe.json create mode 100644 src/routes/_header-layout.developers.atproto.tsx create mode 100644 src/routes/xrpc.$nsid.tsx create mode 100644 src/server/atproto-xrpc/atstore-xrpc-handler.server.ts diff --git a/.oxlintrc.json b/.oxlintrc.json index a287378..74c78c6 100644 --- a/.oxlintrc.json +++ b/.oxlintrc.json @@ -10,6 +10,7 @@ }, "ignorePatterns": [ "**/routeTree.gen.ts", + "**/src/lexicons/generated/bundle.ts", "eslint.config.js", "**/public/**/*.js", "**/node_modules", diff --git a/lexicons/fyi/atstore/auth/thirdPartyReviews.json b/lexicons/fyi/atstore/auth/thirdPartyReviews.json new file mode 100644 index 0000000..ba64dd5 --- /dev/null +++ b/lexicons/fyi/atstore/auth/thirdPartyReviews.json @@ -0,0 +1,32 @@ +{ + "lexicon": 1, + "id": "fyi.atstore.authThirdPartyReviews", + "description": "OAuth permission bundle for third-party apps that let users submit AT Store listing reviews (profile bootstrap + review create + submitReview RPC).", + "defs": { + "main": { + "type": "permission-set", + "title": "Submit AT Store reviews", + "detail": "Ensure fyi.atstore.profile/self exists (first-login parity), create listing reviews on the user's repo, and call the at-store submitReview API using delegated auth.", + "permissions": [ + { + "type": "permission", + "resource": "repo", + "collection": ["fyi.atstore.profile"], + "action": ["create"] + }, + { + "type": "permission", + "resource": "repo", + "collection": ["fyi.atstore.listing.review"], + "action": ["create"] + }, + { + "type": "permission", + "resource": "rpc", + "inheritAud": true, + "lxm": ["fyi.atstore.reviews.submitReview"] + } + ] + } + } +} diff --git a/lexicons/fyi/atstore/directory/getListing.json b/lexicons/fyi/atstore/directory/getListing.json new file mode 100644 index 0000000..101086a --- /dev/null +++ b/lexicons/fyi/atstore/directory/getListing.json @@ -0,0 +1,117 @@ +{ + "lexicon": 1, + "id": "fyi.atstore.directory.getListing", + "defs": { + "listingCardGet": { + "type": "object", + "required": [ + "id", + "name", + "tagline", + "description", + "category", + "accent", + "reviewCount", + "priceLabel", + "appTags", + "categorySlugs" + ], + "properties": { + "id": { "type": "string", "maxLength": 64 }, + "name": { "type": "string", "maxLength": 640 }, + "slug": { "type": "string", "maxLength": 640 }, + "tagline": { "type": "string", "maxLength": 2000 }, + "description": { "type": "string", "maxLength": 20000 }, + "iconUrl": { "type": "string", "maxLength": 8192, "nullable": true }, + "heroImageUrl": { "type": "string", "maxLength": 8192, "nullable": true }, + "categorySlug": { "type": "string", "maxLength": 512, "nullable": true }, + "categorySlugs": { + "type": "array", + "items": { "type": "string", "maxLength": 512 } + }, + "category": { "type": "string", "maxLength": 640 }, + "accent": { + "type": "string", + "maxLength": 16, + "knownValues": ["blue", "pink", "purple", "green"] + }, + "rating": { + "type": "string", + "maxLength": 16, + "nullable": true + }, + "reviewCount": { "type": "integer" }, + "priceLabel": { "type": "string", "maxLength": 32 }, + "productAccountHandle": { "type": "string", "maxLength": 512, "nullable": true }, + "appTags": { + "type": "array", + "items": { "type": "string", "maxLength": 256 } + } + } + }, + "listingLinkRow": { + "type": "object", + "required": ["uri"], + "properties": { + "label": { "type": "string", "maxLength": 640 }, + "uri": { "type": "string", "maxLength": 2048 } + } + }, + "listingDetailResponse": { + "type": "object", + "required": ["listing", "isStoreManaged"], + "properties": { + "listing": { + "type": "ref", + "ref": "#listingCardGet" + }, + "atUri": { "type": "string", "maxLength": 2560, "nullable": true }, + "isStoreManaged": { "type": "boolean" }, + "repoDid": { "type": "string", "maxLength": 2048, "nullable": true }, + "productAccountDid": { "type": "string", "maxLength": 2048, "nullable": true }, + "sourceTagline": { "type": "string", "maxLength": 20000, "nullable": true }, + "sourceFullDescription": { "type": "string", "maxLength": 20000, "nullable": true }, + "screenshots": { + "type": "array", + "items": { "type": "string", "maxLength": 4096 } + }, + "externalUrl": { "type": "string", "maxLength": 2048, "nullable": true }, + "sourceUrl": { "type": "string", "maxLength": 8192, "nullable": true }, + "createdAt": { "type": "string", "maxLength": 64, "nullable": true }, + "updatedAt": { "type": "string", "maxLength": 64, "nullable": true }, + "links": { + "type": "array", + "items": { + "type": "ref", + "ref": "#listingLinkRow" + } + } + } + }, + "main": { + "type": "query", + "description": "Fetch one public verified listing by stable Postgres id (UUID) or by URL slug.", + "parameters": { + "type": "params", + "properties": { + "listingId": { + "type": "string", + "maxLength": 64 + }, + "slug": { + "type": "string", + "maxLength": 640 + } + } + }, + "output": { + "encoding": "application/json", + "schema": { + "type": "ref", + "ref": "#listingDetailResponse" + } + }, + "errors": [{ "name": "ListingNotFound" }, { "name": "InvalidParams" }] + } + } +} diff --git a/lexicons/fyi/atstore/directory/resolveListing.json b/lexicons/fyi/atstore/directory/resolveListing.json new file mode 100644 index 0000000..2d5b0bf --- /dev/null +++ b/lexicons/fyi/atstore/directory/resolveListing.json @@ -0,0 +1,37 @@ +{ + "lexicon": 1, + "id": "fyi.atstore.directory.resolveListing", + "defs": { + "main": { + "type": "query", + "description": "Resolve a storefront external URL to a directory listing when uniquely matched.", + "parameters": { + "type": "params", + "required": ["externalUrl"], + "properties": { + "externalUrl": { + "type": "string", + "maxLength": 2048, + "description": "Listing external_url / product URL as stored on the record." + } + } + }, + "output": { + "encoding": "application/json", + "schema": { + "type": "object", + "required": ["listingId", "slug"], + "properties": { + "listingId": { "type": "string", "maxLength": 64 }, + "slug": { "type": "string", "maxLength": 640 }, + "atUri": { "type": "string", "maxLength": 2560, "nullable": true } + } + } + }, + "errors": [ + { "name": "ListingNotFound" }, + { "name": "AmbiguousResolution" } + ] + } + } +} diff --git a/lexicons/fyi/atstore/directory/searchListings.json b/lexicons/fyi/atstore/directory/searchListings.json new file mode 100644 index 0000000..d9abf25 --- /dev/null +++ b/lexicons/fyi/atstore/directory/searchListings.json @@ -0,0 +1,103 @@ +{ + "lexicon": 1, + "id": "fyi.atstore.directory.searchListings", + "defs": { + "listingCardSearch": { + "type": "object", + "required": [ + "id", + "name", + "tagline", + "description", + "category", + "accent", + "reviewCount", + "priceLabel", + "appTags", + "categorySlugs" + ], + "properties": { + "id": { "type": "string", "maxLength": 64 }, + "name": { "type": "string", "maxLength": 640 }, + "slug": { "type": "string", "maxLength": 640 }, + "tagline": { "type": "string", "maxLength": 2000 }, + "description": { "type": "string", "maxLength": 20000 }, + "iconUrl": { "type": "string", "maxLength": 8192, "nullable": true }, + "heroImageUrl": { "type": "string", "maxLength": 8192, "nullable": true }, + "categorySlug": { "type": "string", "maxLength": 512, "nullable": true }, + "categorySlugs": { + "type": "array", + "items": { "type": "string", "maxLength": 512 } + }, + "category": { "type": "string", "maxLength": 640 }, + "accent": { + "type": "string", + "maxLength": 16, + "knownValues": ["blue", "pink", "purple", "green"] + }, + "rating": { + "type": "string", + "maxLength": 16, + "nullable": true + }, + "reviewCount": { "type": "integer" }, + "priceLabel": { "type": "string", "maxLength": 32 }, + "productAccountHandle": { "type": "string", "maxLength": 512, "nullable": true }, + "appTags": { + "type": "array", + "items": { "type": "string", "maxLength": 256 } + } + } + }, + "main": { + "type": "query", + "description": "Directory listing search and pagination.", + "parameters": { + "type": "params", + "properties": { + "q": { + "type": "string", + "maxLength": 512 + }, + "sort": { + "type": "string", + "maxLength": 24, + "default": "popular", + "enum": ["popular", "newest", "alphabetical"] + }, + "limit": { + "type": "integer", + "minimum": 1, + "maximum": 100, + "default": 24 + }, + "cursor": { + "type": "string", + "maxLength": 512 + } + } + }, + "output": { + "encoding": "application/json", + "schema": { + "type": "object", + "required": ["listings"], + "properties": { + "cursor": { + "type": "string", + "maxLength": 512 + }, + "listings": { + "type": "array", + "items": { + "type": "ref", + "ref": "#listingCardSearch" + } + } + } + } + }, + "errors": [{ "name": "InvalidCursor" }] + } + } +} diff --git a/lexicons/fyi/atstore/reviews/listForListing.json b/lexicons/fyi/atstore/reviews/listForListing.json new file mode 100644 index 0000000..18fcaa5 --- /dev/null +++ b/lexicons/fyi/atstore/reviews/listForListing.json @@ -0,0 +1,75 @@ +{ + "lexicon": 1, + "id": "fyi.atstore.reviews.listForListing", + "defs": { + "listingReviewView": { + "type": "object", + "required": [ + "id", + "authorDid", + "rating", + "reviewCreatedAt", + "replyCount", + "canReply" + ], + "properties": { + "id": { "type": "string", "maxLength": 64 }, + "authorDid": { "type": "string", "format": "did", "maxLength": 2048 }, + "rating": { "type": "integer", "minimum": 1, "maximum": 5 }, + "text": { "type": "string", "maxLength": 8000, "nullable": true }, + "reviewCreatedAt": { + "type": "string", + "format": "datetime", + "maxLength": 64 + }, + "authorDisplayName": { "type": "string", "maxLength": 640, "nullable": true }, + "authorHandle": { "type": "string", "maxLength": 512, "nullable": true }, + "authorAvatarUrl": { "type": "string", "maxLength": 8192, "nullable": true }, + "replyCount": { "type": "integer" }, + "canReply": { "type": "boolean" } + } + }, + "main": { + "type": "query", + "description": "List reviews for a directory listing (mirrored Tap data plus profile enrichment).", + "parameters": { + "type": "params", + "required": ["listingId"], + "properties": { + "listingId": { + "type": "string", + "maxLength": 64 + }, + "limit": { + "type": "integer", + "minimum": 1, + "maximum": 100, + "default": 50 + }, + "cursor": { + "type": "string", + "maxLength": 512 + } + } + }, + "output": { + "encoding": "application/json", + "schema": { + "type": "object", + "required": ["reviews"], + "properties": { + "cursor": { "type": "string", "maxLength": 512 }, + "reviews": { + "type": "array", + "items": { + "type": "ref", + "ref": "#listingReviewView" + } + } + } + } + }, + "errors": [{ "name": "ListingNotFound" }, { "name": "InvalidCursor" }] + } + } +} diff --git a/lexicons/fyi/atstore/reviews/submitReview.json b/lexicons/fyi/atstore/reviews/submitReview.json new file mode 100644 index 0000000..98850cc --- /dev/null +++ b/lexicons/fyi/atstore/reviews/submitReview.json @@ -0,0 +1,61 @@ +{ + "lexicon": 1, + "id": "fyi.atstore.reviews.submitReview", + "defs": { + "main": { + "type": "procedure", + "description": "Create a fyi.atstore.listing.review record on the authenticated user's PDS and mirror it into the directory index. Prefer standard com.atproto.repo.createRecord when not using this proxy.", + "input": { + "encoding": "application/json", + "schema": { + "type": "object", + "required": ["subject", "rating", "createdAt"], + "properties": { + "subject": { + "type": "string", + "format": "at-uri", + "description": "AT URI of the fyi.atstore.listing.detail record." + }, + "rating": { + "type": "integer", + "minimum": 1, + "maximum": 5 + }, + "text": { + "type": "string", + "maxLength": 8000, + "description": "Optional written review." + }, + "createdAt": { + "type": "string", + "format": "datetime" + } + } + } + }, + "output": { + "encoding": "application/json", + "schema": { + "type": "object", + "required": ["uri", "cid", "reviewId"], + "properties": { + "uri": { "type": "string", "format": "at-uri", "maxLength": 2560 }, + "cid": { "type": "string", "maxLength": 128 }, + "reviewId": { + "type": "string", + "maxLength": 64, + "description": "Directory mirror UUID for the review row." + } + } + } + }, + "errors": [ + { "name": "Unauthorized" }, + { "name": "ListingNotFound" }, + { "name": "AlreadyReviewed" }, + { "name": "InvalidSubject" }, + { "name": "ListingNotOnNetwork" } + ] + } + } +} diff --git a/lexicons/fyi/atstore/server/describe.json b/lexicons/fyi/atstore/server/describe.json new file mode 100644 index 0000000..dc10edc --- /dev/null +++ b/lexicons/fyi/atstore/server/describe.json @@ -0,0 +1,44 @@ +{ + "lexicon": 1, + "id": "fyi.atstore.server.describe", + "defs": { + "main": { + "type": "query", + "description": "Describe this deployment's public XRPC surface and defaults.", + "parameters": { + "type": "params", + "properties": {} + }, + "output": { + "encoding": "application/json", + "schema": { + "type": "object", + "required": [ + "service", + "publicReads", + "oauthSubmitReview", + "defaultListingLimit", + "maxListingLimit", + "maxReviewLimit", + "methods" + ], + "properties": { + "service": { + "type": "string", + "maxLength": 256 + }, + "publicReads": { "type": "boolean" }, + "oauthSubmitReview": { "type": "boolean" }, + "defaultListingLimit": { "type": "integer" }, + "maxListingLimit": { "type": "integer" }, + "maxReviewLimit": { "type": "integer" }, + "methods": { + "type": "array", + "items": { "type": "string", "maxLength": 512 } + } + } + } + } + } + } +} diff --git a/package.json b/package.json index b2aee94..84d70d5 100644 --- a/package.json +++ b/package.json @@ -26,7 +26,7 @@ "db:studio": "drizzle-kit studio", "db:backup": "tsx -r dotenv/config scripts/db-backup.ts", "db:seed": "tsx -r dotenv/config scripts/db-seed.ts", - "lex:gen": "bash -c 'mkdir -p src/lexicons/generated && pnpm exec lex gen-ts-obj src/lexicons/generated/bundle.ts lexicons/fyi/atstore/profile.json lexicons/fyi/atstore/listing/detail.json lexicons/fyi/atstore/listing/review.json lexicons/fyi/atstore/listing/reviewReply.json lexicons/fyi/atstore/listing/favorite.json lexicons/fyi/atstore/auth/basic.json > src/lexicons/generated/bundle.ts'", + "lex:gen": "bash -c 'mkdir -p src/lexicons/generated && pnpm exec lex gen-ts-obj lexicons/fyi/atstore/directory/searchListings.json lexicons/fyi/atstore/directory/getListing.json lexicons/fyi/atstore/directory/resolveListing.json lexicons/fyi/atstore/reviews/listForListing.json lexicons/fyi/atstore/reviews/submitReview.json lexicons/fyi/atstore/server/describe.json lexicons/fyi/atstore/profile.json lexicons/fyi/atstore/listing/detail.json lexicons/fyi/atstore/listing/review.json lexicons/fyi/atstore/listing/reviewReply.json lexicons/fyi/atstore/listing/favorite.json lexicons/fyi/atstore/auth/basic.json lexicons/fyi/atstore/auth/thirdPartyReviews.json > src/lexicons/generated/bundle.ts'", "lex:lint": "node scripts/goat-lex.mjs lex lint", "lex:status": "node scripts/goat-lex.mjs lex status", "atproto:publish-lexicons": "node scripts/goat-lex.mjs lex publish", diff --git a/src/components/SiteFooter.tsx b/src/components/SiteFooter.tsx index 37b87d5..8c4a93d 100644 --- a/src/components/SiteFooter.tsx +++ b/src/components/SiteFooter.tsx @@ -8,10 +8,11 @@ const FooterLink = createLink(Link); const FOOTER_LINK_GROUPS = [ { - links: [ + links: [ { href: "/about", label: "About" }, { href: "/home", label: "Home" }, { href: "/search", label: "Search" }, + { href: "/developers/atproto", label: "Developer API" }, { href: "/products/manage", label: "Manage listings" }, ], }, diff --git a/src/integrations/tanstack-query/api-directory-listings.functions.ts b/src/integrations/tanstack-query/api-directory-listings.functions.ts index a17758c..a2c682e 100644 --- a/src/integrations/tanstack-query/api-directory-listings.functions.ts +++ b/src/integrations/tanstack-query/api-directory-listings.functions.ts @@ -6699,6 +6699,17 @@ const createStoreManagedListing = createServerFn({ method: "POST" }) return { uri, slug }; }); +/** Server-only helpers shared with AT Store XRPC handlers. */ +export const directoryListingXrpcHelpers = { + listingPublicWhere, + getListingSelect, + orderByPopularListingSort, + toListingCard, + computeIsStoreManaged, + viewerMayReplyOnListingReview, + normalizeListingLinks, +} as const; + export const directoryListingApi = { getHomePageData, getHomePageQueryOptions, diff --git a/src/lexicons/generated/bundle.ts b/src/lexicons/generated/bundle.ts index 611871b..192d0a7 100644 --- a/src/lexicons/generated/bundle.ts +++ b/src/lexicons/generated/bundle.ts @@ -1,43 +1,532 @@ export const lexicons = [ { - lexicon: 1, - id: "fyi.atstore.authBasic", - description: "Permission set for AT Store write access.", - defs: { - main: { - type: "permission-set", - title: "Full AT Store Access", - detail: - "Provides full access to AT Store profile, listings, reviews, and favorites.", - permissions: [ + "lexicon": 1, + "id": "fyi.atstore.authBasic", + "description": "Permission set for AT Store write access.", + "defs": { + "main": { + "type": "permission-set", + "title": "Full AT Store Access", + "detail": "Provides full access to AT Store profile, listings, reviews, and favorites.", + "permissions": [ { - type: "permission", - resource: "repo", - collection: [ + "type": "permission", + "resource": "repo", + "collection": [ "fyi.atstore.profile", "fyi.atstore.listing.detail", "fyi.atstore.listing.review", "fyi.atstore.listing.reviewReply", - "fyi.atstore.listing.favorite", + "fyi.atstore.listing.favorite" ], - action: ["create", "update", "delete"], + "action": [ + "create", + "update", + "delete" + ] + } + ] + } + } + }, + { + "lexicon": 1, + "id": "fyi.atstore.authThirdPartyReviews", + "description": "OAuth permission bundle for third-party apps that let users submit AT Store listing reviews (profile bootstrap + review create + submitReview RPC).", + "defs": { + "main": { + "type": "permission-set", + "title": "Submit AT Store reviews", + "detail": "Ensure fyi.atstore.profile/self exists (first-login parity), create listing reviews on the user's repo, and call the at-store submitReview API using delegated auth.", + "permissions": [ + { + "type": "permission", + "resource": "repo", + "collection": [ + "fyi.atstore.profile" + ], + "action": [ + "create" + ] }, + { + "type": "permission", + "resource": "repo", + "collection": [ + "fyi.atstore.listing.review" + ], + "action": [ + "create" + ] + }, + { + "type": "permission", + "resource": "rpc", + "inheritAud": true, + "lxm": [ + "fyi.atstore.reviews.submitReview" + ] + } + ] + } + } + }, + { + "lexicon": 1, + "id": "fyi.atstore.directory.getListing", + "defs": { + "listingCardGet": { + "type": "object", + "required": [ + "id", + "name", + "tagline", + "description", + "category", + "accent", + "reviewCount", + "priceLabel", + "appTags", + "categorySlugs" ], + "properties": { + "id": { + "type": "string", + "maxLength": 64 + }, + "name": { + "type": "string", + "maxLength": 640 + }, + "slug": { + "type": "string", + "maxLength": 640 + }, + "tagline": { + "type": "string", + "maxLength": 2000 + }, + "description": { + "type": "string", + "maxLength": 20000 + }, + "iconUrl": { + "type": "string", + "maxLength": 8192, + "nullable": true + }, + "heroImageUrl": { + "type": "string", + "maxLength": 8192, + "nullable": true + }, + "categorySlug": { + "type": "string", + "maxLength": 512, + "nullable": true + }, + "categorySlugs": { + "type": "array", + "items": { + "type": "string", + "maxLength": 512 + } + }, + "category": { + "type": "string", + "maxLength": 640 + }, + "accent": { + "type": "string", + "maxLength": 16, + "knownValues": [ + "blue", + "pink", + "purple", + "green" + ] + }, + "rating": { + "type": "string", + "maxLength": 16, + "nullable": true + }, + "reviewCount": { + "type": "integer" + }, + "priceLabel": { + "type": "string", + "maxLength": 32 + }, + "productAccountHandle": { + "type": "string", + "maxLength": 512, + "nullable": true + }, + "appTags": { + "type": "array", + "items": { + "type": "string", + "maxLength": 256 + } + } + } + }, + "listingLinkRow": { + "type": "object", + "required": [ + "uri" + ], + "properties": { + "label": { + "type": "string", + "maxLength": 640 + }, + "uri": { + "type": "string", + "maxLength": 2048 + } + } }, - }, + "listingDetailResponse": { + "type": "object", + "required": [ + "listing", + "isStoreManaged" + ], + "properties": { + "listing": { + "type": "ref", + "ref": "#listingCardGet" + }, + "atUri": { + "type": "string", + "maxLength": 2560, + "nullable": true + }, + "isStoreManaged": { + "type": "boolean" + }, + "repoDid": { + "type": "string", + "maxLength": 2048, + "nullable": true + }, + "productAccountDid": { + "type": "string", + "maxLength": 2048, + "nullable": true + }, + "sourceTagline": { + "type": "string", + "maxLength": 20000, + "nullable": true + }, + "sourceFullDescription": { + "type": "string", + "maxLength": 20000, + "nullable": true + }, + "screenshots": { + "type": "array", + "items": { + "type": "string", + "maxLength": 4096 + } + }, + "externalUrl": { + "type": "string", + "maxLength": 2048, + "nullable": true + }, + "sourceUrl": { + "type": "string", + "maxLength": 8192, + "nullable": true + }, + "createdAt": { + "type": "string", + "maxLength": 64, + "nullable": true + }, + "updatedAt": { + "type": "string", + "maxLength": 64, + "nullable": true + }, + "links": { + "type": "array", + "items": { + "type": "ref", + "ref": "#listingLinkRow" + } + } + } + }, + "main": { + "type": "query", + "description": "Fetch one public verified listing by stable Postgres id (UUID) or by URL slug.", + "parameters": { + "type": "params", + "properties": { + "listingId": { + "type": "string", + "maxLength": 64 + }, + "slug": { + "type": "string", + "maxLength": 640 + } + } + }, + "output": { + "encoding": "application/json", + "schema": { + "type": "ref", + "ref": "#listingDetailResponse" + } + }, + "errors": [ + { + "name": "ListingNotFound" + }, + { + "name": "InvalidParams" + } + ] + } + } + }, + { + "lexicon": 1, + "id": "fyi.atstore.directory.resolveListing", + "defs": { + "main": { + "type": "query", + "description": "Resolve a storefront external URL to a directory listing when uniquely matched.", + "parameters": { + "type": "params", + "required": [ + "externalUrl" + ], + "properties": { + "externalUrl": { + "type": "string", + "maxLength": 2048, + "description": "Listing external_url / product URL as stored on the record." + } + } + }, + "output": { + "encoding": "application/json", + "schema": { + "type": "object", + "required": [ + "listingId", + "slug" + ], + "properties": { + "listingId": { + "type": "string", + "maxLength": 64 + }, + "slug": { + "type": "string", + "maxLength": 640 + }, + "atUri": { + "type": "string", + "maxLength": 2560, + "nullable": true + } + } + } + }, + "errors": [ + { + "name": "ListingNotFound" + }, + { + "name": "AmbiguousResolution" + } + ] + } + } + }, + { + "lexicon": 1, + "id": "fyi.atstore.directory.searchListings", + "defs": { + "listingCardSearch": { + "type": "object", + "required": [ + "id", + "name", + "tagline", + "description", + "category", + "accent", + "reviewCount", + "priceLabel", + "appTags", + "categorySlugs" + ], + "properties": { + "id": { + "type": "string", + "maxLength": 64 + }, + "name": { + "type": "string", + "maxLength": 640 + }, + "slug": { + "type": "string", + "maxLength": 640 + }, + "tagline": { + "type": "string", + "maxLength": 2000 + }, + "description": { + "type": "string", + "maxLength": 20000 + }, + "iconUrl": { + "type": "string", + "maxLength": 8192, + "nullable": true + }, + "heroImageUrl": { + "type": "string", + "maxLength": 8192, + "nullable": true + }, + "categorySlug": { + "type": "string", + "maxLength": 512, + "nullable": true + }, + "categorySlugs": { + "type": "array", + "items": { + "type": "string", + "maxLength": 512 + } + }, + "category": { + "type": "string", + "maxLength": 640 + }, + "accent": { + "type": "string", + "maxLength": 16, + "knownValues": [ + "blue", + "pink", + "purple", + "green" + ] + }, + "rating": { + "type": "string", + "maxLength": 16, + "nullable": true + }, + "reviewCount": { + "type": "integer" + }, + "priceLabel": { + "type": "string", + "maxLength": 32 + }, + "productAccountHandle": { + "type": "string", + "maxLength": 512, + "nullable": true + }, + "appTags": { + "type": "array", + "items": { + "type": "string", + "maxLength": 256 + } + } + } + }, + "main": { + "type": "query", + "description": "Directory listing search and pagination.", + "parameters": { + "type": "params", + "properties": { + "q": { + "type": "string", + "maxLength": 512 + }, + "sort": { + "type": "string", + "maxLength": 24, + "default": "popular", + "enum": [ + "popular", + "newest", + "alphabetical" + ] + }, + "limit": { + "type": "integer", + "minimum": 1, + "maximum": 100, + "default": 24 + }, + "cursor": { + "type": "string", + "maxLength": 512 + } + } + }, + "output": { + "encoding": "application/json", + "schema": { + "type": "object", + "required": [ + "listings" + ], + "properties": { + "cursor": { + "type": "string", + "maxLength": 512 + }, + "listings": { + "type": "array", + "items": { + "type": "ref", + "ref": "#listingCardSearch" + } + } + } + } + }, + "errors": [ + { + "name": "InvalidCursor" + } + ] + } + } }, { - lexicon: 1, - id: "fyi.atstore.listing.detail", - defs: { - main: { - type: "record", - description: - "Public protocol or app listing in the AT Store directory. Images are stored as repo blobs (Kitchen-style); the web app caches HTTPS URLs in Postgres separately.", - key: "tid", - record: { - type: "object", - required: [ + "lexicon": 1, + "id": "fyi.atstore.listing.detail", + "defs": { + "main": { + "type": "record", + "description": "Public protocol or app listing in the AT Store directory. Images are stored as repo blobs (Kitchen-style); the web app caches HTTPS URLs in Postgres separately.", + "key": "tid", + "record": { + "type": "object", + "required": [ "slug", "name", "tagline", @@ -45,135 +534,132 @@ export const lexicons = [ "icon", "categorySlug", "createdAt", - "updatedAt", + "updatedAt" ], - properties: { - slug: { - type: "string", - minLength: 1, - maxLength: 512, - description: - "Stable URL slug; unique within the publishing account.", + "properties": { + "slug": { + "type": "string", + "minLength": 1, + "maxLength": 512, + "description": "Stable URL slug; unique within the publishing account." }, - name: { - type: "string", - maxLength: 640, + "name": { + "type": "string", + "maxLength": 640 }, - tagline: { - type: "string", - maxLength: 300, + "tagline": { + "type": "string", + "maxLength": 300 }, - description: { - type: "string", - maxLength: 20_000, + "description": { + "type": "string", + "maxLength": 20000 }, - externalUrl: { - type: "string", - format: "uri", - maxLength: 2048, - description: "Primary product or project URL.", + "externalUrl": { + "type": "string", + "format": "uri", + "maxLength": 2048, + "description": "Primary product or project URL." }, - icon: { - type: "blob", - accept: [ + "icon": { + "type": "blob", + "accept": [ "image/png", "image/jpeg", "image/webp", "image/gif", - "image/svg+xml", + "image/svg+xml" ], - maxSize: 2_000_000, - description: - "Square / app icon (uploaded to repo via com.atproto.repo.uploadBlob).", + "maxSize": 2000000, + "description": "Square / app icon (uploaded to repo via com.atproto.repo.uploadBlob)." }, - heroImage: { - type: "blob", - accept: [ + "heroImage": { + "type": "blob", + "accept": [ "image/png", "image/jpeg", "image/webp", "image/gif", - "image/svg+xml", + "image/svg+xml" ], - maxSize: 12_000_000, - description: "Hero / cover image blob.", + "maxSize": 12000000, + "description": "Hero / cover image blob." }, - screenshots: { - type: "array", - maxLength: 20, - items: { - type: "blob", - accept: [ + "screenshots": { + "type": "array", + "maxLength": 20, + "items": { + "type": "blob", + "accept": [ "image/png", "image/jpeg", "image/webp", "image/gif", - "image/svg+xml", + "image/svg+xml" ], - maxSize: 12_000_000, - }, + "maxSize": 12000000 + } }, - categorySlug: { - type: "array", - minLength: 1, - maxLength: 32, - items: { - type: "string", - maxLength: 256, + "categorySlug": { + "type": "array", + "minLength": 1, + "maxLength": 32, + "items": { + "type": "string", + "maxLength": 256 }, - description: - "Browse category keys (e.g. protocol/pds). First entry is the primary category for legacy surfaces.", + "description": "Browse category keys (e.g. protocol/pds). First entry is the primary category for legacy surfaces." }, - createdAt: { - type: "string", - format: "datetime", + "createdAt": { + "type": "string", + "format": "datetime" }, - updatedAt: { - type: "string", - format: "datetime", + "updatedAt": { + "type": "string", + "format": "datetime" }, - appTags: { - type: "array", - maxLength: 64, - items: { - type: "string", - maxLength: 96, - }, - }, - productAccountDid: { - type: "string", - maxLength: 2048, - description: - "Bluesky DID for the product, app, or tool (not the AT Store publisher). Handle is resolved and stored in Postgres only.", + "appTags": { + "type": "array", + "maxLength": 64, + "items": { + "type": "string", + "maxLength": 96 + } }, - migratedFromAtUri: { - type: "string", - format: "at-uri", - maxLength: 8192, - description: - "When this listing.detail record supersedes a prior record in another repo (e.g. moved from the AT Store publisher to a product owner PDS), the at:// URI of that prior fyi.atstore.listing.detail record.", + "productAccountDid": { + "type": "string", + "maxLength": 2048, + "description": "Bluesky DID for the product, app, or tool (not the AT Store publisher). Handle is resolved and stored in Postgres only." }, - links: { - type: "array", - maxLength: 12, - description: - "Relevant links for the app, including trust/compliance, support, and project resources.", - items: { - type: "ref", - ref: "#link", - }, + "migratedFromAtUri": { + "type": "string", + "format": "at-uri", + "maxLength": 8192, + "description": "When this listing.detail record supersedes a prior record in another repo (e.g. moved from the AT Store publisher to a product owner PDS), the at:// URI of that prior fyi.atstore.listing.detail record." }, - }, - }, + "links": { + "type": "array", + "maxLength": 12, + "description": "Relevant links for the app, including trust/compliance, support, and project resources.", + "items": { + "type": "ref", + "ref": "#link" + } + } + } + } }, - link: { - type: "object", - required: ["type", "url"], - properties: { - type: { - type: "string", - maxLength: 32, - knownValues: [ + "link": { + "type": "object", + "required": [ + "type", + "url" + ], + "properties": { + "type": { + "type": "string", + "maxLength": 32, + "knownValues": [ "privacy", "terms", "support", @@ -183,159 +669,429 @@ export const lexicons = [ "changelog", "source", "status", - "other", + "community", + "donate", + "license", + "other" ], - description: "The kind of link.", - }, - url: { - type: "string", - format: "uri", - maxLength: 2048, - description: "The destination URL.", + "description": "The kind of link." }, - label: { - type: "string", - maxLength: 100, - maxGraphemes: 50, - description: - "Optional human-readable label, especially useful when type is 'other'.", + "url": { + "type": "string", + "format": "uri", + "maxLength": 2048, + "description": "The destination URL." }, - }, - }, - }, + "label": { + "type": "string", + "maxLength": 100, + "maxGraphemes": 50, + "description": "Optional human-readable label, especially useful when type is 'other'." + } + } + } + } }, { - lexicon: 1, - id: "fyi.atstore.listing.favorite", - defs: { - main: { - type: "record", - description: - "A user favorite for an AT Store listing. Subject must be the at:// URI of a fyi.atstore.listing.detail record.", - key: "any", - record: { - type: "object", - required: ["subject", "createdAt"], - properties: { - subject: { - type: "string", - format: "at-uri", - description: - "AT URI of the fyi.atstore.listing.detail record being favorited.", - }, - createdAt: { - type: "string", - format: "datetime", + "lexicon": 1, + "id": "fyi.atstore.listing.favorite", + "defs": { + "main": { + "type": "record", + "description": "A user favorite for an AT Store listing. Subject must be the at:// URI of a fyi.atstore.listing.detail record.", + "key": "any", + "record": { + "type": "object", + "required": [ + "subject", + "createdAt" + ], + "properties": { + "subject": { + "type": "string", + "format": "at-uri", + "description": "AT URI of the fyi.atstore.listing.detail record being favorited." }, - }, - }, - }, - }, + "createdAt": { + "type": "string", + "format": "datetime" + } + } + } + } + } }, { - lexicon: 1, - id: "fyi.atstore.listing.review", - defs: { - main: { - type: "record", - description: - "A user review of an AT Store directory listing. Subject must be the at:// URI of a fyi.atstore.listing.detail record.", - key: "tid", - record: { - type: "object", - required: ["subject", "rating", "createdAt"], - properties: { - subject: { - type: "string", - format: "at-uri", - description: - "AT URI of the fyi.atstore.listing.detail record being reviewed.", - }, - rating: { - type: "integer", - minimum: 1, - maximum: 5, - description: "Star rating 1–5.", + "lexicon": 1, + "id": "fyi.atstore.listing.review", + "defs": { + "main": { + "type": "record", + "description": "A user review of an AT Store directory listing. Subject must be the at:// URI of a fyi.atstore.listing.detail record.", + "key": "tid", + "record": { + "type": "object", + "required": [ + "subject", + "rating", + "createdAt" + ], + "properties": { + "subject": { + "type": "string", + "format": "at-uri", + "description": "AT URI of the fyi.atstore.listing.detail record being reviewed." }, - text: { - type: "string", - maxLength: 8000, - description: - "Optional written review; omit for a stars-only rating.", + "rating": { + "type": "integer", + "minimum": 1, + "maximum": 5, + "description": "Star rating 1–5." }, - createdAt: { - type: "string", - format: "datetime", + "text": { + "type": "string", + "maxLength": 8000, + "description": "Optional written review; omit for a stars-only rating." }, - }, - }, - }, - }, + "createdAt": { + "type": "string", + "format": "datetime" + } + } + } + } + } }, { - lexicon: 1, - id: "fyi.atstore.listing.reviewReply", - defs: { - main: { - type: "record", - description: - "A reply on a fyi.atstore.listing.review. Threads are linear (no per-reply parent). By convention, only the listing owner or the review author should post replies; the AT Store app drops replies from any other DID at ingest and at render. The PDS does not enforce this — other indexers MAY surface unauthorized replies if they choose.", - key: "tid", - record: { - type: "object", - required: ["subject", "text", "createdAt"], - properties: { - subject: { - type: "string", - format: "at-uri", - description: - "AT URI of the fyi.atstore.listing.review this reply belongs to.", - }, - text: { - type: "string", - minLength: 1, - maxLength: 8000, + "lexicon": 1, + "id": "fyi.atstore.listing.reviewReply", + "defs": { + "main": { + "type": "record", + "description": "A reply on a fyi.atstore.listing.review. Threads are linear (no per-reply parent). By convention, only the listing owner or the review author should post replies; the AT Store app drops replies from any other DID at ingest and at render. The PDS does not enforce this — other indexers MAY surface unauthorized replies if they choose.", + "key": "tid", + "record": { + "type": "object", + "required": [ + "subject", + "text", + "createdAt" + ], + "properties": { + "subject": { + "type": "string", + "format": "at-uri", + "description": "AT URI of the fyi.atstore.listing.review this reply belongs to." }, - createdAt: { - type: "string", - format: "datetime", + "text": { + "type": "string", + "minLength": 1, + "maxLength": 8000 }, - }, - }, - }, - }, + "createdAt": { + "type": "string", + "format": "datetime" + } + } + } + } + } }, { - lexicon: 1, - id: "fyi.atstore.profile", - defs: { - main: { - type: "record", - description: - "AT Store app profile for discovery and TAP ingestion (Kitchen-style).", - key: "literal:self", - record: { - type: "object", - required: ["displayName"], - properties: { - displayName: { - type: "string", - maxLength: 640, - description: "Human-readable name for the store / app.", + "lexicon": 1, + "id": "fyi.atstore.profile", + "defs": { + "main": { + "type": "record", + "description": "AT Store app profile for discovery and TAP ingestion (Kitchen-style).", + "key": "literal:self", + "record": { + "type": "object", + "required": [ + "displayName" + ], + "properties": { + "displayName": { + "type": "string", + "maxLength": 640, + "description": "Human-readable name for the store / app." }, - description: { - type: "string", - maxLength: 4000, - description: "Longer description shown in directory surfaces.", + "description": { + "type": "string", + "maxLength": 4000, + "description": "Longer description shown in directory surfaces." }, - website: { - type: "string", - format: "uri", - maxLength: 2048, + "website": { + "type": "string", + "format": "uri", + "maxLength": 2048 + } + } + } + } + } + }, + { + "lexicon": 1, + "id": "fyi.atstore.reviews.listForListing", + "defs": { + "listingReviewView": { + "type": "object", + "required": [ + "id", + "authorDid", + "rating", + "reviewCreatedAt", + "replyCount", + "canReply" + ], + "properties": { + "id": { + "type": "string", + "maxLength": 64 + }, + "authorDid": { + "type": "string", + "format": "did", + "maxLength": 2048 + }, + "rating": { + "type": "integer", + "minimum": 1, + "maximum": 5 + }, + "text": { + "type": "string", + "maxLength": 8000, + "nullable": true + }, + "reviewCreatedAt": { + "type": "string", + "format": "datetime", + "maxLength": 64 + }, + "authorDisplayName": { + "type": "string", + "maxLength": 640, + "nullable": true + }, + "authorHandle": { + "type": "string", + "maxLength": 512, + "nullable": true + }, + "authorAvatarUrl": { + "type": "string", + "maxLength": 8192, + "nullable": true + }, + "replyCount": { + "type": "integer" + }, + "canReply": { + "type": "boolean" + } + } + }, + "main": { + "type": "query", + "description": "List reviews for a directory listing (mirrored Tap data plus profile enrichment).", + "parameters": { + "type": "params", + "required": [ + "listingId" + ], + "properties": { + "listingId": { + "type": "string", + "maxLength": 64 + }, + "limit": { + "type": "integer", + "minimum": 1, + "maximum": 100, + "default": 50 }, + "cursor": { + "type": "string", + "maxLength": 512 + } + } + }, + "output": { + "encoding": "application/json", + "schema": { + "type": "object", + "required": [ + "reviews" + ], + "properties": { + "cursor": { + "type": "string", + "maxLength": 512 + }, + "reviews": { + "type": "array", + "items": { + "type": "ref", + "ref": "#listingReviewView" + } + } + } + } + }, + "errors": [ + { + "name": "ListingNotFound" }, + { + "name": "InvalidCursor" + } + ] + } + } + }, + { + "lexicon": 1, + "id": "fyi.atstore.reviews.submitReview", + "defs": { + "main": { + "type": "procedure", + "description": "Create a fyi.atstore.listing.review record on the authenticated user's PDS and mirror it into the directory index. Prefer standard com.atproto.repo.createRecord when not using this proxy.", + "input": { + "encoding": "application/json", + "schema": { + "type": "object", + "required": [ + "subject", + "rating", + "createdAt" + ], + "properties": { + "subject": { + "type": "string", + "format": "at-uri", + "description": "AT URI of the fyi.atstore.listing.detail record." + }, + "rating": { + "type": "integer", + "minimum": 1, + "maximum": 5 + }, + "text": { + "type": "string", + "maxLength": 8000, + "description": "Optional written review." + }, + "createdAt": { + "type": "string", + "format": "datetime" + } + } + } }, - }, - }, + "output": { + "encoding": "application/json", + "schema": { + "type": "object", + "required": [ + "uri", + "cid", + "reviewId" + ], + "properties": { + "uri": { + "type": "string", + "format": "at-uri", + "maxLength": 2560 + }, + "cid": { + "type": "string", + "maxLength": 128 + }, + "reviewId": { + "type": "string", + "maxLength": 64, + "description": "Directory mirror UUID for the review row." + } + } + } + }, + "errors": [ + { + "name": "Unauthorized" + }, + { + "name": "ListingNotFound" + }, + { + "name": "AlreadyReviewed" + }, + { + "name": "InvalidSubject" + }, + { + "name": "ListingNotOnNetwork" + } + ] + } + } }, -]; + { + "lexicon": 1, + "id": "fyi.atstore.server.describe", + "defs": { + "main": { + "type": "query", + "description": "Describe this deployment's public XRPC surface and defaults.", + "parameters": { + "type": "params", + "properties": {} + }, + "output": { + "encoding": "application/json", + "schema": { + "type": "object", + "required": [ + "service", + "publicReads", + "oauthSubmitReview", + "defaultListingLimit", + "maxListingLimit", + "maxReviewLimit", + "methods" + ], + "properties": { + "service": { + "type": "string", + "maxLength": 256 + }, + "publicReads": { + "type": "boolean" + }, + "oauthSubmitReview": { + "type": "boolean" + }, + "defaultListingLimit": { + "type": "integer" + }, + "maxListingLimit": { + "type": "integer" + }, + "maxReviewLimit": { + "type": "integer" + }, + "methods": { + "type": "array", + "items": { + "type": "string", + "maxLength": 512 + } + } + } + } + } + } + } + } +] diff --git a/src/lib/atproto/nsids.ts b/src/lib/atproto/nsids.ts index bebfce8..d04cbde 100644 --- a/src/lib/atproto/nsids.ts +++ b/src/lib/atproto/nsids.ts @@ -1,12 +1,29 @@ /** AT Store lexicon NSIDs (`fyi.atstore.*`). */ export const NSID = { authBasic: "fyi.atstore.authBasic", + authThirdPartyReviews: "fyi.atstore.authThirdPartyReviews", profile: "fyi.atstore.profile", listingDetail: "fyi.atstore.listing.detail", listingReview: "fyi.atstore.listing.review", listingReviewReply: "fyi.atstore.listing.reviewReply", listingFavorite: "fyi.atstore.listing.favorite", lexiconSchema: "com.atproto.lexicon.schema", + directorySearchListings: "fyi.atstore.directory.searchListings", + directoryGetListing: "fyi.atstore.directory.getListing", + directoryResolveListing: "fyi.atstore.directory.resolveListing", + reviewsListForListing: "fyi.atstore.reviews.listForListing", + reviewsSubmitReview: "fyi.atstore.reviews.submitReview", + serverDescribe: "fyi.atstore.server.describe", +} as const; + +/** Stable strings for `/xrpc/:nsid` routing and docs. */ +export const ATSTORE_XRPC_METHOD = { + directorySearchListings: NSID.directorySearchListings, + directoryGetListing: NSID.directoryGetListing, + directoryResolveListing: NSID.directoryResolveListing, + reviewsListForListing: NSID.reviewsListForListing, + reviewsSubmitReview: NSID.reviewsSubmitReview, + serverDescribe: NSID.serverDescribe, } as const; /** Standard.site (product updates / permalinks). */ diff --git a/src/routeTree.gen.ts b/src/routeTree.gen.ts index fa4dbb8..95fcdda 100644 --- a/src/routeTree.gen.ts +++ b/src/routeTree.gen.ts @@ -15,6 +15,7 @@ import { Route as HeaderLayoutRouteImport } from './routes/_header-layout' import { Route as LocaleRouteImport } from './routes/$locale' import { Route as OgIndexRouteImport } from './routes/og.index' import { Route as HeaderLayoutIndexRouteImport } from './routes/_header-layout.index' +import { Route as XrpcNsidRouteImport } from './routes/xrpc.$nsid' import { Route as OgTagRouteImport } from './routes/og.tag' import { Route as OgReviewRouteImport } from './routes/og.review' import { Route as OgProfileRouteImport } from './routes/og.profile' @@ -26,6 +27,7 @@ import { Route as HeaderLayoutProfileActorRouteImport } from './routes/_header-l import { Route as HeaderLayoutProductsManageRouteImport } from './routes/_header-layout.products.manage' import { Route as HeaderLayoutProductsCreateRouteImport } from './routes/_header-layout.products.create' import { Route as HeaderLayoutProductClaimRouteImport } from './routes/_header-layout.product.claim' +import { Route as HeaderLayoutDevelopersAtprotoRouteImport } from './routes/_header-layout.developers.atproto' import { Route as HeaderLayoutCategoriesCategoryIdRouteImport } from './routes/_header-layout.categories.$categoryId' import { Route as HeaderLayoutAppsTagsRouteImport } from './routes/_header-layout.apps.tags' import { Route as HeaderLayoutAppsAllRouteImport } from './routes/_header-layout.apps.all' @@ -83,6 +85,11 @@ const HeaderLayoutIndexRoute = HeaderLayoutIndexRouteImport.update({ path: '/', getParentRoute: () => HeaderLayoutRoute, } as any) +const XrpcNsidRoute = XrpcNsidRouteImport.update({ + id: '/xrpc/$nsid', + path: '/xrpc/$nsid', + getParentRoute: () => rootRouteImport, +} as any) const OgTagRoute = OgTagRouteImport.update({ id: '/og/tag', path: '/og/tag', @@ -142,6 +149,12 @@ const HeaderLayoutProductClaimRoute = path: '/product/claim', getParentRoute: () => HeaderLayoutRoute, } as any) +const HeaderLayoutDevelopersAtprotoRoute = + HeaderLayoutDevelopersAtprotoRouteImport.update({ + id: '/developers/atproto', + path: '/developers/atproto', + getParentRoute: () => HeaderLayoutRoute, + } as any) const HeaderLayoutCategoriesCategoryIdRoute = HeaderLayoutCategoriesCategoryIdRouteImport.update({ id: '/categories/$categoryId', @@ -310,11 +323,13 @@ export interface FileRoutesByFullPath { '/og/profile': typeof OgProfileRoute '/og/review': typeof OgReviewRoute '/og/tag': typeof OgTagRoute + '/xrpc/$nsid': typeof XrpcNsidRoute '/og/': typeof OgIndexRoute '/apps/$tag': typeof HeaderLayoutAppsTagRoute '/apps/all': typeof HeaderLayoutAppsAllRoute '/apps/tags': typeof HeaderLayoutAppsTagsRoute '/categories/$categoryId': typeof HeaderLayoutCategoriesCategoryIdRoute + '/developers/atproto': typeof HeaderLayoutDevelopersAtprotoRoute '/product/claim': typeof HeaderLayoutProductClaimRoute '/products/create': typeof HeaderLayoutProductsCreateRoute '/products/manage': typeof HeaderLayoutProductsManageRoute @@ -354,11 +369,13 @@ export interface FileRoutesByTo { '/og/profile': typeof OgProfileRoute '/og/review': typeof OgReviewRoute '/og/tag': typeof OgTagRoute + '/xrpc/$nsid': typeof XrpcNsidRoute '/og': typeof OgIndexRoute '/apps/$tag': typeof HeaderLayoutAppsTagRoute '/apps/all': typeof HeaderLayoutAppsAllRoute '/apps/tags': typeof HeaderLayoutAppsTagsRoute '/categories/$categoryId': typeof HeaderLayoutCategoriesCategoryIdRoute + '/developers/atproto': typeof HeaderLayoutDevelopersAtprotoRoute '/product/claim': typeof HeaderLayoutProductClaimRoute '/products/create': typeof HeaderLayoutProductsCreateRoute '/products/manage': typeof HeaderLayoutProductsManageRoute @@ -399,12 +416,14 @@ export interface FileRoutesById { '/og/profile': typeof OgProfileRoute '/og/review': typeof OgReviewRoute '/og/tag': typeof OgTagRoute + '/xrpc/$nsid': typeof XrpcNsidRoute '/_header-layout/': typeof HeaderLayoutIndexRoute '/og/': typeof OgIndexRoute '/_header-layout/apps/$tag': typeof HeaderLayoutAppsTagRoute '/_header-layout/apps/all': typeof HeaderLayoutAppsAllRoute '/_header-layout/apps/tags': typeof HeaderLayoutAppsTagsRoute '/_header-layout/categories/$categoryId': typeof HeaderLayoutCategoriesCategoryIdRoute + '/_header-layout/developers/atproto': typeof HeaderLayoutDevelopersAtprotoRoute '/_header-layout/product/claim': typeof HeaderLayoutProductClaimRoute '/_header-layout/products/create': typeof HeaderLayoutProductsCreateRoute '/_header-layout/products/manage': typeof HeaderLayoutProductsManageRoute @@ -446,11 +465,13 @@ export interface FileRouteTypes { | '/og/profile' | '/og/review' | '/og/tag' + | '/xrpc/$nsid' | '/og/' | '/apps/$tag' | '/apps/all' | '/apps/tags' | '/categories/$categoryId' + | '/developers/atproto' | '/product/claim' | '/products/create' | '/products/manage' @@ -490,11 +511,13 @@ export interface FileRouteTypes { | '/og/profile' | '/og/review' | '/og/tag' + | '/xrpc/$nsid' | '/og' | '/apps/$tag' | '/apps/all' | '/apps/tags' | '/categories/$categoryId' + | '/developers/atproto' | '/product/claim' | '/products/create' | '/products/manage' @@ -534,12 +557,14 @@ export interface FileRouteTypes { | '/og/profile' | '/og/review' | '/og/tag' + | '/xrpc/$nsid' | '/_header-layout/' | '/og/' | '/_header-layout/apps/$tag' | '/_header-layout/apps/all' | '/_header-layout/apps/tags' | '/_header-layout/categories/$categoryId' + | '/_header-layout/developers/atproto' | '/_header-layout/product/claim' | '/_header-layout/products/create' | '/_header-layout/products/manage' @@ -577,6 +602,7 @@ export interface RootRouteChildren { OgProfileRoute: typeof OgProfileRoute OgReviewRoute: typeof OgReviewRoute OgTagRoute: typeof OgTagRoute + XrpcNsidRoute: typeof XrpcNsidRoute OgIndexRoute: typeof OgIndexRoute ApiAuthAtprotoAuthorizeRoute: typeof ApiAuthAtprotoAuthorizeRoute ApiAuthAtprotoCallbackRoute: typeof ApiAuthAtprotoCallbackRoute @@ -629,6 +655,13 @@ declare module '@tanstack/react-router' { preLoaderRoute: typeof HeaderLayoutIndexRouteImport parentRoute: typeof HeaderLayoutRoute } + '/xrpc/$nsid': { + id: '/xrpc/$nsid' + path: '/xrpc/$nsid' + fullPath: '/xrpc/$nsid' + preLoaderRoute: typeof XrpcNsidRouteImport + parentRoute: typeof rootRouteImport + } '/og/tag': { id: '/og/tag' path: '/og/tag' @@ -706,6 +739,13 @@ declare module '@tanstack/react-router' { preLoaderRoute: typeof HeaderLayoutProductClaimRouteImport parentRoute: typeof HeaderLayoutRoute } + '/_header-layout/developers/atproto': { + id: '/_header-layout/developers/atproto' + path: '/developers/atproto' + fullPath: '/developers/atproto' + preLoaderRoute: typeof HeaderLayoutDevelopersAtprotoRouteImport + parentRoute: typeof HeaderLayoutRoute + } '/_header-layout/categories/$categoryId': { id: '/_header-layout/categories/$categoryId' path: '/categories/$categoryId' @@ -978,6 +1018,7 @@ interface HeaderLayoutRouteChildren { HeaderLayoutAppsAllRoute: typeof HeaderLayoutAppsAllRoute HeaderLayoutAppsTagsRoute: typeof HeaderLayoutAppsTagsRoute HeaderLayoutCategoriesCategoryIdRoute: typeof HeaderLayoutCategoriesCategoryIdRoute + HeaderLayoutDevelopersAtprotoRoute: typeof HeaderLayoutDevelopersAtprotoRoute HeaderLayoutProductClaimRoute: typeof HeaderLayoutProductClaimRoute HeaderLayoutProductsCreateRoute: typeof HeaderLayoutProductsCreateRoute HeaderLayoutProductsManageRoute: typeof HeaderLayoutProductsManageRoute @@ -999,6 +1040,7 @@ const HeaderLayoutRouteChildren: HeaderLayoutRouteChildren = { HeaderLayoutAppsAllRoute: HeaderLayoutAppsAllRoute, HeaderLayoutAppsTagsRoute: HeaderLayoutAppsTagsRoute, HeaderLayoutCategoriesCategoryIdRoute: HeaderLayoutCategoriesCategoryIdRoute, + HeaderLayoutDevelopersAtprotoRoute: HeaderLayoutDevelopersAtprotoRoute, HeaderLayoutProductClaimRoute: HeaderLayoutProductClaimRoute, HeaderLayoutProductsCreateRoute: HeaderLayoutProductsCreateRoute, HeaderLayoutProductsManageRoute: HeaderLayoutProductsManageRoute, @@ -1027,6 +1069,7 @@ const rootRouteChildren: RootRouteChildren = { OgProfileRoute: OgProfileRoute, OgReviewRoute: OgReviewRoute, OgTagRoute: OgTagRoute, + XrpcNsidRoute: XrpcNsidRoute, OgIndexRoute: OgIndexRoute, ApiAuthAtprotoAuthorizeRoute: ApiAuthAtprotoAuthorizeRoute, ApiAuthAtprotoCallbackRoute: ApiAuthAtprotoCallbackRoute, diff --git a/src/routes/_header-layout.developers.atproto.tsx b/src/routes/_header-layout.developers.atproto.tsx new file mode 100644 index 0000000..a913c9e --- /dev/null +++ b/src/routes/_header-layout.developers.atproto.tsx @@ -0,0 +1,210 @@ +import * as stylex from "@stylexjs/stylex"; +import { createFileRoute } from "@tanstack/react-router"; +import { Flex } from "#/design-system/flex"; +import { Link } from "#/design-system/link"; +import { Page } from "#/design-system/page"; +import { + Table, + TableBody, + TableCell, + TableColumn, + TableHeader, + TableRow, +} from "#/design-system/table"; +import { + horizontalSpace, + verticalSpace, +} from "#/design-system/theme/semantic-spacing.stylex"; +import { + Blockquote, + Body, + Heading2, + Heading3, +} from "#/design-system/typography"; +import { Text } from "#/design-system/typography/text"; +import { ATSTORE_XRPC_METHOD, NSID } from "#/lib/atproto/nsids"; +import { buildRouteOgMeta } from "#/lib/og-meta"; + +const METHOD_ROWS: ReadonlyArray<{ + nsid: string; + method: "GET" | "POST"; + summary: string; +}> = [ + { + nsid: ATSTORE_XRPC_METHOD.serverDescribe, + method: "GET", + summary: "Capabilities, limits, and registered method NSIDs.", + }, + { + nsid: ATSTORE_XRPC_METHOD.directorySearchListings, + method: "GET", + summary: "Search public listings with pagination (`q`, `sort`, `cursor`).", + }, + { + nsid: ATSTORE_XRPC_METHOD.directoryGetListing, + method: "GET", + summary: "Detail projection by `listingId` or `slug`.", + }, + { + nsid: ATSTORE_XRPC_METHOD.directoryResolveListing, + method: "GET", + summary: "Resolve `externalUrl` to listing identifiers.", + }, + { + nsid: ATSTORE_XRPC_METHOD.reviewsListForListing, + method: "GET", + summary: "Reviews for a listing (`listingId`, pagination).", + }, + { + nsid: ATSTORE_XRPC_METHOD.reviewsSubmitReview, + method: "POST", + summary: + "Create a listing review via the user PDS (requires signed-in at-store session cookie today).", + }, +]; + +type MethodTableColumn = { + id: "method" | "nsid" | "summary"; + name: string; +}; + +const METHOD_TABLE_COLUMNS: Array = [ + { id: "method", name: "HTTP" }, + { id: "nsid", name: "NSID" }, + { id: "summary", name: "Summary" }, +]; + +const styles = stylex.create({ + page: { + marginInline: "auto", + paddingInline: horizontalSpace.xl, + maxWidth: 920, + paddingBottom: verticalSpace["10xl"], + paddingTop: verticalSpace["6xl"], + }, + monoTight: { + fontFamily: + "ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace", + fontSize: 13, + lineHeight: 1.4, + wordBreak: "break-word", + }, + methodsTable: { + width: "100%", + }, +}); + +export const Route = createFileRoute("/_header-layout/developers/atproto")({ + head: () => + buildRouteOgMeta({ + title: "AT Protocol API | at-store", + description: + "AT Store XRPC methods and OAuth permission bundle for third-party review integrations.", + }), + component: DevelopersAtprotoPage, +}); + +function DevelopersAtprotoPage() { + const origin = + typeof globalThis.location?.origin === "string" + ? globalThis.location.origin + : "https://your-deployment.example"; + + return ( + + + + AT Protocol on AT Store + + Directory queries are public GET endpoints under{" "} + /xrpc/<nsid>, shaped by the{" "} + XRPC and{" "} + Lexicon specs. + Lexicon JSON lives in this repository under{" "} + lexicons/fyi/atstore/. + + + + + Base URL + + Replace the origin with your deployment (local dev shown when opened + in the browser): + +
{`${origin}/xrpc/`}
+
+ + + Methods + + + {(column) => {column.name}} + + + {(row) => ( + + {(column) => ( + + {column.id === "method" ? ( + {row.method} + ) : column.id === "nsid" ? ( + + {row.nsid} + + ) : ( + {row.summary} + )} + + )} + + )} + +
+
+ + + Third-party review OAuth bundle + + Published permission-set lexicon{" "} + {NSID.authThirdPartyReviews} grants{" "} + repo:create on{" "} + {NSID.profile} and{" "} + {NSID.listingReview}, plus{" "} + rpc access (with{" "} + inheritAud) to{" "} + + {ATSTORE_XRPC_METHOD.reviewsSubmitReview} + + . Third-party apps request it using an{" "} + + include scope + {" "} + such as: + +
+ {`include:${NSID.authThirdPartyReviews}?aud=`} +
+ + The aud fragment must match your deployment's OAuth protected + resource metadata. Blob uploads are{" "} + not bundled + ; apps still need explicit blob: scopes + when uploading media to a PDS. + + + Today submitReview authenticates with + the same signed-in browser session as the web app (cookie). Pure + bearer-token clients should fall back to{" "} + com.atproto.repo.createRecord on the + user's PDS using the review lexicon until bearer verification + lands here. + +
+
+
+ ); +} diff --git a/src/routes/xrpc.$nsid.tsx b/src/routes/xrpc.$nsid.tsx new file mode 100644 index 0000000..3d074be --- /dev/null +++ b/src/routes/xrpc.$nsid.tsx @@ -0,0 +1,15 @@ +import { createFileRoute } from "@tanstack/react-router"; +import { handleAtstoreXrpc } from "#/server/atproto-xrpc/atstore-xrpc-handler.server"; + +export const Route = createFileRoute("/xrpc/$nsid")({ + server: { + handlers: { + GET: ({ request, params }) => + handleAtstoreXrpc(request, decodeURIComponent(params.nsid)), + POST: ({ request, params }) => + handleAtstoreXrpc(request, decodeURIComponent(params.nsid)), + OPTIONS: ({ request, params }) => + handleAtstoreXrpc(request, decodeURIComponent(params.nsid)), + }, + }, +}); diff --git a/src/server/atproto-xrpc/atstore-xrpc-handler.server.ts b/src/server/atproto-xrpc/atstore-xrpc-handler.server.ts new file mode 100644 index 0000000..18d1e30 --- /dev/null +++ b/src/server/atproto-xrpc/atstore-xrpc-handler.server.ts @@ -0,0 +1,691 @@ +import type { Client } from "@atcute/client"; +import type { DirectoryListingCard } from "#/integrations/tanstack-query/api-directory-listings.functions"; +import type { ListingLink } from "#/lib/atproto/listing-record"; + +import { db } from "#/db/index.server"; +import * as schema from "#/db/schema"; +import { directoryListingXrpcHelpers } from "#/integrations/tanstack-query/api-directory-listings.functions"; +import { parseAtUriParts } from "#/lib/atproto/at-uri"; +import { ATSTORE_XRPC_METHOD, NSID } from "#/lib/atproto/nsids"; +import { + createListingReviewRecord, + ensureProfileSelfRecord, +} from "#/lib/atproto/repo-records"; +import { upsertListingReviewFromTap } from "#/lib/atproto/tap-review-sync"; +import { fetchBlueskyPublicProfileFields } from "#/lib/bluesky-public-profile"; +import { getAtprotoSessionForRequest } from "#/middleware/auth"; +import { and, asc, desc, eq, ilike, or, sql } from "drizzle-orm"; + +const LEGACY_DETAIL_SQL = { + rawCategoryHint: sql`null::text`.as("rawCategoryHint"), + scope: sql`null::text`.as("scope"), + productType: sql`null::text`.as("productType"), + domain: sql`null::text`.as("domain"), + vertical: sql`null::text`.as("vertical"), + classificationReason: sql`null::text`.as( + "classificationReason", + ), +}; + +const corsJsonHeaders: HeadersInit = { + "Content-Type": "application/json; charset=utf-8", + "Access-Control-Allow-Origin": "*", +}; + +function xrpcJson(data: unknown, status = 200) { + return new Response(JSON.stringify(data), { + status, + headers: corsJsonHeaders, + }); +} + +function xrpcErr(status: number, error: string, message?: string) { + return xrpcJson({ error, message: message ?? error }, status); +} + +function encodeOffsetCursor(offset: number): string { + return Buffer.from(JSON.stringify({ o: offset }), "utf8").toString( + "base64url", + ); +} + +function decodeOffsetCursor(cursor: string | null): number | undefined { + if (!cursor?.trim()) { + return undefined; + } + try { + const raw = Buffer.from(cursor.trim(), "base64url").toString("utf8"); + const parsed = JSON.parse(raw) as { o?: unknown }; + if ( + typeof parsed.o !== "number" || + !Number.isFinite(parsed.o) || + parsed.o < 0 + ) { + return undefined; + } + return Math.floor(parsed.o); + } catch { + return undefined; + } +} + +function isUuid(value: string): boolean { + return /^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i.test( + value.trim(), + ); +} + +function listingCardJson(card: DirectoryListingCard) { + return { + ...card, + slug: card.slug ?? "", + rating: + card.rating == null || Number.isNaN(Number(card.rating)) + ? null + : String(card.rating), + }; +} + +function normalizeExternalUrlCandidates(raw: string): Array { + const t = raw.trim(); + if (!t) { + return []; + } + const noTrail = t.replace(/\/+$/, ""); + if (noTrail === t) { + return [t]; + } + return [t, noTrail]; +} + +async function handleDescribe() { + const methods = ( + Object.values(ATSTORE_XRPC_METHOD) as Array + ).toSorted((a, b) => a.localeCompare(b)); + return xrpcJson({ + service: "at-store-directory", + publicReads: true, + oauthSubmitReview: true, + defaultListingLimit: 24, + maxListingLimit: 100, + maxReviewLimit: 100, + methods, + }); +} + +async function handleSearchListings(url: URL) { + const q = url.searchParams.get("q")?.trim(); + const sortRaw = url.searchParams.get("sort")?.trim() ?? "popular"; + const sort = + sortRaw === "newest" + ? "newest" + : sortRaw === "alphabetical" + ? "alphabetical" + : "popular"; + const limitParam = Number(url.searchParams.get("limit") ?? "24"); + const limit = Number.isFinite(limitParam) + ? Math.min(100, Math.max(1, Math.floor(limitParam))) + : 24; + const cursorParam = url.searchParams.get("cursor"); + const offset = decodeOffsetCursor(cursorParam); + if (cursorParam?.trim() && offset === undefined) { + return xrpcErr(400, "InvalidCursor"); + } + const start = offset ?? 0; + + const table = schema.storeListings; + const { + listingPublicWhere, + getListingSelect, + orderByPopularListingSort, + toListingCard, + } = directoryListingXrpcHelpers; + + const searchClause = q + ? or( + ilike(table.name, `%${q}%`), + ilike(table.tagline, `%${q}%`), + ilike(table.fullDescription, `%${q}%`), + ilike( + sql`array_to_string(${table.categorySlugs}, ' ')`, + `%${q}%`, + ), + ilike(sql`array_to_string(${table.appTags}, ' ')`, `%${q}%`), + ) + : undefined; + + const listingSelect = getListingSelect(table); + const orderBy = + sort === "newest" + ? [desc(table.createdAt)] + : sort === "alphabetical" + ? [asc(table.name)] + : orderByPopularListingSort(table); + + const rows = await db + .select(listingSelect) + .from(table) + .where(listingPublicWhere(table, searchClause)) + .orderBy(...orderBy) + .offset(start) + .limit(limit + 1); + + const hasMore = rows.length > limit; + const slice = hasMore ? rows.slice(0, limit) : rows; + const listings = slice.map((row) => listingCardJson(toListingCard(row))); + + return xrpcJson({ + listings, + ...(hasMore ? { cursor: encodeOffsetCursor(start + limit) } : {}), + }); +} + +async function fetchVerifiedListingDetailRow(options: { + listingId?: string; + slug?: string; +}) { + const listingId = options.listingId?.trim(); + const slugOnly = options.slug?.trim(); + + if ((listingId && slugOnly) || (!listingId && !slugOnly)) { + return { error: "InvalidParams" as const }; + } + + const table = schema.storeListings; + const { listingPublicWhere } = directoryListingXrpcHelpers; + + let filter; + if (listingId) { + if (!isUuid(listingId)) { + return { error: "InvalidParams" as const }; + } + filter = eq(table.id, listingId); + } else { + filter = eq(table.slug, slugOnly ?? ""); + } + + const [row] = await db + .select({ + id: table.id, + sourceUrl: table.sourceUrl, + name: table.name, + slug: table.slug, + externalUrl: table.externalUrl, + iconUrl: table.iconUrl, + heroImageUrl: table.heroImageUrl, + screenshotUrls: table.screenshotUrls, + tagline: table.tagline, + fullDescription: table.fullDescription, + categorySlugs: table.categorySlugs, + atUri: table.atUri, + repoDid: table.repoDid, + migratedFromAtUri: table.migratedFromAtUri, + productAccountDid: table.productAccountDid, + productAccountHandle: table.productAccountHandle, + reviewCount: table.reviewCount, + averageRating: table.averageRating, + ...LEGACY_DETAIL_SQL, + appTags: table.appTags, + links: table.links, + createdAt: table.createdAt, + updatedAt: table.updatedAt, + }) + .from(table) + .where(listingPublicWhere(table, filter)) + .limit(1); + + if (!row) { + return { error: "ListingNotFound" as const }; + } + return { row }; +} + +async function handleGetListing(url: URL) { + const listingId = url.searchParams.get("listingId") ?? undefined; + const slug = url.searchParams.get("slug") ?? undefined; + + const fetched = await fetchVerifiedListingDetailRow({ listingId, slug }); + if ("error" in fetched) { + switch (fetched.error) { + case "InvalidParams": { + return xrpcErr(400, fetched.error); + } + case "ListingNotFound": { + return xrpcErr(404, fetched.error); + } + default: { + return xrpcErr(500, "InternalError"); + } + } + } + + const { toListingCard, computeIsStoreManaged, normalizeListingLinks } = + directoryListingXrpcHelpers; + + const row = fetched.row; + const listing = listingCardJson(toListingCard(row)); + const isStoreManaged = await computeIsStoreManaged(row); + + const linksRaw = normalizeListingLinks( + row.links as Array | null, + ); + + return xrpcJson({ + listing, + isStoreManaged, + atUri: row.atUri ?? null, + repoDid: row.repoDid ?? null, + productAccountDid: row.productAccountDid ?? null, + sourceTagline: row.tagline ?? null, + sourceFullDescription: row.fullDescription ?? null, + screenshots: row.screenshotUrls ?? [], + externalUrl: row.externalUrl ?? null, + sourceUrl: row.sourceUrl ?? null, + createdAt: row.createdAt?.toISOString() ?? null, + updatedAt: row.updatedAt?.toISOString() ?? null, + links: linksRaw.map((link) => ({ + ...(link.label ? { label: link.label } : {}), + uri: link.url, + })), + }); +} + +async function handleResolveListing(url: URL) { + const externalUrl = url.searchParams.get("externalUrl")?.trim(); + if (!externalUrl) { + return xrpcErr(400, "InvalidParams", "externalUrl is required."); + } + + const variants = normalizeExternalUrlCandidates(externalUrl); + if (variants.length === 0) { + return xrpcErr(400, "InvalidParams"); + } + + const table = schema.storeListings; + const { listingPublicWhere } = directoryListingXrpcHelpers; + + const clause = + variants.length === 1 + ? (() => { + const only = variants[0]; + return only ? eq(table.externalUrl, only) : undefined; + })() + : or(...variants.map((v) => eq(table.externalUrl, v))); + + if (!clause) { + return xrpcErr(400, "InvalidParams"); + } + + const rows = await db + .select({ + id: table.id, + slug: table.slug, + atUri: table.atUri, + }) + .from(table) + .where(listingPublicWhere(table, clause)) + .limit(4); + + if (rows.length === 0) { + return xrpcErr(404, "ListingNotFound"); + } + if (rows.length > 1) { + return xrpcErr(409, "AmbiguousResolution"); + } + + const hit = rows.at(0); + if (!hit) { + return xrpcErr(404, "ListingNotFound"); + } + const slug = hit.slug?.trim(); + if (!slug) { + return xrpcErr(404, "ListingNotFound"); + } + + return xrpcJson({ + listingId: hit.id, + slug, + atUri: hit.atUri ?? null, + }); +} + +async function handleListReviews(url: URL, request: Request) { + const listingId = url.searchParams.get("listingId")?.trim(); + if (!listingId || !isUuid(listingId)) { + return xrpcErr(400, "InvalidParams"); + } + + const limitParam = Number(url.searchParams.get("limit") ?? "50"); + const limit = Number.isFinite(limitParam) + ? Math.min(100, Math.max(1, Math.floor(limitParam))) + : 50; + const cursorParam = url.searchParams.get("cursor"); + const offset = decodeOffsetCursor(cursorParam); + if (cursorParam?.trim() && offset === undefined) { + return xrpcErr(400, "InvalidCursor"); + } + const start = offset ?? 0; + + const table = schema.storeListings; + const { listingPublicWhere, viewerMayReplyOnListingReview } = + directoryListingXrpcHelpers; + + const [listing] = await db + .select({ + id: table.id, + repoDid: table.repoDid, + productAccountDid: table.productAccountDid, + }) + .from(table) + .where(listingPublicWhere(table, eq(table.id, listingId))) + .limit(1); + + if (!listing) { + return xrpcErr(404, "ListingNotFound"); + } + + const session = await getAtprotoSessionForRequest(request); + const viewerDid = session?.did ?? undefined; + + const rev = schema.storeListingReviews; + const rows = await db + .select({ + id: rev.id, + authorDid: rev.authorDid, + rating: rev.rating, + text: rev.text, + reviewCreatedAt: rev.reviewCreatedAt, + authorDisplayName: rev.authorDisplayName, + authorAvatarUrl: rev.authorAvatarUrl, + replyCount: rev.replyCount, + }) + .from(rev) + .where(eq(rev.storeListingId, listing.id)) + .orderBy(desc(rev.reviewCreatedAt)) + .offset(start) + .limit(limit + 1); + + const hasMore = rows.length > limit; + const slice = hasMore ? rows.slice(0, limit) : rows; + + const reviews = await Promise.all( + slice.map(async (row) => { + const profile = await fetchBlueskyPublicProfileFields(row.authorDid); + const handle = + profile?.handle?.trim() && profile.handle.trim().length > 0 + ? profile.handle.trim() + : null; + const displayName = + row.authorDisplayName?.trim() || + profile?.displayName?.trim() || + profile?.handle || + null; + const avatarUrl = + row.authorAvatarUrl?.trim() || profile?.avatarUrl || null; + + return { + id: row.id, + authorDid: row.authorDid, + rating: row.rating, + text: row.text, + reviewCreatedAt: row.reviewCreatedAt.toISOString(), + authorDisplayName: displayName, + authorHandle: handle, + authorAvatarUrl: avatarUrl, + replyCount: Number(row.replyCount ?? 0), + canReply: viewerMayReplyOnListingReview({ + viewerDid, + reviewAuthorDid: row.authorDid, + listingRepoDid: listing.repoDid, + listingProductAccountDid: listing.productAccountDid, + }), + }; + }), + ); + + return xrpcJson({ + reviews, + ...(hasMore ? { cursor: encodeOffsetCursor(start + limit) } : {}), + }); +} + +/** + * Mirror OAuth callback: create `fyi.atstore.profile` / `self` when missing so + * reviewers who never hit `/api/auth/atproto/callback` still get first-login + * repo state (requires delegated `repo:create` on `fyi.atstore.profile`). + */ +async function ensureReviewerProfileLikeFirstLogin(session: { + did: string; + client: Client; + session: { user: { name: string } }; +}): Promise { + try { + const publicProfile = await fetchBlueskyPublicProfileFields(session.did); + const handle = + publicProfile?.handle?.trim() && publicProfile.handle.trim().length > 0 + ? publicProfile.handle.trim() + : ""; + const displayName = + publicProfile?.displayName?.trim() || + handle || + session.session.user.name.trim() || + session.did; + await ensureProfileSelfRecord(session.client, session.did, { + displayName, + }); + } catch (error) { + console.warn( + "Failed to ensure fyi.atstore.profile record during submitReview:", + error, + ); + } +} + +async function handleSubmitReview(request: Request) { + const session = await getAtprotoSessionForRequest(request); + if (!session) { + return xrpcErr(401, "Unauthorized"); + } + + let body: unknown; + try { + body = await request.json(); + } catch { + return xrpcErr(400, "InvalidParams", "JSON body required."); + } + + if (!body || typeof body !== "object") { + return xrpcErr(400, "InvalidParams"); + } + + const rec = body as Record; + const subject = typeof rec.subject === "string" ? rec.subject.trim() : ""; + const rating = rec.rating; + const text = + typeof rec.text === "string" ? rec.text : rec.text == null ? "" : null; + + if (!subject || typeof rating !== "number" || !Number.isInteger(rating)) { + return xrpcErr(400, "InvalidParams"); + } + if (rating < 1 || rating > 5) { + return xrpcErr(400, "InvalidParams"); + } + + if (typeof rec.createdAt !== "string" || !rec.createdAt.trim()) { + return xrpcErr(400, "InvalidParams"); + } + + const createdAtIso = new Date().toISOString(); + + let parsedUri: ReturnType; + try { + parsedUri = parseAtUriParts(subject); + } catch { + return xrpcErr(400, "InvalidSubject"); + } + + if (parsedUri.collection !== NSID.listingDetail) { + return xrpcErr(400, "InvalidSubject"); + } + + const table = schema.storeListings; + const { listingPublicWhere } = directoryListingXrpcHelpers; + + const [listing] = await db + .select({ id: table.id, atUri: table.atUri }) + .from(table) + .where(listingPublicWhere(table, eq(table.atUri, subject))) + .limit(1); + + if (!listing) { + return xrpcErr(404, "ListingNotFound"); + } + + const atUri = listing.atUri?.trim(); + if (!atUri) { + return xrpcErr(400, "ListingNotOnNetwork"); + } + + const rev = schema.storeListingReviews; + const [existing] = await db + .select({ id: rev.id }) + .from(rev) + .where( + and(eq(rev.storeListingId, listing.id), eq(rev.authorDid, session.did)), + ) + .limit(1); + + if (existing) { + return xrpcErr(409, "AlreadyReviewed"); + } + + await ensureReviewerProfileLikeFirstLogin(session); + + const { uri, cid } = await createListingReviewRecord( + session.client, + session.did, + { + subject: atUri, + rating, + createdAt: createdAtIso, + text: typeof text === "string" ? text : undefined, + }, + ); + + const { repo, rkey } = parseAtUriParts(uri); + if (repo !== session.did) { + return xrpcErr(500, "InvalidSubject", "Unexpected review record repo DID."); + } + + const record: { + $type: typeof NSID.listingReview; + subject: string; + rating: number; + createdAt: string; + text?: string; + } = { + $type: NSID.listingReview, + subject: atUri, + rating, + createdAt: createdAtIso, + }; + const trimmed = typeof text === "string" ? text.trim() : ""; + if (trimmed) { + record.text = trimmed; + } + + await upsertListingReviewFromTap({ + db, + did: repo, + rkey, + record, + }); + + const [reviewRow] = await db + .select({ id: rev.id }) + .from(rev) + .where(eq(rev.atUri, uri)) + .limit(1); + + if (!reviewRow) { + return xrpcErr( + 500, + "InvalidSubject", + "Review was created but could not be mirrored locally.", + ); + } + + return xrpcJson({ uri, cid, reviewId: reviewRow.id }); +} + +export async function handleAtstoreXrpc( + request: Request, + nsid: string, +): Promise { + if (request.method === "OPTIONS") { + return new Response(null, { + status: 204, + headers: { + "Access-Control-Allow-Origin": "*", + "Access-Control-Allow-Methods": "GET, POST, OPTIONS", + "Access-Control-Allow-Headers": "Content-Type, Authorization", + "Access-Control-Max-Age": "86400", + }, + }); + } + + const url = new URL(request.url); + + try { + switch (nsid) { + case ATSTORE_XRPC_METHOD.serverDescribe: { + if (request.method !== "GET") { + return xrpcErr(405, "MethodNotAllowed"); + } + return handleDescribe(); + } + + case ATSTORE_XRPC_METHOD.directorySearchListings: { + if (request.method !== "GET") { + return xrpcErr(405, "MethodNotAllowed"); + } + return handleSearchListings(url); + } + + case ATSTORE_XRPC_METHOD.directoryGetListing: { + if (request.method !== "GET") { + return xrpcErr(405, "MethodNotAllowed"); + } + return handleGetListing(url); + } + + case ATSTORE_XRPC_METHOD.directoryResolveListing: { + if (request.method !== "GET") { + return xrpcErr(405, "MethodNotAllowed"); + } + return handleResolveListing(url); + } + + case ATSTORE_XRPC_METHOD.reviewsListForListing: { + if (request.method !== "GET") { + return xrpcErr(405, "MethodNotAllowed"); + } + return handleListReviews(url, request); + } + + case ATSTORE_XRPC_METHOD.reviewsSubmitReview: { + if (request.method !== "POST") { + return xrpcErr(405, "MethodNotAllowed"); + } + return handleSubmitReview(request); + } + + default: { + return xrpcErr(404, "MethodNotFound"); + } + } + } catch (error) { + console.error("atstore xrpc handler error", error); + return xrpcErr(500, "InternalError"); + } +} From b5881e3dd7a65e859e9d2c0f9e3d5c8bd4a64f39 Mon Sep 17 00:00:00 2001 From: Andrew Lisowski Date: Mon, 4 May 2026 23:25:07 -0700 Subject: [PATCH 2/7] implement xrpc methods --- .../fyi/atstore/auth/thirdPartyReviews.json | 10 +- .../fyi/atstore/reviews/submitReview.json | 61 ------ lexicons/fyi/atstore/server/describe.json | 7 +- package.json | 2 +- .../api-directory-listings.functions.ts | 22 ++ src/lexicons/generated/bundle.ts | 105 +--------- src/lib/atproto/nsids.ts | 2 - .../_header-layout.developers.atproto.tsx | 114 ++++++----- src/routes/xrpc.$nsid.tsx | 2 - .../atstore-xrpc-handler.server.ts | 191 +----------------- 10 files changed, 109 insertions(+), 407 deletions(-) delete mode 100644 lexicons/fyi/atstore/reviews/submitReview.json diff --git a/lexicons/fyi/atstore/auth/thirdPartyReviews.json b/lexicons/fyi/atstore/auth/thirdPartyReviews.json index ba64dd5..203c2b5 100644 --- a/lexicons/fyi/atstore/auth/thirdPartyReviews.json +++ b/lexicons/fyi/atstore/auth/thirdPartyReviews.json @@ -1,12 +1,12 @@ { "lexicon": 1, "id": "fyi.atstore.authThirdPartyReviews", - "description": "OAuth permission bundle for third-party apps that let users submit AT Store listing reviews (profile bootstrap + review create + submitReview RPC).", + "description": "OAuth permission bundle for third-party apps that publish AT Store profile self plus listing reviews on the user's repo; reads use public directory XRPC.", "defs": { "main": { "type": "permission-set", "title": "Submit AT Store reviews", - "detail": "Ensure fyi.atstore.profile/self exists (first-login parity), create listing reviews on the user's repo, and call the at-store submitReview API using delegated auth.", + "detail": "Create fyi.atstore.profile/self when needed and fyi.atstore.listing.review records on the user's PDS via repository APIs; read public directory data via XRPC queries.", "permissions": [ { "type": "permission", @@ -19,12 +19,6 @@ "resource": "repo", "collection": ["fyi.atstore.listing.review"], "action": ["create"] - }, - { - "type": "permission", - "resource": "rpc", - "inheritAud": true, - "lxm": ["fyi.atstore.reviews.submitReview"] } ] } diff --git a/lexicons/fyi/atstore/reviews/submitReview.json b/lexicons/fyi/atstore/reviews/submitReview.json deleted file mode 100644 index 98850cc..0000000 --- a/lexicons/fyi/atstore/reviews/submitReview.json +++ /dev/null @@ -1,61 +0,0 @@ -{ - "lexicon": 1, - "id": "fyi.atstore.reviews.submitReview", - "defs": { - "main": { - "type": "procedure", - "description": "Create a fyi.atstore.listing.review record on the authenticated user's PDS and mirror it into the directory index. Prefer standard com.atproto.repo.createRecord when not using this proxy.", - "input": { - "encoding": "application/json", - "schema": { - "type": "object", - "required": ["subject", "rating", "createdAt"], - "properties": { - "subject": { - "type": "string", - "format": "at-uri", - "description": "AT URI of the fyi.atstore.listing.detail record." - }, - "rating": { - "type": "integer", - "minimum": 1, - "maximum": 5 - }, - "text": { - "type": "string", - "maxLength": 8000, - "description": "Optional written review." - }, - "createdAt": { - "type": "string", - "format": "datetime" - } - } - } - }, - "output": { - "encoding": "application/json", - "schema": { - "type": "object", - "required": ["uri", "cid", "reviewId"], - "properties": { - "uri": { "type": "string", "format": "at-uri", "maxLength": 2560 }, - "cid": { "type": "string", "maxLength": 128 }, - "reviewId": { - "type": "string", - "maxLength": 64, - "description": "Directory mirror UUID for the review row." - } - } - } - }, - "errors": [ - { "name": "Unauthorized" }, - { "name": "ListingNotFound" }, - { "name": "AlreadyReviewed" }, - { "name": "InvalidSubject" }, - { "name": "ListingNotOnNetwork" } - ] - } - } -} diff --git a/lexicons/fyi/atstore/server/describe.json b/lexicons/fyi/atstore/server/describe.json index dc10edc..8c63516 100644 --- a/lexicons/fyi/atstore/server/describe.json +++ b/lexicons/fyi/atstore/server/describe.json @@ -16,7 +16,7 @@ "required": [ "service", "publicReads", - "oauthSubmitReview", + "reviewsWrittenOnAuthorRepo", "defaultListingLimit", "maxListingLimit", "maxReviewLimit", @@ -28,7 +28,10 @@ "maxLength": 256 }, "publicReads": { "type": "boolean" }, - "oauthSubmitReview": { "type": "boolean" }, + "reviewsWrittenOnAuthorRepo": { + "type": "boolean", + "description": "When true, listing reviews are created via com.atproto.repo.createRecord on the author's PDS (fyi.atstore.listing.review); this service does not expose a write procedure for reviews." + }, "defaultListingLimit": { "type": "integer" }, "maxListingLimit": { "type": "integer" }, "maxReviewLimit": { "type": "integer" }, diff --git a/package.json b/package.json index 84d70d5..b6ff41e 100644 --- a/package.json +++ b/package.json @@ -26,7 +26,7 @@ "db:studio": "drizzle-kit studio", "db:backup": "tsx -r dotenv/config scripts/db-backup.ts", "db:seed": "tsx -r dotenv/config scripts/db-seed.ts", - "lex:gen": "bash -c 'mkdir -p src/lexicons/generated && pnpm exec lex gen-ts-obj lexicons/fyi/atstore/directory/searchListings.json lexicons/fyi/atstore/directory/getListing.json lexicons/fyi/atstore/directory/resolveListing.json lexicons/fyi/atstore/reviews/listForListing.json lexicons/fyi/atstore/reviews/submitReview.json lexicons/fyi/atstore/server/describe.json lexicons/fyi/atstore/profile.json lexicons/fyi/atstore/listing/detail.json lexicons/fyi/atstore/listing/review.json lexicons/fyi/atstore/listing/reviewReply.json lexicons/fyi/atstore/listing/favorite.json lexicons/fyi/atstore/auth/basic.json lexicons/fyi/atstore/auth/thirdPartyReviews.json > src/lexicons/generated/bundle.ts'", + "lex:gen": "bash -c 'mkdir -p src/lexicons/generated && pnpm exec lex gen-ts-obj lexicons/fyi/atstore/directory/searchListings.json lexicons/fyi/atstore/directory/getListing.json lexicons/fyi/atstore/directory/resolveListing.json lexicons/fyi/atstore/reviews/listForListing.json lexicons/fyi/atstore/server/describe.json lexicons/fyi/atstore/profile.json lexicons/fyi/atstore/listing/detail.json lexicons/fyi/atstore/listing/review.json lexicons/fyi/atstore/listing/reviewReply.json lexicons/fyi/atstore/listing/favorite.json lexicons/fyi/atstore/auth/basic.json lexicons/fyi/atstore/auth/thirdPartyReviews.json > src/lexicons/generated/bundle.ts'", "lex:lint": "node scripts/goat-lex.mjs lex lint", "lex:status": "node scripts/goat-lex.mjs lex status", "atproto:publish-lexicons": "node scripts/goat-lex.mjs lex publish", diff --git a/src/integrations/tanstack-query/api-directory-listings.functions.ts b/src/integrations/tanstack-query/api-directory-listings.functions.ts index a2c682e..167d37d 100644 --- a/src/integrations/tanstack-query/api-directory-listings.functions.ts +++ b/src/integrations/tanstack-query/api-directory-listings.functions.ts @@ -33,6 +33,7 @@ import { createListingReviewRecord, createListingReviewReplyRecord, deleteRecord, + ensureProfileSelfRecord, fetchListingDetailRecord, putListingFavoriteRecord, putListingReviewRecord, @@ -3266,6 +3267,27 @@ const createDirectoryListingReview = createServerFn({ method: "POST" }) throw new Error("You already reviewed this listing."); } + try { + const publicProfile = await fetchBlueskyPublicProfileFields(session.did); + const handle = + publicProfile?.handle?.trim() && publicProfile.handle.trim().length > 0 + ? publicProfile.handle.trim() + : ""; + const displayName = + publicProfile?.displayName?.trim() || + handle || + session.session.user.name.trim() || + session.did; + await ensureProfileSelfRecord(session.client, session.did, { + displayName, + }); + } catch (error) { + console.warn( + "Failed to ensure fyi.atstore.profile record before listing review:", + error, + ); + } + const createdAt = new Date().toISOString(); const { uri } = await createListingReviewRecord( session.client, diff --git a/src/lexicons/generated/bundle.ts b/src/lexicons/generated/bundle.ts index 192d0a7..e92613e 100644 --- a/src/lexicons/generated/bundle.ts +++ b/src/lexicons/generated/bundle.ts @@ -32,12 +32,12 @@ export const lexicons = [ { "lexicon": 1, "id": "fyi.atstore.authThirdPartyReviews", - "description": "OAuth permission bundle for third-party apps that let users submit AT Store listing reviews (profile bootstrap + review create + submitReview RPC).", + "description": "OAuth permission bundle for third-party apps that publish AT Store profile self plus listing reviews on the user's repo; reads use public directory XRPC.", "defs": { "main": { "type": "permission-set", "title": "Submit AT Store reviews", - "detail": "Ensure fyi.atstore.profile/self exists (first-login parity), create listing reviews on the user's repo, and call the at-store submitReview API using delegated auth.", + "detail": "Create fyi.atstore.profile/self when needed and fyi.atstore.listing.review records on the user's PDS via repository APIs; read public directory data via XRPC queries.", "permissions": [ { "type": "permission", @@ -58,14 +58,6 @@ export const lexicons = [ "action": [ "create" ] - }, - { - "type": "permission", - "resource": "rpc", - "inheritAud": true, - "lxm": [ - "fyi.atstore.reviews.submitReview" - ] } ] } @@ -951,92 +943,6 @@ export const lexicons = [ } } }, - { - "lexicon": 1, - "id": "fyi.atstore.reviews.submitReview", - "defs": { - "main": { - "type": "procedure", - "description": "Create a fyi.atstore.listing.review record on the authenticated user's PDS and mirror it into the directory index. Prefer standard com.atproto.repo.createRecord when not using this proxy.", - "input": { - "encoding": "application/json", - "schema": { - "type": "object", - "required": [ - "subject", - "rating", - "createdAt" - ], - "properties": { - "subject": { - "type": "string", - "format": "at-uri", - "description": "AT URI of the fyi.atstore.listing.detail record." - }, - "rating": { - "type": "integer", - "minimum": 1, - "maximum": 5 - }, - "text": { - "type": "string", - "maxLength": 8000, - "description": "Optional written review." - }, - "createdAt": { - "type": "string", - "format": "datetime" - } - } - } - }, - "output": { - "encoding": "application/json", - "schema": { - "type": "object", - "required": [ - "uri", - "cid", - "reviewId" - ], - "properties": { - "uri": { - "type": "string", - "format": "at-uri", - "maxLength": 2560 - }, - "cid": { - "type": "string", - "maxLength": 128 - }, - "reviewId": { - "type": "string", - "maxLength": 64, - "description": "Directory mirror UUID for the review row." - } - } - } - }, - "errors": [ - { - "name": "Unauthorized" - }, - { - "name": "ListingNotFound" - }, - { - "name": "AlreadyReviewed" - }, - { - "name": "InvalidSubject" - }, - { - "name": "ListingNotOnNetwork" - } - ] - } - } - }, { "lexicon": 1, "id": "fyi.atstore.server.describe", @@ -1055,7 +961,7 @@ export const lexicons = [ "required": [ "service", "publicReads", - "oauthSubmitReview", + "reviewsWrittenOnAuthorRepo", "defaultListingLimit", "maxListingLimit", "maxReviewLimit", @@ -1069,8 +975,9 @@ export const lexicons = [ "publicReads": { "type": "boolean" }, - "oauthSubmitReview": { - "type": "boolean" + "reviewsWrittenOnAuthorRepo": { + "type": "boolean", + "description": "When true, listing reviews are created via com.atproto.repo.createRecord on the author's PDS (fyi.atstore.listing.review); this service does not expose a write procedure for reviews." }, "defaultListingLimit": { "type": "integer" diff --git a/src/lib/atproto/nsids.ts b/src/lib/atproto/nsids.ts index d04cbde..d5cdc30 100644 --- a/src/lib/atproto/nsids.ts +++ b/src/lib/atproto/nsids.ts @@ -12,7 +12,6 @@ export const NSID = { directoryGetListing: "fyi.atstore.directory.getListing", directoryResolveListing: "fyi.atstore.directory.resolveListing", reviewsListForListing: "fyi.atstore.reviews.listForListing", - reviewsSubmitReview: "fyi.atstore.reviews.submitReview", serverDescribe: "fyi.atstore.server.describe", } as const; @@ -22,7 +21,6 @@ export const ATSTORE_XRPC_METHOD = { directoryGetListing: NSID.directoryGetListing, directoryResolveListing: NSID.directoryResolveListing, reviewsListForListing: NSID.reviewsListForListing, - reviewsSubmitReview: NSID.reviewsSubmitReview, serverDescribe: NSID.serverDescribe, } as const; diff --git a/src/routes/_header-layout.developers.atproto.tsx b/src/routes/_header-layout.developers.atproto.tsx index a913c9e..2a44c11 100644 --- a/src/routes/_header-layout.developers.atproto.tsx +++ b/src/routes/_header-layout.developers.atproto.tsx @@ -20,6 +20,8 @@ import { Body, Heading2, Heading3, + InlineCode, + Pre, } from "#/design-system/typography"; import { Text } from "#/design-system/typography/text"; import { ATSTORE_XRPC_METHOD, NSID } from "#/lib/atproto/nsids"; @@ -27,7 +29,7 @@ import { buildRouteOgMeta } from "#/lib/og-meta"; const METHOD_ROWS: ReadonlyArray<{ nsid: string; - method: "GET" | "POST"; + method: "GET"; summary: string; }> = [ { @@ -43,7 +45,8 @@ const METHOD_ROWS: ReadonlyArray<{ { nsid: ATSTORE_XRPC_METHOD.directoryGetListing, method: "GET", - summary: "Detail projection by `listingId` or `slug`.", + summary: + "Detail projection by `listingId` or `slug` (includes listing `atUri`).", }, { nsid: ATSTORE_XRPC_METHOD.directoryResolveListing, @@ -53,13 +56,8 @@ const METHOD_ROWS: ReadonlyArray<{ { nsid: ATSTORE_XRPC_METHOD.reviewsListForListing, method: "GET", - summary: "Reviews for a listing (`listingId`, pagination).", - }, - { - nsid: ATSTORE_XRPC_METHOD.reviewsSubmitReview, - method: "POST", summary: - "Create a listing review via the user PDS (requires signed-in at-store session cookie today).", + "Reviews for a listing (`listingId`, pagination); mirrored Tap index.", }, ]; @@ -92,6 +90,10 @@ const styles = stylex.create({ methodsTable: { width: "100%", }, + pre: { + marginBottom: 0, + marginTop: 0, + }, }); export const Route = createFileRoute("/_header-layout/developers/atproto")({ @@ -99,7 +101,7 @@ export const Route = createFileRoute("/_header-layout/developers/atproto")({ buildRouteOgMeta({ title: "AT Protocol API | at-store", description: - "AT Store XRPC methods and OAuth permission bundle for third-party review integrations.", + "Public AT Store directory XRPC endpoints and listing-review integration.", }), component: DevelopersAtprotoPage, }); @@ -116,11 +118,8 @@ function DevelopersAtprotoPage() { AT Protocol on AT Store - Directory queries are public GET endpoints under{" "} - /xrpc/<nsid>, shaped by the{" "} - XRPC and{" "} - Lexicon specs. - Lexicon JSON lives in this repository under{" "} + Public GET endpoints under{" "} + /xrpc/<nsid>. Lexicons:{" "} lexicons/fyi/atstore/. @@ -166,43 +165,68 @@ function DevelopersAtprotoPage() { - - Third-party review OAuth bundle + + Listing reviews - Published permission-set lexicon{" "} - {NSID.authThirdPartyReviews} grants{" "} - repo:create on{" "} - {NSID.profile} and{" "} - {NSID.listingReview}, plus{" "} - rpc access (with{" "} - inheritAud) to{" "} - - {ATSTORE_XRPC_METHOD.reviewsSubmitReview} - - . Third-party apps request it using an{" "} - - include scope - {" "} - such as: + Reviews are written with{" "} + com.atproto.repo.createRecord on the + author's PDS ( + repository + ). Use{" "} + + {ATSTORE_XRPC_METHOD.directoryGetListing} + {" "} + for the listing detail atUri; use it as{" "} + subject on a new{" "} + + {NSID.listingReview} + {" "} + record. -
- {`include:${NSID.authThirdPartyReviews}?aud=`} -
- The aud fragment must match your deployment's OAuth protected - resource metadata. Blob uploads are{" "} - not bundled - ; apps still need explicit blob: scopes - when uploading media to a PDS. + The author's repo must include{" "} + {NSID.profile} at + record key self + —directory ingestion does not pick up{" "} + + {NSID.listingReview} + {" "} + records until that profile exists. - Today submitReview authenticates with - the same signed-in browser session as the web app (cookie). Pure - bearer-token clients should fall back to{" "} - com.atproto.repo.createRecord on the - user's PDS using the review lexicon until bearer verification - lands here. + Example record (omit{" "} + text for stars-only): +
+            
+              {`{
+  "$type": "${NSID.listingReview}",
+  "subject": "at://…/${NSID.listingDetail}/…",
+  "rating": 4,
+  "createdAt": "2026-05-04T12:00:00.000Z",
+  "text": "Optional prose."
+}`}
+            
+          
+
+ + Permission-set{" "} + + {NSID.authThirdPartyReviews} + {" "} + bundles repo:create on{" "} + {NSID.profile}{" "} + and{" "} + + {NSID.listingReview} + {" "} + ( + + permissions + + ). + +
diff --git a/src/routes/xrpc.$nsid.tsx b/src/routes/xrpc.$nsid.tsx index 3d074be..3dd452b 100644 --- a/src/routes/xrpc.$nsid.tsx +++ b/src/routes/xrpc.$nsid.tsx @@ -6,8 +6,6 @@ export const Route = createFileRoute("/xrpc/$nsid")({ handlers: { GET: ({ request, params }) => handleAtstoreXrpc(request, decodeURIComponent(params.nsid)), - POST: ({ request, params }) => - handleAtstoreXrpc(request, decodeURIComponent(params.nsid)), OPTIONS: ({ request, params }) => handleAtstoreXrpc(request, decodeURIComponent(params.nsid)), }, diff --git a/src/server/atproto-xrpc/atstore-xrpc-handler.server.ts b/src/server/atproto-xrpc/atstore-xrpc-handler.server.ts index 18d1e30..db99fe9 100644 --- a/src/server/atproto-xrpc/atstore-xrpc-handler.server.ts +++ b/src/server/atproto-xrpc/atstore-xrpc-handler.server.ts @@ -1,20 +1,13 @@ -import type { Client } from "@atcute/client"; import type { DirectoryListingCard } from "#/integrations/tanstack-query/api-directory-listings.functions"; import type { ListingLink } from "#/lib/atproto/listing-record"; import { db } from "#/db/index.server"; import * as schema from "#/db/schema"; import { directoryListingXrpcHelpers } from "#/integrations/tanstack-query/api-directory-listings.functions"; -import { parseAtUriParts } from "#/lib/atproto/at-uri"; -import { ATSTORE_XRPC_METHOD, NSID } from "#/lib/atproto/nsids"; -import { - createListingReviewRecord, - ensureProfileSelfRecord, -} from "#/lib/atproto/repo-records"; -import { upsertListingReviewFromTap } from "#/lib/atproto/tap-review-sync"; +import { ATSTORE_XRPC_METHOD } from "#/lib/atproto/nsids"; import { fetchBlueskyPublicProfileFields } from "#/lib/bluesky-public-profile"; import { getAtprotoSessionForRequest } from "#/middleware/auth"; -import { and, asc, desc, eq, ilike, or, sql } from "drizzle-orm"; +import { asc, desc, eq, ilike, or, sql } from "drizzle-orm"; const LEGACY_DETAIL_SQL = { rawCategoryHint: sql`null::text`.as("rawCategoryHint"), @@ -105,7 +98,7 @@ async function handleDescribe() { return xrpcJson({ service: "at-store-directory", publicReads: true, - oauthSubmitReview: true, + reviewsWrittenOnAuthorRepo: true, defaultListingLimit: 24, maxListingLimit: 100, maxReviewLimit: 100, @@ -449,175 +442,6 @@ async function handleListReviews(url: URL, request: Request) { }); } -/** - * Mirror OAuth callback: create `fyi.atstore.profile` / `self` when missing so - * reviewers who never hit `/api/auth/atproto/callback` still get first-login - * repo state (requires delegated `repo:create` on `fyi.atstore.profile`). - */ -async function ensureReviewerProfileLikeFirstLogin(session: { - did: string; - client: Client; - session: { user: { name: string } }; -}): Promise { - try { - const publicProfile = await fetchBlueskyPublicProfileFields(session.did); - const handle = - publicProfile?.handle?.trim() && publicProfile.handle.trim().length > 0 - ? publicProfile.handle.trim() - : ""; - const displayName = - publicProfile?.displayName?.trim() || - handle || - session.session.user.name.trim() || - session.did; - await ensureProfileSelfRecord(session.client, session.did, { - displayName, - }); - } catch (error) { - console.warn( - "Failed to ensure fyi.atstore.profile record during submitReview:", - error, - ); - } -} - -async function handleSubmitReview(request: Request) { - const session = await getAtprotoSessionForRequest(request); - if (!session) { - return xrpcErr(401, "Unauthorized"); - } - - let body: unknown; - try { - body = await request.json(); - } catch { - return xrpcErr(400, "InvalidParams", "JSON body required."); - } - - if (!body || typeof body !== "object") { - return xrpcErr(400, "InvalidParams"); - } - - const rec = body as Record; - const subject = typeof rec.subject === "string" ? rec.subject.trim() : ""; - const rating = rec.rating; - const text = - typeof rec.text === "string" ? rec.text : rec.text == null ? "" : null; - - if (!subject || typeof rating !== "number" || !Number.isInteger(rating)) { - return xrpcErr(400, "InvalidParams"); - } - if (rating < 1 || rating > 5) { - return xrpcErr(400, "InvalidParams"); - } - - if (typeof rec.createdAt !== "string" || !rec.createdAt.trim()) { - return xrpcErr(400, "InvalidParams"); - } - - const createdAtIso = new Date().toISOString(); - - let parsedUri: ReturnType; - try { - parsedUri = parseAtUriParts(subject); - } catch { - return xrpcErr(400, "InvalidSubject"); - } - - if (parsedUri.collection !== NSID.listingDetail) { - return xrpcErr(400, "InvalidSubject"); - } - - const table = schema.storeListings; - const { listingPublicWhere } = directoryListingXrpcHelpers; - - const [listing] = await db - .select({ id: table.id, atUri: table.atUri }) - .from(table) - .where(listingPublicWhere(table, eq(table.atUri, subject))) - .limit(1); - - if (!listing) { - return xrpcErr(404, "ListingNotFound"); - } - - const atUri = listing.atUri?.trim(); - if (!atUri) { - return xrpcErr(400, "ListingNotOnNetwork"); - } - - const rev = schema.storeListingReviews; - const [existing] = await db - .select({ id: rev.id }) - .from(rev) - .where( - and(eq(rev.storeListingId, listing.id), eq(rev.authorDid, session.did)), - ) - .limit(1); - - if (existing) { - return xrpcErr(409, "AlreadyReviewed"); - } - - await ensureReviewerProfileLikeFirstLogin(session); - - const { uri, cid } = await createListingReviewRecord( - session.client, - session.did, - { - subject: atUri, - rating, - createdAt: createdAtIso, - text: typeof text === "string" ? text : undefined, - }, - ); - - const { repo, rkey } = parseAtUriParts(uri); - if (repo !== session.did) { - return xrpcErr(500, "InvalidSubject", "Unexpected review record repo DID."); - } - - const record: { - $type: typeof NSID.listingReview; - subject: string; - rating: number; - createdAt: string; - text?: string; - } = { - $type: NSID.listingReview, - subject: atUri, - rating, - createdAt: createdAtIso, - }; - const trimmed = typeof text === "string" ? text.trim() : ""; - if (trimmed) { - record.text = trimmed; - } - - await upsertListingReviewFromTap({ - db, - did: repo, - rkey, - record, - }); - - const [reviewRow] = await db - .select({ id: rev.id }) - .from(rev) - .where(eq(rev.atUri, uri)) - .limit(1); - - if (!reviewRow) { - return xrpcErr( - 500, - "InvalidSubject", - "Review was created but could not be mirrored locally.", - ); - } - - return xrpcJson({ uri, cid, reviewId: reviewRow.id }); -} - export async function handleAtstoreXrpc( request: Request, nsid: string, @@ -627,7 +451,7 @@ export async function handleAtstoreXrpc( status: 204, headers: { "Access-Control-Allow-Origin": "*", - "Access-Control-Allow-Methods": "GET, POST, OPTIONS", + "Access-Control-Allow-Methods": "GET, OPTIONS", "Access-Control-Allow-Headers": "Content-Type, Authorization", "Access-Control-Max-Age": "86400", }, @@ -673,13 +497,6 @@ export async function handleAtstoreXrpc( return handleListReviews(url, request); } - case ATSTORE_XRPC_METHOD.reviewsSubmitReview: { - if (request.method !== "POST") { - return xrpcErr(405, "MethodNotAllowed"); - } - return handleSubmitReview(request); - } - default: { return xrpcErr(404, "MethodNotFound"); } From f5f5377e59077b91ab9934297f27c24beb791da8 Mon Sep 17 00:00:00 2001 From: Andrew Lisowski Date: Mon, 4 May 2026 23:36:37 -0700 Subject: [PATCH 3/7] user uri instead --- .../fyi/atstore/directory/getListing.json | 66 +++++++++---- .../fyi/atstore/directory/resolveListing.json | 13 ++- .../fyi/atstore/directory/searchListings.json | 30 ++++-- .../fyi/atstore/reviews/listForListing.json | 37 ++++++-- .../api-directory-listings.functions.ts | 35 +++++++ src/lexicons/generated/bundle.ts | 75 +++++++-------- .../_header-layout.developers.atproto.tsx | 10 +- .../atstore-xrpc-handler.server.ts | 92 +++++++++---------- 8 files changed, 222 insertions(+), 136 deletions(-) diff --git a/lexicons/fyi/atstore/directory/getListing.json b/lexicons/fyi/atstore/directory/getListing.json index 101086a..103c7ba 100644 --- a/lexicons/fyi/atstore/directory/getListing.json +++ b/lexicons/fyi/atstore/directory/getListing.json @@ -5,7 +5,7 @@ "listingCardGet": { "type": "object", "required": [ - "id", + "uri", "name", "tagline", "description", @@ -17,14 +17,26 @@ "categorySlugs" ], "properties": { - "id": { "type": "string", "maxLength": 64 }, + "uri": { + "type": "string", + "format": "at-uri", + "maxLength": 2560, + "description": "AT URI of the fyi.atstore.listing.detail record." + }, "name": { "type": "string", "maxLength": 640 }, - "slug": { "type": "string", "maxLength": 640 }, "tagline": { "type": "string", "maxLength": 2000 }, "description": { "type": "string", "maxLength": 20000 }, "iconUrl": { "type": "string", "maxLength": 8192, "nullable": true }, - "heroImageUrl": { "type": "string", "maxLength": 8192, "nullable": true }, - "categorySlug": { "type": "string", "maxLength": 512, "nullable": true }, + "heroImageUrl": { + "type": "string", + "maxLength": 8192, + "nullable": true + }, + "categorySlug": { + "type": "string", + "maxLength": 512, + "nullable": true + }, "categorySlugs": { "type": "array", "items": { "type": "string", "maxLength": 512 } @@ -42,7 +54,11 @@ }, "reviewCount": { "type": "integer" }, "priceLabel": { "type": "string", "maxLength": 32 }, - "productAccountHandle": { "type": "string", "maxLength": 512, "nullable": true }, + "productAccountHandle": { + "type": "string", + "maxLength": 512, + "nullable": true + }, "appTags": { "type": "array", "items": { "type": "string", "maxLength": 256 } @@ -65,17 +81,32 @@ "type": "ref", "ref": "#listingCardGet" }, - "atUri": { "type": "string", "maxLength": 2560, "nullable": true }, "isStoreManaged": { "type": "boolean" }, "repoDid": { "type": "string", "maxLength": 2048, "nullable": true }, - "productAccountDid": { "type": "string", "maxLength": 2048, "nullable": true }, - "sourceTagline": { "type": "string", "maxLength": 20000, "nullable": true }, - "sourceFullDescription": { "type": "string", "maxLength": 20000, "nullable": true }, + "productAccountDid": { + "type": "string", + "maxLength": 2048, + "nullable": true + }, + "sourceTagline": { + "type": "string", + "maxLength": 20000, + "nullable": true + }, + "sourceFullDescription": { + "type": "string", + "maxLength": 20000, + "nullable": true + }, "screenshots": { "type": "array", "items": { "type": "string", "maxLength": 4096 } }, - "externalUrl": { "type": "string", "maxLength": 2048, "nullable": true }, + "externalUrl": { + "type": "string", + "maxLength": 2048, + "nullable": true + }, "sourceUrl": { "type": "string", "maxLength": 8192, "nullable": true }, "createdAt": { "type": "string", "maxLength": 64, "nullable": true }, "updatedAt": { "type": "string", "maxLength": 64, "nullable": true }, @@ -90,17 +121,16 @@ }, "main": { "type": "query", - "description": "Fetch one public verified listing by stable Postgres id (UUID) or by URL slug.", + "description": "Fetch one public verified listing by fyi.atstore.listing.detail AT URI.", "parameters": { "type": "params", + "required": ["uri"], "properties": { - "listingId": { - "type": "string", - "maxLength": 64 - }, - "slug": { + "uri": { "type": "string", - "maxLength": 640 + "format": "at-uri", + "maxLength": 2560, + "description": "AT URI of the fyi.atstore.listing.detail record." } } }, diff --git a/lexicons/fyi/atstore/directory/resolveListing.json b/lexicons/fyi/atstore/directory/resolveListing.json index 2d5b0bf..642cea7 100644 --- a/lexicons/fyi/atstore/directory/resolveListing.json +++ b/lexicons/fyi/atstore/directory/resolveListing.json @@ -4,7 +4,7 @@ "defs": { "main": { "type": "query", - "description": "Resolve a storefront external URL to a directory listing when uniquely matched.", + "description": "Resolve a storefront external URL to a directory listing.detail AT URI when uniquely matched.", "parameters": { "type": "params", "required": ["externalUrl"], @@ -20,11 +20,14 @@ "encoding": "application/json", "schema": { "type": "object", - "required": ["listingId", "slug"], + "required": ["uri"], "properties": { - "listingId": { "type": "string", "maxLength": 64 }, - "slug": { "type": "string", "maxLength": 640 }, - "atUri": { "type": "string", "maxLength": 2560, "nullable": true } + "uri": { + "type": "string", + "format": "at-uri", + "maxLength": 2560, + "description": "AT URI of the fyi.atstore.listing.detail record." + } } } }, diff --git a/lexicons/fyi/atstore/directory/searchListings.json b/lexicons/fyi/atstore/directory/searchListings.json index d9abf25..176bce3 100644 --- a/lexicons/fyi/atstore/directory/searchListings.json +++ b/lexicons/fyi/atstore/directory/searchListings.json @@ -5,7 +5,7 @@ "listingCardSearch": { "type": "object", "required": [ - "id", + "uri", "name", "tagline", "description", @@ -17,14 +17,26 @@ "categorySlugs" ], "properties": { - "id": { "type": "string", "maxLength": 64 }, + "uri": { + "type": "string", + "format": "at-uri", + "maxLength": 2560, + "description": "AT URI of the fyi.atstore.listing.detail record." + }, "name": { "type": "string", "maxLength": 640 }, - "slug": { "type": "string", "maxLength": 640 }, "tagline": { "type": "string", "maxLength": 2000 }, "description": { "type": "string", "maxLength": 20000 }, "iconUrl": { "type": "string", "maxLength": 8192, "nullable": true }, - "heroImageUrl": { "type": "string", "maxLength": 8192, "nullable": true }, - "categorySlug": { "type": "string", "maxLength": 512, "nullable": true }, + "heroImageUrl": { + "type": "string", + "maxLength": 8192, + "nullable": true + }, + "categorySlug": { + "type": "string", + "maxLength": 512, + "nullable": true + }, "categorySlugs": { "type": "array", "items": { "type": "string", "maxLength": 512 } @@ -42,7 +54,11 @@ }, "reviewCount": { "type": "integer" }, "priceLabel": { "type": "string", "maxLength": 32 }, - "productAccountHandle": { "type": "string", "maxLength": 512, "nullable": true }, + "productAccountHandle": { + "type": "string", + "maxLength": 512, + "nullable": true + }, "appTags": { "type": "array", "items": { "type": "string", "maxLength": 256 } @@ -51,7 +67,7 @@ }, "main": { "type": "query", - "description": "Directory listing search and pagination.", + "description": "Directory listing search and pagination (verified listings with a listing.detail AT URI only).", "parameters": { "type": "params", "properties": { diff --git a/lexicons/fyi/atstore/reviews/listForListing.json b/lexicons/fyi/atstore/reviews/listForListing.json index 18fcaa5..52c0596 100644 --- a/lexicons/fyi/atstore/reviews/listForListing.json +++ b/lexicons/fyi/atstore/reviews/listForListing.json @@ -22,9 +22,21 @@ "format": "datetime", "maxLength": 64 }, - "authorDisplayName": { "type": "string", "maxLength": 640, "nullable": true }, - "authorHandle": { "type": "string", "maxLength": 512, "nullable": true }, - "authorAvatarUrl": { "type": "string", "maxLength": 8192, "nullable": true }, + "authorDisplayName": { + "type": "string", + "maxLength": 640, + "nullable": true + }, + "authorHandle": { + "type": "string", + "maxLength": 512, + "nullable": true + }, + "authorAvatarUrl": { + "type": "string", + "maxLength": 8192, + "nullable": true + }, "replyCount": { "type": "integer" }, "canReply": { "type": "boolean" } } @@ -34,11 +46,13 @@ "description": "List reviews for a directory listing (mirrored Tap data plus profile enrichment).", "parameters": { "type": "params", - "required": ["listingId"], + "required": ["uri"], "properties": { - "listingId": { + "uri": { "type": "string", - "maxLength": 64 + "format": "at-uri", + "maxLength": 2560, + "description": "AT URI of the fyi.atstore.listing.detail record." }, "limit": { "type": "integer", @@ -58,7 +72,10 @@ "type": "object", "required": ["reviews"], "properties": { - "cursor": { "type": "string", "maxLength": 512 }, + "cursor": { + "type": "string", + "maxLength": 512 + }, "reviews": { "type": "array", "items": { @@ -69,7 +86,11 @@ } } }, - "errors": [{ "name": "ListingNotFound" }, { "name": "InvalidCursor" }] + "errors": [ + { "name": "ListingNotFound" }, + { "name": "InvalidParams" }, + { "name": "InvalidCursor" } + ] } } } diff --git a/src/integrations/tanstack-query/api-directory-listings.functions.ts b/src/integrations/tanstack-query/api-directory-listings.functions.ts index 167d37d..f9543a2 100644 --- a/src/integrations/tanstack-query/api-directory-listings.functions.ts +++ b/src/integrations/tanstack-query/api-directory-listings.functions.ts @@ -77,6 +77,7 @@ import { eq, ilike, inArray, + isNotNull, isNull, ne, or, @@ -141,6 +142,18 @@ function listingPublicWhere(table: typeof dbSchema.storeListings, extra?: SQL) { return extra ? and(pub, extra) : pub; } +/** Verified public listings exposed over directory XRPC — must have a published listing.detail AT URI. */ +function listingXrpcPublicWhere( + table: typeof dbSchema.storeListings, + extra?: SQL, +) { + const uriClause = and( + isNotNull(table.atUri), + sql`trim(${table.atUri}) <> ''`, + ); + return listingPublicWhere(table, extra ? and(extra, uriClause) : uriClause); +} + function viewerMayReplyOnListingReview(opts: { viewerDid?: string | null; reviewAuthorDid: string; @@ -205,6 +218,7 @@ type DirectoryListingRow = { id: string; name: string; slug: string | null; + atUri: string | null; iconUrl: string | null; /** Dedicated hero/cover from `store_listings.hero_image_url` (Tap / publish). */ heroImageUrl: string | null; @@ -248,6 +262,14 @@ export interface DirectoryListingCard { appTags: Array; } +/** Listing card shape for directory XRPC responses (`uri` only — no store UUID/slug). */ +export type DirectoryListingCardXrpc = Omit< + DirectoryListingCard, + "id" | "slug" +> & { + uri: string; +}; + export interface DirectoryListingDetail extends DirectoryListingCard { /** Canonical AT URI for `fyi.atstore.listing.detail` when Tap-synced; needed to publish reviews. */ atUri: string | null; @@ -915,6 +937,16 @@ function toListingCard(row: DirectoryListingRow): DirectoryListingCard { }; } +function toListingCardXrpc(row: DirectoryListingRow): DirectoryListingCardXrpc { + const uri = row.atUri?.trim(); + if (!uri) { + throw new Error("listingXrpcPublicWhere invariant violated: missing atUri"); + } + const base = toListingCard(row); + const { id: _id, slug: _slug, ...rest } = base; + return { ...rest, uri }; +} + type DirectoryListingDetailRow = DirectoryListingRow & { atUri: string | null; repoDid: string | null; @@ -1434,6 +1466,7 @@ function getListingSelect(table: typeof dbSchema.storeListings) { id: table.id, name: table.name, slug: table.slug, + atUri: table.atUri, iconUrl: table.iconUrl, heroImageUrl: table.heroImageUrl, screenshotUrls: table.screenshotUrls, @@ -6724,9 +6757,11 @@ const createStoreManagedListing = createServerFn({ method: "POST" }) /** Server-only helpers shared with AT Store XRPC handlers. */ export const directoryListingXrpcHelpers = { listingPublicWhere, + listingXrpcPublicWhere, getListingSelect, orderByPopularListingSort, toListingCard, + toListingCardXrpc, computeIsStoreManaged, viewerMayReplyOnListingReview, normalizeListingLinks, diff --git a/src/lexicons/generated/bundle.ts b/src/lexicons/generated/bundle.ts index e92613e..6bffb8d 100644 --- a/src/lexicons/generated/bundle.ts +++ b/src/lexicons/generated/bundle.ts @@ -70,7 +70,7 @@ export const lexicons = [ "listingCardGet": { "type": "object", "required": [ - "id", + "uri", "name", "tagline", "description", @@ -82,18 +82,16 @@ export const lexicons = [ "categorySlugs" ], "properties": { - "id": { + "uri": { "type": "string", - "maxLength": 64 + "format": "at-uri", + "maxLength": 2560, + "description": "AT URI of the fyi.atstore.listing.detail record." }, "name": { "type": "string", "maxLength": 640 }, - "slug": { - "type": "string", - "maxLength": 640 - }, "tagline": { "type": "string", "maxLength": 2000 @@ -191,11 +189,6 @@ export const lexicons = [ "type": "ref", "ref": "#listingCardGet" }, - "atUri": { - "type": "string", - "maxLength": 2560, - "nullable": true - }, "isStoreManaged": { "type": "boolean" }, @@ -257,17 +250,18 @@ export const lexicons = [ }, "main": { "type": "query", - "description": "Fetch one public verified listing by stable Postgres id (UUID) or by URL slug.", + "description": "Fetch one public verified listing by fyi.atstore.listing.detail AT URI.", "parameters": { "type": "params", + "required": [ + "uri" + ], "properties": { - "listingId": { + "uri": { "type": "string", - "maxLength": 64 - }, - "slug": { - "type": "string", - "maxLength": 640 + "format": "at-uri", + "maxLength": 2560, + "description": "AT URI of the fyi.atstore.listing.detail record." } } }, @@ -295,7 +289,7 @@ export const lexicons = [ "defs": { "main": { "type": "query", - "description": "Resolve a storefront external URL to a directory listing when uniquely matched.", + "description": "Resolve a storefront external URL to a directory listing.detail AT URI when uniquely matched.", "parameters": { "type": "params", "required": [ @@ -314,22 +308,14 @@ export const lexicons = [ "schema": { "type": "object", "required": [ - "listingId", - "slug" + "uri" ], "properties": { - "listingId": { - "type": "string", - "maxLength": 64 - }, - "slug": { - "type": "string", - "maxLength": 640 - }, - "atUri": { + "uri": { "type": "string", + "format": "at-uri", "maxLength": 2560, - "nullable": true + "description": "AT URI of the fyi.atstore.listing.detail record." } } } @@ -352,7 +338,7 @@ export const lexicons = [ "listingCardSearch": { "type": "object", "required": [ - "id", + "uri", "name", "tagline", "description", @@ -364,18 +350,16 @@ export const lexicons = [ "categorySlugs" ], "properties": { - "id": { + "uri": { "type": "string", - "maxLength": 64 + "format": "at-uri", + "maxLength": 2560, + "description": "AT URI of the fyi.atstore.listing.detail record." }, "name": { "type": "string", "maxLength": 640 }, - "slug": { - "type": "string", - "maxLength": 640 - }, "tagline": { "type": "string", "maxLength": 2000 @@ -448,7 +432,7 @@ export const lexicons = [ }, "main": { "type": "query", - "description": "Directory listing search and pagination.", + "description": "Directory listing search and pagination (verified listings with a listing.detail AT URI only).", "parameters": { "type": "params", "properties": { @@ -891,12 +875,14 @@ export const lexicons = [ "parameters": { "type": "params", "required": [ - "listingId" + "uri" ], "properties": { - "listingId": { + "uri": { "type": "string", - "maxLength": 64 + "format": "at-uri", + "maxLength": 2560, + "description": "AT URI of the fyi.atstore.listing.detail record." }, "limit": { "type": "integer", @@ -936,6 +922,9 @@ export const lexicons = [ { "name": "ListingNotFound" }, + { + "name": "InvalidParams" + }, { "name": "InvalidCursor" } diff --git a/src/routes/_header-layout.developers.atproto.tsx b/src/routes/_header-layout.developers.atproto.tsx index 2a44c11..f7af0bd 100644 --- a/src/routes/_header-layout.developers.atproto.tsx +++ b/src/routes/_header-layout.developers.atproto.tsx @@ -46,18 +46,18 @@ const METHOD_ROWS: ReadonlyArray<{ nsid: ATSTORE_XRPC_METHOD.directoryGetListing, method: "GET", summary: - "Detail projection by `listingId` or `slug` (includes listing `atUri`).", + "Detail projection by listing.detail AT URI (`uri` query param); card uses `listing.uri`.", }, { nsid: ATSTORE_XRPC_METHOD.directoryResolveListing, method: "GET", - summary: "Resolve `externalUrl` to listing identifiers.", + summary: "Resolve `externalUrl` to listing.detail AT URI (`uri`).", }, { nsid: ATSTORE_XRPC_METHOD.reviewsListForListing, method: "GET", summary: - "Reviews for a listing (`listingId`, pagination); mirrored Tap index.", + "Reviews for a listing (`uri` listing.detail AT URI, pagination); mirrored Tap index.", }, ]; @@ -176,7 +176,9 @@ function DevelopersAtprotoPage() { {ATSTORE_XRPC_METHOD.directoryGetListing} {" "} - for the listing detail atUri; use it as{" "} + with query param uri (the + listing.detail AT URI); the JSON includes it as{" "} + listing.uri. Use that value as{" "} subject on a new{" "} {NSID.listingReview} diff --git a/src/server/atproto-xrpc/atstore-xrpc-handler.server.ts b/src/server/atproto-xrpc/atstore-xrpc-handler.server.ts index db99fe9..ab5c54d 100644 --- a/src/server/atproto-xrpc/atstore-xrpc-handler.server.ts +++ b/src/server/atproto-xrpc/atstore-xrpc-handler.server.ts @@ -1,10 +1,11 @@ -import type { DirectoryListingCard } from "#/integrations/tanstack-query/api-directory-listings.functions"; +import type { DirectoryListingCardXrpc } from "#/integrations/tanstack-query/api-directory-listings.functions"; import type { ListingLink } from "#/lib/atproto/listing-record"; import { db } from "#/db/index.server"; import * as schema from "#/db/schema"; import { directoryListingXrpcHelpers } from "#/integrations/tanstack-query/api-directory-listings.functions"; -import { ATSTORE_XRPC_METHOD } from "#/lib/atproto/nsids"; +import { parseAtUriParts } from "#/lib/atproto/at-uri"; +import { ATSTORE_XRPC_METHOD, NSID } from "#/lib/atproto/nsids"; import { fetchBlueskyPublicProfileFields } from "#/lib/bluesky-public-profile"; import { getAtprotoSessionForRequest } from "#/middleware/auth"; import { asc, desc, eq, ilike, or, sql } from "drizzle-orm"; @@ -62,16 +63,22 @@ function decodeOffsetCursor(cursor: string | null): number | undefined { } } -function isUuid(value: string): boolean { - return /^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i.test( - value.trim(), - ); +function listingDetailUriOrNull(uriRaw: string): string | null { + const trimmed = uriRaw.trim(); + if (!trimmed) { + return null; + } + try { + const { collection } = parseAtUriParts(trimmed); + return collection === NSID.listingDetail ? trimmed : null; + } catch { + return null; + } } -function listingCardJson(card: DirectoryListingCard) { +function listingCardXrpcJson(card: DirectoryListingCardXrpc) { return { ...card, - slug: card.slug ?? "", rating: card.rating == null || Number.isNaN(Number(card.rating)) ? null @@ -128,10 +135,10 @@ async function handleSearchListings(url: URL) { const table = schema.storeListings; const { - listingPublicWhere, + listingXrpcPublicWhere, getListingSelect, orderByPopularListingSort, - toListingCard, + toListingCardXrpc, } = directoryListingXrpcHelpers; const searchClause = q @@ -158,14 +165,16 @@ async function handleSearchListings(url: URL) { const rows = await db .select(listingSelect) .from(table) - .where(listingPublicWhere(table, searchClause)) + .where(listingXrpcPublicWhere(table, searchClause)) .orderBy(...orderBy) .offset(start) .limit(limit + 1); const hasMore = rows.length > limit; const slice = hasMore ? rows.slice(0, limit) : rows; - const listings = slice.map((row) => listingCardJson(toListingCard(row))); + const listings = slice.map((row) => + listingCardXrpcJson(toListingCardXrpc(row)), + ); return xrpcJson({ listings, @@ -173,29 +182,16 @@ async function handleSearchListings(url: URL) { }); } -async function fetchVerifiedListingDetailRow(options: { - listingId?: string; - slug?: string; -}) { - const listingId = options.listingId?.trim(); - const slugOnly = options.slug?.trim(); - - if ((listingId && slugOnly) || (!listingId && !slugOnly)) { +async function fetchVerifiedListingDetailRowByUri(uriRaw: string) { + const canonical = listingDetailUriOrNull(uriRaw); + if (!canonical) { return { error: "InvalidParams" as const }; } const table = schema.storeListings; - const { listingPublicWhere } = directoryListingXrpcHelpers; + const { listingXrpcPublicWhere } = directoryListingXrpcHelpers; - let filter; - if (listingId) { - if (!isUuid(listingId)) { - return { error: "InvalidParams" as const }; - } - filter = eq(table.id, listingId); - } else { - filter = eq(table.slug, slugOnly ?? ""); - } + const filter = eq(table.atUri, canonical); const [row] = await db .select({ @@ -224,7 +220,7 @@ async function fetchVerifiedListingDetailRow(options: { updatedAt: table.updatedAt, }) .from(table) - .where(listingPublicWhere(table, filter)) + .where(listingXrpcPublicWhere(table, filter)) .limit(1); if (!row) { @@ -234,10 +230,8 @@ async function fetchVerifiedListingDetailRow(options: { } async function handleGetListing(url: URL) { - const listingId = url.searchParams.get("listingId") ?? undefined; - const slug = url.searchParams.get("slug") ?? undefined; - - const fetched = await fetchVerifiedListingDetailRow({ listingId, slug }); + const uriParam = url.searchParams.get("uri") ?? ""; + const fetched = await fetchVerifiedListingDetailRowByUri(uriParam); if ("error" in fetched) { switch (fetched.error) { case "InvalidParams": { @@ -252,11 +246,11 @@ async function handleGetListing(url: URL) { } } - const { toListingCard, computeIsStoreManaged, normalizeListingLinks } = + const { toListingCardXrpc, computeIsStoreManaged, normalizeListingLinks } = directoryListingXrpcHelpers; const row = fetched.row; - const listing = listingCardJson(toListingCard(row)); + const listing = listingCardXrpcJson(toListingCardXrpc(row)); const isStoreManaged = await computeIsStoreManaged(row); const linksRaw = normalizeListingLinks( @@ -266,7 +260,6 @@ async function handleGetListing(url: URL) { return xrpcJson({ listing, isStoreManaged, - atUri: row.atUri ?? null, repoDid: row.repoDid ?? null, productAccountDid: row.productAccountDid ?? null, sourceTagline: row.tagline ?? null, @@ -295,7 +288,7 @@ async function handleResolveListing(url: URL) { } const table = schema.storeListings; - const { listingPublicWhere } = directoryListingXrpcHelpers; + const { listingXrpcPublicWhere } = directoryListingXrpcHelpers; const clause = variants.length === 1 @@ -311,12 +304,10 @@ async function handleResolveListing(url: URL) { const rows = await db .select({ - id: table.id, - slug: table.slug, atUri: table.atUri, }) .from(table) - .where(listingPublicWhere(table, clause)) + .where(listingXrpcPublicWhere(table, clause)) .limit(4); if (rows.length === 0) { @@ -330,21 +321,20 @@ async function handleResolveListing(url: URL) { if (!hit) { return xrpcErr(404, "ListingNotFound"); } - const slug = hit.slug?.trim(); - if (!slug) { + const uri = hit.atUri?.trim(); + if (!uri) { return xrpcErr(404, "ListingNotFound"); } return xrpcJson({ - listingId: hit.id, - slug, - atUri: hit.atUri ?? null, + uri, }); } async function handleListReviews(url: URL, request: Request) { - const listingId = url.searchParams.get("listingId")?.trim(); - if (!listingId || !isUuid(listingId)) { + const uriParam = url.searchParams.get("uri") ?? ""; + const canonical = listingDetailUriOrNull(uriParam); + if (!canonical) { return xrpcErr(400, "InvalidParams"); } @@ -360,7 +350,7 @@ async function handleListReviews(url: URL, request: Request) { const start = offset ?? 0; const table = schema.storeListings; - const { listingPublicWhere, viewerMayReplyOnListingReview } = + const { listingXrpcPublicWhere, viewerMayReplyOnListingReview } = directoryListingXrpcHelpers; const [listing] = await db @@ -370,7 +360,7 @@ async function handleListReviews(url: URL, request: Request) { productAccountDid: table.productAccountDid, }) .from(table) - .where(listingPublicWhere(table, eq(table.id, listingId))) + .where(listingXrpcPublicWhere(table, eq(table.atUri, canonical))) .limit(1); if (!listing) { From 76b16090eb87de9d2bb89cb8087d748a520b91ad Mon Sep 17 00:00:00 2001 From: Andrew Lisowski Date: Mon, 4 May 2026 23:40:14 -0700 Subject: [PATCH 4/7] fix format --- src/components/SiteFooter.tsx | 2 +- src/lexicons/generated/bundle.ts | 1675 +++++++++++++++--------------- 2 files changed, 822 insertions(+), 855 deletions(-) diff --git a/src/components/SiteFooter.tsx b/src/components/SiteFooter.tsx index 8c4a93d..0ee2248 100644 --- a/src/components/SiteFooter.tsx +++ b/src/components/SiteFooter.tsx @@ -8,7 +8,7 @@ const FooterLink = createLink(Link); const FOOTER_LINK_GROUPS = [ { - links: [ + links: [ { href: "/about", label: "About" }, { href: "/home", label: "Home" }, { href: "/search", label: "Search" }, diff --git a/src/lexicons/generated/bundle.ts b/src/lexicons/generated/bundle.ts index 6bffb8d..9a9cf19 100644 --- a/src/lexicons/generated/bundle.ts +++ b/src/lexicons/generated/bundle.ts @@ -1,75 +1,66 @@ export const lexicons = [ { - "lexicon": 1, - "id": "fyi.atstore.authBasic", - "description": "Permission set for AT Store write access.", - "defs": { - "main": { - "type": "permission-set", - "title": "Full AT Store Access", - "detail": "Provides full access to AT Store profile, listings, reviews, and favorites.", - "permissions": [ + lexicon: 1, + id: "fyi.atstore.authBasic", + description: "Permission set for AT Store write access.", + defs: { + main: { + type: "permission-set", + title: "Full AT Store Access", + detail: + "Provides full access to AT Store profile, listings, reviews, and favorites.", + permissions: [ { - "type": "permission", - "resource": "repo", - "collection": [ + type: "permission", + resource: "repo", + collection: [ "fyi.atstore.profile", "fyi.atstore.listing.detail", "fyi.atstore.listing.review", "fyi.atstore.listing.reviewReply", - "fyi.atstore.listing.favorite" + "fyi.atstore.listing.favorite", ], - "action": [ - "create", - "update", - "delete" - ] - } - ] - } - } + action: ["create", "update", "delete"], + }, + ], + }, + }, }, { - "lexicon": 1, - "id": "fyi.atstore.authThirdPartyReviews", - "description": "OAuth permission bundle for third-party apps that publish AT Store profile self plus listing reviews on the user's repo; reads use public directory XRPC.", - "defs": { - "main": { - "type": "permission-set", - "title": "Submit AT Store reviews", - "detail": "Create fyi.atstore.profile/self when needed and fyi.atstore.listing.review records on the user's PDS via repository APIs; read public directory data via XRPC queries.", - "permissions": [ + lexicon: 1, + id: "fyi.atstore.authThirdPartyReviews", + description: + "OAuth permission bundle for third-party apps that publish AT Store profile self plus listing reviews on the user's repo; reads use public directory XRPC.", + defs: { + main: { + type: "permission-set", + title: "Submit AT Store reviews", + detail: + "Create fyi.atstore.profile/self when needed and fyi.atstore.listing.review records on the user's PDS via repository APIs; read public directory data via XRPC queries.", + permissions: [ { - "type": "permission", - "resource": "repo", - "collection": [ - "fyi.atstore.profile" - ], - "action": [ - "create" - ] + type: "permission", + resource: "repo", + collection: ["fyi.atstore.profile"], + action: ["create"], }, { - "type": "permission", - "resource": "repo", - "collection": [ - "fyi.atstore.listing.review" - ], - "action": [ - "create" - ] - } - ] - } - } + type: "permission", + resource: "repo", + collection: ["fyi.atstore.listing.review"], + action: ["create"], + }, + ], + }, + }, }, { - "lexicon": 1, - "id": "fyi.atstore.directory.getListing", - "defs": { - "listingCardGet": { - "type": "object", - "required": [ + lexicon: 1, + id: "fyi.atstore.directory.getListing", + defs: { + listingCardGet: { + type: "object", + required: [ "uri", "name", "tagline", @@ -79,265 +70,252 @@ export const lexicons = [ "reviewCount", "priceLabel", "appTags", - "categorySlugs" + "categorySlugs", ], - "properties": { - "uri": { - "type": "string", - "format": "at-uri", - "maxLength": 2560, - "description": "AT URI of the fyi.atstore.listing.detail record." - }, - "name": { - "type": "string", - "maxLength": 640 - }, - "tagline": { - "type": "string", - "maxLength": 2000 - }, - "description": { - "type": "string", - "maxLength": 20000 - }, - "iconUrl": { - "type": "string", - "maxLength": 8192, - "nullable": true - }, - "heroImageUrl": { - "type": "string", - "maxLength": 8192, - "nullable": true - }, - "categorySlug": { - "type": "string", - "maxLength": 512, - "nullable": true - }, - "categorySlugs": { - "type": "array", - "items": { - "type": "string", - "maxLength": 512 - } - }, - "category": { - "type": "string", - "maxLength": 640 - }, - "accent": { - "type": "string", - "maxLength": 16, - "knownValues": [ - "blue", - "pink", - "purple", - "green" - ] - }, - "rating": { - "type": "string", - "maxLength": 16, - "nullable": true - }, - "reviewCount": { - "type": "integer" - }, - "priceLabel": { - "type": "string", - "maxLength": 32 - }, - "productAccountHandle": { - "type": "string", - "maxLength": 512, - "nullable": true - }, - "appTags": { - "type": "array", - "items": { - "type": "string", - "maxLength": 256 - } - } - } + properties: { + uri: { + type: "string", + format: "at-uri", + maxLength: 2560, + description: "AT URI of the fyi.atstore.listing.detail record.", + }, + name: { + type: "string", + maxLength: 640, + }, + tagline: { + type: "string", + maxLength: 2000, + }, + description: { + type: "string", + maxLength: 20000, + }, + iconUrl: { + type: "string", + maxLength: 8192, + nullable: true, + }, + heroImageUrl: { + type: "string", + maxLength: 8192, + nullable: true, + }, + categorySlug: { + type: "string", + maxLength: 512, + nullable: true, + }, + categorySlugs: { + type: "array", + items: { + type: "string", + maxLength: 512, + }, + }, + category: { + type: "string", + maxLength: 640, + }, + accent: { + type: "string", + maxLength: 16, + knownValues: ["blue", "pink", "purple", "green"], + }, + rating: { + type: "string", + maxLength: 16, + nullable: true, + }, + reviewCount: { + type: "integer", + }, + priceLabel: { + type: "string", + maxLength: 32, + }, + productAccountHandle: { + type: "string", + maxLength: 512, + nullable: true, + }, + appTags: { + type: "array", + items: { + type: "string", + maxLength: 256, + }, + }, + }, }, - "listingLinkRow": { - "type": "object", - "required": [ - "uri" - ], - "properties": { - "label": { - "type": "string", - "maxLength": 640 - }, - "uri": { - "type": "string", - "maxLength": 2048 - } - } + listingLinkRow: { + type: "object", + required: ["uri"], + properties: { + label: { + type: "string", + maxLength: 640, + }, + uri: { + type: "string", + maxLength: 2048, + }, + }, }, - "listingDetailResponse": { - "type": "object", - "required": [ - "listing", - "isStoreManaged" - ], - "properties": { - "listing": { - "type": "ref", - "ref": "#listingCardGet" - }, - "isStoreManaged": { - "type": "boolean" - }, - "repoDid": { - "type": "string", - "maxLength": 2048, - "nullable": true - }, - "productAccountDid": { - "type": "string", - "maxLength": 2048, - "nullable": true - }, - "sourceTagline": { - "type": "string", - "maxLength": 20000, - "nullable": true - }, - "sourceFullDescription": { - "type": "string", - "maxLength": 20000, - "nullable": true - }, - "screenshots": { - "type": "array", - "items": { - "type": "string", - "maxLength": 4096 - } - }, - "externalUrl": { - "type": "string", - "maxLength": 2048, - "nullable": true - }, - "sourceUrl": { - "type": "string", - "maxLength": 8192, - "nullable": true - }, - "createdAt": { - "type": "string", - "maxLength": 64, - "nullable": true - }, - "updatedAt": { - "type": "string", - "maxLength": 64, - "nullable": true - }, - "links": { - "type": "array", - "items": { - "type": "ref", - "ref": "#listingLinkRow" - } - } - } + listingDetailResponse: { + type: "object", + required: ["listing", "isStoreManaged"], + properties: { + listing: { + type: "ref", + ref: "#listingCardGet", + }, + isStoreManaged: { + type: "boolean", + }, + repoDid: { + type: "string", + maxLength: 2048, + nullable: true, + }, + productAccountDid: { + type: "string", + maxLength: 2048, + nullable: true, + }, + sourceTagline: { + type: "string", + maxLength: 20000, + nullable: true, + }, + sourceFullDescription: { + type: "string", + maxLength: 20000, + nullable: true, + }, + screenshots: { + type: "array", + items: { + type: "string", + maxLength: 4096, + }, + }, + externalUrl: { + type: "string", + maxLength: 2048, + nullable: true, + }, + sourceUrl: { + type: "string", + maxLength: 8192, + nullable: true, + }, + createdAt: { + type: "string", + maxLength: 64, + nullable: true, + }, + updatedAt: { + type: "string", + maxLength: 64, + nullable: true, + }, + links: { + type: "array", + items: { + type: "ref", + ref: "#listingLinkRow", + }, + }, + }, }, - "main": { - "type": "query", - "description": "Fetch one public verified listing by fyi.atstore.listing.detail AT URI.", - "parameters": { - "type": "params", - "required": [ - "uri" - ], - "properties": { - "uri": { - "type": "string", - "format": "at-uri", - "maxLength": 2560, - "description": "AT URI of the fyi.atstore.listing.detail record." - } - } + main: { + type: "query", + description: + "Fetch one public verified listing by fyi.atstore.listing.detail AT URI.", + parameters: { + type: "params", + required: ["uri"], + properties: { + uri: { + type: "string", + format: "at-uri", + maxLength: 2560, + description: "AT URI of the fyi.atstore.listing.detail record.", + }, + }, }, - "output": { - "encoding": "application/json", - "schema": { - "type": "ref", - "ref": "#listingDetailResponse" - } + output: { + encoding: "application/json", + schema: { + type: "ref", + ref: "#listingDetailResponse", + }, }, - "errors": [ + errors: [ { - "name": "ListingNotFound" + name: "ListingNotFound", }, { - "name": "InvalidParams" - } - ] - } - } + name: "InvalidParams", + }, + ], + }, + }, }, { - "lexicon": 1, - "id": "fyi.atstore.directory.resolveListing", - "defs": { - "main": { - "type": "query", - "description": "Resolve a storefront external URL to a directory listing.detail AT URI when uniquely matched.", - "parameters": { - "type": "params", - "required": [ - "externalUrl" - ], - "properties": { - "externalUrl": { - "type": "string", - "maxLength": 2048, - "description": "Listing external_url / product URL as stored on the record." - } - } + lexicon: 1, + id: "fyi.atstore.directory.resolveListing", + defs: { + main: { + type: "query", + description: + "Resolve a storefront external URL to a directory listing.detail AT URI when uniquely matched.", + parameters: { + type: "params", + required: ["externalUrl"], + properties: { + externalUrl: { + type: "string", + maxLength: 2048, + description: + "Listing external_url / product URL as stored on the record.", + }, + }, }, - "output": { - "encoding": "application/json", - "schema": { - "type": "object", - "required": [ - "uri" - ], - "properties": { - "uri": { - "type": "string", - "format": "at-uri", - "maxLength": 2560, - "description": "AT URI of the fyi.atstore.listing.detail record." - } - } - } + output: { + encoding: "application/json", + schema: { + type: "object", + required: ["uri"], + properties: { + uri: { + type: "string", + format: "at-uri", + maxLength: 2560, + description: "AT URI of the fyi.atstore.listing.detail record.", + }, + }, + }, }, - "errors": [ + errors: [ { - "name": "ListingNotFound" + name: "ListingNotFound", }, { - "name": "AmbiguousResolution" - } - ] - } - } + name: "AmbiguousResolution", + }, + ], + }, + }, }, { - "lexicon": 1, - "id": "fyi.atstore.directory.searchListings", - "defs": { - "listingCardSearch": { - "type": "object", - "required": [ + lexicon: 1, + id: "fyi.atstore.directory.searchListings", + defs: { + listingCardSearch: { + type: "object", + required: [ "uri", "name", "tagline", @@ -347,162 +325,153 @@ export const lexicons = [ "reviewCount", "priceLabel", "appTags", - "categorySlugs" + "categorySlugs", ], - "properties": { - "uri": { - "type": "string", - "format": "at-uri", - "maxLength": 2560, - "description": "AT URI of the fyi.atstore.listing.detail record." - }, - "name": { - "type": "string", - "maxLength": 640 - }, - "tagline": { - "type": "string", - "maxLength": 2000 - }, - "description": { - "type": "string", - "maxLength": 20000 - }, - "iconUrl": { - "type": "string", - "maxLength": 8192, - "nullable": true - }, - "heroImageUrl": { - "type": "string", - "maxLength": 8192, - "nullable": true - }, - "categorySlug": { - "type": "string", - "maxLength": 512, - "nullable": true - }, - "categorySlugs": { - "type": "array", - "items": { - "type": "string", - "maxLength": 512 - } - }, - "category": { - "type": "string", - "maxLength": 640 - }, - "accent": { - "type": "string", - "maxLength": 16, - "knownValues": [ - "blue", - "pink", - "purple", - "green" - ] - }, - "rating": { - "type": "string", - "maxLength": 16, - "nullable": true - }, - "reviewCount": { - "type": "integer" - }, - "priceLabel": { - "type": "string", - "maxLength": 32 - }, - "productAccountHandle": { - "type": "string", - "maxLength": 512, - "nullable": true - }, - "appTags": { - "type": "array", - "items": { - "type": "string", - "maxLength": 256 - } - } - } + properties: { + uri: { + type: "string", + format: "at-uri", + maxLength: 2560, + description: "AT URI of the fyi.atstore.listing.detail record.", + }, + name: { + type: "string", + maxLength: 640, + }, + tagline: { + type: "string", + maxLength: 2000, + }, + description: { + type: "string", + maxLength: 20000, + }, + iconUrl: { + type: "string", + maxLength: 8192, + nullable: true, + }, + heroImageUrl: { + type: "string", + maxLength: 8192, + nullable: true, + }, + categorySlug: { + type: "string", + maxLength: 512, + nullable: true, + }, + categorySlugs: { + type: "array", + items: { + type: "string", + maxLength: 512, + }, + }, + category: { + type: "string", + maxLength: 640, + }, + accent: { + type: "string", + maxLength: 16, + knownValues: ["blue", "pink", "purple", "green"], + }, + rating: { + type: "string", + maxLength: 16, + nullable: true, + }, + reviewCount: { + type: "integer", + }, + priceLabel: { + type: "string", + maxLength: 32, + }, + productAccountHandle: { + type: "string", + maxLength: 512, + nullable: true, + }, + appTags: { + type: "array", + items: { + type: "string", + maxLength: 256, + }, + }, + }, }, - "main": { - "type": "query", - "description": "Directory listing search and pagination (verified listings with a listing.detail AT URI only).", - "parameters": { - "type": "params", - "properties": { - "q": { - "type": "string", - "maxLength": 512 - }, - "sort": { - "type": "string", - "maxLength": 24, - "default": "popular", - "enum": [ - "popular", - "newest", - "alphabetical" - ] - }, - "limit": { - "type": "integer", - "minimum": 1, - "maximum": 100, - "default": 24 - }, - "cursor": { - "type": "string", - "maxLength": 512 - } - } + main: { + type: "query", + description: + "Directory listing search and pagination (verified listings with a listing.detail AT URI only).", + parameters: { + type: "params", + properties: { + q: { + type: "string", + maxLength: 512, + }, + sort: { + type: "string", + maxLength: 24, + default: "popular", + enum: ["popular", "newest", "alphabetical"], + }, + limit: { + type: "integer", + minimum: 1, + maximum: 100, + default: 24, + }, + cursor: { + type: "string", + maxLength: 512, + }, + }, }, - "output": { - "encoding": "application/json", - "schema": { - "type": "object", - "required": [ - "listings" - ], - "properties": { - "cursor": { - "type": "string", - "maxLength": 512 + output: { + encoding: "application/json", + schema: { + type: "object", + required: ["listings"], + properties: { + cursor: { + type: "string", + maxLength: 512, + }, + listings: { + type: "array", + items: { + type: "ref", + ref: "#listingCardSearch", + }, }, - "listings": { - "type": "array", - "items": { - "type": "ref", - "ref": "#listingCardSearch" - } - } - } - } + }, + }, }, - "errors": [ + errors: [ { - "name": "InvalidCursor" - } - ] - } - } + name: "InvalidCursor", + }, + ], + }, + }, }, { - "lexicon": 1, - "id": "fyi.atstore.listing.detail", - "defs": { - "main": { - "type": "record", - "description": "Public protocol or app listing in the AT Store directory. Images are stored as repo blobs (Kitchen-style); the web app caches HTTPS URLs in Postgres separately.", - "key": "tid", - "record": { - "type": "object", - "required": [ + lexicon: 1, + id: "fyi.atstore.listing.detail", + defs: { + main: { + type: "record", + description: + "Public protocol or app listing in the AT Store directory. Images are stored as repo blobs (Kitchen-style); the web app caches HTTPS URLs in Postgres separately.", + key: "tid", + record: { + type: "object", + required: [ "slug", "name", "tagline", @@ -510,132 +479,135 @@ export const lexicons = [ "icon", "categorySlug", "createdAt", - "updatedAt" + "updatedAt", ], - "properties": { - "slug": { - "type": "string", - "minLength": 1, - "maxLength": 512, - "description": "Stable URL slug; unique within the publishing account." - }, - "name": { - "type": "string", - "maxLength": 640 - }, - "tagline": { - "type": "string", - "maxLength": 300 - }, - "description": { - "type": "string", - "maxLength": 20000 - }, - "externalUrl": { - "type": "string", - "format": "uri", - "maxLength": 2048, - "description": "Primary product or project URL." - }, - "icon": { - "type": "blob", - "accept": [ + properties: { + slug: { + type: "string", + minLength: 1, + maxLength: 512, + description: + "Stable URL slug; unique within the publishing account.", + }, + name: { + type: "string", + maxLength: 640, + }, + tagline: { + type: "string", + maxLength: 300, + }, + description: { + type: "string", + maxLength: 20000, + }, + externalUrl: { + type: "string", + format: "uri", + maxLength: 2048, + description: "Primary product or project URL.", + }, + icon: { + type: "blob", + accept: [ "image/png", "image/jpeg", "image/webp", "image/gif", - "image/svg+xml" + "image/svg+xml", ], - "maxSize": 2000000, - "description": "Square / app icon (uploaded to repo via com.atproto.repo.uploadBlob)." + maxSize: 2000000, + description: + "Square / app icon (uploaded to repo via com.atproto.repo.uploadBlob).", }, - "heroImage": { - "type": "blob", - "accept": [ + heroImage: { + type: "blob", + accept: [ "image/png", "image/jpeg", "image/webp", "image/gif", - "image/svg+xml" + "image/svg+xml", ], - "maxSize": 12000000, - "description": "Hero / cover image blob." - }, - "screenshots": { - "type": "array", - "maxLength": 20, - "items": { - "type": "blob", - "accept": [ + maxSize: 12000000, + description: "Hero / cover image blob.", + }, + screenshots: { + type: "array", + maxLength: 20, + items: { + type: "blob", + accept: [ "image/png", "image/jpeg", "image/webp", "image/gif", - "image/svg+xml" + "image/svg+xml", ], - "maxSize": 12000000 - } - }, - "categorySlug": { - "type": "array", - "minLength": 1, - "maxLength": 32, - "items": { - "type": "string", - "maxLength": 256 + maxSize: 12000000, + }, + }, + categorySlug: { + type: "array", + minLength: 1, + maxLength: 32, + items: { + type: "string", + maxLength: 256, }, - "description": "Browse category keys (e.g. protocol/pds). First entry is the primary category for legacy surfaces." - }, - "createdAt": { - "type": "string", - "format": "datetime" - }, - "updatedAt": { - "type": "string", - "format": "datetime" - }, - "appTags": { - "type": "array", - "maxLength": 64, - "items": { - "type": "string", - "maxLength": 96 - } - }, - "productAccountDid": { - "type": "string", - "maxLength": 2048, - "description": "Bluesky DID for the product, app, or tool (not the AT Store publisher). Handle is resolved and stored in Postgres only." - }, - "migratedFromAtUri": { - "type": "string", - "format": "at-uri", - "maxLength": 8192, - "description": "When this listing.detail record supersedes a prior record in another repo (e.g. moved from the AT Store publisher to a product owner PDS), the at:// URI of that prior fyi.atstore.listing.detail record." - }, - "links": { - "type": "array", - "maxLength": 12, - "description": "Relevant links for the app, including trust/compliance, support, and project resources.", - "items": { - "type": "ref", - "ref": "#link" - } - } - } - } + description: + "Browse category keys (e.g. protocol/pds). First entry is the primary category for legacy surfaces.", + }, + createdAt: { + type: "string", + format: "datetime", + }, + updatedAt: { + type: "string", + format: "datetime", + }, + appTags: { + type: "array", + maxLength: 64, + items: { + type: "string", + maxLength: 96, + }, + }, + productAccountDid: { + type: "string", + maxLength: 2048, + description: + "Bluesky DID for the product, app, or tool (not the AT Store publisher). Handle is resolved and stored in Postgres only.", + }, + migratedFromAtUri: { + type: "string", + format: "at-uri", + maxLength: 8192, + description: + "When this listing.detail record supersedes a prior record in another repo (e.g. moved from the AT Store publisher to a product owner PDS), the at:// URI of that prior fyi.atstore.listing.detail record.", + }, + links: { + type: "array", + maxLength: 12, + description: + "Relevant links for the app, including trust/compliance, support, and project resources.", + items: { + type: "ref", + ref: "#link", + }, + }, + }, + }, }, - "link": { - "type": "object", - "required": [ - "type", - "url" - ], - "properties": { - "type": { - "type": "string", - "maxLength": 32, - "knownValues": [ + link: { + type: "object", + required: ["type", "url"], + properties: { + type: { + type: "string", + maxLength: 32, + knownValues: [ "privacy", "terms", "support", @@ -648,346 +620,341 @@ export const lexicons = [ "community", "donate", "license", - "other" + "other", ], - "description": "The kind of link." - }, - "url": { - "type": "string", - "format": "uri", - "maxLength": 2048, - "description": "The destination URL." - }, - "label": { - "type": "string", - "maxLength": 100, - "maxGraphemes": 50, - "description": "Optional human-readable label, especially useful when type is 'other'." - } - } - } - } + description: "The kind of link.", + }, + url: { + type: "string", + format: "uri", + maxLength: 2048, + description: "The destination URL.", + }, + label: { + type: "string", + maxLength: 100, + maxGraphemes: 50, + description: + "Optional human-readable label, especially useful when type is 'other'.", + }, + }, + }, + }, }, { - "lexicon": 1, - "id": "fyi.atstore.listing.favorite", - "defs": { - "main": { - "type": "record", - "description": "A user favorite for an AT Store listing. Subject must be the at:// URI of a fyi.atstore.listing.detail record.", - "key": "any", - "record": { - "type": "object", - "required": [ - "subject", - "createdAt" - ], - "properties": { - "subject": { - "type": "string", - "format": "at-uri", - "description": "AT URI of the fyi.atstore.listing.detail record being favorited." - }, - "createdAt": { - "type": "string", - "format": "datetime" - } - } - } - } - } + lexicon: 1, + id: "fyi.atstore.listing.favorite", + defs: { + main: { + type: "record", + description: + "A user favorite for an AT Store listing. Subject must be the at:// URI of a fyi.atstore.listing.detail record.", + key: "any", + record: { + type: "object", + required: ["subject", "createdAt"], + properties: { + subject: { + type: "string", + format: "at-uri", + description: + "AT URI of the fyi.atstore.listing.detail record being favorited.", + }, + createdAt: { + type: "string", + format: "datetime", + }, + }, + }, + }, + }, }, { - "lexicon": 1, - "id": "fyi.atstore.listing.review", - "defs": { - "main": { - "type": "record", - "description": "A user review of an AT Store directory listing. Subject must be the at:// URI of a fyi.atstore.listing.detail record.", - "key": "tid", - "record": { - "type": "object", - "required": [ - "subject", - "rating", - "createdAt" - ], - "properties": { - "subject": { - "type": "string", - "format": "at-uri", - "description": "AT URI of the fyi.atstore.listing.detail record being reviewed." - }, - "rating": { - "type": "integer", - "minimum": 1, - "maximum": 5, - "description": "Star rating 1–5." - }, - "text": { - "type": "string", - "maxLength": 8000, - "description": "Optional written review; omit for a stars-only rating." - }, - "createdAt": { - "type": "string", - "format": "datetime" - } - } - } - } - } + lexicon: 1, + id: "fyi.atstore.listing.review", + defs: { + main: { + type: "record", + description: + "A user review of an AT Store directory listing. Subject must be the at:// URI of a fyi.atstore.listing.detail record.", + key: "tid", + record: { + type: "object", + required: ["subject", "rating", "createdAt"], + properties: { + subject: { + type: "string", + format: "at-uri", + description: + "AT URI of the fyi.atstore.listing.detail record being reviewed.", + }, + rating: { + type: "integer", + minimum: 1, + maximum: 5, + description: "Star rating 1–5.", + }, + text: { + type: "string", + maxLength: 8000, + description: + "Optional written review; omit for a stars-only rating.", + }, + createdAt: { + type: "string", + format: "datetime", + }, + }, + }, + }, + }, }, { - "lexicon": 1, - "id": "fyi.atstore.listing.reviewReply", - "defs": { - "main": { - "type": "record", - "description": "A reply on a fyi.atstore.listing.review. Threads are linear (no per-reply parent). By convention, only the listing owner or the review author should post replies; the AT Store app drops replies from any other DID at ingest and at render. The PDS does not enforce this — other indexers MAY surface unauthorized replies if they choose.", - "key": "tid", - "record": { - "type": "object", - "required": [ - "subject", - "text", - "createdAt" - ], - "properties": { - "subject": { - "type": "string", - "format": "at-uri", - "description": "AT URI of the fyi.atstore.listing.review this reply belongs to." - }, - "text": { - "type": "string", - "minLength": 1, - "maxLength": 8000 - }, - "createdAt": { - "type": "string", - "format": "datetime" - } - } - } - } - } + lexicon: 1, + id: "fyi.atstore.listing.reviewReply", + defs: { + main: { + type: "record", + description: + "A reply on a fyi.atstore.listing.review. Threads are linear (no per-reply parent). By convention, only the listing owner or the review author should post replies; the AT Store app drops replies from any other DID at ingest and at render. The PDS does not enforce this — other indexers MAY surface unauthorized replies if they choose.", + key: "tid", + record: { + type: "object", + required: ["subject", "text", "createdAt"], + properties: { + subject: { + type: "string", + format: "at-uri", + description: + "AT URI of the fyi.atstore.listing.review this reply belongs to.", + }, + text: { + type: "string", + minLength: 1, + maxLength: 8000, + }, + createdAt: { + type: "string", + format: "datetime", + }, + }, + }, + }, + }, }, { - "lexicon": 1, - "id": "fyi.atstore.profile", - "defs": { - "main": { - "type": "record", - "description": "AT Store app profile for discovery and TAP ingestion (Kitchen-style).", - "key": "literal:self", - "record": { - "type": "object", - "required": [ - "displayName" - ], - "properties": { - "displayName": { - "type": "string", - "maxLength": 640, - "description": "Human-readable name for the store / app." - }, - "description": { - "type": "string", - "maxLength": 4000, - "description": "Longer description shown in directory surfaces." - }, - "website": { - "type": "string", - "format": "uri", - "maxLength": 2048 - } - } - } - } - } + lexicon: 1, + id: "fyi.atstore.profile", + defs: { + main: { + type: "record", + description: + "AT Store app profile for discovery and TAP ingestion (Kitchen-style).", + key: "literal:self", + record: { + type: "object", + required: ["displayName"], + properties: { + displayName: { + type: "string", + maxLength: 640, + description: "Human-readable name for the store / app.", + }, + description: { + type: "string", + maxLength: 4000, + description: "Longer description shown in directory surfaces.", + }, + website: { + type: "string", + format: "uri", + maxLength: 2048, + }, + }, + }, + }, + }, }, { - "lexicon": 1, - "id": "fyi.atstore.reviews.listForListing", - "defs": { - "listingReviewView": { - "type": "object", - "required": [ + lexicon: 1, + id: "fyi.atstore.reviews.listForListing", + defs: { + listingReviewView: { + type: "object", + required: [ "id", "authorDid", "rating", "reviewCreatedAt", "replyCount", - "canReply" + "canReply", ], - "properties": { - "id": { - "type": "string", - "maxLength": 64 - }, - "authorDid": { - "type": "string", - "format": "did", - "maxLength": 2048 - }, - "rating": { - "type": "integer", - "minimum": 1, - "maximum": 5 - }, - "text": { - "type": "string", - "maxLength": 8000, - "nullable": true - }, - "reviewCreatedAt": { - "type": "string", - "format": "datetime", - "maxLength": 64 - }, - "authorDisplayName": { - "type": "string", - "maxLength": 640, - "nullable": true - }, - "authorHandle": { - "type": "string", - "maxLength": 512, - "nullable": true - }, - "authorAvatarUrl": { - "type": "string", - "maxLength": 8192, - "nullable": true - }, - "replyCount": { - "type": "integer" - }, - "canReply": { - "type": "boolean" - } - } + properties: { + id: { + type: "string", + maxLength: 64, + }, + authorDid: { + type: "string", + format: "did", + maxLength: 2048, + }, + rating: { + type: "integer", + minimum: 1, + maximum: 5, + }, + text: { + type: "string", + maxLength: 8000, + nullable: true, + }, + reviewCreatedAt: { + type: "string", + format: "datetime", + maxLength: 64, + }, + authorDisplayName: { + type: "string", + maxLength: 640, + nullable: true, + }, + authorHandle: { + type: "string", + maxLength: 512, + nullable: true, + }, + authorAvatarUrl: { + type: "string", + maxLength: 8192, + nullable: true, + }, + replyCount: { + type: "integer", + }, + canReply: { + type: "boolean", + }, + }, }, - "main": { - "type": "query", - "description": "List reviews for a directory listing (mirrored Tap data plus profile enrichment).", - "parameters": { - "type": "params", - "required": [ - "uri" - ], - "properties": { - "uri": { - "type": "string", - "format": "at-uri", - "maxLength": 2560, - "description": "AT URI of the fyi.atstore.listing.detail record." - }, - "limit": { - "type": "integer", - "minimum": 1, - "maximum": 100, - "default": 50 - }, - "cursor": { - "type": "string", - "maxLength": 512 - } - } + main: { + type: "query", + description: + "List reviews for a directory listing (mirrored Tap data plus profile enrichment).", + parameters: { + type: "params", + required: ["uri"], + properties: { + uri: { + type: "string", + format: "at-uri", + maxLength: 2560, + description: "AT URI of the fyi.atstore.listing.detail record.", + }, + limit: { + type: "integer", + minimum: 1, + maximum: 100, + default: 50, + }, + cursor: { + type: "string", + maxLength: 512, + }, + }, }, - "output": { - "encoding": "application/json", - "schema": { - "type": "object", - "required": [ - "reviews" - ], - "properties": { - "cursor": { - "type": "string", - "maxLength": 512 + output: { + encoding: "application/json", + schema: { + type: "object", + required: ["reviews"], + properties: { + cursor: { + type: "string", + maxLength: 512, }, - "reviews": { - "type": "array", - "items": { - "type": "ref", - "ref": "#listingReviewView" - } - } - } - } + reviews: { + type: "array", + items: { + type: "ref", + ref: "#listingReviewView", + }, + }, + }, + }, }, - "errors": [ + errors: [ { - "name": "ListingNotFound" + name: "ListingNotFound", }, { - "name": "InvalidParams" + name: "InvalidParams", }, { - "name": "InvalidCursor" - } - ] - } - } + name: "InvalidCursor", + }, + ], + }, + }, }, { - "lexicon": 1, - "id": "fyi.atstore.server.describe", - "defs": { - "main": { - "type": "query", - "description": "Describe this deployment's public XRPC surface and defaults.", - "parameters": { - "type": "params", - "properties": {} + lexicon: 1, + id: "fyi.atstore.server.describe", + defs: { + main: { + type: "query", + description: + "Describe this deployment's public XRPC surface and defaults.", + parameters: { + type: "params", + properties: {}, }, - "output": { - "encoding": "application/json", - "schema": { - "type": "object", - "required": [ + output: { + encoding: "application/json", + schema: { + type: "object", + required: [ "service", "publicReads", "reviewsWrittenOnAuthorRepo", "defaultListingLimit", "maxListingLimit", "maxReviewLimit", - "methods" + "methods", ], - "properties": { - "service": { - "type": "string", - "maxLength": 256 + properties: { + service: { + type: "string", + maxLength: 256, }, - "publicReads": { - "type": "boolean" + publicReads: { + type: "boolean", }, - "reviewsWrittenOnAuthorRepo": { - "type": "boolean", - "description": "When true, listing reviews are created via com.atproto.repo.createRecord on the author's PDS (fyi.atstore.listing.review); this service does not expose a write procedure for reviews." + reviewsWrittenOnAuthorRepo: { + type: "boolean", + description: + "When true, listing reviews are created via com.atproto.repo.createRecord on the author's PDS (fyi.atstore.listing.review); this service does not expose a write procedure for reviews.", }, - "defaultListingLimit": { - "type": "integer" + defaultListingLimit: { + type: "integer", }, - "maxListingLimit": { - "type": "integer" + maxListingLimit: { + type: "integer", }, - "maxReviewLimit": { - "type": "integer" + maxReviewLimit: { + type: "integer", }, - "methods": { - "type": "array", - "items": { - "type": "string", - "maxLength": 512 - } - } - } - } - } - } - } - } -] + methods: { + type: "array", + items: { + type: "string", + maxLength: 512, + }, + }, + }, + }, + }, + }, + }, + }, +]; From da78730d6ec6656185294906c51bc072f23e9acf Mon Sep 17 00:00:00 2001 From: Andrew Lisowski Date: Mon, 4 May 2026 23:48:27 -0700 Subject: [PATCH 5/7] simplify --- .../fyi/atstore/directory/getListing.json | 14 +- .../fyi/atstore/directory/resolveListing.json | 40 - package.json | 2 +- src/lexicons/generated/bundle.ts | 1642 ++++++++--------- src/lib/atproto/nsids.ts | 2 - .../_header-layout.developers.atproto.tsx | 86 +- .../atstore-xrpc-handler.server.ts | 191 +- 7 files changed, 981 insertions(+), 996 deletions(-) delete mode 100644 lexicons/fyi/atstore/directory/resolveListing.json diff --git a/lexicons/fyi/atstore/directory/getListing.json b/lexicons/fyi/atstore/directory/getListing.json index 103c7ba..eb64266 100644 --- a/lexicons/fyi/atstore/directory/getListing.json +++ b/lexicons/fyi/atstore/directory/getListing.json @@ -121,16 +121,20 @@ }, "main": { "type": "query", - "description": "Fetch one public verified listing by fyi.atstore.listing.detail AT URI.", + "description": "Fetch one public verified listing. Provide exactly one of `uri` (fyi.atstore.listing.detail AT URI) or `externalUrl` (unique storefront URL); `externalUrl` uses the same matching rules as the former resolve endpoint.", "parameters": { "type": "params", - "required": ["uri"], "properties": { "uri": { "type": "string", "format": "at-uri", "maxLength": 2560, "description": "AT URI of the fyi.atstore.listing.detail record." + }, + "externalUrl": { + "type": "string", + "maxLength": 2048, + "description": "Listing external_url / product URL as stored on the record; must match at most one public listing." } } }, @@ -141,7 +145,11 @@ "ref": "#listingDetailResponse" } }, - "errors": [{ "name": "ListingNotFound" }, { "name": "InvalidParams" }] + "errors": [ + { "name": "ListingNotFound" }, + { "name": "InvalidParams" }, + { "name": "AmbiguousResolution" } + ] } } } diff --git a/lexicons/fyi/atstore/directory/resolveListing.json b/lexicons/fyi/atstore/directory/resolveListing.json deleted file mode 100644 index 642cea7..0000000 --- a/lexicons/fyi/atstore/directory/resolveListing.json +++ /dev/null @@ -1,40 +0,0 @@ -{ - "lexicon": 1, - "id": "fyi.atstore.directory.resolveListing", - "defs": { - "main": { - "type": "query", - "description": "Resolve a storefront external URL to a directory listing.detail AT URI when uniquely matched.", - "parameters": { - "type": "params", - "required": ["externalUrl"], - "properties": { - "externalUrl": { - "type": "string", - "maxLength": 2048, - "description": "Listing external_url / product URL as stored on the record." - } - } - }, - "output": { - "encoding": "application/json", - "schema": { - "type": "object", - "required": ["uri"], - "properties": { - "uri": { - "type": "string", - "format": "at-uri", - "maxLength": 2560, - "description": "AT URI of the fyi.atstore.listing.detail record." - } - } - } - }, - "errors": [ - { "name": "ListingNotFound" }, - { "name": "AmbiguousResolution" } - ] - } - } -} diff --git a/package.json b/package.json index b6ff41e..5b26144 100644 --- a/package.json +++ b/package.json @@ -26,7 +26,7 @@ "db:studio": "drizzle-kit studio", "db:backup": "tsx -r dotenv/config scripts/db-backup.ts", "db:seed": "tsx -r dotenv/config scripts/db-seed.ts", - "lex:gen": "bash -c 'mkdir -p src/lexicons/generated && pnpm exec lex gen-ts-obj lexicons/fyi/atstore/directory/searchListings.json lexicons/fyi/atstore/directory/getListing.json lexicons/fyi/atstore/directory/resolveListing.json lexicons/fyi/atstore/reviews/listForListing.json lexicons/fyi/atstore/server/describe.json lexicons/fyi/atstore/profile.json lexicons/fyi/atstore/listing/detail.json lexicons/fyi/atstore/listing/review.json lexicons/fyi/atstore/listing/reviewReply.json lexicons/fyi/atstore/listing/favorite.json lexicons/fyi/atstore/auth/basic.json lexicons/fyi/atstore/auth/thirdPartyReviews.json > src/lexicons/generated/bundle.ts'", + "lex:gen": "bash -c 'mkdir -p src/lexicons/generated && pnpm exec lex gen-ts-obj lexicons/fyi/atstore/directory/searchListings.json lexicons/fyi/atstore/directory/getListing.json lexicons/fyi/atstore/reviews/listForListing.json lexicons/fyi/atstore/server/describe.json lexicons/fyi/atstore/profile.json lexicons/fyi/atstore/listing/detail.json lexicons/fyi/atstore/listing/review.json lexicons/fyi/atstore/listing/reviewReply.json lexicons/fyi/atstore/listing/favorite.json lexicons/fyi/atstore/auth/basic.json lexicons/fyi/atstore/auth/thirdPartyReviews.json > src/lexicons/generated/bundle.ts'", "lex:lint": "node scripts/goat-lex.mjs lex lint", "lex:status": "node scripts/goat-lex.mjs lex status", "atproto:publish-lexicons": "node scripts/goat-lex.mjs lex publish", diff --git a/src/lexicons/generated/bundle.ts b/src/lexicons/generated/bundle.ts index 9a9cf19..bd6dc1c 100644 --- a/src/lexicons/generated/bundle.ts +++ b/src/lexicons/generated/bundle.ts @@ -1,66 +1,75 @@ export const lexicons = [ { - lexicon: 1, - id: "fyi.atstore.authBasic", - description: "Permission set for AT Store write access.", - defs: { - main: { - type: "permission-set", - title: "Full AT Store Access", - detail: - "Provides full access to AT Store profile, listings, reviews, and favorites.", - permissions: [ + "lexicon": 1, + "id": "fyi.atstore.authBasic", + "description": "Permission set for AT Store write access.", + "defs": { + "main": { + "type": "permission-set", + "title": "Full AT Store Access", + "detail": "Provides full access to AT Store profile, listings, reviews, and favorites.", + "permissions": [ { - type: "permission", - resource: "repo", - collection: [ + "type": "permission", + "resource": "repo", + "collection": [ "fyi.atstore.profile", "fyi.atstore.listing.detail", "fyi.atstore.listing.review", "fyi.atstore.listing.reviewReply", - "fyi.atstore.listing.favorite", + "fyi.atstore.listing.favorite" ], - action: ["create", "update", "delete"], - }, - ], - }, - }, + "action": [ + "create", + "update", + "delete" + ] + } + ] + } + } }, { - lexicon: 1, - id: "fyi.atstore.authThirdPartyReviews", - description: - "OAuth permission bundle for third-party apps that publish AT Store profile self plus listing reviews on the user's repo; reads use public directory XRPC.", - defs: { - main: { - type: "permission-set", - title: "Submit AT Store reviews", - detail: - "Create fyi.atstore.profile/self when needed and fyi.atstore.listing.review records on the user's PDS via repository APIs; read public directory data via XRPC queries.", - permissions: [ + "lexicon": 1, + "id": "fyi.atstore.authThirdPartyReviews", + "description": "OAuth permission bundle for third-party apps that publish AT Store profile self plus listing reviews on the user's repo; reads use public directory XRPC.", + "defs": { + "main": { + "type": "permission-set", + "title": "Submit AT Store reviews", + "detail": "Create fyi.atstore.profile/self when needed and fyi.atstore.listing.review records on the user's PDS via repository APIs; read public directory data via XRPC queries.", + "permissions": [ { - type: "permission", - resource: "repo", - collection: ["fyi.atstore.profile"], - action: ["create"], + "type": "permission", + "resource": "repo", + "collection": [ + "fyi.atstore.profile" + ], + "action": [ + "create" + ] }, { - type: "permission", - resource: "repo", - collection: ["fyi.atstore.listing.review"], - action: ["create"], - }, - ], - }, - }, + "type": "permission", + "resource": "repo", + "collection": [ + "fyi.atstore.listing.review" + ], + "action": [ + "create" + ] + } + ] + } + } }, { - lexicon: 1, - id: "fyi.atstore.directory.getListing", - defs: { - listingCardGet: { - type: "object", - required: [ + "lexicon": 1, + "id": "fyi.atstore.directory.getListing", + "defs": { + "listingCardGet": { + "type": "object", + "required": [ "uri", "name", "tagline", @@ -70,252 +79,222 @@ export const lexicons = [ "reviewCount", "priceLabel", "appTags", - "categorySlugs", + "categorySlugs" ], - properties: { - uri: { - type: "string", - format: "at-uri", - maxLength: 2560, - description: "AT URI of the fyi.atstore.listing.detail record.", - }, - name: { - type: "string", - maxLength: 640, - }, - tagline: { - type: "string", - maxLength: 2000, - }, - description: { - type: "string", - maxLength: 20000, - }, - iconUrl: { - type: "string", - maxLength: 8192, - nullable: true, - }, - heroImageUrl: { - type: "string", - maxLength: 8192, - nullable: true, - }, - categorySlug: { - type: "string", - maxLength: 512, - nullable: true, - }, - categorySlugs: { - type: "array", - items: { - type: "string", - maxLength: 512, - }, - }, - category: { - type: "string", - maxLength: 640, - }, - accent: { - type: "string", - maxLength: 16, - knownValues: ["blue", "pink", "purple", "green"], - }, - rating: { - type: "string", - maxLength: 16, - nullable: true, - }, - reviewCount: { - type: "integer", - }, - priceLabel: { - type: "string", - maxLength: 32, - }, - productAccountHandle: { - type: "string", - maxLength: 512, - nullable: true, - }, - appTags: { - type: "array", - items: { - type: "string", - maxLength: 256, - }, - }, - }, + "properties": { + "uri": { + "type": "string", + "format": "at-uri", + "maxLength": 2560, + "description": "AT URI of the fyi.atstore.listing.detail record." + }, + "name": { + "type": "string", + "maxLength": 640 + }, + "tagline": { + "type": "string", + "maxLength": 2000 + }, + "description": { + "type": "string", + "maxLength": 20000 + }, + "iconUrl": { + "type": "string", + "maxLength": 8192, + "nullable": true + }, + "heroImageUrl": { + "type": "string", + "maxLength": 8192, + "nullable": true + }, + "categorySlug": { + "type": "string", + "maxLength": 512, + "nullable": true + }, + "categorySlugs": { + "type": "array", + "items": { + "type": "string", + "maxLength": 512 + } + }, + "category": { + "type": "string", + "maxLength": 640 + }, + "accent": { + "type": "string", + "maxLength": 16, + "knownValues": [ + "blue", + "pink", + "purple", + "green" + ] + }, + "rating": { + "type": "string", + "maxLength": 16, + "nullable": true + }, + "reviewCount": { + "type": "integer" + }, + "priceLabel": { + "type": "string", + "maxLength": 32 + }, + "productAccountHandle": { + "type": "string", + "maxLength": 512, + "nullable": true + }, + "appTags": { + "type": "array", + "items": { + "type": "string", + "maxLength": 256 + } + } + } }, - listingLinkRow: { - type: "object", - required: ["uri"], - properties: { - label: { - type: "string", - maxLength: 640, - }, - uri: { - type: "string", - maxLength: 2048, - }, - }, - }, - listingDetailResponse: { - type: "object", - required: ["listing", "isStoreManaged"], - properties: { - listing: { - type: "ref", - ref: "#listingCardGet", - }, - isStoreManaged: { - type: "boolean", - }, - repoDid: { - type: "string", - maxLength: 2048, - nullable: true, - }, - productAccountDid: { - type: "string", - maxLength: 2048, - nullable: true, - }, - sourceTagline: { - type: "string", - maxLength: 20000, - nullable: true, - }, - sourceFullDescription: { - type: "string", - maxLength: 20000, - nullable: true, - }, - screenshots: { - type: "array", - items: { - type: "string", - maxLength: 4096, - }, - }, - externalUrl: { - type: "string", - maxLength: 2048, - nullable: true, - }, - sourceUrl: { - type: "string", - maxLength: 8192, - nullable: true, - }, - createdAt: { - type: "string", - maxLength: 64, - nullable: true, - }, - updatedAt: { - type: "string", - maxLength: 64, - nullable: true, - }, - links: { - type: "array", - items: { - type: "ref", - ref: "#listingLinkRow", - }, - }, - }, + "listingLinkRow": { + "type": "object", + "required": [ + "uri" + ], + "properties": { + "label": { + "type": "string", + "maxLength": 640 + }, + "uri": { + "type": "string", + "maxLength": 2048 + } + } }, - main: { - type: "query", - description: - "Fetch one public verified listing by fyi.atstore.listing.detail AT URI.", - parameters: { - type: "params", - required: ["uri"], - properties: { - uri: { - type: "string", - format: "at-uri", - maxLength: 2560, - description: "AT URI of the fyi.atstore.listing.detail record.", - }, - }, - }, - output: { - encoding: "application/json", - schema: { - type: "ref", - ref: "#listingDetailResponse", - }, - }, - errors: [ - { - name: "ListingNotFound", - }, - { - name: "InvalidParams", - }, + "listingDetailResponse": { + "type": "object", + "required": [ + "listing", + "isStoreManaged" ], + "properties": { + "listing": { + "type": "ref", + "ref": "#listingCardGet" + }, + "isStoreManaged": { + "type": "boolean" + }, + "repoDid": { + "type": "string", + "maxLength": 2048, + "nullable": true + }, + "productAccountDid": { + "type": "string", + "maxLength": 2048, + "nullable": true + }, + "sourceTagline": { + "type": "string", + "maxLength": 20000, + "nullable": true + }, + "sourceFullDescription": { + "type": "string", + "maxLength": 20000, + "nullable": true + }, + "screenshots": { + "type": "array", + "items": { + "type": "string", + "maxLength": 4096 + } + }, + "externalUrl": { + "type": "string", + "maxLength": 2048, + "nullable": true + }, + "sourceUrl": { + "type": "string", + "maxLength": 8192, + "nullable": true + }, + "createdAt": { + "type": "string", + "maxLength": 64, + "nullable": true + }, + "updatedAt": { + "type": "string", + "maxLength": 64, + "nullable": true + }, + "links": { + "type": "array", + "items": { + "type": "ref", + "ref": "#listingLinkRow" + } + } + } }, - }, - }, - { - lexicon: 1, - id: "fyi.atstore.directory.resolveListing", - defs: { - main: { - type: "query", - description: - "Resolve a storefront external URL to a directory listing.detail AT URI when uniquely matched.", - parameters: { - type: "params", - required: ["externalUrl"], - properties: { - externalUrl: { - type: "string", - maxLength: 2048, - description: - "Listing external_url / product URL as stored on the record.", - }, - }, + "main": { + "type": "query", + "description": "Fetch one public verified listing. Provide exactly one of `uri` (fyi.atstore.listing.detail AT URI) or `externalUrl` (unique storefront URL); `externalUrl` uses the same matching rules as the former resolve endpoint.", + "parameters": { + "type": "params", + "properties": { + "uri": { + "type": "string", + "format": "at-uri", + "maxLength": 2560, + "description": "AT URI of the fyi.atstore.listing.detail record." + }, + "externalUrl": { + "type": "string", + "maxLength": 2048, + "description": "Listing external_url / product URL as stored on the record; must match at most one public listing." + } + } }, - output: { - encoding: "application/json", - schema: { - type: "object", - required: ["uri"], - properties: { - uri: { - type: "string", - format: "at-uri", - maxLength: 2560, - description: "AT URI of the fyi.atstore.listing.detail record.", - }, - }, - }, + "output": { + "encoding": "application/json", + "schema": { + "type": "ref", + "ref": "#listingDetailResponse" + } }, - errors: [ + "errors": [ { - name: "ListingNotFound", + "name": "ListingNotFound" }, { - name: "AmbiguousResolution", + "name": "InvalidParams" }, - ], - }, - }, + { + "name": "AmbiguousResolution" + } + ] + } + } }, { - lexicon: 1, - id: "fyi.atstore.directory.searchListings", - defs: { - listingCardSearch: { - type: "object", - required: [ + "lexicon": 1, + "id": "fyi.atstore.directory.searchListings", + "defs": { + "listingCardSearch": { + "type": "object", + "required": [ "uri", "name", "tagline", @@ -325,153 +304,162 @@ export const lexicons = [ "reviewCount", "priceLabel", "appTags", - "categorySlugs", + "categorySlugs" ], - properties: { - uri: { - type: "string", - format: "at-uri", - maxLength: 2560, - description: "AT URI of the fyi.atstore.listing.detail record.", - }, - name: { - type: "string", - maxLength: 640, - }, - tagline: { - type: "string", - maxLength: 2000, - }, - description: { - type: "string", - maxLength: 20000, - }, - iconUrl: { - type: "string", - maxLength: 8192, - nullable: true, - }, - heroImageUrl: { - type: "string", - maxLength: 8192, - nullable: true, - }, - categorySlug: { - type: "string", - maxLength: 512, - nullable: true, - }, - categorySlugs: { - type: "array", - items: { - type: "string", - maxLength: 512, - }, - }, - category: { - type: "string", - maxLength: 640, - }, - accent: { - type: "string", - maxLength: 16, - knownValues: ["blue", "pink", "purple", "green"], - }, - rating: { - type: "string", - maxLength: 16, - nullable: true, - }, - reviewCount: { - type: "integer", - }, - priceLabel: { - type: "string", - maxLength: 32, - }, - productAccountHandle: { - type: "string", - maxLength: 512, - nullable: true, - }, - appTags: { - type: "array", - items: { - type: "string", - maxLength: 256, - }, - }, - }, + "properties": { + "uri": { + "type": "string", + "format": "at-uri", + "maxLength": 2560, + "description": "AT URI of the fyi.atstore.listing.detail record." + }, + "name": { + "type": "string", + "maxLength": 640 + }, + "tagline": { + "type": "string", + "maxLength": 2000 + }, + "description": { + "type": "string", + "maxLength": 20000 + }, + "iconUrl": { + "type": "string", + "maxLength": 8192, + "nullable": true + }, + "heroImageUrl": { + "type": "string", + "maxLength": 8192, + "nullable": true + }, + "categorySlug": { + "type": "string", + "maxLength": 512, + "nullable": true + }, + "categorySlugs": { + "type": "array", + "items": { + "type": "string", + "maxLength": 512 + } + }, + "category": { + "type": "string", + "maxLength": 640 + }, + "accent": { + "type": "string", + "maxLength": 16, + "knownValues": [ + "blue", + "pink", + "purple", + "green" + ] + }, + "rating": { + "type": "string", + "maxLength": 16, + "nullable": true + }, + "reviewCount": { + "type": "integer" + }, + "priceLabel": { + "type": "string", + "maxLength": 32 + }, + "productAccountHandle": { + "type": "string", + "maxLength": 512, + "nullable": true + }, + "appTags": { + "type": "array", + "items": { + "type": "string", + "maxLength": 256 + } + } + } }, - main: { - type: "query", - description: - "Directory listing search and pagination (verified listings with a listing.detail AT URI only).", - parameters: { - type: "params", - properties: { - q: { - type: "string", - maxLength: 512, - }, - sort: { - type: "string", - maxLength: 24, - default: "popular", - enum: ["popular", "newest", "alphabetical"], - }, - limit: { - type: "integer", - minimum: 1, - maximum: 100, - default: 24, - }, - cursor: { - type: "string", - maxLength: 512, - }, - }, + "main": { + "type": "query", + "description": "Directory listing search and pagination (verified listings with a listing.detail AT URI only).", + "parameters": { + "type": "params", + "properties": { + "q": { + "type": "string", + "maxLength": 512 + }, + "sort": { + "type": "string", + "maxLength": 24, + "default": "popular", + "enum": [ + "popular", + "newest", + "alphabetical" + ] + }, + "limit": { + "type": "integer", + "minimum": 1, + "maximum": 100, + "default": 24 + }, + "cursor": { + "type": "string", + "maxLength": 512 + } + } }, - output: { - encoding: "application/json", - schema: { - type: "object", - required: ["listings"], - properties: { - cursor: { - type: "string", - maxLength: 512, - }, - listings: { - type: "array", - items: { - type: "ref", - ref: "#listingCardSearch", - }, + "output": { + "encoding": "application/json", + "schema": { + "type": "object", + "required": [ + "listings" + ], + "properties": { + "cursor": { + "type": "string", + "maxLength": 512 }, - }, - }, + "listings": { + "type": "array", + "items": { + "type": "ref", + "ref": "#listingCardSearch" + } + } + } + } }, - errors: [ + "errors": [ { - name: "InvalidCursor", - }, - ], - }, - }, + "name": "InvalidCursor" + } + ] + } + } }, { - lexicon: 1, - id: "fyi.atstore.listing.detail", - defs: { - main: { - type: "record", - description: - "Public protocol or app listing in the AT Store directory. Images are stored as repo blobs (Kitchen-style); the web app caches HTTPS URLs in Postgres separately.", - key: "tid", - record: { - type: "object", - required: [ + "lexicon": 1, + "id": "fyi.atstore.listing.detail", + "defs": { + "main": { + "type": "record", + "description": "Public protocol or app listing in the AT Store directory. Images are stored as repo blobs (Kitchen-style); the web app caches HTTPS URLs in Postgres separately.", + "key": "tid", + "record": { + "type": "object", + "required": [ "slug", "name", "tagline", @@ -479,135 +467,132 @@ export const lexicons = [ "icon", "categorySlug", "createdAt", - "updatedAt", + "updatedAt" ], - properties: { - slug: { - type: "string", - minLength: 1, - maxLength: 512, - description: - "Stable URL slug; unique within the publishing account.", - }, - name: { - type: "string", - maxLength: 640, - }, - tagline: { - type: "string", - maxLength: 300, - }, - description: { - type: "string", - maxLength: 20000, - }, - externalUrl: { - type: "string", - format: "uri", - maxLength: 2048, - description: "Primary product or project URL.", - }, - icon: { - type: "blob", - accept: [ + "properties": { + "slug": { + "type": "string", + "minLength": 1, + "maxLength": 512, + "description": "Stable URL slug; unique within the publishing account." + }, + "name": { + "type": "string", + "maxLength": 640 + }, + "tagline": { + "type": "string", + "maxLength": 300 + }, + "description": { + "type": "string", + "maxLength": 20000 + }, + "externalUrl": { + "type": "string", + "format": "uri", + "maxLength": 2048, + "description": "Primary product or project URL." + }, + "icon": { + "type": "blob", + "accept": [ "image/png", "image/jpeg", "image/webp", "image/gif", - "image/svg+xml", + "image/svg+xml" ], - maxSize: 2000000, - description: - "Square / app icon (uploaded to repo via com.atproto.repo.uploadBlob).", + "maxSize": 2000000, + "description": "Square / app icon (uploaded to repo via com.atproto.repo.uploadBlob)." }, - heroImage: { - type: "blob", - accept: [ + "heroImage": { + "type": "blob", + "accept": [ "image/png", "image/jpeg", "image/webp", "image/gif", - "image/svg+xml", + "image/svg+xml" ], - maxSize: 12000000, - description: "Hero / cover image blob.", - }, - screenshots: { - type: "array", - maxLength: 20, - items: { - type: "blob", - accept: [ + "maxSize": 12000000, + "description": "Hero / cover image blob." + }, + "screenshots": { + "type": "array", + "maxLength": 20, + "items": { + "type": "blob", + "accept": [ "image/png", "image/jpeg", "image/webp", "image/gif", - "image/svg+xml", + "image/svg+xml" ], - maxSize: 12000000, - }, - }, - categorySlug: { - type: "array", - minLength: 1, - maxLength: 32, - items: { - type: "string", - maxLength: 256, + "maxSize": 12000000 + } + }, + "categorySlug": { + "type": "array", + "minLength": 1, + "maxLength": 32, + "items": { + "type": "string", + "maxLength": 256 }, - description: - "Browse category keys (e.g. protocol/pds). First entry is the primary category for legacy surfaces.", - }, - createdAt: { - type: "string", - format: "datetime", - }, - updatedAt: { - type: "string", - format: "datetime", - }, - appTags: { - type: "array", - maxLength: 64, - items: { - type: "string", - maxLength: 96, - }, - }, - productAccountDid: { - type: "string", - maxLength: 2048, - description: - "Bluesky DID for the product, app, or tool (not the AT Store publisher). Handle is resolved and stored in Postgres only.", - }, - migratedFromAtUri: { - type: "string", - format: "at-uri", - maxLength: 8192, - description: - "When this listing.detail record supersedes a prior record in another repo (e.g. moved from the AT Store publisher to a product owner PDS), the at:// URI of that prior fyi.atstore.listing.detail record.", - }, - links: { - type: "array", - maxLength: 12, - description: - "Relevant links for the app, including trust/compliance, support, and project resources.", - items: { - type: "ref", - ref: "#link", - }, - }, - }, - }, + "description": "Browse category keys (e.g. protocol/pds). First entry is the primary category for legacy surfaces." + }, + "createdAt": { + "type": "string", + "format": "datetime" + }, + "updatedAt": { + "type": "string", + "format": "datetime" + }, + "appTags": { + "type": "array", + "maxLength": 64, + "items": { + "type": "string", + "maxLength": 96 + } + }, + "productAccountDid": { + "type": "string", + "maxLength": 2048, + "description": "Bluesky DID for the product, app, or tool (not the AT Store publisher). Handle is resolved and stored in Postgres only." + }, + "migratedFromAtUri": { + "type": "string", + "format": "at-uri", + "maxLength": 8192, + "description": "When this listing.detail record supersedes a prior record in another repo (e.g. moved from the AT Store publisher to a product owner PDS), the at:// URI of that prior fyi.atstore.listing.detail record." + }, + "links": { + "type": "array", + "maxLength": 12, + "description": "Relevant links for the app, including trust/compliance, support, and project resources.", + "items": { + "type": "ref", + "ref": "#link" + } + } + } + } }, - link: { - type: "object", - required: ["type", "url"], - properties: { - type: { - type: "string", - maxLength: 32, - knownValues: [ + "link": { + "type": "object", + "required": [ + "type", + "url" + ], + "properties": { + "type": { + "type": "string", + "maxLength": 32, + "knownValues": [ "privacy", "terms", "support", @@ -620,341 +605,346 @@ export const lexicons = [ "community", "donate", "license", - "other", + "other" ], - description: "The kind of link.", - }, - url: { - type: "string", - format: "uri", - maxLength: 2048, - description: "The destination URL.", - }, - label: { - type: "string", - maxLength: 100, - maxGraphemes: 50, - description: - "Optional human-readable label, especially useful when type is 'other'.", - }, - }, - }, - }, + "description": "The kind of link." + }, + "url": { + "type": "string", + "format": "uri", + "maxLength": 2048, + "description": "The destination URL." + }, + "label": { + "type": "string", + "maxLength": 100, + "maxGraphemes": 50, + "description": "Optional human-readable label, especially useful when type is 'other'." + } + } + } + } }, { - lexicon: 1, - id: "fyi.atstore.listing.favorite", - defs: { - main: { - type: "record", - description: - "A user favorite for an AT Store listing. Subject must be the at:// URI of a fyi.atstore.listing.detail record.", - key: "any", - record: { - type: "object", - required: ["subject", "createdAt"], - properties: { - subject: { - type: "string", - format: "at-uri", - description: - "AT URI of the fyi.atstore.listing.detail record being favorited.", - }, - createdAt: { - type: "string", - format: "datetime", - }, - }, - }, - }, - }, + "lexicon": 1, + "id": "fyi.atstore.listing.favorite", + "defs": { + "main": { + "type": "record", + "description": "A user favorite for an AT Store listing. Subject must be the at:// URI of a fyi.atstore.listing.detail record.", + "key": "any", + "record": { + "type": "object", + "required": [ + "subject", + "createdAt" + ], + "properties": { + "subject": { + "type": "string", + "format": "at-uri", + "description": "AT URI of the fyi.atstore.listing.detail record being favorited." + }, + "createdAt": { + "type": "string", + "format": "datetime" + } + } + } + } + } }, { - lexicon: 1, - id: "fyi.atstore.listing.review", - defs: { - main: { - type: "record", - description: - "A user review of an AT Store directory listing. Subject must be the at:// URI of a fyi.atstore.listing.detail record.", - key: "tid", - record: { - type: "object", - required: ["subject", "rating", "createdAt"], - properties: { - subject: { - type: "string", - format: "at-uri", - description: - "AT URI of the fyi.atstore.listing.detail record being reviewed.", - }, - rating: { - type: "integer", - minimum: 1, - maximum: 5, - description: "Star rating 1–5.", - }, - text: { - type: "string", - maxLength: 8000, - description: - "Optional written review; omit for a stars-only rating.", - }, - createdAt: { - type: "string", - format: "datetime", - }, - }, - }, - }, - }, + "lexicon": 1, + "id": "fyi.atstore.listing.review", + "defs": { + "main": { + "type": "record", + "description": "A user review of an AT Store directory listing. Subject must be the at:// URI of a fyi.atstore.listing.detail record.", + "key": "tid", + "record": { + "type": "object", + "required": [ + "subject", + "rating", + "createdAt" + ], + "properties": { + "subject": { + "type": "string", + "format": "at-uri", + "description": "AT URI of the fyi.atstore.listing.detail record being reviewed." + }, + "rating": { + "type": "integer", + "minimum": 1, + "maximum": 5, + "description": "Star rating 1–5." + }, + "text": { + "type": "string", + "maxLength": 8000, + "description": "Optional written review; omit for a stars-only rating." + }, + "createdAt": { + "type": "string", + "format": "datetime" + } + } + } + } + } }, { - lexicon: 1, - id: "fyi.atstore.listing.reviewReply", - defs: { - main: { - type: "record", - description: - "A reply on a fyi.atstore.listing.review. Threads are linear (no per-reply parent). By convention, only the listing owner or the review author should post replies; the AT Store app drops replies from any other DID at ingest and at render. The PDS does not enforce this — other indexers MAY surface unauthorized replies if they choose.", - key: "tid", - record: { - type: "object", - required: ["subject", "text", "createdAt"], - properties: { - subject: { - type: "string", - format: "at-uri", - description: - "AT URI of the fyi.atstore.listing.review this reply belongs to.", - }, - text: { - type: "string", - minLength: 1, - maxLength: 8000, - }, - createdAt: { - type: "string", - format: "datetime", - }, - }, - }, - }, - }, + "lexicon": 1, + "id": "fyi.atstore.listing.reviewReply", + "defs": { + "main": { + "type": "record", + "description": "A reply on a fyi.atstore.listing.review. Threads are linear (no per-reply parent). By convention, only the listing owner or the review author should post replies; the AT Store app drops replies from any other DID at ingest and at render. The PDS does not enforce this — other indexers MAY surface unauthorized replies if they choose.", + "key": "tid", + "record": { + "type": "object", + "required": [ + "subject", + "text", + "createdAt" + ], + "properties": { + "subject": { + "type": "string", + "format": "at-uri", + "description": "AT URI of the fyi.atstore.listing.review this reply belongs to." + }, + "text": { + "type": "string", + "minLength": 1, + "maxLength": 8000 + }, + "createdAt": { + "type": "string", + "format": "datetime" + } + } + } + } + } }, { - lexicon: 1, - id: "fyi.atstore.profile", - defs: { - main: { - type: "record", - description: - "AT Store app profile for discovery and TAP ingestion (Kitchen-style).", - key: "literal:self", - record: { - type: "object", - required: ["displayName"], - properties: { - displayName: { - type: "string", - maxLength: 640, - description: "Human-readable name for the store / app.", - }, - description: { - type: "string", - maxLength: 4000, - description: "Longer description shown in directory surfaces.", - }, - website: { - type: "string", - format: "uri", - maxLength: 2048, - }, - }, - }, - }, - }, + "lexicon": 1, + "id": "fyi.atstore.profile", + "defs": { + "main": { + "type": "record", + "description": "AT Store app profile for discovery and TAP ingestion (Kitchen-style).", + "key": "literal:self", + "record": { + "type": "object", + "required": [ + "displayName" + ], + "properties": { + "displayName": { + "type": "string", + "maxLength": 640, + "description": "Human-readable name for the store / app." + }, + "description": { + "type": "string", + "maxLength": 4000, + "description": "Longer description shown in directory surfaces." + }, + "website": { + "type": "string", + "format": "uri", + "maxLength": 2048 + } + } + } + } + } }, { - lexicon: 1, - id: "fyi.atstore.reviews.listForListing", - defs: { - listingReviewView: { - type: "object", - required: [ + "lexicon": 1, + "id": "fyi.atstore.reviews.listForListing", + "defs": { + "listingReviewView": { + "type": "object", + "required": [ "id", "authorDid", "rating", "reviewCreatedAt", "replyCount", - "canReply", + "canReply" ], - properties: { - id: { - type: "string", - maxLength: 64, - }, - authorDid: { - type: "string", - format: "did", - maxLength: 2048, - }, - rating: { - type: "integer", - minimum: 1, - maximum: 5, - }, - text: { - type: "string", - maxLength: 8000, - nullable: true, - }, - reviewCreatedAt: { - type: "string", - format: "datetime", - maxLength: 64, - }, - authorDisplayName: { - type: "string", - maxLength: 640, - nullable: true, - }, - authorHandle: { - type: "string", - maxLength: 512, - nullable: true, - }, - authorAvatarUrl: { - type: "string", - maxLength: 8192, - nullable: true, - }, - replyCount: { - type: "integer", - }, - canReply: { - type: "boolean", - }, - }, + "properties": { + "id": { + "type": "string", + "maxLength": 64 + }, + "authorDid": { + "type": "string", + "format": "did", + "maxLength": 2048 + }, + "rating": { + "type": "integer", + "minimum": 1, + "maximum": 5 + }, + "text": { + "type": "string", + "maxLength": 8000, + "nullable": true + }, + "reviewCreatedAt": { + "type": "string", + "format": "datetime", + "maxLength": 64 + }, + "authorDisplayName": { + "type": "string", + "maxLength": 640, + "nullable": true + }, + "authorHandle": { + "type": "string", + "maxLength": 512, + "nullable": true + }, + "authorAvatarUrl": { + "type": "string", + "maxLength": 8192, + "nullable": true + }, + "replyCount": { + "type": "integer" + }, + "canReply": { + "type": "boolean" + } + } }, - main: { - type: "query", - description: - "List reviews for a directory listing (mirrored Tap data plus profile enrichment).", - parameters: { - type: "params", - required: ["uri"], - properties: { - uri: { - type: "string", - format: "at-uri", - maxLength: 2560, - description: "AT URI of the fyi.atstore.listing.detail record.", - }, - limit: { - type: "integer", - minimum: 1, - maximum: 100, - default: 50, - }, - cursor: { - type: "string", - maxLength: 512, - }, - }, + "main": { + "type": "query", + "description": "List reviews for a directory listing (mirrored Tap data plus profile enrichment).", + "parameters": { + "type": "params", + "required": [ + "uri" + ], + "properties": { + "uri": { + "type": "string", + "format": "at-uri", + "maxLength": 2560, + "description": "AT URI of the fyi.atstore.listing.detail record." + }, + "limit": { + "type": "integer", + "minimum": 1, + "maximum": 100, + "default": 50 + }, + "cursor": { + "type": "string", + "maxLength": 512 + } + } }, - output: { - encoding: "application/json", - schema: { - type: "object", - required: ["reviews"], - properties: { - cursor: { - type: "string", - maxLength: 512, - }, - reviews: { - type: "array", - items: { - type: "ref", - ref: "#listingReviewView", - }, + "output": { + "encoding": "application/json", + "schema": { + "type": "object", + "required": [ + "reviews" + ], + "properties": { + "cursor": { + "type": "string", + "maxLength": 512 }, - }, - }, + "reviews": { + "type": "array", + "items": { + "type": "ref", + "ref": "#listingReviewView" + } + } + } + } }, - errors: [ + "errors": [ { - name: "ListingNotFound", + "name": "ListingNotFound" }, { - name: "InvalidParams", + "name": "InvalidParams" }, { - name: "InvalidCursor", - }, - ], - }, - }, + "name": "InvalidCursor" + } + ] + } + } }, { - lexicon: 1, - id: "fyi.atstore.server.describe", - defs: { - main: { - type: "query", - description: - "Describe this deployment's public XRPC surface and defaults.", - parameters: { - type: "params", - properties: {}, + "lexicon": 1, + "id": "fyi.atstore.server.describe", + "defs": { + "main": { + "type": "query", + "description": "Describe this deployment's public XRPC surface and defaults.", + "parameters": { + "type": "params", + "properties": {} }, - output: { - encoding: "application/json", - schema: { - type: "object", - required: [ + "output": { + "encoding": "application/json", + "schema": { + "type": "object", + "required": [ "service", "publicReads", "reviewsWrittenOnAuthorRepo", "defaultListingLimit", "maxListingLimit", "maxReviewLimit", - "methods", + "methods" ], - properties: { - service: { - type: "string", - maxLength: 256, + "properties": { + "service": { + "type": "string", + "maxLength": 256 }, - publicReads: { - type: "boolean", + "publicReads": { + "type": "boolean" }, - reviewsWrittenOnAuthorRepo: { - type: "boolean", - description: - "When true, listing reviews are created via com.atproto.repo.createRecord on the author's PDS (fyi.atstore.listing.review); this service does not expose a write procedure for reviews.", + "reviewsWrittenOnAuthorRepo": { + "type": "boolean", + "description": "When true, listing reviews are created via com.atproto.repo.createRecord on the author's PDS (fyi.atstore.listing.review); this service does not expose a write procedure for reviews." }, - defaultListingLimit: { - type: "integer", + "defaultListingLimit": { + "type": "integer" }, - maxListingLimit: { - type: "integer", + "maxListingLimit": { + "type": "integer" }, - maxReviewLimit: { - type: "integer", + "maxReviewLimit": { + "type": "integer" }, - methods: { - type: "array", - items: { - type: "string", - maxLength: 512, - }, - }, - }, - }, - }, - }, - }, - }, -]; + "methods": { + "type": "array", + "items": { + "type": "string", + "maxLength": 512 + } + } + } + } + } + } + } + } +] diff --git a/src/lib/atproto/nsids.ts b/src/lib/atproto/nsids.ts index d5cdc30..970e35e 100644 --- a/src/lib/atproto/nsids.ts +++ b/src/lib/atproto/nsids.ts @@ -10,7 +10,6 @@ export const NSID = { lexiconSchema: "com.atproto.lexicon.schema", directorySearchListings: "fyi.atstore.directory.searchListings", directoryGetListing: "fyi.atstore.directory.getListing", - directoryResolveListing: "fyi.atstore.directory.resolveListing", reviewsListForListing: "fyi.atstore.reviews.listForListing", serverDescribe: "fyi.atstore.server.describe", } as const; @@ -19,7 +18,6 @@ export const NSID = { export const ATSTORE_XRPC_METHOD = { directorySearchListings: NSID.directorySearchListings, directoryGetListing: NSID.directoryGetListing, - directoryResolveListing: NSID.directoryResolveListing, reviewsListForListing: NSID.reviewsListForListing, serverDescribe: NSID.serverDescribe, } as const; diff --git a/src/routes/_header-layout.developers.atproto.tsx b/src/routes/_header-layout.developers.atproto.tsx index f7af0bd..6869870 100644 --- a/src/routes/_header-layout.developers.atproto.tsx +++ b/src/routes/_header-layout.developers.atproto.tsx @@ -26,6 +26,7 @@ import { import { Text } from "#/design-system/typography/text"; import { ATSTORE_XRPC_METHOD, NSID } from "#/lib/atproto/nsids"; import { buildRouteOgMeta } from "#/lib/og-meta"; +import { ResizableTableContainer } from "react-aria-components"; const METHOD_ROWS: ReadonlyArray<{ nsid: string; @@ -46,12 +47,7 @@ const METHOD_ROWS: ReadonlyArray<{ nsid: ATSTORE_XRPC_METHOD.directoryGetListing, method: "GET", summary: - "Detail projection by listing.detail AT URI (`uri` query param); card uses `listing.uri`.", - }, - { - nsid: ATSTORE_XRPC_METHOD.directoryResolveListing, - method: "GET", - summary: "Resolve `externalUrl` to listing.detail AT URI (`uri`).", + "Listing detail: exactly one of `uri` (listing.detail AT URI) or `externalUrl` (unique storefront URL).", }, { nsid: ATSTORE_XRPC_METHOD.reviewsListForListing, @@ -116,7 +112,7 @@ function DevelopersAtprotoPage() { - AT Protocol on AT Store + AT Protocol on ATStore Public GET endpoints under{" "} /xrpc/<nsid>. Lexicons:{" "} @@ -135,34 +131,51 @@ function DevelopersAtprotoPage() { Methods - - - {(column) => {column.name}} - - - {(row) => ( - - {(column) => ( - - {column.id === "method" ? ( - {row.method} - ) : column.id === "nsid" ? ( - - {row.nsid} - - ) : ( - {row.summary} - )} - - )} - - )} - -
+ + + + {(column) => ( + + {column.name} + + )} + + + {(row) => ( + + {(column) => ( + + {column.id === "method" ? ( + {row.method} + ) : column.id === "nsid" ? ( + + {row.nsid} + + ) : ( + {row.summary} + )} + + )} + + )} + +
+
@@ -177,7 +190,8 @@ function DevelopersAtprotoPage() { {ATSTORE_XRPC_METHOD.directoryGetListing}
{" "} with query param uri (the - listing.detail AT URI); the JSON includes it as{" "} + listing.detail AT URI) or externalUrl{" "} + (unique storefront URL); do not pass both. The JSON includes{" "} listing.uri. Use that value as{" "} subject on a new{" "} diff --git a/src/server/atproto-xrpc/atstore-xrpc-handler.server.ts b/src/server/atproto-xrpc/atstore-xrpc-handler.server.ts index ab5c54d..f075907 100644 --- a/src/server/atproto-xrpc/atstore-xrpc-handler.server.ts +++ b/src/server/atproto-xrpc/atstore-xrpc-handler.server.ts @@ -182,74 +182,54 @@ async function handleSearchListings(url: URL) { }); } -async function fetchVerifiedListingDetailRowByUri(uriRaw: string) { - const canonical = listingDetailUriOrNull(uriRaw); - if (!canonical) { - return { error: "InvalidParams" as const }; - } - - const table = schema.storeListings; - const { listingXrpcPublicWhere } = directoryListingXrpcHelpers; - - const filter = eq(table.atUri, canonical); - - const [row] = await db - .select({ - id: table.id, - sourceUrl: table.sourceUrl, - name: table.name, - slug: table.slug, - externalUrl: table.externalUrl, - iconUrl: table.iconUrl, - heroImageUrl: table.heroImageUrl, - screenshotUrls: table.screenshotUrls, - tagline: table.tagline, - fullDescription: table.fullDescription, - categorySlugs: table.categorySlugs, - atUri: table.atUri, - repoDid: table.repoDid, - migratedFromAtUri: table.migratedFromAtUri, - productAccountDid: table.productAccountDid, - productAccountHandle: table.productAccountHandle, - reviewCount: table.reviewCount, - averageRating: table.averageRating, - ...LEGACY_DETAIL_SQL, - appTags: table.appTags, - links: table.links, - createdAt: table.createdAt, - updatedAt: table.updatedAt, - }) - .from(table) - .where(listingXrpcPublicWhere(table, filter)) - .limit(1); - - if (!row) { - return { error: "ListingNotFound" as const }; - } - return { row }; +function verifiedListingDetailSelect(table: typeof schema.storeListings) { + return { + id: table.id, + sourceUrl: table.sourceUrl, + name: table.name, + slug: table.slug, + externalUrl: table.externalUrl, + iconUrl: table.iconUrl, + heroImageUrl: table.heroImageUrl, + screenshotUrls: table.screenshotUrls, + tagline: table.tagline, + fullDescription: table.fullDescription, + categorySlugs: table.categorySlugs, + atUri: table.atUri, + repoDid: table.repoDid, + migratedFromAtUri: table.migratedFromAtUri, + productAccountDid: table.productAccountDid, + productAccountHandle: table.productAccountHandle, + reviewCount: table.reviewCount, + averageRating: table.averageRating, + ...LEGACY_DETAIL_SQL, + appTags: table.appTags, + links: table.links, + createdAt: table.createdAt, + updatedAt: table.updatedAt, + }; } -async function handleGetListing(url: URL) { - const uriParam = url.searchParams.get("uri") ?? ""; - const fetched = await fetchVerifiedListingDetailRowByUri(uriParam); - if ("error" in fetched) { - switch (fetched.error) { - case "InvalidParams": { - return xrpcErr(400, fetched.error); - } - case "ListingNotFound": { - return xrpcErr(404, fetched.error); - } - default: { - return xrpcErr(500, "InternalError"); - } - } - } +type VerifiedListingDetailRowForXrpc = Parameters< + (typeof directoryListingXrpcHelpers)["toListingCardXrpc"] +>[0] & { + links: unknown; + repoDid: string | null; + migratedFromAtUri: string | null; + productAccountDid: string | null; + sourceUrl: string; + externalUrl: string | null; + tagline: string | null; + fullDescription: string | null; + screenshotUrls: Array; + createdAt: Date; + updatedAt: Date; +}; +async function listingDetailXrpcPayload(row: VerifiedListingDetailRowForXrpc) { const { toListingCardXrpc, computeIsStoreManaged, normalizeListingLinks } = directoryListingXrpcHelpers; - const row = fetched.row; const listing = listingCardXrpcJson(toListingCardXrpc(row)); const isStoreManaged = await computeIsStoreManaged(row); @@ -257,7 +237,7 @@ async function handleGetListing(url: URL) { row.links as Array | null, ); - return xrpcJson({ + return { listing, isStoreManaged, repoDid: row.repoDid ?? null, @@ -273,16 +253,66 @@ async function handleGetListing(url: URL) { ...(link.label ? { label: link.label } : {}), uri: link.url, })), - }); + }; +} + +async function fetchVerifiedListingDetailRowByUri(uriRaw: string) { + const canonical = listingDetailUriOrNull(uriRaw); + if (!canonical) { + return { error: "InvalidParams" as const }; + } + + const table = schema.storeListings; + const { listingXrpcPublicWhere } = directoryListingXrpcHelpers; + + const filter = eq(table.atUri, canonical); + + const [row] = await db + .select(verifiedListingDetailSelect(table)) + .from(table) + .where(listingXrpcPublicWhere(table, filter)) + .limit(1); + + if (!row) { + return { error: "ListingNotFound" as const }; + } + return { row }; } -async function handleResolveListing(url: URL) { - const externalUrl = url.searchParams.get("externalUrl")?.trim(); - if (!externalUrl) { - return xrpcErr(400, "InvalidParams", "externalUrl is required."); +async function handleGetListing(url: URL) { + const uriParam = url.searchParams.get("uri")?.trim() ?? ""; + const externalUrlParam = url.searchParams.get("externalUrl")?.trim() ?? ""; + const hasUri = uriParam.length > 0; + const hasExternal = externalUrlParam.length > 0; + + if (hasUri === hasExternal) { + return xrpcErr( + 400, + "InvalidParams", + "Provide exactly one of uri or externalUrl.", + ); + } + + if (hasUri) { + const fetched = await fetchVerifiedListingDetailRowByUri(uriParam); + if ("error" in fetched) { + switch (fetched.error) { + case "InvalidParams": { + return xrpcErr(400, fetched.error); + } + case "ListingNotFound": { + return xrpcErr(404, fetched.error); + } + default: { + return xrpcErr(500, "InternalError"); + } + } + } + + return xrpcJson(await listingDetailXrpcPayload(fetched.row)); } - const variants = normalizeExternalUrlCandidates(externalUrl); + const variants = normalizeExternalUrlCandidates(externalUrlParam); if (variants.length === 0) { return xrpcErr(400, "InvalidParams"); } @@ -303,9 +333,7 @@ async function handleResolveListing(url: URL) { } const rows = await db - .select({ - atUri: table.atUri, - }) + .select(verifiedListingDetailSelect(table)) .from(table) .where(listingXrpcPublicWhere(table, clause)) .limit(4); @@ -317,18 +345,12 @@ async function handleResolveListing(url: URL) { return xrpcErr(409, "AmbiguousResolution"); } - const hit = rows.at(0); - if (!hit) { - return xrpcErr(404, "ListingNotFound"); - } - const uri = hit.atUri?.trim(); - if (!uri) { + const row = rows.at(0); + if (!row?.atUri?.trim()) { return xrpcErr(404, "ListingNotFound"); } - return xrpcJson({ - uri, - }); + return xrpcJson(await listingDetailXrpcPayload(row)); } async function handleListReviews(url: URL, request: Request) { @@ -473,13 +495,6 @@ export async function handleAtstoreXrpc( return handleGetListing(url); } - case ATSTORE_XRPC_METHOD.directoryResolveListing: { - if (request.method !== "GET") { - return xrpcErr(405, "MethodNotAllowed"); - } - return handleResolveListing(url); - } - case ATSTORE_XRPC_METHOD.reviewsListForListing: { if (request.method !== "GET") { return xrpcErr(405, "MethodNotAllowed"); From 1ddc04af78c9216ebca06af9fa80a76be00b9301 Mon Sep 17 00:00:00 2001 From: Andrew Lisowski Date: Mon, 4 May 2026 23:53:03 -0700 Subject: [PATCH 6/7] tweak docs --- src/routes/_header-layout.developers.atproto.tsx | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/src/routes/_header-layout.developers.atproto.tsx b/src/routes/_header-layout.developers.atproto.tsx index 6869870..eb52073 100644 --- a/src/routes/_header-layout.developers.atproto.tsx +++ b/src/routes/_header-layout.developers.atproto.tsx @@ -83,8 +83,11 @@ const styles = stylex.create({ lineHeight: 1.4, wordBreak: "break-word", }, + tableWrapper: { + overflow: "auto", + }, methodsTable: { - width: "100%", + width: "100% !important", }, pre: { marginBottom: 0, @@ -131,7 +134,7 @@ function DevelopersAtprotoPage() { Methods - + {column.name} From b0673e2013233786a6b8c7e0d1370834d022835f Mon Sep 17 00:00:00 2001 From: Andrew Lisowski Date: Mon, 4 May 2026 23:54:44 -0700 Subject: [PATCH 7/7] fix format --- src/lexicons/generated/bundle.ts | 1604 +++++++++++++++--------------- 1 file changed, 788 insertions(+), 816 deletions(-) diff --git a/src/lexicons/generated/bundle.ts b/src/lexicons/generated/bundle.ts index bd6dc1c..331f3fd 100644 --- a/src/lexicons/generated/bundle.ts +++ b/src/lexicons/generated/bundle.ts @@ -1,75 +1,66 @@ export const lexicons = [ { - "lexicon": 1, - "id": "fyi.atstore.authBasic", - "description": "Permission set for AT Store write access.", - "defs": { - "main": { - "type": "permission-set", - "title": "Full AT Store Access", - "detail": "Provides full access to AT Store profile, listings, reviews, and favorites.", - "permissions": [ + lexicon: 1, + id: "fyi.atstore.authBasic", + description: "Permission set for AT Store write access.", + defs: { + main: { + type: "permission-set", + title: "Full AT Store Access", + detail: + "Provides full access to AT Store profile, listings, reviews, and favorites.", + permissions: [ { - "type": "permission", - "resource": "repo", - "collection": [ + type: "permission", + resource: "repo", + collection: [ "fyi.atstore.profile", "fyi.atstore.listing.detail", "fyi.atstore.listing.review", "fyi.atstore.listing.reviewReply", - "fyi.atstore.listing.favorite" + "fyi.atstore.listing.favorite", ], - "action": [ - "create", - "update", - "delete" - ] - } - ] - } - } + action: ["create", "update", "delete"], + }, + ], + }, + }, }, { - "lexicon": 1, - "id": "fyi.atstore.authThirdPartyReviews", - "description": "OAuth permission bundle for third-party apps that publish AT Store profile self plus listing reviews on the user's repo; reads use public directory XRPC.", - "defs": { - "main": { - "type": "permission-set", - "title": "Submit AT Store reviews", - "detail": "Create fyi.atstore.profile/self when needed and fyi.atstore.listing.review records on the user's PDS via repository APIs; read public directory data via XRPC queries.", - "permissions": [ + lexicon: 1, + id: "fyi.atstore.authThirdPartyReviews", + description: + "OAuth permission bundle for third-party apps that publish AT Store profile self plus listing reviews on the user's repo; reads use public directory XRPC.", + defs: { + main: { + type: "permission-set", + title: "Submit AT Store reviews", + detail: + "Create fyi.atstore.profile/self when needed and fyi.atstore.listing.review records on the user's PDS via repository APIs; read public directory data via XRPC queries.", + permissions: [ { - "type": "permission", - "resource": "repo", - "collection": [ - "fyi.atstore.profile" - ], - "action": [ - "create" - ] + type: "permission", + resource: "repo", + collection: ["fyi.atstore.profile"], + action: ["create"], }, { - "type": "permission", - "resource": "repo", - "collection": [ - "fyi.atstore.listing.review" - ], - "action": [ - "create" - ] - } - ] - } - } + type: "permission", + resource: "repo", + collection: ["fyi.atstore.listing.review"], + action: ["create"], + }, + ], + }, + }, }, { - "lexicon": 1, - "id": "fyi.atstore.directory.getListing", - "defs": { - "listingCardGet": { - "type": "object", - "required": [ + lexicon: 1, + id: "fyi.atstore.directory.getListing", + defs: { + listingCardGet: { + type: "object", + required: [ "uri", "name", "tagline", @@ -79,222 +70,214 @@ export const lexicons = [ "reviewCount", "priceLabel", "appTags", - "categorySlugs" + "categorySlugs", ], - "properties": { - "uri": { - "type": "string", - "format": "at-uri", - "maxLength": 2560, - "description": "AT URI of the fyi.atstore.listing.detail record." - }, - "name": { - "type": "string", - "maxLength": 640 - }, - "tagline": { - "type": "string", - "maxLength": 2000 - }, - "description": { - "type": "string", - "maxLength": 20000 - }, - "iconUrl": { - "type": "string", - "maxLength": 8192, - "nullable": true - }, - "heroImageUrl": { - "type": "string", - "maxLength": 8192, - "nullable": true - }, - "categorySlug": { - "type": "string", - "maxLength": 512, - "nullable": true - }, - "categorySlugs": { - "type": "array", - "items": { - "type": "string", - "maxLength": 512 - } - }, - "category": { - "type": "string", - "maxLength": 640 - }, - "accent": { - "type": "string", - "maxLength": 16, - "knownValues": [ - "blue", - "pink", - "purple", - "green" - ] - }, - "rating": { - "type": "string", - "maxLength": 16, - "nullable": true - }, - "reviewCount": { - "type": "integer" - }, - "priceLabel": { - "type": "string", - "maxLength": 32 - }, - "productAccountHandle": { - "type": "string", - "maxLength": 512, - "nullable": true - }, - "appTags": { - "type": "array", - "items": { - "type": "string", - "maxLength": 256 - } - } - } + properties: { + uri: { + type: "string", + format: "at-uri", + maxLength: 2560, + description: "AT URI of the fyi.atstore.listing.detail record.", + }, + name: { + type: "string", + maxLength: 640, + }, + tagline: { + type: "string", + maxLength: 2000, + }, + description: { + type: "string", + maxLength: 20000, + }, + iconUrl: { + type: "string", + maxLength: 8192, + nullable: true, + }, + heroImageUrl: { + type: "string", + maxLength: 8192, + nullable: true, + }, + categorySlug: { + type: "string", + maxLength: 512, + nullable: true, + }, + categorySlugs: { + type: "array", + items: { + type: "string", + maxLength: 512, + }, + }, + category: { + type: "string", + maxLength: 640, + }, + accent: { + type: "string", + maxLength: 16, + knownValues: ["blue", "pink", "purple", "green"], + }, + rating: { + type: "string", + maxLength: 16, + nullable: true, + }, + reviewCount: { + type: "integer", + }, + priceLabel: { + type: "string", + maxLength: 32, + }, + productAccountHandle: { + type: "string", + maxLength: 512, + nullable: true, + }, + appTags: { + type: "array", + items: { + type: "string", + maxLength: 256, + }, + }, + }, }, - "listingLinkRow": { - "type": "object", - "required": [ - "uri" - ], - "properties": { - "label": { - "type": "string", - "maxLength": 640 - }, - "uri": { - "type": "string", - "maxLength": 2048 - } - } + listingLinkRow: { + type: "object", + required: ["uri"], + properties: { + label: { + type: "string", + maxLength: 640, + }, + uri: { + type: "string", + maxLength: 2048, + }, + }, }, - "listingDetailResponse": { - "type": "object", - "required": [ - "listing", - "isStoreManaged" - ], - "properties": { - "listing": { - "type": "ref", - "ref": "#listingCardGet" - }, - "isStoreManaged": { - "type": "boolean" - }, - "repoDid": { - "type": "string", - "maxLength": 2048, - "nullable": true - }, - "productAccountDid": { - "type": "string", - "maxLength": 2048, - "nullable": true - }, - "sourceTagline": { - "type": "string", - "maxLength": 20000, - "nullable": true - }, - "sourceFullDescription": { - "type": "string", - "maxLength": 20000, - "nullable": true - }, - "screenshots": { - "type": "array", - "items": { - "type": "string", - "maxLength": 4096 - } - }, - "externalUrl": { - "type": "string", - "maxLength": 2048, - "nullable": true - }, - "sourceUrl": { - "type": "string", - "maxLength": 8192, - "nullable": true - }, - "createdAt": { - "type": "string", - "maxLength": 64, - "nullable": true - }, - "updatedAt": { - "type": "string", - "maxLength": 64, - "nullable": true - }, - "links": { - "type": "array", - "items": { - "type": "ref", - "ref": "#listingLinkRow" - } - } - } + listingDetailResponse: { + type: "object", + required: ["listing", "isStoreManaged"], + properties: { + listing: { + type: "ref", + ref: "#listingCardGet", + }, + isStoreManaged: { + type: "boolean", + }, + repoDid: { + type: "string", + maxLength: 2048, + nullable: true, + }, + productAccountDid: { + type: "string", + maxLength: 2048, + nullable: true, + }, + sourceTagline: { + type: "string", + maxLength: 20000, + nullable: true, + }, + sourceFullDescription: { + type: "string", + maxLength: 20000, + nullable: true, + }, + screenshots: { + type: "array", + items: { + type: "string", + maxLength: 4096, + }, + }, + externalUrl: { + type: "string", + maxLength: 2048, + nullable: true, + }, + sourceUrl: { + type: "string", + maxLength: 8192, + nullable: true, + }, + createdAt: { + type: "string", + maxLength: 64, + nullable: true, + }, + updatedAt: { + type: "string", + maxLength: 64, + nullable: true, + }, + links: { + type: "array", + items: { + type: "ref", + ref: "#listingLinkRow", + }, + }, + }, }, - "main": { - "type": "query", - "description": "Fetch one public verified listing. Provide exactly one of `uri` (fyi.atstore.listing.detail AT URI) or `externalUrl` (unique storefront URL); `externalUrl` uses the same matching rules as the former resolve endpoint.", - "parameters": { - "type": "params", - "properties": { - "uri": { - "type": "string", - "format": "at-uri", - "maxLength": 2560, - "description": "AT URI of the fyi.atstore.listing.detail record." - }, - "externalUrl": { - "type": "string", - "maxLength": 2048, - "description": "Listing external_url / product URL as stored on the record; must match at most one public listing." - } - } + main: { + type: "query", + description: + "Fetch one public verified listing. Provide exactly one of `uri` (fyi.atstore.listing.detail AT URI) or `externalUrl` (unique storefront URL); `externalUrl` uses the same matching rules as the former resolve endpoint.", + parameters: { + type: "params", + properties: { + uri: { + type: "string", + format: "at-uri", + maxLength: 2560, + description: "AT URI of the fyi.atstore.listing.detail record.", + }, + externalUrl: { + type: "string", + maxLength: 2048, + description: + "Listing external_url / product URL as stored on the record; must match at most one public listing.", + }, + }, }, - "output": { - "encoding": "application/json", - "schema": { - "type": "ref", - "ref": "#listingDetailResponse" - } + output: { + encoding: "application/json", + schema: { + type: "ref", + ref: "#listingDetailResponse", + }, }, - "errors": [ + errors: [ { - "name": "ListingNotFound" + name: "ListingNotFound", }, { - "name": "InvalidParams" + name: "InvalidParams", }, { - "name": "AmbiguousResolution" - } - ] - } - } + name: "AmbiguousResolution", + }, + ], + }, + }, }, { - "lexicon": 1, - "id": "fyi.atstore.directory.searchListings", - "defs": { - "listingCardSearch": { - "type": "object", - "required": [ + lexicon: 1, + id: "fyi.atstore.directory.searchListings", + defs: { + listingCardSearch: { + type: "object", + required: [ "uri", "name", "tagline", @@ -304,162 +287,153 @@ export const lexicons = [ "reviewCount", "priceLabel", "appTags", - "categorySlugs" + "categorySlugs", ], - "properties": { - "uri": { - "type": "string", - "format": "at-uri", - "maxLength": 2560, - "description": "AT URI of the fyi.atstore.listing.detail record." - }, - "name": { - "type": "string", - "maxLength": 640 - }, - "tagline": { - "type": "string", - "maxLength": 2000 - }, - "description": { - "type": "string", - "maxLength": 20000 - }, - "iconUrl": { - "type": "string", - "maxLength": 8192, - "nullable": true - }, - "heroImageUrl": { - "type": "string", - "maxLength": 8192, - "nullable": true - }, - "categorySlug": { - "type": "string", - "maxLength": 512, - "nullable": true - }, - "categorySlugs": { - "type": "array", - "items": { - "type": "string", - "maxLength": 512 - } - }, - "category": { - "type": "string", - "maxLength": 640 - }, - "accent": { - "type": "string", - "maxLength": 16, - "knownValues": [ - "blue", - "pink", - "purple", - "green" - ] - }, - "rating": { - "type": "string", - "maxLength": 16, - "nullable": true - }, - "reviewCount": { - "type": "integer" - }, - "priceLabel": { - "type": "string", - "maxLength": 32 - }, - "productAccountHandle": { - "type": "string", - "maxLength": 512, - "nullable": true - }, - "appTags": { - "type": "array", - "items": { - "type": "string", - "maxLength": 256 - } - } - } + properties: { + uri: { + type: "string", + format: "at-uri", + maxLength: 2560, + description: "AT URI of the fyi.atstore.listing.detail record.", + }, + name: { + type: "string", + maxLength: 640, + }, + tagline: { + type: "string", + maxLength: 2000, + }, + description: { + type: "string", + maxLength: 20000, + }, + iconUrl: { + type: "string", + maxLength: 8192, + nullable: true, + }, + heroImageUrl: { + type: "string", + maxLength: 8192, + nullable: true, + }, + categorySlug: { + type: "string", + maxLength: 512, + nullable: true, + }, + categorySlugs: { + type: "array", + items: { + type: "string", + maxLength: 512, + }, + }, + category: { + type: "string", + maxLength: 640, + }, + accent: { + type: "string", + maxLength: 16, + knownValues: ["blue", "pink", "purple", "green"], + }, + rating: { + type: "string", + maxLength: 16, + nullable: true, + }, + reviewCount: { + type: "integer", + }, + priceLabel: { + type: "string", + maxLength: 32, + }, + productAccountHandle: { + type: "string", + maxLength: 512, + nullable: true, + }, + appTags: { + type: "array", + items: { + type: "string", + maxLength: 256, + }, + }, + }, }, - "main": { - "type": "query", - "description": "Directory listing search and pagination (verified listings with a listing.detail AT URI only).", - "parameters": { - "type": "params", - "properties": { - "q": { - "type": "string", - "maxLength": 512 - }, - "sort": { - "type": "string", - "maxLength": 24, - "default": "popular", - "enum": [ - "popular", - "newest", - "alphabetical" - ] - }, - "limit": { - "type": "integer", - "minimum": 1, - "maximum": 100, - "default": 24 - }, - "cursor": { - "type": "string", - "maxLength": 512 - } - } + main: { + type: "query", + description: + "Directory listing search and pagination (verified listings with a listing.detail AT URI only).", + parameters: { + type: "params", + properties: { + q: { + type: "string", + maxLength: 512, + }, + sort: { + type: "string", + maxLength: 24, + default: "popular", + enum: ["popular", "newest", "alphabetical"], + }, + limit: { + type: "integer", + minimum: 1, + maximum: 100, + default: 24, + }, + cursor: { + type: "string", + maxLength: 512, + }, + }, }, - "output": { - "encoding": "application/json", - "schema": { - "type": "object", - "required": [ - "listings" - ], - "properties": { - "cursor": { - "type": "string", - "maxLength": 512 + output: { + encoding: "application/json", + schema: { + type: "object", + required: ["listings"], + properties: { + cursor: { + type: "string", + maxLength: 512, }, - "listings": { - "type": "array", - "items": { - "type": "ref", - "ref": "#listingCardSearch" - } - } - } - } + listings: { + type: "array", + items: { + type: "ref", + ref: "#listingCardSearch", + }, + }, + }, + }, }, - "errors": [ + errors: [ { - "name": "InvalidCursor" - } - ] - } - } + name: "InvalidCursor", + }, + ], + }, + }, }, { - "lexicon": 1, - "id": "fyi.atstore.listing.detail", - "defs": { - "main": { - "type": "record", - "description": "Public protocol or app listing in the AT Store directory. Images are stored as repo blobs (Kitchen-style); the web app caches HTTPS URLs in Postgres separately.", - "key": "tid", - "record": { - "type": "object", - "required": [ + lexicon: 1, + id: "fyi.atstore.listing.detail", + defs: { + main: { + type: "record", + description: + "Public protocol or app listing in the AT Store directory. Images are stored as repo blobs (Kitchen-style); the web app caches HTTPS URLs in Postgres separately.", + key: "tid", + record: { + type: "object", + required: [ "slug", "name", "tagline", @@ -467,132 +441,135 @@ export const lexicons = [ "icon", "categorySlug", "createdAt", - "updatedAt" + "updatedAt", ], - "properties": { - "slug": { - "type": "string", - "minLength": 1, - "maxLength": 512, - "description": "Stable URL slug; unique within the publishing account." - }, - "name": { - "type": "string", - "maxLength": 640 - }, - "tagline": { - "type": "string", - "maxLength": 300 - }, - "description": { - "type": "string", - "maxLength": 20000 - }, - "externalUrl": { - "type": "string", - "format": "uri", - "maxLength": 2048, - "description": "Primary product or project URL." - }, - "icon": { - "type": "blob", - "accept": [ + properties: { + slug: { + type: "string", + minLength: 1, + maxLength: 512, + description: + "Stable URL slug; unique within the publishing account.", + }, + name: { + type: "string", + maxLength: 640, + }, + tagline: { + type: "string", + maxLength: 300, + }, + description: { + type: "string", + maxLength: 20000, + }, + externalUrl: { + type: "string", + format: "uri", + maxLength: 2048, + description: "Primary product or project URL.", + }, + icon: { + type: "blob", + accept: [ "image/png", "image/jpeg", "image/webp", "image/gif", - "image/svg+xml" + "image/svg+xml", ], - "maxSize": 2000000, - "description": "Square / app icon (uploaded to repo via com.atproto.repo.uploadBlob)." + maxSize: 2000000, + description: + "Square / app icon (uploaded to repo via com.atproto.repo.uploadBlob).", }, - "heroImage": { - "type": "blob", - "accept": [ + heroImage: { + type: "blob", + accept: [ "image/png", "image/jpeg", "image/webp", "image/gif", - "image/svg+xml" + "image/svg+xml", ], - "maxSize": 12000000, - "description": "Hero / cover image blob." - }, - "screenshots": { - "type": "array", - "maxLength": 20, - "items": { - "type": "blob", - "accept": [ + maxSize: 12000000, + description: "Hero / cover image blob.", + }, + screenshots: { + type: "array", + maxLength: 20, + items: { + type: "blob", + accept: [ "image/png", "image/jpeg", "image/webp", "image/gif", - "image/svg+xml" + "image/svg+xml", ], - "maxSize": 12000000 - } - }, - "categorySlug": { - "type": "array", - "minLength": 1, - "maxLength": 32, - "items": { - "type": "string", - "maxLength": 256 + maxSize: 12000000, }, - "description": "Browse category keys (e.g. protocol/pds). First entry is the primary category for legacy surfaces." - }, - "createdAt": { - "type": "string", - "format": "datetime" - }, - "updatedAt": { - "type": "string", - "format": "datetime" - }, - "appTags": { - "type": "array", - "maxLength": 64, - "items": { - "type": "string", - "maxLength": 96 - } - }, - "productAccountDid": { - "type": "string", - "maxLength": 2048, - "description": "Bluesky DID for the product, app, or tool (not the AT Store publisher). Handle is resolved and stored in Postgres only." - }, - "migratedFromAtUri": { - "type": "string", - "format": "at-uri", - "maxLength": 8192, - "description": "When this listing.detail record supersedes a prior record in another repo (e.g. moved from the AT Store publisher to a product owner PDS), the at:// URI of that prior fyi.atstore.listing.detail record." - }, - "links": { - "type": "array", - "maxLength": 12, - "description": "Relevant links for the app, including trust/compliance, support, and project resources.", - "items": { - "type": "ref", - "ref": "#link" - } - } - } - } + }, + categorySlug: { + type: "array", + minLength: 1, + maxLength: 32, + items: { + type: "string", + maxLength: 256, + }, + description: + "Browse category keys (e.g. protocol/pds). First entry is the primary category for legacy surfaces.", + }, + createdAt: { + type: "string", + format: "datetime", + }, + updatedAt: { + type: "string", + format: "datetime", + }, + appTags: { + type: "array", + maxLength: 64, + items: { + type: "string", + maxLength: 96, + }, + }, + productAccountDid: { + type: "string", + maxLength: 2048, + description: + "Bluesky DID for the product, app, or tool (not the AT Store publisher). Handle is resolved and stored in Postgres only.", + }, + migratedFromAtUri: { + type: "string", + format: "at-uri", + maxLength: 8192, + description: + "When this listing.detail record supersedes a prior record in another repo (e.g. moved from the AT Store publisher to a product owner PDS), the at:// URI of that prior fyi.atstore.listing.detail record.", + }, + links: { + type: "array", + maxLength: 12, + description: + "Relevant links for the app, including trust/compliance, support, and project resources.", + items: { + type: "ref", + ref: "#link", + }, + }, + }, + }, }, - "link": { - "type": "object", - "required": [ - "type", - "url" - ], - "properties": { - "type": { - "type": "string", - "maxLength": 32, - "knownValues": [ + link: { + type: "object", + required: ["type", "url"], + properties: { + type: { + type: "string", + maxLength: 32, + knownValues: [ "privacy", "terms", "support", @@ -605,346 +582,341 @@ export const lexicons = [ "community", "donate", "license", - "other" + "other", ], - "description": "The kind of link." - }, - "url": { - "type": "string", - "format": "uri", - "maxLength": 2048, - "description": "The destination URL." - }, - "label": { - "type": "string", - "maxLength": 100, - "maxGraphemes": 50, - "description": "Optional human-readable label, especially useful when type is 'other'." - } - } - } - } + description: "The kind of link.", + }, + url: { + type: "string", + format: "uri", + maxLength: 2048, + description: "The destination URL.", + }, + label: { + type: "string", + maxLength: 100, + maxGraphemes: 50, + description: + "Optional human-readable label, especially useful when type is 'other'.", + }, + }, + }, + }, }, { - "lexicon": 1, - "id": "fyi.atstore.listing.favorite", - "defs": { - "main": { - "type": "record", - "description": "A user favorite for an AT Store listing. Subject must be the at:// URI of a fyi.atstore.listing.detail record.", - "key": "any", - "record": { - "type": "object", - "required": [ - "subject", - "createdAt" - ], - "properties": { - "subject": { - "type": "string", - "format": "at-uri", - "description": "AT URI of the fyi.atstore.listing.detail record being favorited." - }, - "createdAt": { - "type": "string", - "format": "datetime" - } - } - } - } - } + lexicon: 1, + id: "fyi.atstore.listing.favorite", + defs: { + main: { + type: "record", + description: + "A user favorite for an AT Store listing. Subject must be the at:// URI of a fyi.atstore.listing.detail record.", + key: "any", + record: { + type: "object", + required: ["subject", "createdAt"], + properties: { + subject: { + type: "string", + format: "at-uri", + description: + "AT URI of the fyi.atstore.listing.detail record being favorited.", + }, + createdAt: { + type: "string", + format: "datetime", + }, + }, + }, + }, + }, }, { - "lexicon": 1, - "id": "fyi.atstore.listing.review", - "defs": { - "main": { - "type": "record", - "description": "A user review of an AT Store directory listing. Subject must be the at:// URI of a fyi.atstore.listing.detail record.", - "key": "tid", - "record": { - "type": "object", - "required": [ - "subject", - "rating", - "createdAt" - ], - "properties": { - "subject": { - "type": "string", - "format": "at-uri", - "description": "AT URI of the fyi.atstore.listing.detail record being reviewed." - }, - "rating": { - "type": "integer", - "minimum": 1, - "maximum": 5, - "description": "Star rating 1–5." - }, - "text": { - "type": "string", - "maxLength": 8000, - "description": "Optional written review; omit for a stars-only rating." - }, - "createdAt": { - "type": "string", - "format": "datetime" - } - } - } - } - } + lexicon: 1, + id: "fyi.atstore.listing.review", + defs: { + main: { + type: "record", + description: + "A user review of an AT Store directory listing. Subject must be the at:// URI of a fyi.atstore.listing.detail record.", + key: "tid", + record: { + type: "object", + required: ["subject", "rating", "createdAt"], + properties: { + subject: { + type: "string", + format: "at-uri", + description: + "AT URI of the fyi.atstore.listing.detail record being reviewed.", + }, + rating: { + type: "integer", + minimum: 1, + maximum: 5, + description: "Star rating 1–5.", + }, + text: { + type: "string", + maxLength: 8000, + description: + "Optional written review; omit for a stars-only rating.", + }, + createdAt: { + type: "string", + format: "datetime", + }, + }, + }, + }, + }, }, { - "lexicon": 1, - "id": "fyi.atstore.listing.reviewReply", - "defs": { - "main": { - "type": "record", - "description": "A reply on a fyi.atstore.listing.review. Threads are linear (no per-reply parent). By convention, only the listing owner or the review author should post replies; the AT Store app drops replies from any other DID at ingest and at render. The PDS does not enforce this — other indexers MAY surface unauthorized replies if they choose.", - "key": "tid", - "record": { - "type": "object", - "required": [ - "subject", - "text", - "createdAt" - ], - "properties": { - "subject": { - "type": "string", - "format": "at-uri", - "description": "AT URI of the fyi.atstore.listing.review this reply belongs to." - }, - "text": { - "type": "string", - "minLength": 1, - "maxLength": 8000 - }, - "createdAt": { - "type": "string", - "format": "datetime" - } - } - } - } - } + lexicon: 1, + id: "fyi.atstore.listing.reviewReply", + defs: { + main: { + type: "record", + description: + "A reply on a fyi.atstore.listing.review. Threads are linear (no per-reply parent). By convention, only the listing owner or the review author should post replies; the AT Store app drops replies from any other DID at ingest and at render. The PDS does not enforce this — other indexers MAY surface unauthorized replies if they choose.", + key: "tid", + record: { + type: "object", + required: ["subject", "text", "createdAt"], + properties: { + subject: { + type: "string", + format: "at-uri", + description: + "AT URI of the fyi.atstore.listing.review this reply belongs to.", + }, + text: { + type: "string", + minLength: 1, + maxLength: 8000, + }, + createdAt: { + type: "string", + format: "datetime", + }, + }, + }, + }, + }, }, { - "lexicon": 1, - "id": "fyi.atstore.profile", - "defs": { - "main": { - "type": "record", - "description": "AT Store app profile for discovery and TAP ingestion (Kitchen-style).", - "key": "literal:self", - "record": { - "type": "object", - "required": [ - "displayName" - ], - "properties": { - "displayName": { - "type": "string", - "maxLength": 640, - "description": "Human-readable name for the store / app." - }, - "description": { - "type": "string", - "maxLength": 4000, - "description": "Longer description shown in directory surfaces." - }, - "website": { - "type": "string", - "format": "uri", - "maxLength": 2048 - } - } - } - } - } + lexicon: 1, + id: "fyi.atstore.profile", + defs: { + main: { + type: "record", + description: + "AT Store app profile for discovery and TAP ingestion (Kitchen-style).", + key: "literal:self", + record: { + type: "object", + required: ["displayName"], + properties: { + displayName: { + type: "string", + maxLength: 640, + description: "Human-readable name for the store / app.", + }, + description: { + type: "string", + maxLength: 4000, + description: "Longer description shown in directory surfaces.", + }, + website: { + type: "string", + format: "uri", + maxLength: 2048, + }, + }, + }, + }, + }, }, { - "lexicon": 1, - "id": "fyi.atstore.reviews.listForListing", - "defs": { - "listingReviewView": { - "type": "object", - "required": [ + lexicon: 1, + id: "fyi.atstore.reviews.listForListing", + defs: { + listingReviewView: { + type: "object", + required: [ "id", "authorDid", "rating", "reviewCreatedAt", "replyCount", - "canReply" + "canReply", ], - "properties": { - "id": { - "type": "string", - "maxLength": 64 - }, - "authorDid": { - "type": "string", - "format": "did", - "maxLength": 2048 - }, - "rating": { - "type": "integer", - "minimum": 1, - "maximum": 5 - }, - "text": { - "type": "string", - "maxLength": 8000, - "nullable": true - }, - "reviewCreatedAt": { - "type": "string", - "format": "datetime", - "maxLength": 64 - }, - "authorDisplayName": { - "type": "string", - "maxLength": 640, - "nullable": true - }, - "authorHandle": { - "type": "string", - "maxLength": 512, - "nullable": true - }, - "authorAvatarUrl": { - "type": "string", - "maxLength": 8192, - "nullable": true - }, - "replyCount": { - "type": "integer" - }, - "canReply": { - "type": "boolean" - } - } + properties: { + id: { + type: "string", + maxLength: 64, + }, + authorDid: { + type: "string", + format: "did", + maxLength: 2048, + }, + rating: { + type: "integer", + minimum: 1, + maximum: 5, + }, + text: { + type: "string", + maxLength: 8000, + nullable: true, + }, + reviewCreatedAt: { + type: "string", + format: "datetime", + maxLength: 64, + }, + authorDisplayName: { + type: "string", + maxLength: 640, + nullable: true, + }, + authorHandle: { + type: "string", + maxLength: 512, + nullable: true, + }, + authorAvatarUrl: { + type: "string", + maxLength: 8192, + nullable: true, + }, + replyCount: { + type: "integer", + }, + canReply: { + type: "boolean", + }, + }, }, - "main": { - "type": "query", - "description": "List reviews for a directory listing (mirrored Tap data plus profile enrichment).", - "parameters": { - "type": "params", - "required": [ - "uri" - ], - "properties": { - "uri": { - "type": "string", - "format": "at-uri", - "maxLength": 2560, - "description": "AT URI of the fyi.atstore.listing.detail record." - }, - "limit": { - "type": "integer", - "minimum": 1, - "maximum": 100, - "default": 50 - }, - "cursor": { - "type": "string", - "maxLength": 512 - } - } + main: { + type: "query", + description: + "List reviews for a directory listing (mirrored Tap data plus profile enrichment).", + parameters: { + type: "params", + required: ["uri"], + properties: { + uri: { + type: "string", + format: "at-uri", + maxLength: 2560, + description: "AT URI of the fyi.atstore.listing.detail record.", + }, + limit: { + type: "integer", + minimum: 1, + maximum: 100, + default: 50, + }, + cursor: { + type: "string", + maxLength: 512, + }, + }, }, - "output": { - "encoding": "application/json", - "schema": { - "type": "object", - "required": [ - "reviews" - ], - "properties": { - "cursor": { - "type": "string", - "maxLength": 512 + output: { + encoding: "application/json", + schema: { + type: "object", + required: ["reviews"], + properties: { + cursor: { + type: "string", + maxLength: 512, }, - "reviews": { - "type": "array", - "items": { - "type": "ref", - "ref": "#listingReviewView" - } - } - } - } + reviews: { + type: "array", + items: { + type: "ref", + ref: "#listingReviewView", + }, + }, + }, + }, }, - "errors": [ + errors: [ { - "name": "ListingNotFound" + name: "ListingNotFound", }, { - "name": "InvalidParams" + name: "InvalidParams", }, { - "name": "InvalidCursor" - } - ] - } - } + name: "InvalidCursor", + }, + ], + }, + }, }, { - "lexicon": 1, - "id": "fyi.atstore.server.describe", - "defs": { - "main": { - "type": "query", - "description": "Describe this deployment's public XRPC surface and defaults.", - "parameters": { - "type": "params", - "properties": {} + lexicon: 1, + id: "fyi.atstore.server.describe", + defs: { + main: { + type: "query", + description: + "Describe this deployment's public XRPC surface and defaults.", + parameters: { + type: "params", + properties: {}, }, - "output": { - "encoding": "application/json", - "schema": { - "type": "object", - "required": [ + output: { + encoding: "application/json", + schema: { + type: "object", + required: [ "service", "publicReads", "reviewsWrittenOnAuthorRepo", "defaultListingLimit", "maxListingLimit", "maxReviewLimit", - "methods" + "methods", ], - "properties": { - "service": { - "type": "string", - "maxLength": 256 + properties: { + service: { + type: "string", + maxLength: 256, + }, + publicReads: { + type: "boolean", }, - "publicReads": { - "type": "boolean" + reviewsWrittenOnAuthorRepo: { + type: "boolean", + description: + "When true, listing reviews are created via com.atproto.repo.createRecord on the author's PDS (fyi.atstore.listing.review); this service does not expose a write procedure for reviews.", }, - "reviewsWrittenOnAuthorRepo": { - "type": "boolean", - "description": "When true, listing reviews are created via com.atproto.repo.createRecord on the author's PDS (fyi.atstore.listing.review); this service does not expose a write procedure for reviews." + defaultListingLimit: { + type: "integer", }, - "defaultListingLimit": { - "type": "integer" + maxListingLimit: { + type: "integer", }, - "maxListingLimit": { - "type": "integer" + maxReviewLimit: { + type: "integer", }, - "maxReviewLimit": { - "type": "integer" + methods: { + type: "array", + items: { + type: "string", + maxLength: 512, + }, }, - "methods": { - "type": "array", - "items": { - "type": "string", - "maxLength": 512 - } - } - } - } - } - } - } - } -] + }, + }, + }, + }, + }, + }, +];