diff --git a/drizzle/0038_store_listing_oauth_probes_lexicon_keys.sql b/drizzle/0038_store_listing_oauth_probes_lexicon_keys.sql new file mode 100644 index 0000000..b1c3097 --- /dev/null +++ b/drizzle/0038_store_listing_oauth_probes_lexicon_keys.sql @@ -0,0 +1,3 @@ +ALTER TABLE "store_listing_oauth_probes" ADD COLUMN "oauth_lexicon_keys" text[] DEFAULT '{}'::text[] NOT NULL; +--> statement-breakpoint +CREATE INDEX "store_listing_oauth_probes_oauth_lexicon_keys_idx" ON "store_listing_oauth_probes" USING gin ("oauth_lexicon_keys"); diff --git a/drizzle/0039_oauth_lexicon_hub_snapshot.sql b/drizzle/0039_oauth_lexicon_hub_snapshot.sql new file mode 100644 index 0000000..ce588b4 --- /dev/null +++ b/drizzle/0039_oauth_lexicon_hub_snapshot.sql @@ -0,0 +1,5 @@ +CREATE TABLE "oauth_lexicon_hub_snapshot" ( + "singleton_key" text PRIMARY KEY NOT NULL, + "payload" jsonb NOT NULL, + "computed_at" timestamp with time zone NOT NULL +); diff --git a/drizzle/meta/_journal.json b/drizzle/meta/_journal.json index 1a39312..6c37b1a 100644 --- a/drizzle/meta/_journal.json +++ b/drizzle/meta/_journal.json @@ -260,6 +260,20 @@ "when": 1778800000000, "tag": "0037_fund_at_mirror_tables", "breakpoints": true + }, + { + "idx": 38, + "version": "7", + "when": 1778900000000, + "tag": "0038_store_listing_oauth_probes_lexicon_keys", + "breakpoints": true + }, + { + "idx": 39, + "version": "7", + "when": 1779000000000, + "tag": "0039_oauth_lexicon_hub_snapshot", + "breakpoints": true } ] } diff --git a/package.json b/package.json index 5b26144..d869fbf 100644 --- a/package.json +++ b/package.json @@ -19,6 +19,8 @@ "listing:restore-from-backup-db": "tsx -r dotenv/config scripts/restore-store-listing-from-backup-db.ts", "oauth:detect-scopes": "tsx scripts/detect-listing-oauth-scopes.ts", "listing:oauth-probes-sync": "tsx -r dotenv/config scripts/sync-listing-oauth-probes.ts", + "listing:oauth-lexicon-hub-refresh": "tsx -r dotenv/config scripts/refresh-oauth-lexicon-hub.ts", + "listing:oauth-lexicon-keys-backfill": "tsx -r dotenv/config scripts/backfill-oauth-lexicon-keys.ts", "db:generate": "drizzle-kit generate", "db:migrate": "tsx scripts/db-migrate.ts", "db:migrate:kit": "drizzle-kit migrate", diff --git a/scripts/backfill-oauth-lexicon-keys.ts b/scripts/backfill-oauth-lexicon-keys.ts new file mode 100644 index 0000000..eeb0684 --- /dev/null +++ b/scripts/backfill-oauth-lexicon-keys.ts @@ -0,0 +1,88 @@ +#!/usr/bin/env node +/** + * Fills `store_listing_oauth_probes.oauth_lexicon_keys` from existing + * `oauth_scopes_distinct` and expanded permission-set checklists in `report_json` (no HTTP). + * + * pnpm exec tsx -r dotenv/config scripts/backfill-oauth-lexicon-keys.ts + */ +import "dotenv/config"; +import * as schema from "#/db/schema"; +import { refreshOAuthLexiconHubSnapshot } from "#/lib/oauth-lexicon-hub-snapshot.server"; +import { extractOAuthLexiconKeysForStorefrontProbe } from "#/lib/oauth-scope-lexicon-keys"; +import { eq } from "drizzle-orm"; + +function ts(): string { + return new Date().toISOString(); +} + +async function main() { + if (!process.env.DATABASE_URL?.trim()) { + console.error( + `[backfill-oauth-lexicon-keys] ${ts()} DATABASE_URL is required`, + ); + process.exit(1); + } + + const { db, dbClient } = await import("#/db/index.server"); + const probes = schema.storeListingOAuthProbes; + + const rows = await db + .select({ + storeListingId: probes.storeListingId, + oauthScopesDistinct: probes.oauthScopesDistinct, + oauthLexiconKeys: probes.oauthLexiconKeys, + reportJson: probes.reportJson, + }) + .from(probes); + + let updated = 0; + let skipped = 0; + + for (const row of rows) { + const next = extractOAuthLexiconKeysForStorefrontProbe({ + oauthScopesDistinct: row.oauthScopesDistinct ?? [], + scopeHumanReadable: row.reportJson?.summary?.scopeHumanReadable, + }); + const prevSorted = [...row.oauthLexiconKeys].toSorted((a, b) => + a.localeCompare(b), + ); + const same = + next.length === prevSorted.length && + next.every((k, i) => k === prevSorted[i]); + if (same) { + skipped++; + continue; + } + await db + .update(probes) + .set({ oauthLexiconKeys: next }) + .where(eq(probes.storeListingId, row.storeListingId)); + updated++; + } + + console.log( + `[backfill-oauth-lexicon-keys] ${ts()} rows=${String(rows.length)} updated=${String(updated)} skipped_unchanged=${String(skipped)}`, + ); + + try { + const hub = await refreshOAuthLexiconHubSnapshot(db); + console.log( + `[backfill-oauth-lexicon-keys] ${ts()} oauth_lexicon_hub_snapshot clusterCount=${String(hub.clusterCount)}`, + ); + } catch (error) { + console.error( + `[backfill-oauth-lexicon-keys] ${ts()} oauth_lexicon_hub_snapshot refresh failed`, + error instanceof Error ? (error.stack ?? error.message) : error, + ); + } + + await dbClient.end({ timeout: 5 }).catch(() => {}); +} + +main().catch((error) => { + console.error( + `[backfill-oauth-lexicon-keys] fatal`, + error instanceof Error ? (error.stack ?? error.message) : error, + ); + process.exit(1); +}); diff --git a/scripts/refresh-oauth-lexicon-hub.ts b/scripts/refresh-oauth-lexicon-hub.ts new file mode 100644 index 0000000..14ff3d5 --- /dev/null +++ b/scripts/refresh-oauth-lexicon-hub.ts @@ -0,0 +1,39 @@ +#!/usr/bin/env node +/** + * Recompute `oauth_lexicon_hub_snapshot` from current probes (slow: resolves lexicon JSON). + * + * pnpm listing:oauth-lexicon-hub-refresh + */ +import "dotenv/config"; +import { refreshOAuthLexiconHubSnapshot } from "#/lib/oauth-lexicon-hub-snapshot.server"; + +function ts(): string { + return new Date().toISOString(); +} + +async function main() { + if (!process.env.DATABASE_URL?.trim()) { + console.error( + `[refresh-oauth-lexicon-hub] ${ts()} DATABASE_URL is required`, + ); + process.exit(1); + } + + const { db, dbClient } = await import("#/db/index.server"); + try { + const hub = await refreshOAuthLexiconHubSnapshot(db); + console.log( + `[refresh-oauth-lexicon-hub] ${ts()} clusterCount=${String(hub.clusterCount)} computedAt=${hub.computedAt.toISOString()}`, + ); + } finally { + await dbClient.end({ timeout: 5 }).catch(() => {}); + } +} + +main().catch((error) => { + console.error( + `[refresh-oauth-lexicon-hub] fatal`, + error instanceof Error ? (error.stack ?? error.message) : error, + ); + process.exit(1); +}); diff --git a/scripts/sync-listing-oauth-probes.ts b/scripts/sync-listing-oauth-probes.ts index c7ea6d9..85b5e54 100644 --- a/scripts/sync-listing-oauth-probes.ts +++ b/scripts/sync-listing-oauth-probes.ts @@ -2,6 +2,8 @@ /** * Batch-probe every `store_listings.external_url` for OAuth / authorization metadata * (same logic as `pnpm oauth:detect-scopes`) and upsert into `store_listing_oauth_probes`. + * After a non–dry-run pass completes, recomputes the `/apps/lexicons` hub snapshot + * (`oauth_lexicon_hub_snapshot`) so the hub stays fast without on-demand HTTP. * * Railway cron (suggested): weekly is enough for slow-changing OAuth metadata; avoids * hammering third-party sites. Example crontab UTC: @@ -20,7 +22,9 @@ */ import "dotenv/config"; import * as schema from "#/db/schema"; +import { refreshOAuthLexiconHubSnapshot } from "#/lib/oauth-lexicon-hub-snapshot.server"; import { probeOAuthListingAuth } from "#/lib/oauth-listing-auth-probe"; +import { extractOAuthLexiconKeysForStorefrontProbe } from "#/lib/oauth-scope-lexicon-keys"; import { asc, isNotNull } from "drizzle-orm"; function ts(): string { @@ -149,6 +153,7 @@ async function main() { probedUrl: null as string | null, probedAt: now, oauthScopesDistinct: [] as Array, + oauthLexiconKeys: [] as Array, transitionalScopes: [] as Array, publishesAtprotoScope: null as boolean | null, clientScopeRawLine: null as string | null, @@ -187,6 +192,10 @@ async function main() { probedUrl: report.inputUrl, probedAt: now, oauthScopesDistinct: report.summary.oauthScopesDistinct, + oauthLexiconKeys: extractOAuthLexiconKeysForStorefrontProbe({ + oauthScopesDistinct: report.summary.oauthScopesDistinct, + scopeHumanReadable: report.summary.scopeHumanReadable, + }), transitionalScopes: report.summary.transitionalScopesPresent, publishesAtprotoScope: report.summary.publishesAtprotoAs, clientScopeRawLine: report.summary.clientScopeRawLine, @@ -224,6 +233,7 @@ async function main() { probedUrl: rawUrl, probedAt: now, oauthScopesDistinct: [] as Array, + oauthLexiconKeys: [] as Array, transitionalScopes: [] as Array, publishesAtprotoScope: null as boolean | null, clientScopeRawLine: null as string | null, @@ -301,6 +311,23 @@ async function main() { elapsedMs, }); + if (!dryRun) { + try { + const hub = await refreshOAuthLexiconHubSnapshot(db); + log("info", "oauth_lexicon_hub_snapshot_refreshed", { + clusterCount: hub.clusterCount, + computedAt: hub.computedAt.toISOString(), + }); + } catch (error) { + log("error", "oauth_lexicon_hub_snapshot_refresh_failed", { + error: + error instanceof Error + ? (error.stack ?? error.message) + : String(error), + }); + } + } + await dbClient.end({ timeout: 5 }).catch(() => {}); } diff --git a/src/components/AppTagHero.tsx b/src/components/AppTagHero.tsx index f12ec3c..e3e10c9 100644 --- a/src/components/AppTagHero.tsx +++ b/src/components/AppTagHero.tsx @@ -37,7 +37,7 @@ import { publicMediaUrlOrNull } from "../lib/listing-image-url"; interface AppTagHeroProps { eyebrow?: string; title: string; - description?: string; + description?: React.ReactNode; /** * When provided, render an accent hero: gradient + emoji scatter with eyebrow, title, * description, and action **inside** the panel (`uiColor.textContrast` + shadows for @@ -339,7 +339,7 @@ export function AppTagHero({ ) : null} - + {eyebrow ? ( {eyebrow} @@ -370,14 +370,14 @@ export function AppTagHero({ ) : ( <> -
- {bannerSrc ? ( + {bannerSrc ? ( +
- ) : null} -
+
+ ) : null} - + {eyebrow ? ( {eyebrow} ) : null} diff --git a/src/components/SiteFooter.tsx b/src/components/SiteFooter.tsx index 0ee2248..6af9e70 100644 --- a/src/components/SiteFooter.tsx +++ b/src/components/SiteFooter.tsx @@ -21,6 +21,7 @@ const FOOTER_LINK_GROUPS = [ links: [ { href: "/apps/all", label: "All Apps" }, { href: "/apps/tags", label: "Categories" }, + { href: "/apps/lexicons", label: "Shared data" }, ], }, ] as const; diff --git a/src/db/schema.ts b/src/db/schema.ts index b4c6fdd..e4fba67 100644 --- a/src/db/schema.ts +++ b/src/db/schema.ts @@ -1,4 +1,5 @@ import type { ListingLink } from "#/lib/atproto/listing-record"; +import type { DirectoryOAuthLexiconHubData } from "#/lib/oauth-lexicon-hub.types"; import type { OAuthAuthProbeReport } from "#/lib/oauth-listing-auth-probe"; import { relations, sql } from "drizzle-orm"; @@ -674,6 +675,15 @@ export const storeListingOAuthProbes = pgTable( .array() .notNull() .default(sql`'{}'::text[]`), + /** + * Normalized lexicon identifiers from {@link oauthScopesDistinct} and from + * resolved `include:` permission-set checklists in {@link reportJson} + * (`repo:…` / `rpc:…` NSIDs inside bundles). See `extractOAuthLexiconKeysForStorefrontProbe`. + */ + oauthLexiconKeys: text("oauth_lexicon_keys") + .array() + .notNull() + .default(sql`'{}'::text[]`), publishesAtprotoScope: boolean("publishes_atproto_scope"), clientScopeRawLine: text("client_scope_raw_line"), @@ -697,9 +707,23 @@ export const storeListingOAuthProbes = pgTable( (table) => [ index("store_listing_oauth_probes_probed_at_idx").on(table.probedAt), index("store_listing_oauth_probes_slug_idx").on(table.slug), + index("store_listing_oauth_probes_oauth_lexicon_keys_idx").using( + "gin", + table.oauthLexiconKeys, + ), ], ); +/** + * Precomputed payload for `/apps/lexicons` (clusters + resolved lexicon descriptions). + * Rebuilt by `scripts/sync-listing-oauth-probes.ts` after OAuth probes complete, or manually. + */ +export const oauthLexiconHubSnapshot = pgTable("oauth_lexicon_hub_snapshot", { + singletonKey: text("singleton_key").primaryKey().notNull(), + payload: jsonb("payload").$type().notNull(), + computedAt: timestamp("computed_at", { withTimezone: true }).notNull(), +}); + /** Ordered homepage hero slots managed from admin. */ export const homePageHeroListings = pgTable( "home_page_hero_listings", diff --git a/src/integrations/tanstack-query/api-directory-listings.functions.ts b/src/integrations/tanstack-query/api-directory-listings.functions.ts index f9543a2..2d5994e 100644 --- a/src/integrations/tanstack-query/api-directory-listings.functions.ts +++ b/src/integrations/tanstack-query/api-directory-listings.functions.ts @@ -55,14 +55,26 @@ import { } from "#/lib/bluesky-public-profile"; import { bskyAppPostUrlFromAtUri } from "#/lib/bsky-app-urls"; import { resolveGermDmHrefFromRecordJson } from "#/lib/germ-network-dm"; +import { loadLexiconRecordDescriptionsForWorkspace } from "#/lib/lexicon-local-record-description"; import { httpsListingImageUrlOrNull, publicMediaUrlOrNull, } from "#/lib/listing-image-url"; +import { + computeOAuthLexiconHubData, + getOAuthLexiconHubSnapshot, +} from "#/lib/oauth-lexicon-hub-snapshot.server"; import { oauthClientDistinctTokensFromPublishedScopeLine, probeOAuthListingAuth, } from "#/lib/oauth-listing-auth-probe"; +import { + compareOAuthLexiconKeysForDisplayOrder, + extractOAuthLexiconKeysForStorefrontProbe, + filterLexiconKeysForCrossAppMatching, + normalizeLexiconClusterKeysForHub, + parseOAuthLexiconKey, +} from "#/lib/oauth-scope-lexicon-keys"; import { findEligibleProductClaimsForDid } from "#/lib/product-claim-eligibility"; import { trendingScoreSortEnabled } from "#/lib/trending/config"; import { @@ -71,6 +83,7 @@ import { } from "#/middleware/auth"; import { and, + arrayOverlaps, asc, count, desc, @@ -342,6 +355,35 @@ export interface DirectoryListingOAuthProbe { hasAuthorizationServerMetadata: boolean; successfulClientMetadataUrl: string | null; scopeHumanReadable: Array; + /** Normalized lexicon keys derived from OAuth scopes (`include:…`, `repo:…`, `rpc:…`). */ + oauthLexiconKeys: Array; +} + +export type { + DirectoryOAuthLexiconClusterSummary, + DirectoryOAuthLexiconHubData, +} from "#/lib/oauth-lexicon-hub.types"; + +export interface DirectoryOAuthLexiconClusterPagePayload { + keys: Array; + count: number; + listings: Array; +} + +export interface RelatedAppsByOAuthLexiconPayload { + listings: Array; +} + +/** Full list of public app listings that share any OAuth lexicon key with the source listing. */ +export interface LexiconCompatibleAppsPagePayload { + /** + * Repo record collection lexicon keys from this listing’s OAuth probe (`repo:…` + * only—permission sets and RPC keys are omitted). Each entry counts **other** + * public app listings that declare that key. Zero-match entries are omitted. + */ + matchLexiconEntries: Array<{ key: string; otherAppCount: number }>; + count: number; + listings: Array; } export interface DirectoryListingReviewReply { @@ -1009,6 +1051,7 @@ async function fetchStoreListingOAuthProbe( hasAuthorizationServerMetadata: probes.hasAuthorizationServerMetadata, successfulClientMetadataUrl: probes.successfulClientMetadataUrl, reportJson: probes.reportJson, + oauthLexiconKeys: probes.oauthLexiconKeys, }) .from(probes) .where(eq(probes.storeListingId, listingId)) @@ -1063,6 +1106,7 @@ async function fetchStoreListingOAuthProbe( hasAuthorizationServerMetadata: row.hasAuthorizationServerMetadata, successfulClientMetadataUrl: row.successfulClientMetadataUrl, scopeHumanReadable, + oauthLexiconKeys: row.oauthLexiconKeys ?? [], }; } @@ -1153,6 +1197,7 @@ const rescanListingOAuthProbeDev = createServerFn({ method: "POST" }) probedUrl: null, probedAt: now, oauthScopesDistinct: [], + oauthLexiconKeys: [], transitionalScopes: [], publishesAtprotoScope: null, clientScopeRawLine: null, @@ -1187,6 +1232,10 @@ const rescanListingOAuthProbeDev = createServerFn({ method: "POST" }) probedUrl: report.inputUrl, probedAt: now, oauthScopesDistinct: report.summary.oauthScopesDistinct, + oauthLexiconKeys: extractOAuthLexiconKeysForStorefrontProbe({ + oauthScopesDistinct: report.summary.oauthScopesDistinct, + scopeHumanReadable: report.summary.scopeHumanReadable, + }), transitionalScopes: report.summary.transitionalScopesPresent, publishesAtprotoScope: report.summary.publishesAtprotoAs, clientScopeRawLine: report.summary.clientScopeRawLine, @@ -1209,6 +1258,7 @@ const rescanListingOAuthProbeDev = createServerFn({ method: "POST" }) probedUrl: storefrontUrl, probedAt: now, oauthScopesDistinct: [], + oauthLexiconKeys: [], transitionalScopes: [], publishesAtprotoScope: null, clientScopeRawLine: null, @@ -2348,6 +2398,361 @@ function getAppsByTagPageQueryOptions( }); } +const getAppsOAuthLexiconSummaries = createServerFn({ method: "GET" }) + .middleware([dbMiddleware]) + .handler(async ({ context }) => { + const cached = await getOAuthLexiconHubSnapshot(context.db); + if (cached) { + return cached; + } + return computeOAuthLexiconHubData(context.db); + }); + +const getAppsOAuthLexiconSummariesQueryOptions = queryOptions({ + queryKey: ["storeListings", "appsOAuthLexiconSummaries"], + queryFn: async () => getAppsOAuthLexiconSummaries(), +}); + +const getAppsByLexiconPageInput = z.object({ + key: z.string().min(1), + sort: listingSortInput, +}); + +const getAppsByLexiconPage = createServerFn({ method: "GET" }) + .middleware([dbMiddleware]) + .inputValidator(getAppsByLexiconPageInput) + .handler(async ({ data, context }) => { + const input = getAppsByLexiconPageInput.parse(data); + const table = context.schema.storeListings; + const probe = context.schema.storeListingOAuthProbes; + + const rows = await context.db + .select({ + ...getListingSelect(table), + updatedAt: table.updatedAt, + createdAt: table.createdAt, + }) + .from(table) + .innerJoin(probe, eq(probe.storeListingId, table.id)) + .where( + and( + listingPublicWhere(table), + sqlCategorySlugsMatchesLike(table.categorySlugs, "apps/%"), + arrayOverlaps(probe.oauthLexiconKeys, [input.key]), + ), + ) + .orderBy( + ...(input.sort === "newest" + ? [desc(table.createdAt)] + : input.sort === "alphabetical" + ? [asc(table.name)] + : orderByPopularListingSort(table)), + ); + + return { + key: input.key, + count: rows.length, + listings: rows.map((row) => toListingCard(row)), + }; + }); + +function getAppsByLexiconPageQueryOptions( + input: z.input, +) { + const normalizedInput = getAppsByLexiconPageInput.parse(input); + + return queryOptions({ + queryKey: ["storeListings", "appsByLexiconPage", normalizedInput], + queryFn: async () => getAppsByLexiconPage({ data: normalizedInput }), + }); +} + +const getLexiconRecordMainDescriptionForNsidInput = z.object({ + nsid: z.string().min(1), +}); + +const getLexiconRecordMainDescriptionForNsid = createServerFn({ + method: "GET", +}) + .inputValidator(getLexiconRecordMainDescriptionForNsidInput) + .handler(async ({ data }) => { + const { nsid } = getLexiconRecordMainDescriptionForNsidInput.parse(data); + const map = await loadLexiconRecordDescriptionsForWorkspace([nsid]); + return map[nsid] ?? null; + }); + +function getLexiconRecordMainDescriptionForNsidQueryOptions(nsid: string) { + return queryOptions({ + queryKey: ["lexiconRecordMainDescription", nsid], + queryFn: async () => + getLexiconRecordMainDescriptionForNsid({ data: { nsid } }), + }); +} + +const getAppsByLexiconClusterPageInput = z.object({ + keys: z.array(z.string().min(1)).min(1).max(48), + sort: listingSortInput, +}); + +const getAppsByLexiconClusterPage = createServerFn({ method: "GET" }) + .middleware([dbMiddleware]) + .inputValidator(getAppsByLexiconClusterPageInput) + .handler(async ({ data, context }) => { + const input = getAppsByLexiconClusterPageInput.parse(data); + const normalizedKeys = normalizeLexiconClusterKeysForHub(input.keys); + if (normalizedKeys.length === 0) { + return null; + } + + const table = context.schema.storeListings; + const probe = context.schema.storeListingOAuthProbes; + + const overlapPredicates = normalizedKeys.map((k) => + arrayOverlaps(probe.oauthLexiconKeys, [k]), + ); + + const rows = await context.db + .select({ + ...getListingSelect(table), + updatedAt: table.updatedAt, + createdAt: table.createdAt, + }) + .from(table) + .innerJoin(probe, eq(probe.storeListingId, table.id)) + .where( + and( + listingPublicWhere(table), + sqlCategorySlugsMatchesLike(table.categorySlugs, "apps/%"), + ...overlapPredicates, + ), + ) + .orderBy( + ...(input.sort === "newest" + ? [desc(table.createdAt)] + : input.sort === "alphabetical" + ? [asc(table.name)] + : orderByPopularListingSort(table)), + ); + + return { + keys: normalizedKeys, + count: rows.length, + listings: rows.map((row) => toListingCard(row)), + } satisfies DirectoryOAuthLexiconClusterPagePayload; + }); + +function getAppsByLexiconClusterPageQueryOptions( + input: z.input, +) { + const normalizedInput = getAppsByLexiconClusterPageInput.parse(input); + + return queryOptions({ + queryKey: ["storeListings", "appsByLexiconClusterPage", normalizedInput], + queryFn: async () => getAppsByLexiconClusterPage({ data: normalizedInput }), + }); +} + +const OAUTH_LEXICON_COMPATIBLE_APPS_PAGE_LIMIT = 250; + +async function loadCrossAppMatchingOAuthLexiconKeysForListing( + context: { db: Database; schema: typeof dbSchema }, + listingId: string, +): Promise | null> { + const listTable = context.schema.storeListings; + const probeTable = context.schema.storeListingOAuthProbes; + + const [[probeRow], [listingRow]] = await Promise.all([ + context.db + .select({ + keys: probeTable.oauthLexiconKeys, + oauthScopesDistinct: probeTable.oauthScopesDistinct, + reportJson: probeTable.reportJson, + }) + .from(probeTable) + .where(eq(probeTable.storeListingId, listingId)) + .limit(1), + context.db + .select({ categorySlugs: listTable.categorySlugs }) + .from(listTable) + .where(eq(listTable.id, listingId)) + .limit(1), + ]); + + const keysRaw = + probeRow && probeRow.keys.length > 0 + ? probeRow.keys + : probeRow + ? extractOAuthLexiconKeysForStorefrontProbe({ + oauthScopesDistinct: probeRow.oauthScopesDistinct ?? [], + scopeHumanReadable: + probeRow.reportJson?.summary?.scopeHumanReadable, + }) + : []; + + const isBlueskyPlatformListing = + primaryCategorySlug(listingRow?.categorySlugs ?? []) === "apps/bluesky"; + + const keys = filterLexiconKeysForCrossAppMatching(keysRaw, { + isBlueskyPlatformListing, + }); + + return keys.length > 0 ? keys : null; +} + +const getRelatedAppsBySharedLexiconKeysInput = z.object({ + listingId: z.string().uuid(), + limit: z.number().int().min(1).max(24).optional().default(6), +}); + +const getRelatedAppsBySharedLexiconKeys = createServerFn({ method: "GET" }) + .middleware([dbMiddleware]) + .inputValidator(getRelatedAppsBySharedLexiconKeysInput) + .handler(async ({ data, context }) => { + const input = getRelatedAppsBySharedLexiconKeysInput.parse(data); + const listTable = context.schema.storeListings; + const probeTable = context.schema.storeListingOAuthProbes; + + const keys = await loadCrossAppMatchingOAuthLexiconKeysForListing( + context, + input.listingId, + ); + + if (keys == null) { + return { + listings: [], + } satisfies RelatedAppsByOAuthLexiconPayload; + } + + /** Require materialized keys so overlap queries stay index-friendly. */ + const candidateRows = await context.db + .select(getListingSelect(listTable)) + .from(listTable) + .innerJoin(probeTable, eq(probeTable.storeListingId, listTable.id)) + .where( + and( + listingPublicWhere(listTable), + sqlCategorySlugsMatchesLike(listTable.categorySlugs, "apps/%"), + ne(listTable.id, input.listingId), + sql`cardinality(${probeTable.oauthLexiconKeys}) > 0`, + arrayOverlaps(probeTable.oauthLexiconKeys, keys), + ), + ) + .orderBy(...orderByPopularListingSort(listTable)) + .limit(input.limit); + + return { + listings: candidateRows.map((row) => toListingCard(row)), + } satisfies RelatedAppsByOAuthLexiconPayload; + }); + +function getRelatedAppsBySharedLexiconKeysQueryOptions( + input: z.input, +) { + const normalizedInput = getRelatedAppsBySharedLexiconKeysInput.parse(input); + + return queryOptions({ + queryKey: ["storeListings", "relatedAppsByLexicon", normalizedInput], + queryFn: async () => + getRelatedAppsBySharedLexiconKeys({ data: normalizedInput }), + }); +} + +const getLexiconCompatibleAppsPageInput = z.object({ + listingId: z.string().uuid(), + sort: listingSortInput, +}); + +const getLexiconCompatibleAppsPage = createServerFn({ method: "GET" }) + .middleware([dbMiddleware]) + .inputValidator(getLexiconCompatibleAppsPageInput) + .handler(async ({ data, context }) => { + const input = getLexiconCompatibleAppsPageInput.parse(data); + const listTable = context.schema.storeListings; + const probeTable = context.schema.storeListingOAuthProbes; + + const keys = await loadCrossAppMatchingOAuthLexiconKeysForListing( + context, + input.listingId, + ); + + if (keys == null) { + return null; + } + + const matchLexiconKeys = keys.toSorted( + compareOAuthLexiconKeysForDisplayOrder, + ); + + /** Badge list: repo collection NSIDs only (omit `include:` / `rpc:` keys). */ + const badgeLexiconKeys = matchLexiconKeys.filter( + (k) => parseOAuthLexiconKey(k)?.kind === "repo", + ); + + const overlapsOneKeyPredicate = (k: string) => + and( + listingPublicWhere(listTable), + sqlCategorySlugsMatchesLike(listTable.categorySlugs, "apps/%"), + ne(listTable.id, input.listingId), + sql`cardinality(${probeTable.oauthLexiconKeys}) > 0`, + arrayOverlaps(probeTable.oauthLexiconKeys, [k]), + ); + + const allEntries = await Promise.all( + badgeLexiconKeys.map(async (key) => { + const [row] = await context.db + .select({ otherAppCount: count() }) + .from(listTable) + .innerJoin(probeTable, eq(probeTable.storeListingId, listTable.id)) + .where(overlapsOneKeyPredicate(key)); + return { + key, + otherAppCount: Number(row?.otherAppCount ?? 0), + }; + }), + ); + const matchLexiconEntries = allEntries.filter((e) => e.otherAppCount > 0); + + const rows = await context.db + .select(getListingSelect(listTable)) + .from(listTable) + .innerJoin(probeTable, eq(probeTable.storeListingId, listTable.id)) + .where( + and( + listingPublicWhere(listTable), + sqlCategorySlugsMatchesLike(listTable.categorySlugs, "apps/%"), + ne(listTable.id, input.listingId), + sql`cardinality(${probeTable.oauthLexiconKeys}) > 0`, + arrayOverlaps(probeTable.oauthLexiconKeys, keys), + ), + ) + .orderBy( + ...(input.sort === "newest" + ? [desc(listTable.createdAt)] + : input.sort === "alphabetical" + ? [asc(listTable.name)] + : orderByPopularListingSort(listTable)), + ) + .limit(OAUTH_LEXICON_COMPATIBLE_APPS_PAGE_LIMIT); + + return { + matchLexiconEntries, + count: rows.length, + listings: rows.map((row) => toListingCard(row)), + } satisfies LexiconCompatibleAppsPagePayload; + }); + +function getLexiconCompatibleAppsPageQueryOptions( + input: z.input, +) { + const normalizedInput = getLexiconCompatibleAppsPageInput.parse(input); + + return queryOptions({ + queryKey: ["storeListings", "lexiconCompatibleAppsPage", normalizedInput], + queryFn: async () => + getLexiconCompatibleAppsPage({ data: normalizedInput }), + }); +} + const getAllListingsInput = z.object({ sort: listingSortInput, }); @@ -6784,6 +7189,17 @@ export const directoryListingApi = { getAllDirectoryListingAppTagsQueryOptions, getAppsByTagPage, getAppsByTagPageQueryOptions, + getAppsOAuthLexiconSummaries, + getAppsOAuthLexiconSummariesQueryOptions, + getAppsByLexiconPage, + getAppsByLexiconPageQueryOptions, + getLexiconRecordMainDescriptionForNsidQueryOptions, + getAppsByLexiconClusterPage, + getAppsByLexiconClusterPageQueryOptions, + getRelatedAppsBySharedLexiconKeys, + getRelatedAppsBySharedLexiconKeysQueryOptions, + getLexiconCompatibleAppsPage, + getLexiconCompatibleAppsPageQueryOptions, getAllListings, getAllListingsQueryOptions, getDirectoryListingDetail, diff --git a/src/lib/lexicon-local-record-description.ts b/src/lib/lexicon-local-record-description.ts new file mode 100644 index 0000000..0ce6318 --- /dev/null +++ b/src/lib/lexicon-local-record-description.ts @@ -0,0 +1,195 @@ +import { access, constants, readFile } from "node:fs/promises"; +import { resolve } from "node:path"; + +import { getLexiconProducerSiteFromRepoNsid } from "./lexicon-producer-site"; +import { fetchLexiconSchemaRecordValue } from "./oauth-listing-auth-probe"; + +const LEXICON_DESC_UA = + "at-store-lexicon-description/1.0 (+https://github.com/ATProtocol-Community/ATStore)"; +const LEXICON_DESC_FETCH_TIMEOUT_MS = 12_000; + +/** Last NSID segment `authBasic` -> path tail `auth/basic` (matches repo `lexicons/` layouts). */ +function splitCamelTailToPathPieces(segment: string): Array { + const spaced = segment + .replaceAll(/([a-z0-9])([A-Z])/g, "$1 $2") + .replaceAll("_", " ") + .trim(); + const parts = spaced.toLowerCase().split(/\s+/).filter(Boolean); + return parts.length > 0 ? parts : [segment.toLowerCase()]; +} + +/** `fyi.atstore.listing.detail` -> `fyi/atstore/listing/detail` */ +export function nsidToLexiconsWorkspaceRelativePath( + nsid: string, +): string | null { + const segs = nsid + .split(".") + .map((s) => s.trim()) + .filter(Boolean); + if (segs.length < 2) { + return null; + } + const authority = segs.slice(0, -1).map((s) => s.toLowerCase()); + const lastSegment = segs.at(-1); + if (!lastSegment) { + return null; + } + const tail = splitCamelTailToPathPieces(lastSegment); + return [...authority, ...tail].join("/"); +} + +function extractLexiconRecordMainDescription(doc: unknown): string | null { + if (doc == null || typeof doc !== "object" || Array.isArray(doc)) { + return null; + } + const rec = doc as Record; + const defs = rec.defs; + if (defs == null || typeof defs !== "object" || Array.isArray(defs)) { + return null; + } + const main = (defs as Record).main; + if (main == null || typeof main !== "object" || Array.isArray(main)) { + return null; + } + const mainRec = main as Record; + if (mainRec.type !== "record") { + return null; + } + const d = mainRec.description; + if (typeof d !== "string" || !d.trim()) { + return null; + } + return d.trim(); +} + +/** Load `defs.main.description` when `lexicons/${path}.json` exists in the repo. */ +export async function tryReadLexiconRecordMainDescriptionFromWorkspace( + nsid: string, +): Promise { + const rel = nsidToLexiconsWorkspaceRelativePath(nsid); + if (!rel) { + return null; + } + const abs = resolve(process.cwd(), "lexicons", `${rel}.json`); + try { + await access(abs, constants.R_OK); + const text = await readFile(abs, "utf8"); + const parsed: unknown = JSON.parse(text); + const idField = + parsed && + typeof parsed === "object" && + !Array.isArray(parsed) && + (parsed as { id?: unknown }).id; + if (typeof idField !== "string" || idField !== nsid) { + return null; + } + return extractLexiconRecordMainDescription(parsed); + } catch { + return null; + } +} + +async function tryFetchLexiconRecordMainDescriptionFromHttps( + url: string, + nsid: string, +): Promise { + const ctrl = new AbortController(); + const timer = setTimeout(() => ctrl.abort(), LEXICON_DESC_FETCH_TIMEOUT_MS); + try { + const res = await fetch(url, { + signal: ctrl.signal, + headers: { Accept: "application/json", "User-Agent": LEXICON_DESC_UA }, + }); + if (!res.ok) { + return null; + } + const parsed: unknown = await res.json(); + const idField = + parsed && + typeof parsed === "object" && + !Array.isArray(parsed) && + (parsed as { id?: unknown }).id; + if (typeof idField !== "string" || idField !== nsid) { + return null; + } + return extractLexiconRecordMainDescription(parsed); + } catch { + return null; + } finally { + clearTimeout(timer); + } +} + +/** Same path convention as repo `lexicons/` and OAuth `GET {origin}/lexicons/{rel}.json` probes. */ +async function tryFetchLexiconRecordMainDescriptionFromProducerSite( + nsid: string, +): Promise { + const rel = nsidToLexiconsWorkspaceRelativePath(nsid); + if (!rel) { + return null; + } + const site = getLexiconProducerSiteFromRepoNsid(nsid); + if (!site?.siteOrigin) { + return null; + } + const origin = site.siteOrigin.replace(/\/+$/, ""); + const url = `${origin}/lexicons/${rel}.json`; + return tryFetchLexiconRecordMainDescriptionFromHttps(url, nsid); +} + +async function tryFetchLexiconRecordMainDescriptionFromRegistry( + nsid: string, +): Promise { + const rec = await fetchLexiconSchemaRecordValue(nsid); + if (!rec) { + return null; + } + return extractLexiconRecordMainDescription(rec); +} + +/** + * Resolve `defs.main.description` per NSID: local `lexicons/` file, then + * `{producerOrigin}/lexicons/…` (reversed authority), then `com.atproto.lexicon.schema` via + * `_lexicon.*` DNS + PDS. + */ +export async function loadLexiconRecordDescriptionsForWorkspace( + nsids: ReadonlyArray, +): Promise> { + const unique = [...new Set(nsids.map((n) => n.trim()).filter(Boolean))]; + const out: Record = {}; + const localResults = await Promise.all( + unique.map(async (nsid) => { + const d = await tryReadLexiconRecordMainDescriptionFromWorkspace(nsid); + return { nsid, d }; + }), + ); + const needRemote: Array = []; + for (const { nsid, d } of localResults) { + if (d) { + out[nsid] = d; + } else { + needRemote.push(nsid); + } + } + + const BATCH = 6; + for (let i = 0; i < needRemote.length; i += BATCH) { + const batch = needRemote.slice(i, i + BATCH); + await Promise.all( + batch.map(async (nsid) => { + const fromSite = + await tryFetchLexiconRecordMainDescriptionFromProducerSite(nsid); + if (fromSite) { + out[nsid] = fromSite; + return; + } + const fromReg = + await tryFetchLexiconRecordMainDescriptionFromRegistry(nsid); + if (fromReg) { + out[nsid] = fromReg; + } + }), + ); + } + return out; +} diff --git a/src/lib/lexicon-producer-site.test.ts b/src/lib/lexicon-producer-site.test.ts new file mode 100644 index 0000000..dfd7cd0 --- /dev/null +++ b/src/lib/lexicon-producer-site.test.ts @@ -0,0 +1,33 @@ +import { describe, expect, it } from "vitest"; + +import { getLexiconProducerSiteFromRepoNsid } from "./lexicon-producer-site"; + +describe("getLexiconProducerSiteFromRepoNsid", () => { + it("maps site.standard.* to standard.site", () => { + expect( + getLexiconProducerSiteFromRepoNsid("site.standard.document"), + ).toEqual({ + groupKey: "site.standard", + siteLabel: "standard.site", + siteOrigin: "https://standard.site", + }); + }); + + it("uses two-part NSID as full authority", () => { + expect(getLexiconProducerSiteFromRepoNsid("fyi.atstore")).toEqual({ + groupKey: "fyi.atstore", + siteLabel: "atstore.fyi", + siteOrigin: "https://atstore.fyi", + }); + }); + + it("groups and links from the first two NSID segments only", () => { + expect( + getLexiconProducerSiteFromRepoNsid("app.bsky.actor.profile"), + ).toEqual({ + groupKey: "app.bsky", + siteLabel: "bsky.app", + siteOrigin: "https://bsky.app", + }); + }); +}); diff --git a/src/lib/lexicon-producer-site.ts b/src/lib/lexicon-producer-site.ts new file mode 100644 index 0000000..661e96a --- /dev/null +++ b/src/lib/lexicon-producer-site.ts @@ -0,0 +1,29 @@ +/** + * Map repo record NSIDs to a producer grouping key and a **two-label** public host for URLs + * (first two NSID segments reversed: `site.standard.document` → `https://standard.site`, + * `app.bsky.actor.profile` → `https://bsky.app`). Subdomain-style hosts from reversing the + * full authority are avoided. `groupKey` is the first two NSID segments for bucketing. + */ +export function getLexiconProducerSiteFromRepoNsid(nsid: string): { + /** First two NSID segments, e.g. `site.standard` or `app.bsky` — sort / dedupe / section key */ + groupKey: string; + /** Public site host from the first two NSID segments only, e.g. `standard.site` */ + siteLabel: string; + /** `https://` + `siteLabel` */ + siteOrigin: string; +} | null { + const parts = nsid + .split(".") + .map((s) => s.trim()) + .filter(Boolean); + if (parts.length < 2) { + return null; + } + const groupKey = parts.slice(0, 2).join(".").toLowerCase(); + + const hostPieces = parts.slice(0, 2).map((s) => s.toLowerCase()); + const siteLabel = [...hostPieces].toReversed().join("."); + const siteOrigin = `https://${siteLabel}`; + + return { groupKey, siteLabel, siteOrigin }; +} diff --git a/src/lib/oauth-lexicon-hub-snapshot.server.ts b/src/lib/oauth-lexicon-hub-snapshot.server.ts new file mode 100644 index 0000000..f6ae2cb --- /dev/null +++ b/src/lib/oauth-lexicon-hub-snapshot.server.ts @@ -0,0 +1,142 @@ +import type { Database } from "#/db/index.server"; +import type { + DirectoryOAuthLexiconClusterSummary, + DirectoryOAuthLexiconHubData, +} from "#/lib/oauth-lexicon-hub.types"; +import type { AnyPgColumn } from "drizzle-orm/pg-core"; + +import * as dbSchema from "#/db/schema"; +import { loadLexiconRecordDescriptionsForWorkspace } from "#/lib/lexicon-local-record-description"; +import { + compareOAuthLexiconKeysForDisplayOrder, + isRepoLexiconKeyForLexiconHub, + parseOAuthLexiconKey, +} from "#/lib/oauth-scope-lexicon-keys"; +import { and, eq, sql } from "drizzle-orm"; + +const HUB_SNAPSHOT_KEY = "default" as const; + +function sqlCategorySlugsMatchesLike(col: AnyPgColumn, pattern: string) { + return sql`exists ( + select 1 from unnest(${col}) as u(slug) where trim(both from u.slug::text) like ${pattern} + )`; +} + +/** + * Builds `/apps/lexicons` payload from current DB rows (slow: fetches lexicon descriptions remotely). + */ +export async function computeOAuthLexiconHubData( + db: Database, +): Promise { + const list = dbSchema.storeListings; + const probe = dbSchema.storeListingOAuthProbes; + + const rows = await db + .select({ id: list.id, keys: probe.oauthLexiconKeys }) + .from(list) + .innerJoin(probe, eq(probe.storeListingId, list.id)) + .where( + and( + eq(list.verificationStatus, "verified"), + sqlCategorySlugsMatchesLike(list.categorySlugs, "apps/%"), + sql`cardinality(${probe.oauthLexiconKeys}) > 0`, + ), + ); + + const keyToListings = new Map>(); + for (const row of rows) { + for (const k of row.keys) { + if (!isRepoLexiconKeyForLexiconHub(k)) continue; + let set = keyToListings.get(k); + if (!set) { + set = new Set(); + keyToListings.set(k, set); + } + set.add(row.id); + } + } + + const clusterMap = new Map< + string, + { keys: Array; appCount: number; listingIds: Array } + >(); + for (const [key, listingSet] of keyToListings) { + if (listingSet.size < 2) continue; + const sig = [...listingSet].toSorted().join("\u001F"); + let entry = clusterMap.get(sig); + if (!entry) { + entry = { + keys: [], + appCount: listingSet.size, + listingIds: [...listingSet].toSorted(), + }; + clusterMap.set(sig, entry); + } + entry.keys.push(key); + } + + const clustersUnsorted = [...clusterMap.values()].map( + (row): DirectoryOAuthLexiconClusterSummary => ({ + keys: row.keys.toSorted(compareOAuthLexiconKeysForDisplayOrder), + appCount: row.appCount, + listingIds: row.listingIds, + }), + ); + + const clusters = clustersUnsorted.toSorted((a, b) => { + if (b.appCount !== a.appCount) return b.appCount - a.appCount; + const ak = a.keys[0] ?? ""; + const bk = b.keys[0] ?? ""; + return compareOAuthLexiconKeysForDisplayOrder(ak, bk); + }); + + const repoNsids = new Set(); + for (const c of clusters) { + for (const k of c.keys) { + const p = parseOAuthLexiconKey(k); + if (p?.kind === "repo" && p.nsid) { + repoNsids.add(p.nsid); + } + } + } + const descriptionsByRepoNsid = + await loadLexiconRecordDescriptionsForWorkspace([...repoNsids]); + + return { + clusters, + descriptionsByRepoNsid, + }; +} + +export async function getOAuthLexiconHubSnapshot( + db: Database, +): Promise { + const snap = dbSchema.oauthLexiconHubSnapshot; + const [row] = await db + .select({ payload: snap.payload }) + .from(snap) + .where(eq(snap.singletonKey, HUB_SNAPSHOT_KEY)) + .limit(1); + return row?.payload ?? null; +} + +export async function refreshOAuthLexiconHubSnapshot(db: Database): Promise<{ + clusterCount: number; + computedAt: Date; +}> { + const payload = await computeOAuthLexiconHubData(db); + const snap = dbSchema.oauthLexiconHubSnapshot; + const computedAt = new Date(); + await db + .insert(snap) + .values({ + singletonKey: HUB_SNAPSHOT_KEY, + payload, + computedAt, + }) + .onConflictDoUpdate({ + target: snap.singletonKey, + set: { payload, computedAt }, + }); + return { clusterCount: payload.clusters.length, computedAt }; +} diff --git a/src/lib/oauth-lexicon-hub.types.ts b/src/lib/oauth-lexicon-hub.types.ts new file mode 100644 index 0000000..2abd80f --- /dev/null +++ b/src/lib/oauth-lexicon-hub.types.ts @@ -0,0 +1,21 @@ +/** + * Hub row for /apps/lexicons — a cluster of repo collection NSIDs that appear + * on exactly the same set of listings (≥2 apps). `include:` and `rpc:` are omitted. + */ +export interface DirectoryOAuthLexiconClusterSummary { + keys: Array; + /** Number of app listings in this cluster (same for every key in `keys`). */ + appCount: number; + /** + * Store listing IDs in this cluster — the same sorted set for every key in `keys` + * (used to dedupe apps across clusters when grouping by lexicon producer). + */ + listingIds: Array; +} + +/** Hub payload for `/apps/lexicons` — clusters plus optional local lexicon descriptions. */ +export interface DirectoryOAuthLexiconHubData { + clusters: Array; + /** Repo NSID (no `repo:` prefix) -> `defs.main.description` when present under `lexicons/`. */ + descriptionsByRepoNsid: Record; +} diff --git a/src/lib/oauth-listing-auth-probe.ts b/src/lib/oauth-listing-auth-probe.ts index bd6c264..08cfc86 100644 --- a/src/lib/oauth-listing-auth-probe.ts +++ b/src/lib/oauth-listing-auth-probe.ts @@ -298,6 +298,15 @@ async function fetchPermissionSetViaLexiconRegistry( return { lexiconDoc: rec, sourceUri }; } +/** Authoritative lexicon JSON from `com.atproto.lexicon.schema` via `_lexicon.*` DNS + PDS getRecord. */ +export async function fetchLexiconSchemaRecordValue( + nsid: string, +): Promise { + const attemptedUrls: Array = []; + const r = await fetchPermissionSetViaLexiconRegistry(nsid, attemptedUrls); + return r?.lexiconDoc ?? null; +} + function extractResourceMetadataUrlFromWwwAuthenticate( raw: string | null, ): string | null { diff --git a/src/lib/oauth-scope-lexicon-keys.test.ts b/src/lib/oauth-scope-lexicon-keys.test.ts new file mode 100644 index 0000000..997d426 --- /dev/null +++ b/src/lib/oauth-scope-lexicon-keys.test.ts @@ -0,0 +1,263 @@ +import { describe, expect, it } from "vitest"; + +import type { SummaryScopeHumanRow } from "./oauth-listing-auth-probe"; + +import { PERMISSION_GRANT_RECORDS_LIST_LABEL } from "./oauth-permission-grant-ui"; +import { + extractLexiconKeysFromOAuthScopeTokens, + extractOAuthLexiconKeysForStorefrontProbe, + filterLexiconKeysForCrossAppMatching, + formatLexiconClusterPageTitle, + formatLexiconNsidRecordTitle, + formatOAuthLexiconKeyClusterStyleHeadline, + formatOAuthLexiconKeyHeadline, + isAppBskyOAuthLexiconKey, + isBareWildcardOAuthLexiconNsid, + isRepoLexiconKeyForLexiconHub, + parseOAuthLexiconKey, + pickPrimaryOAuthLexiconBrowseKey, + stringifyLexiconClusterSearchParam, + tryParseLexiconClusterSearchParam, +} from "./oauth-scope-lexicon-keys"; + +describe("isBareWildcardOAuthLexiconNsid", () => { + it("is true only for a lone asterisk", () => { + expect(isBareWildcardOAuthLexiconNsid("*")).toBe(true); + expect(isBareWildcardOAuthLexiconNsid(" * ")).toBe(true); + expect(isBareWildcardOAuthLexiconNsid("com.example.*")).toBe(false); + expect(isBareWildcardOAuthLexiconNsid("")).toBe(false); + }); +}); + +describe("filterLexiconKeysForCrossAppMatching", () => { + it("drops keys whose NSID is only *", () => { + expect( + filterLexiconKeysForCrossAppMatching( + ["repo:*", "repo:site.standard.document"], + { isBlueskyPlatformListing: false }, + ), + ).toEqual(["repo:site.standard.document"]); + }); + + it("drops app.bsky.* keys for normal listings", () => { + expect( + filterLexiconKeysForCrossAppMatching( + ["repo:app.bsky.actor.profile", "repo:site.standard.document"], + { isBlueskyPlatformListing: false }, + ), + ).toEqual(["repo:site.standard.document"]); + }); + + it("keeps app.bsky.* keys for the Bluesky platform listing", () => { + expect( + filterLexiconKeysForCrossAppMatching( + ["repo:app.bsky.actor.profile", "repo:site.standard.document"], + { isBlueskyPlatformListing: true }, + ), + ).toEqual(["repo:app.bsky.actor.profile", "repo:site.standard.document"]); + }); +}); + +describe("isAppBskyOAuthLexiconKey", () => { + it("detects repo keys in the Bluesky namespace", () => { + expect(isAppBskyOAuthLexiconKey("repo:app.bsky.feed.post")).toBe(true); + expect(isAppBskyOAuthLexiconKey("repo:site.standard.document")).toBe(false); + }); +}); + +describe("extractOAuthLexiconKeysForStorefrontProbe", () => { + it("adds repo NSIDs from resolved include bundle checklists", () => { + const scopeHumanReadable: Array = [ + { + token: "include:leaflet.standard.auth", + description: "test", + includePermissionSet: { + resolved: true, + nsid: "leaflet.standard.auth", + sourceKind: "remote", + sourceUrl: "https://example.com/lexicons/leaflet.standard.auth.json", + structuredLines: [ + { + kind: "unorderedList", + label: PERMISSION_GRANT_RECORDS_LIST_LABEL, + items: ["site.standard.document", "site.standard.publication"], + }, + ], + }, + }, + ]; + expect( + extractOAuthLexiconKeysForStorefrontProbe({ + oauthScopesDistinct: ["include:leaflet.standard.auth"], + scopeHumanReadable, + }), + ).toEqual([ + "include:leaflet.standard.auth", + "repo:site.standard.document", + "repo:site.standard.publication", + ]); + }); +}); + +describe("extractLexiconKeysFromOAuthScopeTokens", () => { + it("omits repo collection *", () => { + expect( + extractLexiconKeysFromOAuthScopeTokens([ + "repo?collection=*&action=read", + "repo?collection=com.example.foo", + ]).toSorted((a, b) => a.localeCompare(b)), + ).toEqual(["repo:com.example.foo"]); + }); + + it("collects include colon and query forms into the same key", () => { + expect( + extractLexiconKeysFromOAuthScopeTokens([ + "include:fyi.atstore.authBasic", + "include?nsid=app.bsky.feed.post", + ]).toSorted((a, b) => a.localeCompare(b)), + ).toEqual(["include:app.bsky.feed.post", "include:fyi.atstore.authBasic"]); + }); + + it("collects repo collection NSIDs", () => { + expect( + extractLexiconKeysFromOAuthScopeTokens([ + "repo?collection=com.example.foo&collection=com.example.bar&action=read", + ]), + ).toEqual(["repo:com.example.bar", "repo:com.example.foo"]); + }); + + it("collects rpc lxm NSIDs", () => { + expect( + extractLexiconKeysFromOAuthScopeTokens([ + "rpc?lxm=com.atproto.identity.resolveHandle", + ]), + ).toEqual(["rpc:com.atproto.identity.resolveHandle"]); + }); +}); + +describe("parseOAuthLexiconKey", () => { + it("parses include keys", () => { + expect(parseOAuthLexiconKey("include:fyi.atstore.authBasic")).toEqual({ + kind: "include", + nsid: "fyi.atstore.authBasic", + }); + }); +}); + +describe("pickPrimaryOAuthLexiconBrowseKey", () => { + it("prefers include keys over repo/rpc", () => { + expect( + pickPrimaryOAuthLexiconBrowseKey([ + "repo:com.example.a", + "include:fyi.b", + "rpc:com.example.c", + ]), + ).toBe("include:fyi.b"); + }); +}); + +describe("formatLexiconNsidRecordTitle", () => { + it("uses the last segment with camelCase split and title case", () => { + expect(formatLexiconNsidRecordTitle("at.margin.someThing")).toBe( + "Some Thing", + ); + }); + + it("title-cases a single plain last segment", () => { + expect(formatLexiconNsidRecordTitle("site.standard.document")).toBe( + "Document", + ); + }); + + it("splits authBasic-style tails", () => { + expect(formatLexiconNsidRecordTitle("fyi.atstore.authBasic")).toBe( + "Auth Basic", + ); + }); +}); + +describe("formatOAuthLexiconKeyHeadline", () => { + it("strips repo prefix and formats the NSID tail", () => { + expect(formatOAuthLexiconKeyHeadline("repo:at.margin.someThing")).toBe( + "Some Thing", + ); + }); + + it("falls back to the raw key when unparsable", () => { + expect(formatOAuthLexiconKeyHeadline("not-a-key")).toBe("not-a-key"); + }); +}); + +describe("formatOAuthLexiconKeyClusterStyleHeadline", () => { + it("uses Standard.Site-style producer dot plus record name", () => { + expect( + formatOAuthLexiconKeyClusterStyleHeadline("repo:site.standard.document"), + ).toBe("Standard.Site Document"); + }); + + it("uses only producer dot for two-segment NSIDs", () => { + expect(formatOAuthLexiconKeyClusterStyleHeadline("repo:fyi.atstore")).toBe( + "Atstore.Fyi", + ); + }); + + it("handles app.bsky tails", () => { + expect( + formatOAuthLexiconKeyClusterStyleHeadline("repo:app.bsky.actor.profile"), + ).toBe("Bsky.App Profile"); + }); +}); + +describe("formatLexiconClusterPageTitle", () => { + it("joins and truncates", () => { + expect( + formatLexiconClusterPageTitle([ + "repo:site.standard.document", + "repo:site.standard.publication", + ]), + ).toBe("Standard.Site Document · Standard.Site Publication"); + expect( + formatLexiconClusterPageTitle(["repo:a.b.c", "repo:a.b.d", "repo:a.b.e"]), + ).toBe("B.A C · B.A D +1"); + }); +}); + +describe("tryParseLexiconClusterSearchParam", () => { + it("round-trips hub keys", () => { + const keys = ["repo:com.example.a", "repo:com.example.b"]; + const raw = stringifyLexiconClusterSearchParam(keys); + expect(tryParseLexiconClusterSearchParam(raw)).toEqual( + keys.toSorted((a, b) => a.localeCompare(b)), + ); + }); + + it("keeps only repo hub-eligible keys", () => { + const raw = stringifyLexiconClusterSearchParam(["repo:com.example.a"]); + const parsed = JSON.parse(raw) as { v: number; keys: Array }; + parsed.keys.push("include:foo", "repo:app.bsky.feed.post"); + expect(tryParseLexiconClusterSearchParam(JSON.stringify(parsed))).toEqual( + ["repo:com.example.a", "repo:app.bsky.feed.post"].toSorted((a, b) => + a.localeCompare(b), + ), + ); + }); + + it("returns null on garbage", () => { + expect(tryParseLexiconClusterSearchParam("")).toBeNull(); + expect(tryParseLexiconClusterSearchParam("{}")).toBeNull(); + expect(tryParseLexiconClusterSearchParam('{"v":1,"keys":[]}')).toBeNull(); + }); +}); + +describe("isRepoLexiconKeyForLexiconHub", () => { + it("matches repo keys including app.bsky; not include or repo:*", () => { + expect(isRepoLexiconKeyForLexiconHub("repo:com.example.foo")).toBe(true); + expect(isRepoLexiconKeyForLexiconHub("repo:app.bsky.actor.profile")).toBe( + true, + ); + expect(isRepoLexiconKeyForLexiconHub("include:fyi.atstore.authBasic")).toBe( + false, + ); + expect(isRepoLexiconKeyForLexiconHub("repo:*")).toBe(false); + }); +}); diff --git a/src/lib/oauth-scope-lexicon-keys.ts b/src/lib/oauth-scope-lexicon-keys.ts new file mode 100644 index 0000000..f33e6e0 --- /dev/null +++ b/src/lib/oauth-scope-lexicon-keys.ts @@ -0,0 +1,391 @@ +import type { SummaryScopeHumanRow } from "./oauth-listing-auth-probe"; + +import { + PERMISSION_GRANT_BACKEND_CALLS_LIST_LABEL, + PERMISSION_GRANT_RECORDS_LIST_LABEL, + isPermissionGrantUnorderedList, +} from "./oauth-permission-grant-ui"; +import { + parseIncludeScopeToken, + parseRepoScopeForStorefront, + parseRpcScopeForStorefront, +} from "./oauth-scope-include-parse"; + +/** Normalized key for `include:` / permission-set OAuth scopes. */ +export const OAUTH_LEXICON_KEY_PREFIX_INCLUDE = "include:" as const; +/** Normalized key prefix for `repo` scope collection NSIDs. */ +export const OAUTH_LEXICON_KEY_PREFIX_REPO = "repo:" as const; +/** Normalized key prefix for `rpc` scope `lxm` NSIDs. */ +export const OAUTH_LEXICON_KEY_PREFIX_RPC = "rpc:" as const; + +export type OAuthLexiconKeyKind = "include" | "repo" | "rpc"; + +/** NSID payload is only `*` — not a concrete lexicon id; omit from indexes and UI. */ +export function isBareWildcardOAuthLexiconNsid(nsid: string): boolean { + return nsid.trim() === "*"; +} + +function oauthLexiconKeyKindRank( + kind: OAuthLexiconKeyKind | undefined, +): number { + switch (kind) { + case "include": { + return 0; + } + case "repo": { + return 1; + } + case "rpc": { + return 2; + } + default: { + return 3; + } + } +} + +/** + * Derives stable lexicon identifiers from OAuth scope tokens so listings can be + * grouped by overlapping vocabulary (`require` / `include` bundles, repo collections, RPCs). + */ +export function extractLexiconKeysFromOAuthScopeTokens( + tokens: ReadonlyArray, +): Array { + const out = new Set(); + for (const raw of tokens) { + const t = raw.trim(); + if (!t) continue; + + const inc = parseIncludeScopeToken(t); + if (inc?.nsid?.trim()) { + const n = inc.nsid.trim(); + if (!isBareWildcardOAuthLexiconNsid(n)) { + out.add(`${OAUTH_LEXICON_KEY_PREFIX_INCLUDE}${n}`); + } + continue; + } + + const repo = parseRepoScopeForStorefront(t); + if (repo && repo.collectionsSorted.length > 0) { + for (const nsid of repo.collectionsSorted) { + const n = nsid.trim(); + if (n && !isBareWildcardOAuthLexiconNsid(n)) { + out.add(`${OAUTH_LEXICON_KEY_PREFIX_REPO}${n}`); + } + } + continue; + } + + const rpc = parseRpcScopeForStorefront(t); + if (rpc && rpc.lxmsSorted.length > 0) { + for (const nsid of rpc.lxmsSorted) { + const n = nsid.trim(); + if (n && !isBareWildcardOAuthLexiconNsid(n)) { + out.add(`${OAUTH_LEXICON_KEY_PREFIX_RPC}${n}`); + } + } + } + } + return [...out].toSorted((a, b) => a.localeCompare(b)); +} + +/** + * Repo collection + RPC method NSIDs declared inside **resolved** `include:` permission-set + * checklists (not duplicated as top-level `repo:` / `rpc:` scope strings). + */ +export function extractLexiconKeysFromSummaryScopeHumanReadable( + rows: ReadonlyArray | null | undefined, +): Array { + if (rows == null || rows.length === 0) { + return []; + } + const out = new Set(); + for (const row of rows) { + if (!("includePermissionSet" in row)) continue; + const pr = row.includePermissionSet; + if (!pr.resolved) continue; + + for (const line of pr.structuredLines) { + if (!isPermissionGrantUnorderedList(line)) continue; + if (line.label === PERMISSION_GRANT_RECORDS_LIST_LABEL) { + for (const item of line.items) { + const n = item.trim(); + if (n && !isBareWildcardOAuthLexiconNsid(n)) { + out.add(`${OAUTH_LEXICON_KEY_PREFIX_REPO}${n}`); + } + } + } else if (line.label === PERMISSION_GRANT_BACKEND_CALLS_LIST_LABEL) { + for (const item of line.items) { + const n = item.trim(); + if (n && !isBareWildcardOAuthLexiconNsid(n)) { + out.add(`${OAUTH_LEXICON_KEY_PREFIX_RPC}${n}`); + } + } + } + } + } + return [...out].toSorted((a, b) => a.localeCompare(b)); +} + +/** + * Full lexicon key set for storefront OAuth probe persistence: raw scope tokens plus + * repo/RPC NSIDs from expanded permission-set bundles (e.g. `site.standard.document` + * inside an `include:` checklist). + */ +export function extractOAuthLexiconKeysForStorefrontProbe(input: { + oauthScopesDistinct: ReadonlyArray; + scopeHumanReadable?: ReadonlyArray | null; +}): Array { + const fromTokens = extractLexiconKeysFromOAuthScopeTokens( + input.oauthScopesDistinct, + ); + const fromBundles = extractLexiconKeysFromSummaryScopeHumanReadable( + input.scopeHumanReadable, + ); + return [...new Set([...fromTokens, ...fromBundles])].toSorted((a, b) => + a.localeCompare(b), + ); +} + +export function parseOAuthLexiconKey( + key: string, +): { kind: OAuthLexiconKeyKind; nsid: string } | null { + const k = key.trim(); + if (k.startsWith(OAUTH_LEXICON_KEY_PREFIX_INCLUDE)) { + const nsid = k.slice(OAUTH_LEXICON_KEY_PREFIX_INCLUDE.length).trim(); + return nsid ? { kind: "include", nsid } : null; + } + if (k.startsWith(OAUTH_LEXICON_KEY_PREFIX_REPO)) { + const nsid = k.slice(OAUTH_LEXICON_KEY_PREFIX_REPO.length).trim(); + return nsid ? { kind: "repo", nsid } : null; + } + if (k.startsWith(OAUTH_LEXICON_KEY_PREFIX_RPC)) { + const nsid = k.slice(OAUTH_LEXICON_KEY_PREFIX_RPC.length).trim(); + return nsid ? { kind: "rpc", nsid } : null; + } + return null; +} + +/** NSIDs published by the Bluesky client (`app.bsky.*`) — noisy for cross-app matching. */ +export function isAppBskyLexiconNsid(nsid: string): boolean { + return nsid.trim().toLowerCase().startsWith("app.bsky."); +} + +export function isAppBskyOAuthLexiconKey(key: string): boolean { + const p = parseOAuthLexiconKey(key); + return p != null && isAppBskyLexiconNsid(p.nsid); +} + +/** Repo collection keys shown on `/apps/lexicons` and lexicon-set URLs (`repo:` only, not `include:` / `rpc:`). */ +export function isRepoLexiconKeyForLexiconHub(key: string): boolean { + const p = parseOAuthLexiconKey(key.trim()); + return ( + p != null && p.kind === "repo" && !isBareWildcardOAuthLexiconNsid(p.nsid) + ); +} + +const LEXICON_CLUSTER_SEARCH_VERSION = 1 as const; + +/** Normalize + dedupe cluster keys for hub + URL state (repo hub-eligible only). */ +export function normalizeLexiconClusterKeysForHub( + keys: ReadonlyArray, +): Array { + const seen = new Set(); + for (const raw of keys) { + const k = raw.trim(); + if (isRepoLexiconKeyForLexiconHub(k)) seen.add(k); + } + return [...seen].toSorted(compareOAuthLexiconKeysForDisplayOrder); +} + +/** JSON string for `LexiconSetRoute` search param `c` (router URL-encodes this value). */ +export function stringifyLexiconClusterSearchParam( + keys: ReadonlyArray, +): string { + const normalized = normalizeLexiconClusterKeysForHub(keys); + if (normalized.length === 0) { + throw new Error("stringifyLexiconClusterSearchParam: empty cluster"); + } + return JSON.stringify({ + v: LEXICON_CLUSTER_SEARCH_VERSION, + keys: normalized, + }); +} + +/** Parse `c` from search; returns null if malformed or no valid hub keys. */ +export function tryParseLexiconClusterSearchParam( + raw: string | undefined, +): Array | null { + if (raw == null || typeof raw !== "string") { + return null; + } + const trimmed = raw.trim(); + if (!trimmed) { + return null; + } + let parsed: unknown; + try { + parsed = JSON.parse(trimmed); + } catch { + return null; + } + if (parsed == null || typeof parsed !== "object") { + return null; + } + const rec = parsed as { v?: unknown; keys?: unknown }; + if (rec.v !== LEXICON_CLUSTER_SEARCH_VERSION || !Array.isArray(rec.keys)) { + return null; + } + const normalized = normalizeLexiconClusterKeysForHub( + rec.keys.filter((x): x is string => typeof x === "string"), + ); + return normalized.length > 0 ? normalized : null; +} + +/** + * Strips `app.bsky.*` keys unless the listing is the official Bluesky client + * (`primaryCategorySlug === "apps/bluesky"`). + */ +export function filterLexiconKeysForCrossAppMatching( + keys: ReadonlyArray, + options: { isBlueskyPlatformListing: boolean }, +): Array { + const withoutWildcard = keys.filter((k) => { + const p = parseOAuthLexiconKey(k); + if (p == null) { + return true; + } + return !isBareWildcardOAuthLexiconNsid(p.nsid); + }); + if (options.isBlueskyPlatformListing) { + return [...withoutWildcard]; + } + return withoutWildcard.filter((k) => !isAppBskyOAuthLexiconKey(k)); +} + +export function oauthLexiconKeyKindLabel(kind: OAuthLexiconKeyKind): string { + switch (kind) { + case "include": { + return "Permission set"; + } + case "repo": { + return "Data type"; + } + case "rpc": { + return "RPC"; + } + default: { + const _exhaustive: never = kind; + return _exhaustive; + } + } +} + +/** + * Short title from an NSID: last segment only, camelCase / underscores split into Title Case + * (`at.margin.someThing` → `Some Thing`, `site.standard.document` → `Document`). + */ +export function formatLexiconNsidRecordTitle(nsid: string): string { + const segs = nsid + .split(".") + .map((s) => s.trim()) + .filter(Boolean); + const last = segs.at(-1); + if (!last) { + return nsid.trim(); + } + const spaced = last + .replaceAll(/([a-z0-9])([A-Z])/g, "$1 $2") + .replaceAll("_", " ") + .trim(); + const words = spaced.split(/\s+/).filter(Boolean); + if (words.length === 0) { + return last; + } + return words + .map((w) => w.charAt(0).toUpperCase() + w.slice(1).toLowerCase()) + .join(" "); +} + +function formatLexiconNsidSegmentLabel(segment: string): string { + const s = segment.trim(); + if (!s) { + return s; + } + return s.charAt(0).toUpperCase() + s.slice(1).toLowerCase(); +} + +/** + * Shared lexicon cluster page: reversed first-two NSID segments with a dot, then the record name + * (`site.standard.document` → `Standard.Site Document`). Two-segment NSIDs → `Atstore.Fyi` only. + */ +export function formatOAuthLexiconKeyClusterStyleHeadline(key: string): string { + const parsed = parseOAuthLexiconKey(key); + if (!parsed?.nsid) { + return key; + } + const nsid = parsed.nsid; + const segs = nsid + .split(".") + .map((s) => s.trim()) + .filter(Boolean); + if (segs.length < 2) { + return formatLexiconNsidRecordTitle(nsid); + } + // oxlint-disable-next-line typescript/no-non-null-assertion + const producerDot = `${formatLexiconNsidSegmentLabel(segs[1]!)}.${formatLexiconNsidSegmentLabel(segs[0]!)}`; + if (segs.length === 2) { + return producerDot; + } + return `${producerDot} ${formatLexiconNsidRecordTitle(nsid)}`; +} + +/** Hero / OG title line for a cluster (joins multiple keys with ` · `, truncates with `+N`). */ +export function formatLexiconClusterPageTitle( + keys: ReadonlyArray, +): string { + if (keys.length === 0) { + return "Lexicon cluster"; + } + const labels = keys.map((k) => formatOAuthLexiconKeyClusterStyleHeadline(k)); + return labels.length <= 2 + ? labels.join(" · ") + : `${labels.slice(0, 2).join(" · ")} +${String(labels.length - 2)}`; +} + +/** Human label for hub cards and detail page titles (record name only, not full NSID). */ +export function formatOAuthLexiconKeyHeadline(key: string): string { + const parsed = parseOAuthLexiconKey(key); + if (parsed?.nsid) { + return formatLexiconNsidRecordTitle(parsed.nsid); + } + return key; +} + +export function compareOAuthLexiconKeysForDisplayOrder( + a: string, + b: string, +): number { + const ra = oauthLexiconKeyKindRank(parseOAuthLexiconKey(a)?.kind); + const rb = oauthLexiconKeyKindRank(parseOAuthLexiconKey(b)?.kind); + if (ra !== rb) return ra - rb; + return a.localeCompare(b); +} + +/** + * Prefer an `include:` permission-set key for “compatible apps” deep links when possible. + */ +export function pickPrimaryOAuthLexiconBrowseKey( + keys: ReadonlyArray, +): string | null { + if (keys.length === 0) return null; + const sorted = [...keys].toSorted(compareOAuthLexiconKeysForDisplayOrder); + return sorted[0] ?? null; +} + +export function buildAppsLexiconBrowseHref(key: string): string { + const q = new URLSearchParams(); + q.set("key", key); + q.set("sort", "popular"); + return `/apps/lexicon?${q.toString()}`; +} diff --git a/src/routeTree.gen.ts b/src/routeTree.gen.ts index 95fcdda..b72a3fd 100644 --- a/src/routeTree.gen.ts +++ b/src/routeTree.gen.ts @@ -30,6 +30,9 @@ import { Route as HeaderLayoutProductClaimRouteImport } from './routes/_header-l 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 HeaderLayoutAppsLexiconsRouteImport } from './routes/_header-layout.apps.lexicons' +import { Route as HeaderLayoutAppsLexiconSetRouteImport } from './routes/_header-layout.apps.lexicon-set' +import { Route as HeaderLayoutAppsLexiconRouteImport } from './routes/_header-layout.apps.lexicon' import { Route as HeaderLayoutAppsAllRouteImport } from './routes/_header-layout.apps.all' import { Route as HeaderLayoutAppsTagRouteImport } from './routes/_header-layout.apps.$tag' import { Route as HeaderLayoutProductsProductIdIndexRouteImport } from './routes/_header-layout.products.$productId.index' @@ -42,6 +45,7 @@ import { Route as ApiAuthAtprotoCallbackRouteImport } from './routes/api.auth.at import { Route as ApiAuthAtprotoAuthorizeRouteImport } from './routes/api.auth.atproto.authorize' import { Route as HeaderLayoutProductsProductIdReviewsRouteImport } from './routes/_header-layout.products.$productId.reviews' import { Route as HeaderLayoutProductsProductIdMentionsRouteImport } from './routes/_header-layout.products.$productId.mentions' +import { Route as HeaderLayoutProductsProductIdLexiconCompatibleRouteImport } from './routes/_header-layout.products.$productId.lexicon-compatible' import { Route as HeaderLayoutProductsProductIdEditRouteImport } from './routes/_header-layout.products.$productId.edit' import { Route as HeaderLayoutEcosystemsAppAllRouteImport } from './routes/_header-layout.ecosystems.$app.all' import { Route as HeaderLayoutAdminLayoutAdminUnverifiedListingsRouteImport } from './routes/_header-layout._admin-layout.admin.unverified-listings' @@ -166,6 +170,23 @@ const HeaderLayoutAppsTagsRoute = HeaderLayoutAppsTagsRouteImport.update({ path: '/apps/tags', getParentRoute: () => HeaderLayoutRoute, } as any) +const HeaderLayoutAppsLexiconsRoute = + HeaderLayoutAppsLexiconsRouteImport.update({ + id: '/apps/lexicons', + path: '/apps/lexicons', + getParentRoute: () => HeaderLayoutRoute, + } as any) +const HeaderLayoutAppsLexiconSetRoute = + HeaderLayoutAppsLexiconSetRouteImport.update({ + id: '/apps/lexicon-set', + path: '/apps/lexicon-set', + getParentRoute: () => HeaderLayoutRoute, + } as any) +const HeaderLayoutAppsLexiconRoute = HeaderLayoutAppsLexiconRouteImport.update({ + id: '/apps/lexicon', + path: '/apps/lexicon', + getParentRoute: () => HeaderLayoutRoute, +} as any) const HeaderLayoutAppsAllRoute = HeaderLayoutAppsAllRouteImport.update({ id: '/apps/all', path: '/apps/all', @@ -233,6 +254,12 @@ const HeaderLayoutProductsProductIdMentionsRoute = path: '/products/$productId/mentions', getParentRoute: () => HeaderLayoutRoute, } as any) +const HeaderLayoutProductsProductIdLexiconCompatibleRoute = + HeaderLayoutProductsProductIdLexiconCompatibleRouteImport.update({ + id: '/products/$productId/lexicon-compatible', + path: '/products/$productId/lexicon-compatible', + getParentRoute: () => HeaderLayoutRoute, + } as any) const HeaderLayoutProductsProductIdEditRoute = HeaderLayoutProductsProductIdEditRouteImport.update({ id: '/products/$productId/edit', @@ -327,6 +354,9 @@ export interface FileRoutesByFullPath { '/og/': typeof OgIndexRoute '/apps/$tag': typeof HeaderLayoutAppsTagRoute '/apps/all': typeof HeaderLayoutAppsAllRoute + '/apps/lexicon': typeof HeaderLayoutAppsLexiconRoute + '/apps/lexicon-set': typeof HeaderLayoutAppsLexiconSetRoute + '/apps/lexicons': typeof HeaderLayoutAppsLexiconsRoute '/apps/tags': typeof HeaderLayoutAppsTagsRoute '/categories/$categoryId': typeof HeaderLayoutCategoriesCategoryIdRoute '/developers/atproto': typeof HeaderLayoutDevelopersAtprotoRoute @@ -344,6 +374,7 @@ export interface FileRoutesByFullPath { '/admin/unverified-listings': typeof HeaderLayoutAdminLayoutAdminUnverifiedListingsRoute '/ecosystems/$app/all': typeof HeaderLayoutEcosystemsAppAllRoute '/products/$productId/edit': typeof HeaderLayoutProductsProductIdEditRoute + '/products/$productId/lexicon-compatible': typeof HeaderLayoutProductsProductIdLexiconCompatibleRoute '/products/$productId/mentions': typeof HeaderLayoutProductsProductIdMentionsRoute '/products/$productId/reviews': typeof HeaderLayoutProductsProductIdReviewsRouteWithChildren '/api/auth/atproto/authorize': typeof ApiAuthAtprotoAuthorizeRoute @@ -373,6 +404,9 @@ export interface FileRoutesByTo { '/og': typeof OgIndexRoute '/apps/$tag': typeof HeaderLayoutAppsTagRoute '/apps/all': typeof HeaderLayoutAppsAllRoute + '/apps/lexicon': typeof HeaderLayoutAppsLexiconRoute + '/apps/lexicon-set': typeof HeaderLayoutAppsLexiconSetRoute + '/apps/lexicons': typeof HeaderLayoutAppsLexiconsRoute '/apps/tags': typeof HeaderLayoutAppsTagsRoute '/categories/$categoryId': typeof HeaderLayoutCategoriesCategoryIdRoute '/developers/atproto': typeof HeaderLayoutDevelopersAtprotoRoute @@ -390,6 +424,7 @@ export interface FileRoutesByTo { '/admin/unverified-listings': typeof HeaderLayoutAdminLayoutAdminUnverifiedListingsRoute '/ecosystems/$app/all': typeof HeaderLayoutEcosystemsAppAllRoute '/products/$productId/edit': typeof HeaderLayoutProductsProductIdEditRoute + '/products/$productId/lexicon-compatible': typeof HeaderLayoutProductsProductIdLexiconCompatibleRoute '/products/$productId/mentions': typeof HeaderLayoutProductsProductIdMentionsRoute '/api/auth/atproto/authorize': typeof ApiAuthAtprotoAuthorizeRoute '/api/auth/atproto/callback': typeof ApiAuthAtprotoCallbackRoute @@ -421,6 +456,9 @@ export interface FileRoutesById { '/og/': typeof OgIndexRoute '/_header-layout/apps/$tag': typeof HeaderLayoutAppsTagRoute '/_header-layout/apps/all': typeof HeaderLayoutAppsAllRoute + '/_header-layout/apps/lexicon': typeof HeaderLayoutAppsLexiconRoute + '/_header-layout/apps/lexicon-set': typeof HeaderLayoutAppsLexiconSetRoute + '/_header-layout/apps/lexicons': typeof HeaderLayoutAppsLexiconsRoute '/_header-layout/apps/tags': typeof HeaderLayoutAppsTagsRoute '/_header-layout/categories/$categoryId': typeof HeaderLayoutCategoriesCategoryIdRoute '/_header-layout/developers/atproto': typeof HeaderLayoutDevelopersAtprotoRoute @@ -438,6 +476,7 @@ export interface FileRoutesById { '/_header-layout/_admin-layout/admin/unverified-listings': typeof HeaderLayoutAdminLayoutAdminUnverifiedListingsRoute '/_header-layout/ecosystems/$app/all': typeof HeaderLayoutEcosystemsAppAllRoute '/_header-layout/products/$productId/edit': typeof HeaderLayoutProductsProductIdEditRoute + '/_header-layout/products/$productId/lexicon-compatible': typeof HeaderLayoutProductsProductIdLexiconCompatibleRoute '/_header-layout/products/$productId/mentions': typeof HeaderLayoutProductsProductIdMentionsRoute '/_header-layout/products/$productId/reviews': typeof HeaderLayoutProductsProductIdReviewsRouteWithChildren '/api/auth/atproto/authorize': typeof ApiAuthAtprotoAuthorizeRoute @@ -469,6 +508,9 @@ export interface FileRouteTypes { | '/og/' | '/apps/$tag' | '/apps/all' + | '/apps/lexicon' + | '/apps/lexicon-set' + | '/apps/lexicons' | '/apps/tags' | '/categories/$categoryId' | '/developers/atproto' @@ -486,6 +528,7 @@ export interface FileRouteTypes { | '/admin/unverified-listings' | '/ecosystems/$app/all' | '/products/$productId/edit' + | '/products/$productId/lexicon-compatible' | '/products/$productId/mentions' | '/products/$productId/reviews' | '/api/auth/atproto/authorize' @@ -515,6 +558,9 @@ export interface FileRouteTypes { | '/og' | '/apps/$tag' | '/apps/all' + | '/apps/lexicon' + | '/apps/lexicon-set' + | '/apps/lexicons' | '/apps/tags' | '/categories/$categoryId' | '/developers/atproto' @@ -532,6 +578,7 @@ export interface FileRouteTypes { | '/admin/unverified-listings' | '/ecosystems/$app/all' | '/products/$productId/edit' + | '/products/$productId/lexicon-compatible' | '/products/$productId/mentions' | '/api/auth/atproto/authorize' | '/api/auth/atproto/callback' @@ -562,6 +609,9 @@ export interface FileRouteTypes { | '/og/' | '/_header-layout/apps/$tag' | '/_header-layout/apps/all' + | '/_header-layout/apps/lexicon' + | '/_header-layout/apps/lexicon-set' + | '/_header-layout/apps/lexicons' | '/_header-layout/apps/tags' | '/_header-layout/categories/$categoryId' | '/_header-layout/developers/atproto' @@ -579,6 +629,7 @@ export interface FileRouteTypes { | '/_header-layout/_admin-layout/admin/unverified-listings' | '/_header-layout/ecosystems/$app/all' | '/_header-layout/products/$productId/edit' + | '/_header-layout/products/$productId/lexicon-compatible' | '/_header-layout/products/$productId/mentions' | '/_header-layout/products/$productId/reviews' | '/api/auth/atproto/authorize' @@ -760,6 +811,27 @@ declare module '@tanstack/react-router' { preLoaderRoute: typeof HeaderLayoutAppsTagsRouteImport parentRoute: typeof HeaderLayoutRoute } + '/_header-layout/apps/lexicons': { + id: '/_header-layout/apps/lexicons' + path: '/apps/lexicons' + fullPath: '/apps/lexicons' + preLoaderRoute: typeof HeaderLayoutAppsLexiconsRouteImport + parentRoute: typeof HeaderLayoutRoute + } + '/_header-layout/apps/lexicon-set': { + id: '/_header-layout/apps/lexicon-set' + path: '/apps/lexicon-set' + fullPath: '/apps/lexicon-set' + preLoaderRoute: typeof HeaderLayoutAppsLexiconSetRouteImport + parentRoute: typeof HeaderLayoutRoute + } + '/_header-layout/apps/lexicon': { + id: '/_header-layout/apps/lexicon' + path: '/apps/lexicon' + fullPath: '/apps/lexicon' + preLoaderRoute: typeof HeaderLayoutAppsLexiconRouteImport + parentRoute: typeof HeaderLayoutRoute + } '/_header-layout/apps/all': { id: '/_header-layout/apps/all' path: '/apps/all' @@ -844,6 +916,13 @@ declare module '@tanstack/react-router' { preLoaderRoute: typeof HeaderLayoutProductsProductIdMentionsRouteImport parentRoute: typeof HeaderLayoutRoute } + '/_header-layout/products/$productId/lexicon-compatible': { + id: '/_header-layout/products/$productId/lexicon-compatible' + path: '/products/$productId/lexicon-compatible' + fullPath: '/products/$productId/lexicon-compatible' + preLoaderRoute: typeof HeaderLayoutProductsProductIdLexiconCompatibleRouteImport + parentRoute: typeof HeaderLayoutRoute + } '/_header-layout/products/$productId/edit': { id: '/_header-layout/products/$productId/edit' path: '/products/$productId/edit' @@ -1016,6 +1095,9 @@ interface HeaderLayoutRouteChildren { HeaderLayoutIndexRoute: typeof HeaderLayoutIndexRoute HeaderLayoutAppsTagRoute: typeof HeaderLayoutAppsTagRoute HeaderLayoutAppsAllRoute: typeof HeaderLayoutAppsAllRoute + HeaderLayoutAppsLexiconRoute: typeof HeaderLayoutAppsLexiconRoute + HeaderLayoutAppsLexiconSetRoute: typeof HeaderLayoutAppsLexiconSetRoute + HeaderLayoutAppsLexiconsRoute: typeof HeaderLayoutAppsLexiconsRoute HeaderLayoutAppsTagsRoute: typeof HeaderLayoutAppsTagsRoute HeaderLayoutCategoriesCategoryIdRoute: typeof HeaderLayoutCategoriesCategoryIdRoute HeaderLayoutDevelopersAtprotoRoute: typeof HeaderLayoutDevelopersAtprotoRoute @@ -1025,6 +1107,7 @@ interface HeaderLayoutRouteChildren { HeaderLayoutProfileActorRoute: typeof HeaderLayoutProfileActorRoute HeaderLayoutEcosystemsAppAllRoute: typeof HeaderLayoutEcosystemsAppAllRoute HeaderLayoutProductsProductIdEditRoute: typeof HeaderLayoutProductsProductIdEditRoute + HeaderLayoutProductsProductIdLexiconCompatibleRoute: typeof HeaderLayoutProductsProductIdLexiconCompatibleRoute HeaderLayoutProductsProductIdMentionsRoute: typeof HeaderLayoutProductsProductIdMentionsRoute HeaderLayoutProductsProductIdReviewsRoute: typeof HeaderLayoutProductsProductIdReviewsRouteWithChildren HeaderLayoutEcosystemsAppIndexRoute: typeof HeaderLayoutEcosystemsAppIndexRoute @@ -1038,6 +1121,9 @@ const HeaderLayoutRouteChildren: HeaderLayoutRouteChildren = { HeaderLayoutIndexRoute: HeaderLayoutIndexRoute, HeaderLayoutAppsTagRoute: HeaderLayoutAppsTagRoute, HeaderLayoutAppsAllRoute: HeaderLayoutAppsAllRoute, + HeaderLayoutAppsLexiconRoute: HeaderLayoutAppsLexiconRoute, + HeaderLayoutAppsLexiconSetRoute: HeaderLayoutAppsLexiconSetRoute, + HeaderLayoutAppsLexiconsRoute: HeaderLayoutAppsLexiconsRoute, HeaderLayoutAppsTagsRoute: HeaderLayoutAppsTagsRoute, HeaderLayoutCategoriesCategoryIdRoute: HeaderLayoutCategoriesCategoryIdRoute, HeaderLayoutDevelopersAtprotoRoute: HeaderLayoutDevelopersAtprotoRoute, @@ -1048,6 +1134,8 @@ const HeaderLayoutRouteChildren: HeaderLayoutRouteChildren = { HeaderLayoutEcosystemsAppAllRoute: HeaderLayoutEcosystemsAppAllRoute, HeaderLayoutProductsProductIdEditRoute: HeaderLayoutProductsProductIdEditRoute, + HeaderLayoutProductsProductIdLexiconCompatibleRoute: + HeaderLayoutProductsProductIdLexiconCompatibleRoute, HeaderLayoutProductsProductIdMentionsRoute: HeaderLayoutProductsProductIdMentionsRoute, HeaderLayoutProductsProductIdReviewsRoute: diff --git a/src/routes/_header-layout.apps.$tag.tsx b/src/routes/_header-layout.apps.$tag.tsx index 4fdd345..8efee27 100644 --- a/src/routes/_header-layout.apps.$tag.tsx +++ b/src/routes/_header-layout.apps.$tag.tsx @@ -214,6 +214,7 @@ function AppsTagPage() { All tags + OAuth lexicons ({ + c: typeof search.c === "string" ? search.c : "", + sort: + search.sort === "newest" + ? "newest" + : search.sort === "alphabetical" + ? "alphabetical" + : "popular", + }), + loaderDeps: ({ search }) => ({ + c: search.c.trim(), + sort: search.sort, + }), + loader: async ({ context, deps }) => { + const clusterKeys = tryParseLexiconClusterSearchParam(deps.c); + if (clusterKeys == null) { + throw redirect({ to: "/apps/lexicons" }); + } + + const data = await context.queryClient.ensureQueryData( + directoryListingApi.getAppsByLexiconClusterPageQueryOptions({ + keys: clusterKeys, + sort: deps.sort, + }), + ); + + if (data == null) { + throw notFound(); + } + + const keyLabels = data.keys.map((k) => + formatOAuthLexiconKeyClusterStyleHeadline(k), + ); + const titleSuffix = formatLexiconClusterPageTitle(data.keys); + + let lexiconRecordDescription: string | null = null; + if (data.keys.length === 1) { + const onlyKey = data.keys[0]; + const parsed = onlyKey ? parseOAuthLexiconKey(onlyKey) : null; + const nsid = parsed?.nsid?.trim() ?? ""; + if (nsid.length > 0) { + lexiconRecordDescription = await context.queryClient.ensureQueryData( + directoryListingApi.getLexiconRecordMainDescriptionForNsidQueryOptions( + nsid, + ), + ); + } + } + + const baseOg = `Verified apps that advertise all of these repo record lexicons in OAuth scopes (${String(data.count)} listing${data.count === 1 ? "" : "s"}): ${keyLabels.join(", ")}.`; + const ogDescription = lexiconRecordDescription?.trim() + ? `${lexiconRecordDescription.trim()} ${baseOg}` + : baseOg; + + return { + clusterKeys: data.keys, + lexiconRecordDescription, + ogTitle: `${titleSuffix} · shared OAuth lexicons | at-store`, + ogDescription, + }; + }, + head: ({ loaderData }) => + buildRouteOgMeta({ + title: loaderData?.ogTitle ?? "OAuth lexicon cluster | at-store", + description: + loaderData?.ogDescription ?? + "Explore apps grouped by overlapping OAuth lexicon identifiers.", + }), + component: AppsLexiconSetPage, +}); + +function AppsLexiconSetPage() { + const search = Route.useSearch(); + const router = useRouter(); + const { clusterKeys, lexiconRecordDescription } = Route.useLoaderData(); + const { data } = useSuspenseQuery( + directoryListingApi.getAppsByLexiconClusterPageQueryOptions({ + keys: clusterKeys, + sort: search.sort, + }), + ); + + if (data == null) { + throw notFound(); + } + + const gridKey = data.keys.join("\u001F"); + + const defaultHeroDescription = + "These apps use the same data sources, so they can interoperate with each other."; + const heroDescription = + data.keys.length === 1 && + lexiconRecordDescription != null && + lexiconRecordDescription.trim() !== "" + ? lexiconRecordDescription.trim() + : defaultHeroDescription; + + return ( + + + + + + + All lexicon collections + + + + { + if ( + sortKey !== "popular" && + sortKey !== "newest" && + sortKey !== "alphabetical" + ) { + return; + } + + void router.navigate({ + to: "/apps/lexicon-set", + search: { c: search.c, sort: sortKey }, + }); + }} + > + {(item) => {item.label}} + + } + /> + {data.keys.length > 1 ? ( + + {data.keys.map((k) => { + const label = formatOAuthLexiconKeyClusterStyleHeadline(k); + return ( + + + {label} + + + ); + })} + + ) : null} + + + {data.listings.length > 0 ? ( + `${gridKey}-${listing.id}`} + canFeature={(listing) => Boolean(listing.heroImageUrl)} + renderItem={(listing, { featured }) => ( + + )} + /> + ) : ( + + + No verified app listings reference this exact cluster yet. Try the + hub or a single-key browse page—or wait for the next OAuth probe + sync. + + + )} + + + ); +} + +function LexiconListingCard({ + listing, + featured = false, +}: { + listing: DirectoryListingCard; + featured?: boolean; +}) { + return ( + + {featured ? ( + listing.heroImageUrl ? ( + + ) : ( + + ) + ) : ( + + + + + + + {listing.name} + + + @ + {listing.productAccountHandle?.replace(/^@/, "") || "unknown"} + + + + + {listing.tagline} + + + + {listing.rating == null ? "—" : listing.rating.toFixed(1)} + + + + + + )} + + ); +} diff --git a/src/routes/_header-layout.apps.lexicon.tsx b/src/routes/_header-layout.apps.lexicon.tsx new file mode 100644 index 0000000..c7058d1 --- /dev/null +++ b/src/routes/_header-layout.apps.lexicon.tsx @@ -0,0 +1,332 @@ +import * as stylex from "@stylexjs/stylex"; +import { useSuspenseQuery } from "@tanstack/react-query"; +import { + Link as RouterLink, + createFileRoute, + createLink, + notFound, + redirect, + useRouter, +} from "@tanstack/react-router"; +import { StarRating } from "#/design-system/star-rating"; +import { ChevronLeft } from "lucide-react"; + +import type { DirectoryListingCard } from "../integrations/tanstack-query/api-directory-listings.functions"; + +import { AppTagHero } from "../components/AppTagHero"; +import { FeaturedListingFallbackCard } from "../components/FeaturedListingFallbackCard"; +import { FeaturedListingGrid } from "../components/FeaturedListingGrid"; +import { HeroImage } from "../components/HeroImage"; +import { Avatar } from "../design-system/avatar"; +import { Card } from "../design-system/card"; +import { Flex } from "../design-system/flex"; +import { Link } from "../design-system/link"; +import { Page } from "../design-system/page"; +import { Select, SelectItem } from "../design-system/select"; +import { breakpoints } from "../design-system/theme/media-queries.stylex"; +import { + gap, + horizontalSpace, + verticalSpace, +} from "../design-system/theme/semantic-spacing.stylex"; +import { Body, SmallBody } from "../design-system/typography"; +import { Text } from "../design-system/typography/text"; +import { directoryListingApi } from "../integrations/tanstack-query/api-directory-listings.functions"; +import { getDirectoryListingSlug } from "../lib/directory-listing-slugs"; +import { getInitials } from "../lib/get-initials"; +import { getDirectoryListingHeroImageAlt } from "../lib/listing-copy"; +import { + formatOAuthLexiconKeyClusterStyleHeadline, + oauthLexiconKeyKindLabel, + parseOAuthLexiconKey, +} from "../lib/oauth-scope-lexicon-keys"; +import { buildRouteOgMeta } from "../lib/og-meta"; + +const sortOptions = [ + { id: "popular", label: "Popular" }, + { id: "newest", label: "Newest" }, + { id: "alphabetical", label: "Alphabetical" }, +] as const; + +const LinkLink = createLink(Link); + +const styles = stylex.create({ + pageContent: { + gap: { + default: gap["6xl"], + [breakpoints.xl]: gap["7xl"], + }, + }, + listingTagline: { + flexGrow: 1, + }, + page: { + paddingBottom: verticalSpace["10xl"], + paddingTop: verticalSpace["6xl"], + }, + navLinks: { + flexWrap: "wrap", + }, + sortSelect: { + flexGrow: { + default: 1, + [breakpoints.sm]: 0, + }, + minWidth: "12rem", + }, + listingLink: { + textDecoration: "none", + display: "block", + position: "relative", + zIndex: 1, + height: "100%", + }, + listingLinkFeatured: { + zIndex: 0, + }, + listingCard: { + boxSizing: "border-box", + height: "100%", + width: "100%", + }, + listingCardBody: { + gap: gap["4xl"], + position: "relative", + height: "100%", + paddingBottom: verticalSpace["xl"], + paddingLeft: horizontalSpace["xl"], + paddingRight: horizontalSpace["xl"], + paddingTop: verticalSpace["xl"], + }, + listingHeader: { + gap: gap["2xl"], + position: "relative", + zIndex: 1, + }, + listingInfo: { + flexBasis: "0%", + flexGrow: "1", + flexShrink: "1", + minWidth: 0, + }, + emptyState: { + gap: gap["lg"], + maxWidth: "40rem", + }, +}); + +export const Route = createFileRoute("/_header-layout/apps/lexicon")({ + validateSearch: ( + search, + ): { key: string; sort: "popular" | "newest" | "alphabetical" } => ({ + key: typeof search.key === "string" ? search.key : "", + sort: + search.sort === "newest" + ? "newest" + : search.sort === "alphabetical" + ? "alphabetical" + : "popular", + }), + loaderDeps: ({ search }) => ({ + key: search.key, + sort: search.sort, + }), + loader: async ({ context, deps }) => { + const key = deps.key.trim(); + if (!key) { + throw redirect({ to: "/apps/lexicons" }); + } + if (!parseOAuthLexiconKey(key)) { + throw notFound(); + } + const data = await context.queryClient.ensureQueryData( + directoryListingApi.getAppsByLexiconPageQueryOptions({ + key, + sort: deps.sort, + }), + ); + const parsed = parseOAuthLexiconKey(key); + const headline = formatOAuthLexiconKeyClusterStyleHeadline(key); + const kind = parsed ? oauthLexiconKeyKindLabel(parsed.kind) : "Lexicon"; + const nsid = parsed?.nsid?.trim() ?? ""; + const lexiconRecordDescription = + nsid.length > 0 + ? await context.queryClient.ensureQueryData( + directoryListingApi.getLexiconRecordMainDescriptionForNsidQueryOptions( + nsid, + ), + ) + : null; + const baseOg = `Verified apps that advertise overlapping OAuth scope vocabulary for ${headline} (${kind.toLowerCase()}). ${String(data.count)} listing${data.count === 1 ? "" : "s"}.`; + const ogDescription = lexiconRecordDescription?.trim() + ? `${lexiconRecordDescription.trim()} ${baseOg}` + : baseOg; + return { + key, + lexiconRecordDescription, + ogTitle: `${headline} · ${kind} | at-store`, + ogDescription, + }; + }, + head: ({ loaderData }) => + buildRouteOgMeta({ + title: loaderData?.ogTitle ?? "OAuth lexicon | at-store", + description: + loaderData?.ogDescription || + "Explore apps grouped by overlapping OAuth lexicon identifiers.", + }), + component: AppsLexiconPage, +}); + +function AppsLexiconPage() { + const search = Route.useSearch(); + const router = useRouter(); + const { key, lexiconRecordDescription } = Route.useLoaderData(); + const { data } = useSuspenseQuery( + directoryListingApi.getAppsByLexiconPageQueryOptions({ + key, + sort: search.sort, + }), + ); + + const parsed = parseOAuthLexiconKey(key); + const kindLabel = parsed ? oauthLexiconKeyKindLabel(parsed.kind) : "Lexicon"; + const headline = formatOAuthLexiconKeyClusterStyleHeadline(key); + const heroDescription = + lexiconRecordDescription != null && lexiconRecordDescription.trim() !== "" + ? lexiconRecordDescription.trim() + : `These verified apps include ${headline} in their published OAuth scope vocabulary, so they may interoperate with the same AT Protocol permissions layer.`; + + return ( + + + + + + + All lexicon collections + + + + { + if ( + sortKey !== "popular" && + sortKey !== "newest" && + sortKey !== "alphabetical" + ) { + return; + } + + void router.navigate({ + to: "/apps/lexicon", + search: { key, sort: sortKey }, + }); + }} + > + {(item) => {item.label}} + + } + /> + + + {data.listings.length > 0 ? ( + `${key}-${listing.id}`} + canFeature={(listing) => Boolean(listing.heroImageUrl)} + renderItem={(listing, { featured }) => ( + + )} + /> + ) : ( + + + No verified app listings reference this lexicon key yet. Lexicon + keys are populated from storefront OAuth probes — try again after + the next sync, or pick another collection from the hub. + + + )} + + + ); +} + +function LexiconListingCard({ + listing, + featured = false, +}: { + listing: DirectoryListingCard; + featured?: boolean; +}) { + return ( + + {featured ? ( + listing.heroImageUrl ? ( + + ) : ( + + ) + ) : ( + + + + + + + {listing.name} + + + @ + {listing.productAccountHandle?.replace(/^@/, "") || "unknown"} + + + + + {listing.tagline} + + + + {listing.rating == null ? "—" : listing.rating.toFixed(1)} + + + + + + )} + + ); +} diff --git a/src/routes/_header-layout.apps.lexicons.tsx b/src/routes/_header-layout.apps.lexicons.tsx new file mode 100644 index 0000000..82e8d32 --- /dev/null +++ b/src/routes/_header-layout.apps.lexicons.tsx @@ -0,0 +1,372 @@ +import * as stylex from "@stylexjs/stylex"; +import { useSuspenseQuery } from "@tanstack/react-query"; +import { + Link as RouterLink, + createFileRoute, + createLink, +} from "@tanstack/react-router"; +import { ChevronLeft, ChevronRight, ExternalLink } from "lucide-react"; +import { useMemo } from "react"; + +import type { DirectoryOAuthLexiconClusterSummary } from "../integrations/tanstack-query/api-directory-listings.functions"; + +import { AppTagHero } from "../components/AppTagHero"; +import { Card } from "../design-system/card"; +import { + Disclosure, + DisclosurePanel, + DisclosureTitle, +} from "../design-system/disclosure"; +import { Flex } from "../design-system/flex"; +import { Grid } from "../design-system/grid"; +import { Link } from "../design-system/link"; +import { Page } from "../design-system/page"; +import { breakpoints } from "../design-system/theme/media-queries.stylex"; +import { + gap, + verticalSpace, +} from "../design-system/theme/semantic-spacing.stylex"; +import { Body, SmallBody } from "../design-system/typography"; +import { Text } from "../design-system/typography/text"; +import { directoryListingApi } from "../integrations/tanstack-query/api-directory-listings.functions"; +import { getLexiconProducerSiteFromRepoNsid } from "../lib/lexicon-producer-site"; +import { + compareOAuthLexiconKeysForDisplayOrder, + formatOAuthLexiconKeyHeadline, + parseOAuthLexiconKey, + stringifyLexiconClusterSearchParam, +} from "../lib/oauth-scope-lexicon-keys"; +import { buildRouteOgMeta } from "../lib/og-meta"; + +const OTHER_GROUP_KEY = "zz-other"; + +const LinkLink = createLink(Link); + +const styles = stylex.create({ + grow: { + flexGrow: 1, + }, + page: { + paddingBottom: verticalSpace["10xl"], + paddingTop: verticalSpace["6xl"], + }, + navLinks: { + flexWrap: "wrap", + }, + grid: { + gap: gap["2xl"], + display: "grid", + gridTemplateColumns: { + default: "repeat(1, minmax(0, 1fr))", + [breakpoints.sm]: "repeat(2, minmax(0, 1fr))", + [breakpoints.lg]: "repeat(3, minmax(0, 1fr))", + }, + }, + cardLink: { + textDecoration: "none", + color: "inherit", + display: "block", + height: "100%", + }, + card: { + height: "100%", + }, + cardInner: { + gap: gap["2xl"], + height: "100%", + paddingBottom: verticalSpace["2xl"], + paddingLeft: verticalSpace["3xl"], + paddingRight: verticalSpace["3xl"], + paddingTop: verticalSpace["2xl"], + }, + emptyState: { + gap: gap["lg"], + maxWidth: "40rem", + }, + cardTitleText: { + wordBreak: "break-word", + }, + keyDescription: { + // oxlint-disable-next-line @stylexjs/valid-styles + lineClamp: 3, + overflow: "hidden", + WebkitBoxOrient: "vertical", + WebkitLineClamp: 3, + display: "-webkit-box", + }, + siteSection: { + width: "100%", + }, + disclosureTitleInner: { + gap: gap.md, + alignItems: "center", + display: "flex", + flexBasis: "0%", + flexGrow: 1, + flexShrink: 1, + textAlign: "start", + minWidth: 0, + }, + siteExternalLink: { + display: "inline-flex", + flexShrink: 0, + }, + siteHeadingLink: { + gap: gap.sm, + textDecoration: "none", + alignItems: "center", + color: "inherit", + display: "inline-flex", + }, +}); + +export const Route = createFileRoute("/_header-layout/apps/lexicons")({ + loader: ({ context }) => + context.queryClient.ensureQueryData( + directoryListingApi.getAppsOAuthLexiconSummariesQueryOptions, + ), + head: () => + buildRouteOgMeta({ + title: "OAuth lexicon collections | at-store", + description: + "Browse verified apps that share repo record collection lexicons from OAuth scopes.", + }), + component: AppsLexiconsHubPage, +}); + +function formatLexiconCount(n: number) { + return `${String(n)} app${n === 1 ? "" : "s"}`; +} + +function clusterProducerSection(cluster: DirectoryOAuthLexiconClusterSummary): { + groupKey: string; + siteLabel: string; + siteOrigin: string; +} { + const sites = cluster.keys + .map((k) => { + const p = parseOAuthLexiconKey(k); + if (!p || p.kind !== "repo") { + return null; + } + return getLexiconProducerSiteFromRepoNsid(p.nsid); + }) + .filter((x): x is NonNullable => x != null); + if (sites.length === 0) { + return { groupKey: OTHER_GROUP_KEY, siteLabel: "Other", siteOrigin: "" }; + } + return sites.toSorted((a, b) => a.groupKey.localeCompare(b.groupKey))[0]; +} + +function LexiconClusterHubCard({ + row, + descriptionsByRepoNsid, +}: { + row: DirectoryOAuthLexiconClusterSummary; + descriptionsByRepoNsid: Record; +}) { + const c = stringifyLexiconClusterSearchParam(row.keys); + + return ( + + + + + + {row.keys.map((key) => { + const parsed = parseOAuthLexiconKey(key); + const nsid = parsed?.nsid; + const desc = + nsid != null && nsid.length > 0 + ? descriptionsByRepoNsid[nsid] + : undefined; + return ( + + + {formatOAuthLexiconKeyHeadline(key)} + + {desc ? ( + + {desc} + + ) : null} + + ); + })} + + + + + {formatLexiconCount(row.appCount)} + + + + + ); +} + +type SiteSection = { + groupKey: string; + siteLabel: string; + siteOrigin: string; + clusters: Array; +}; + +function AppsLexiconsHubPage() { + const { data: hub } = useSuspenseQuery( + directoryListingApi.getAppsOAuthLexiconSummariesQueryOptions, + ); + + const siteSections = useMemo((): Array => { + const bucket = new Map< + string, + { + siteLabel: string; + siteOrigin: string; + clusters: SiteSection["clusters"]; + } + >(); + for (const row of hub.clusters) { + const meta = clusterProducerSection(row); + let g = bucket.get(meta.groupKey); + if (!g) { + g = { + siteLabel: meta.siteLabel, + siteOrigin: meta.siteOrigin, + clusters: [], + }; + bucket.set(meta.groupKey, g); + } + g.clusters.push(row); + } + + return [...bucket.entries()] + .map(([groupKey, g]) => { + g.clusters.sort((c1, c2) => { + if (c2.appCount !== c1.appCount) return c2.appCount - c1.appCount; + const ak = c1.keys[0] ?? ""; + const bk = c2.keys[0] ?? ""; + return compareOAuthLexiconKeysForDisplayOrder(ak, bk); + }); + const uniqueAppCount = new Set(g.clusters.flatMap((c) => c.listingIds)) + .size; + return { groupKey, g, uniqueAppCount }; + }) + .toSorted((a, b) => { + if (b.uniqueAppCount !== a.uniqueAppCount) { + return b.uniqueAppCount - a.uniqueAppCount; + } + return a.groupKey.localeCompare(b.groupKey); + }) + .map(({ groupKey, g }) => ({ + groupKey, + siteLabel: g.siteLabel, + siteOrigin: g.siteOrigin, + clusters: g.clusters, + })); + }, [hub.clusters]); + + return ( + + + + + + + All tags + + + + All apps + + + + + + + {hub.clusters.length > 0 ? ( + + {siteSections.map((section) => ( + + + + + {section.siteLabel} + + {section.siteOrigin ? ( + { + e.stopPropagation(); + }} + onPointerDown={(e) => { + e.stopPropagation(); + }} + {...stylex.props( + styles.siteHeadingLink, + styles.siteExternalLink, + )} + > + + + ) : null} + + + + + {section.clusters.map((row) => ( + + ))} + + + + ))} + + ) : ( + + + No shared repo record lexicon collections yet. Run storefront + OAuth probes (`listing:oauth-probes-sync`) so we can index scope + vocabulary—or every `repo:` key may appear on only one app (we + require two or more listings). This page omits `include:` and + `rpc:` keys. On product pages, compatible-app matching still omits + `app.bsky.*` except for the Bluesky client listing. + + + )} + + + ); +} diff --git a/src/routes/_header-layout.apps.tags.tsx b/src/routes/_header-layout.apps.tags.tsx index 2d2d3cc..91bd5e9 100644 --- a/src/routes/_header-layout.apps.tags.tsx +++ b/src/routes/_header-layout.apps.tags.tsx @@ -80,6 +80,8 @@ function AppsAllPage() { All apps + + OAuth lexicons 0) { + await context.queryClient.ensureQueryData( + directoryListingApi.getLexiconCompatibleAppsPageQueryOptions({ + listingId: listing.id, + sort: "popular", + }), + ); + } const categoryGroup = listing.categorySlug ? await context.queryClient.ensureQueryData( directoryListingApi.getDirectoryCategoryPageQueryOptions({ @@ -218,6 +232,7 @@ export const Route = createFileRoute("/_header-layout/products/$productId/")({ ecosystemRootId, listing, relatedProducts, + relatedAppsByOAuthLexicon, relatedCategoryListings, listingReviews, listingProductUpdates, @@ -703,6 +718,7 @@ function ProductPage() { ecosystemRootId, listing, relatedProducts, + relatedAppsByOAuthLexicon, relatedCategoryListings, listingReviews, listingProductUpdates, @@ -726,12 +742,20 @@ function ProductPage() { listingProductUpdates.length > PRODUCT_UPDATES_PREVIEW_COUNT && productUpdatesPublicationUrl != null && productUpdatesPublicationUrl.length > 0; - const relatedSectionTitle = - relatedCategoryListings.length > 0 ? "More in this category" : "More Apps"; - const relatedSectionListings = + const compatibleRelatedIds = new Set( + relatedAppsByOAuthLexicon.listings.map((l) => l.id), + ); + const relatedSectionListingsBase = relatedCategoryListings.length > 0 ? relatedCategoryListings : relatedProducts; + const relatedSectionListings = relatedSectionListingsBase.filter( + (l) => !compatibleRelatedIds.has(l.id), + ); + const relatedSectionTitle = + relatedCategoryListings.length > 0 + ? "More in this category" + : "Similar apps"; const [type, scope, domain] = listing.categoryPathLabel?.split(" / ") || []; const isRootApp = type === "Apps" && scope && !domain; @@ -1093,6 +1117,35 @@ function ProductPage() { ) : null} + {relatedAppsByOAuthLexicon.listings.length > 0 ? ( + + + + Compatible apps + + + View all + + + + {relatedAppsByOAuthLexicon.listings.map((lexListing) => ( + + ))} + + + ) : null} + {relatedSectionListings.length > 0 ? ( ; title?: string; diff --git a/src/routes/_header-layout.products.$productId.lexicon-compatible.tsx b/src/routes/_header-layout.products.$productId.lexicon-compatible.tsx new file mode 100644 index 0000000..f1f81ba --- /dev/null +++ b/src/routes/_header-layout.products.$productId.lexicon-compatible.tsx @@ -0,0 +1,442 @@ +import * as stylex from "@stylexjs/stylex"; +import { useSuspenseQuery } from "@tanstack/react-query"; +import { + Link as RouterLink, + createFileRoute, + createLink, + notFound, + redirect, + useRouter, +} from "@tanstack/react-router"; +import { StarRating } from "#/design-system/star-rating"; +import { ChevronLeft } from "lucide-react"; + +import type { DirectoryListingCard } from "../integrations/tanstack-query/api-directory-listings.functions"; + +import { AppTagHero } from "../components/AppTagHero"; +import { FeaturedListingFallbackCard } from "../components/FeaturedListingFallbackCard"; +import { FeaturedListingGrid } from "../components/FeaturedListingGrid"; +import { HeroImage } from "../components/HeroImage"; +import { Avatar } from "../design-system/avatar"; +import { Badge } from "../design-system/badge"; +import { Card } from "../design-system/card"; +import { Flex } from "../design-system/flex"; +import { Link } from "../design-system/link"; +import { Page } from "../design-system/page"; +import { Select, SelectItem } from "../design-system/select"; +import { breakpoints } from "../design-system/theme/media-queries.stylex"; +import { + gap, + horizontalSpace, + size, + verticalSpace, +} from "../design-system/theme/semantic-spacing.stylex"; +import { Body, SmallBody } from "../design-system/typography"; +import { Text } from "../design-system/typography/text"; +import { directoryListingApi } from "../integrations/tanstack-query/api-directory-listings.functions"; +import { + getDirectoryListingSlug, + getLegacyDirectoryListingId, +} from "../lib/directory-listing-slugs"; +import { getInitials } from "../lib/get-initials"; +import { getDirectoryListingHeroImageAlt } from "../lib/listing-copy"; +import { + formatOAuthLexiconKeyClusterStyleHeadline, + oauthLexiconKeyKindLabel, + parseOAuthLexiconKey, +} from "../lib/oauth-scope-lexicon-keys"; +import { buildRouteOgMeta } from "../lib/og-meta"; + +const LEXICON_COMPATIBLE_PAGE_CAP = 250; + +const sortOptions = [ + { id: "popular", label: "Popular" }, + { id: "newest", label: "Newest" }, + { id: "alphabetical", label: "Alphabetical" }, +] as const; + +const LinkLink = createLink(Link); + +const styles = stylex.create({ + pageContent: { + gap: { + default: gap["6xl"], + [breakpoints.xl]: gap["7xl"], + }, + }, + listingTagline: { + flexGrow: 1, + }, + page: { + paddingBottom: verticalSpace["10xl"], + paddingTop: verticalSpace["6xl"], + }, + navLinks: { + flexWrap: "wrap", + }, + sortSelect: { + flexGrow: { + default: 1, + [breakpoints.sm]: 0, + }, + minWidth: "12rem", + }, + listingLink: { + textDecoration: "none", + display: "block", + position: "relative", + zIndex: 1, + height: "100%", + }, + listingLinkFeatured: { + zIndex: 0, + }, + listingCard: { + boxSizing: "border-box", + height: "100%", + width: "100%", + }, + listingCardBody: { + gap: gap["4xl"], + position: "relative", + height: "100%", + paddingBottom: verticalSpace["xl"], + paddingLeft: horizontalSpace["xl"], + paddingRight: horizontalSpace["xl"], + paddingTop: verticalSpace["xl"], + }, + listingHeader: { + gap: gap["2xl"], + position: "relative", + zIndex: 1, + }, + listingInfo: { + flexBasis: "0%", + flexGrow: "1", + flexShrink: "1", + minWidth: 0, + }, + emptyState: { + gap: gap["lg"], + maxWidth: "40rem", + }, + lexiconBadgeGrow: { + alignItems: "center", + height: "auto", + minHeight: size.lg, + paddingBottom: verticalSpace.xs, + paddingTop: verticalSpace.xs, + }, + badgeRow: { + gap: gap.md, + alignItems: "center", + display: "flex", + flexWrap: "wrap", + rowGap: gap.md, + }, + badgeLink: { + textDecoration: "none", + color: "inherit", + display: "block", + maxWidth: "100%", + }, + badgeInner: { + gap: gap.sm, + alignItems: "center", + display: "flex", + maxWidth: { + default: "24rem", + [breakpoints.sm]: "36rem", + }, + minWidth: 0, + }, + badgeLabel: { + display: "block", + flexShrink: 1, + whiteSpace: "normal", + wordBreak: "break-word", + minWidth: 0, + }, + badgeCount: { + flexShrink: 0, + fontVariantNumeric: "tabular-nums", + }, +}); + +export const Route = createFileRoute( + "/_header-layout/products/$productId/lexicon-compatible", +)({ + validateSearch: ( + search, + ): { sort: "popular" | "newest" | "alphabetical" } => ({ + sort: + search.sort === "newest" + ? "newest" + : search.sort === "alphabetical" + ? "alphabetical" + : "popular", + }), + loaderDeps: ({ search }) => ({ sort: search.sort }), + loader: async ({ context, params, deps }) => { + const legacyListingId = getLegacyDirectoryListingId(params.productId); + const listing = await context.queryClient.ensureQueryData( + legacyListingId + ? directoryListingApi.getDirectoryListingDetailQueryOptions( + legacyListingId, + ) + : directoryListingApi.getDirectoryListingDetailBySlugQueryOptions( + params.productId, + ), + ); + + if (!listing) { + throw notFound(); + } + + const productSlug = getDirectoryListingSlug(listing); + + if (params.productId !== productSlug) { + throw redirect({ + to: "/products/$productId/lexicon-compatible", + params: { productId: productSlug }, + search: { sort: deps.sort }, + replace: true, + }); + } + + const data = await context.queryClient.ensureQueryData( + directoryListingApi.getLexiconCompatibleAppsPageQueryOptions({ + listingId: listing.id, + sort: deps.sort, + }), + ); + + if (data == null) { + throw redirect({ + to: "/products/$productId", + params: { productId: productSlug }, + }); + } + + return { + productId: listing.id, + productSlug, + listingName: listing.name, + ogTitle: `Lexicon-compatible apps · ${listing.name} | at-store`, + ogDescription: `${String(data.count)} verified app${data.count === 1 ? "" : "s"} overlap OAuth scope vocabulary with ${listing.name} (up to ${String(LEXICON_COMPATIBLE_PAGE_CAP)} in the grid). Badges list ${String(data.matchLexiconEntries.length)} repo record collection${data.matchLexiconEntries.length === 1 ? "" : "s"} with other-app counts.`, + ogImage: listing.heroImageUrl || null, + }; + }, + head: ({ loaderData }) => + buildRouteOgMeta({ + title: loaderData?.ogTitle ?? "Lexicon-compatible apps | at-store", + description: + loaderData?.ogDescription ?? + "Browse apps that overlap with this product OAuth scope vocabulary.", + image: loaderData?.ogImage, + }), + component: LexiconCompatibleAppsPage, +}); + +function LexiconCompatibleAppsPage() { + const search = Route.useSearch(); + const router = useRouter(); + const { productId, productSlug, listingName } = Route.useLoaderData(); + const { data } = useSuspenseQuery( + directoryListingApi.getLexiconCompatibleAppsPageQueryOptions({ + listingId: productId, + sort: search.sort, + }), + ); + + if (data == null) { + throw redirect({ + to: "/products/$productId", + params: { productId: productSlug }, + }); + } + + return ( + + + + + + + {listingName} + + + + + + These apps use the same data sources and permissions, so they + can interoperate with each other. + + {data.matchLexiconEntries.length > 0 ? ( + + {data.matchLexiconEntries.map(({ key, otherAppCount }) => ( + + ))} + + ) : null} + + } + action={ + + } + /> + + + {data.listings.length > 0 ? ( + listing.id} + canFeature={(listing) => Boolean(listing.heroImageUrl)} + renderItem={(listing, { featured }) => ( + + )} + /> + ) : ( + + + No other verified apps overlap these lexicon identifiers yet, or + probes are still incomplete. + + + )} +
+ + ); +} + +function LexiconMatchBadge({ + lexiconKey, + otherAppCount, +}: { + lexiconKey: string; + otherAppCount: number; +}) { + const parsed = parseOAuthLexiconKey(lexiconKey); + const kindLabel = parsed ? oauthLexiconKeyKindLabel(parsed.kind) : "Lexicon"; + const headline = formatOAuthLexiconKeyClusterStyleHeadline(lexiconKey); + const title = `${headline} — ${String(otherAppCount)} other app${otherAppCount === 1 ? "" : "s"} (${kindLabel}: ${lexiconKey})`; + + return ( + + + + {headline} + {otherAppCount} + + + + ); +} + +function CompatibleAppListingCard({ + listing, + featured = false, +}: { + listing: DirectoryListingCard; + featured?: boolean; +}) { + return ( + + {featured ? ( + listing.heroImageUrl ? ( + + ) : ( + + ) + ) : ( + + + + + + + {listing.name} + + + @ + {listing.productAccountHandle?.replace(/^@/, "") || "unknown"} + + + + + {listing.tagline} + + + + {listing.rating == null ? "—" : listing.rating.toFixed(1)} + + + + + + )} + + ); +}