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..203c2b5 --- /dev/null +++ b/lexicons/fyi/atstore/auth/thirdPartyReviews.json @@ -0,0 +1,26 @@ +{ + "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.listing.review"], + "action": ["create"] + } + ] + } + } +} diff --git a/lexicons/fyi/atstore/directory/getListing.json b/lexicons/fyi/atstore/directory/getListing.json new file mode 100644 index 0000000..eb64266 --- /dev/null +++ b/lexicons/fyi/atstore/directory/getListing.json @@ -0,0 +1,155 @@ +{ + "lexicon": 1, + "id": "fyi.atstore.directory.getListing", + "defs": { + "listingCardGet": { + "type": "object", + "required": [ + "uri", + "name", + "tagline", + "description", + "category", + "accent", + "reviewCount", + "priceLabel", + "appTags", + "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 } + } + } + }, + "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" + } + } + } + }, + "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" + } + }, + "errors": [ + { "name": "ListingNotFound" }, + { "name": "InvalidParams" }, + { "name": "AmbiguousResolution" } + ] + } + } +} diff --git a/lexicons/fyi/atstore/directory/searchListings.json b/lexicons/fyi/atstore/directory/searchListings.json new file mode 100644 index 0000000..176bce3 --- /dev/null +++ b/lexicons/fyi/atstore/directory/searchListings.json @@ -0,0 +1,119 @@ +{ + "lexicon": 1, + "id": "fyi.atstore.directory.searchListings", + "defs": { + "listingCardSearch": { + "type": "object", + "required": [ + "uri", + "name", + "tagline", + "description", + "category", + "accent", + "reviewCount", + "priceLabel", + "appTags", + "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 } + } + } + }, + "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" + } + } + } + } + }, + "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..52c0596 --- /dev/null +++ b/lexicons/fyi/atstore/reviews/listForListing.json @@ -0,0 +1,96 @@ +{ + "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": ["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" + } + } + } + } + }, + "errors": [ + { "name": "ListingNotFound" }, + { "name": "InvalidParams" }, + { "name": "InvalidCursor" } + ] + } + } +} diff --git a/lexicons/fyi/atstore/server/describe.json b/lexicons/fyi/atstore/server/describe.json new file mode 100644 index 0000000..8c63516 --- /dev/null +++ b/lexicons/fyi/atstore/server/describe.json @@ -0,0 +1,47 @@ +{ + "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", + "reviewsWrittenOnAuthorRepo", + "defaultListingLimit", + "maxListingLimit", + "maxReviewLimit", + "methods" + ], + "properties": { + "service": { + "type": "string", + "maxLength": 256 + }, + "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." + }, + "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..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 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/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/components/SiteFooter.tsx b/src/components/SiteFooter.tsx index 37b87d5..0ee2248 100644 --- a/src/components/SiteFooter.tsx +++ b/src/components/SiteFooter.tsx @@ -12,6 +12,7 @@ const FOOTER_LINK_GROUPS = [ { 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..f9543a2 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, @@ -76,6 +77,7 @@ import { eq, ilike, inArray, + isNotNull, isNull, ne, or, @@ -140,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; @@ -204,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; @@ -247,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; @@ -914,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; @@ -1433,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, @@ -3266,6 +3300,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, @@ -6699,6 +6754,19 @@ const createStoreManagedListing = createServerFn({ method: "POST" }) return { uri, slug }; }); +/** Server-only helpers shared with AT Store XRPC handlers. */ +export const directoryListingXrpcHelpers = { + listingPublicWhere, + listingXrpcPublicWhere, + getListingSelect, + orderByPopularListingSort, + toListingCard, + toListingCardXrpc, + 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..331f3fd 100644 --- a/src/lexicons/generated/bundle.ts +++ b/src/lexicons/generated/bundle.ts @@ -26,6 +26,402 @@ export const lexicons = [ }, }, }, + { + 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.listing.review"], + action: ["create"], + }, + ], + }, + }, + }, + { + lexicon: 1, + id: "fyi.atstore.directory.getListing", + defs: { + listingCardGet: { + type: "object", + required: [ + "uri", + "name", + "tagline", + "description", + "category", + "accent", + "reviewCount", + "priceLabel", + "appTags", + "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, + }, + }, + }, + }, + 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", + }, + }, + }, + }, + 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", + }, + }, + errors: [ + { + name: "ListingNotFound", + }, + { + name: "InvalidParams", + }, + { + name: "AmbiguousResolution", + }, + ], + }, + }, + }, + { + lexicon: 1, + id: "fyi.atstore.directory.searchListings", + defs: { + listingCardSearch: { + type: "object", + required: [ + "uri", + "name", + "tagline", + "description", + "category", + "accent", + "reviewCount", + "priceLabel", + "appTags", + "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, + }, + }, + }, + }, + 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", + }, + }, + }, + }, + }, + errors: [ + { + name: "InvalidCursor", + }, + ], + }, + }, + }, { lexicon: 1, id: "fyi.atstore.listing.detail", @@ -65,7 +461,7 @@ export const lexicons = [ }, description: { type: "string", - maxLength: 20_000, + maxLength: 20000, }, externalUrl: { type: "string", @@ -82,7 +478,7 @@ export const lexicons = [ "image/gif", "image/svg+xml", ], - maxSize: 2_000_000, + maxSize: 2000000, description: "Square / app icon (uploaded to repo via com.atproto.repo.uploadBlob).", }, @@ -95,7 +491,7 @@ export const lexicons = [ "image/gif", "image/svg+xml", ], - maxSize: 12_000_000, + maxSize: 12000000, description: "Hero / cover image blob.", }, screenshots: { @@ -110,7 +506,7 @@ export const lexicons = [ "image/gif", "image/svg+xml", ], - maxSize: 12_000_000, + maxSize: 12000000, }, }, categorySlug: { @@ -183,6 +579,9 @@ export const lexicons = [ "changelog", "source", "status", + "community", + "donate", + "license", "other", ], description: "The kind of link.", @@ -338,4 +737,186 @@ export const lexicons = [ }, }, }, + { + 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: ["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", + }, + }, + }, + }, + }, + errors: [ + { + name: "ListingNotFound", + }, + { + name: "InvalidParams", + }, + { + 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: {}, + }, + output: { + encoding: "application/json", + schema: { + type: "object", + required: [ + "service", + "publicReads", + "reviewsWrittenOnAuthorRepo", + "defaultListingLimit", + "maxListingLimit", + "maxReviewLimit", + "methods", + ], + properties: { + service: { + type: "string", + maxLength: 256, + }, + 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.", + }, + 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..970e35e 100644 --- a/src/lib/atproto/nsids.ts +++ b/src/lib/atproto/nsids.ts @@ -1,12 +1,25 @@ /** 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", + reviewsListForListing: "fyi.atstore.reviews.listForListing", + 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, + reviewsListForListing: NSID.reviewsListForListing, + 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..eb52073 --- /dev/null +++ b/src/routes/_header-layout.developers.atproto.tsx @@ -0,0 +1,253 @@ +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, + InlineCode, + Pre, +} 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"; +import { ResizableTableContainer } from "react-aria-components"; + +const METHOD_ROWS: ReadonlyArray<{ + nsid: string; + method: "GET"; + 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: + "Listing detail: exactly one of `uri` (listing.detail AT URI) or `externalUrl` (unique storefront URL).", + }, + { + nsid: ATSTORE_XRPC_METHOD.reviewsListForListing, + method: "GET", + summary: + "Reviews for a listing (`uri` listing.detail AT URI, pagination); mirrored Tap index.", + }, +]; + +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", + }, + tableWrapper: { + overflow: "auto", + }, + methodsTable: { + width: "100% !important", + }, + pre: { + marginBottom: 0, + marginTop: 0, + }, +}); + +export const Route = createFileRoute("/_header-layout/developers/atproto")({ + head: () => + buildRouteOgMeta({ + title: "AT Protocol API | at-store", + description: + "Public AT Store directory XRPC endpoints and listing-review integration.", + }), + component: DevelopersAtprotoPage, +}); + +function DevelopersAtprotoPage() { + const origin = + typeof globalThis.location?.origin === "string" + ? globalThis.location.origin + : "https://your-deployment.example"; + + return ( + + + + AT Protocol on ATStore + + Public GET endpoints under{" "} + /xrpc/<nsid>. Lexicons:{" "} + 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} + )} + + )} + + )} + +
+
+
+ + + Listing reviews + + Reviews are written with{" "} + com.atproto.repo.createRecord on the + author's PDS ( + repository + ). Use{" "} + + {ATSTORE_XRPC_METHOD.directoryGetListing} + {" "} + with query param uri (the + 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{" "} + + {NSID.listingReview} + {" "} + record. + + + 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. + + + 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 new file mode 100644 index 0000000..3dd452b --- /dev/null +++ b/src/routes/xrpc.$nsid.tsx @@ -0,0 +1,13 @@ +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)), + 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..f075907 --- /dev/null +++ b/src/server/atproto-xrpc/atstore-xrpc-handler.server.ts @@ -0,0 +1,513 @@ +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 { 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"; + +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 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 listingCardXrpcJson(card: DirectoryListingCardXrpc) { + return { + ...card, + 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, + reviewsWrittenOnAuthorRepo: 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 { + listingXrpcPublicWhere, + getListingSelect, + orderByPopularListingSort, + toListingCardXrpc, + } = 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(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) => + listingCardXrpcJson(toListingCardXrpc(row)), + ); + + return xrpcJson({ + listings, + ...(hasMore ? { cursor: encodeOffsetCursor(start + limit) } : {}), + }); +} + +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, + }; +} + +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 listing = listingCardXrpcJson(toListingCardXrpc(row)); + const isStoreManaged = await computeIsStoreManaged(row); + + const linksRaw = normalizeListingLinks( + row.links as Array | null, + ); + + return { + listing, + isStoreManaged, + 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 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 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(externalUrlParam); + if (variants.length === 0) { + return xrpcErr(400, "InvalidParams"); + } + + const table = schema.storeListings; + const { listingXrpcPublicWhere } = 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(verifiedListingDetailSelect(table)) + .from(table) + .where(listingXrpcPublicWhere(table, clause)) + .limit(4); + + if (rows.length === 0) { + return xrpcErr(404, "ListingNotFound"); + } + if (rows.length > 1) { + return xrpcErr(409, "AmbiguousResolution"); + } + + const row = rows.at(0); + if (!row?.atUri?.trim()) { + return xrpcErr(404, "ListingNotFound"); + } + + return xrpcJson(await listingDetailXrpcPayload(row)); +} + +async function handleListReviews(url: URL, request: Request) { + const uriParam = url.searchParams.get("uri") ?? ""; + const canonical = listingDetailUriOrNull(uriParam); + if (!canonical) { + 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 { listingXrpcPublicWhere, viewerMayReplyOnListingReview } = + directoryListingXrpcHelpers; + + const [listing] = await db + .select({ + id: table.id, + repoDid: table.repoDid, + productAccountDid: table.productAccountDid, + }) + .from(table) + .where(listingXrpcPublicWhere(table, eq(table.atUri, canonical))) + .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) } : {}), + }); +} + +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, 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.reviewsListForListing: { + if (request.method !== "GET") { + return xrpcErr(405, "MethodNotAllowed"); + } + return handleListReviews(url, request); + } + + default: { + return xrpcErr(404, "MethodNotFound"); + } + } + } catch (error) { + console.error("atstore xrpc handler error", error); + return xrpcErr(500, "InternalError"); + } +}