Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
66 changes: 66 additions & 0 deletions lexicons/fyi/atstore/directory/getListingAuth.json
Original file line number Diff line number Diff line change
@@ -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" }]
}
}
}
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
77 changes: 77 additions & 0 deletions src/lexicons/generated/bundle.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
2 changes: 2 additions & 0 deletions src/lib/atproto/nsids.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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;
Expand Down
6 changes: 6 additions & 0 deletions src/routes/_header-layout.developers.atproto.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
76 changes: 76 additions & 0 deletions src/server/atproto-xrpc/atstore-xrpc-handler.server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down Expand Up @@ -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");
Expand Down
Loading