diff --git a/lexicons/fyi/atstore/directory/getListingAuth.json b/lexicons/fyi/atstore/directory/getListingAuth.json new file mode 100644 index 0000000..9aaef19 --- /dev/null +++ b/lexicons/fyi/atstore/directory/getListingAuth.json @@ -0,0 +1,66 @@ +{ + "lexicon": 1, + "id": "fyi.atstore.directory.getListingAuth", + "defs": { + "main": { + "type": "query", + "description": "How a public verified listing's app signs users in: its atproto OAuth client metadata URL (client_id) when known, and the scope that metadata publishes. Lets a PDS or client find the permissions an app asks for, e.g. to make a scoped app password, without probing the app's site itself.", + "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": "object", + "required": ["uri", "authMethod"], + "nullable": ["clientMetadataUrl", "scope", "checkedAt"], + "properties": { + "uri": { + "type": "string", + "format": "at-uri", + "maxLength": 2560 + }, + "authMethod": { + "type": "string", + "maxLength": 32, + "knownValues": [ + "oauth", + "app_password", + "unsupported_mobile", + "unknown" + ], + "description": "oauth: signs in with atproto OAuth (see clientMetadataUrl). app_password: web sign-in with an app password. unsupported_mobile: native/mobile app only. unknown: not classified yet." + }, + "clientMetadataUrl": { + "type": "string", + "format": "uri", + "maxLength": 2048, + "description": "The app's OAuth client metadata URL (its client_id). Fetch it for the authoritative scope." + }, + "scope": { + "type": "string", + "maxLength": 10000, + "description": "The scope line from the app's client metadata as last probed. May be stale; clientMetadataUrl is authoritative." + }, + "checkedAt": { + "type": "string", + "format": "datetime", + "maxLength": 64, + "description": "When this listing's sign-in was last probed." + } + } + } + }, + "errors": [{ "name": "InvalidParams" }, { "name": "ListingNotFound" }] + } + } +} diff --git a/package.json b/package.json index d213bfd..7108db2 100644 --- a/package.json +++ b/package.json @@ -33,7 +33,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/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 && pnpm exec oxfmt 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/getListingAuth.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 && pnpm exec oxfmt src/lexicons/generated/bundle.ts'", "lex:lint": "node scripts/goat-lex.mjs lex lint", "lex:check-dns": "node scripts/goat-lex.mjs lex check-dns", "lex:status": "node scripts/goat-lex.mjs lex status", diff --git a/src/lexicons/generated/bundle.ts b/src/lexicons/generated/bundle.ts index 4b52b20..f3bdf57 100644 --- a/src/lexicons/generated/bundle.ts +++ b/src/lexicons/generated/bundle.ts @@ -290,6 +290,83 @@ export const lexicons = [ }, }, }, + { + lexicon: 1, + id: "fyi.atstore.directory.getListingAuth", + defs: { + main: { + type: "query", + description: + "How a public verified listing's app signs users in: its atproto OAuth client metadata URL (client_id) when known, and the scope that metadata publishes. Lets a PDS or client find the permissions an app asks for, e.g. to make a scoped app password, without probing the app's site itself.", + 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: "object", + required: ["uri", "authMethod"], + nullable: ["clientMetadataUrl", "scope", "checkedAt"], + properties: { + uri: { + type: "string", + format: "at-uri", + maxLength: 2560, + }, + authMethod: { + type: "string", + maxLength: 32, + knownValues: [ + "oauth", + "app_password", + "unsupported_mobile", + "unknown", + ], + description: + "oauth: signs in with atproto OAuth (see clientMetadataUrl). app_password: web sign-in with an app password. unsupported_mobile: native/mobile app only. unknown: not classified yet.", + }, + clientMetadataUrl: { + type: "string", + format: "uri", + maxLength: 2048, + description: + "The app's OAuth client metadata URL (its client_id). Fetch it for the authoritative scope.", + }, + scope: { + type: "string", + maxLength: 10000, + description: + "The scope line from the app's client metadata as last probed. May be stale; clientMetadataUrl is authoritative.", + }, + checkedAt: { + type: "string", + format: "datetime", + maxLength: 64, + description: "When this listing's sign-in was last probed.", + }, + }, + }, + }, + errors: [ + { + name: "InvalidParams", + }, + { + name: "ListingNotFound", + }, + ], + }, + }, + }, { lexicon: 1, id: "fyi.atstore.directory.searchListings", diff --git a/src/lib/atproto/nsids.ts b/src/lib/atproto/nsids.ts index 970e35e..da2feeb 100644 --- a/src/lib/atproto/nsids.ts +++ b/src/lib/atproto/nsids.ts @@ -10,6 +10,7 @@ export const NSID = { lexiconSchema: "com.atproto.lexicon.schema", directorySearchListings: "fyi.atstore.directory.searchListings", directoryGetListing: "fyi.atstore.directory.getListing", + directoryGetListingAuth: "fyi.atstore.directory.getListingAuth", reviewsListForListing: "fyi.atstore.reviews.listForListing", serverDescribe: "fyi.atstore.server.describe", } as const; @@ -18,6 +19,7 @@ export const NSID = { export const ATSTORE_XRPC_METHOD = { directorySearchListings: NSID.directorySearchListings, directoryGetListing: NSID.directoryGetListing, + directoryGetListingAuth: NSID.directoryGetListingAuth, 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 1a10872..b693769 100644 --- a/src/routes/_header-layout.developers.atproto.tsx +++ b/src/routes/_header-layout.developers.atproto.tsx @@ -50,6 +50,12 @@ const METHOD_ROWS: ReadonlyArray<{ summary: "Listing detail: exactly one of `uri` (listing.detail AT URI) or `externalUrl` (unique storefront URL).", }, + { + nsid: ATSTORE_XRPC_METHOD.directoryGetListingAuth, + method: "GET", + summary: + "How a listing's app signs in (`uri` listing.detail AT URI): `authMethod`, its OAuth `clientMetadataUrl` (client_id) when known, and the last probed `scope`.", + }, { nsid: ATSTORE_XRPC_METHOD.reviewsListForListing, method: "GET", diff --git a/src/server/atproto-xrpc/atstore-xrpc-handler.server.ts b/src/server/atproto-xrpc/atstore-xrpc-handler.server.ts index d23e437..4a713f0 100644 --- a/src/server/atproto-xrpc/atstore-xrpc-handler.server.ts +++ b/src/server/atproto-xrpc/atstore-xrpc-handler.server.ts @@ -362,6 +362,75 @@ async function handleGetListing(url: URL) { return xrpcJson(await listingDetailXrpcPayload(row)); } +const LISTING_AUTH_METHODS = new Set([ + "oauth", + "app_password", + "unsupported_mobile", +]); + +/** + * `client_metadata_url` from `store_listing_oauth_discovery` (well-known probes, + * Playwright or manual triage) wins over the periodic probe's + * `successful_client_metadata_url`, which only tries well-known paths. + */ +async function handleGetListingAuth(url: URL) { + const canonical = listingDetailUriOrNull(url.searchParams.get("uri") ?? ""); + if (!canonical) { + return xrpcErr(400, "InvalidParams"); + } + + const table = schema.storeListings; + const discovery = schema.storeListingOauthDiscovery; + const probes = schema.storeListingOAuthProbes; + const { listingXrpcPublicWhere } = directoryListingXrpcHelpers; + + const [row] = await db + .select({ + authMethod: discovery.authMethod, + discoveredClientMetadataUrl: discovery.clientMetadataUrl, + probedClientMetadataUrl: probes.successfulClientMetadataUrl, + scope: probes.clientScopeRawLine, + probedAt: probes.probedAt, + }) + .from(table) + .leftJoin(discovery, eq(discovery.storeListingId, table.id)) + .leftJoin(probes, eq(probes.storeListingId, table.id)) + .where(listingXrpcPublicWhere(table, eq(table.atUri, canonical))) + .limit(1); + + if (!row) { + return xrpcErr(404, "ListingNotFound"); + } + + const clientMetadataUrl = + row.discoveredClientMetadataUrl?.trim() || + row.probedClientMetadataUrl?.trim() || + null; + const authMethod = + row.authMethod && LISTING_AUTH_METHODS.has(row.authMethod) + ? row.authMethod + : clientMetadataUrl + ? "oauth" + : "unknown"; + // the probe's scope belongs to the URL it found; drop it if discovery + // settled on a different client + const scope = + row.scope?.trim() && + (!row.discoveredClientMetadataUrl?.trim() || + row.discoveredClientMetadataUrl.trim() === + row.probedClientMetadataUrl?.trim()) + ? row.scope.trim() + : null; + + return xrpcJson({ + uri: canonical, + authMethod, + clientMetadataUrl, + scope, + checkedAt: row.probedAt?.toISOString() ?? null, + }); +} + async function handleListReviews(url: URL, request: Request) { const uriParam = url.searchParams.get("uri") ?? ""; const canonical = listingDetailUriOrNull(uriParam); @@ -504,6 +573,13 @@ export async function handleAtstoreXrpc( return handleGetListing(url); } + case ATSTORE_XRPC_METHOD.directoryGetListingAuth: { + if (request.method !== "GET") { + return xrpcErr(405, "MethodNotAllowed"); + } + return handleGetListingAuth(url); + } + case ATSTORE_XRPC_METHOD.reviewsListForListing: { if (request.method !== "GET") { return xrpcErr(405, "MethodNotAllowed");