this repo has no description
0
fork

Configure Feed

Select the types of activity you want to include in your feed.

fix(registry): support external profile ingestion

Avoid permission-set resolution during OAuth and make profile indexing tolerant of records created outside the Atmosphere client.

Made-with: Cursor

+173 -75
+9 -9
docs/PUBLISHING_LEXICONS.md
··· 133 133 134 134 ## OAuth integration notes 135 135 136 - The login flow requests this scope (see `lib/oauth.ts`): 136 + The login flow currently requests explicit granular scopes (see `lib/oauth.ts`): 137 137 138 138 ``` 139 - atproto include:com.atmosphereaccount.registry.fullPermissions blob:image/* 139 + atproto repo:com.atmosphereaccount.registry.profile repo:com.atmosphereaccount.registry.review repo:com.atmosphereaccount.registry.update blob:image/* 140 140 ``` 141 141 142 - - **`include:...`** — the authorization server resolves this NSID via DNS and 143 - reads the schema record's `title` / `detail` to render the consent dialog. It 144 - also expands the `permissions[]` array into the actual granted scope. If 145 - resolution fails the PDS returns `invalid_scope`, which is exactly what 146 - blocked logins until DNS was set up. 142 + - **`repo:...`** — direct write access to the Atmosphere profile, review, and 143 + update collections. We request direct scopes so login keeps working on PDSes 144 + that do not yet resolve DNS-backed permission sets correctly. 147 145 - **`blob:image/*`** is a top-level scope on purpose. The atproto permission 148 146 spec 149 147 [explicitly disallows `blob` permissions inside 150 148 permission sets](https://atproto.com/specs/permission#permission-sets) — they 151 149 must always be requested separately. 152 150 151 + We still publish `com.atmosphereaccount.registry.fullPermissions` so compatible 152 + authorization servers can present a human-friendly permission set in the future. 153 153 If you change the permission set's `title`, `detail`, or `permissions[]`, 154 - remember the consent dialog won't reflect it until you `lex:publish:update` 155 - **and** the cache on the user's auth server expires. 154 + remember compatible consent dialogs won't reflect it until you 155 + `lex:publish:update` **and** the cache on the user's auth server expires. 156 156 157 157 --- 158 158
+12 -25
lib/oauth.ts
··· 38 38 /** 39 39 * Minimum-permission scope. 40 40 * 41 - * Composed of three parts: 42 - * - `atproto` - identity 43 - * - `include:com.atmosphereaccount.registry.fullPermissions` - repo writes 44 - * to our 45 - * profile + 46 - * review 47 - * collections 48 - * (resolved 49 - * dynamically 50 - * from the 51 - * published 52 - * permission-set 53 - * lexicon) 54 - * - `blob:image/*` - avatar + 55 - * SVG icon 56 - * uploads 41 + * Composed of explicit granular scopes: 42 + * - `atproto` - identity 43 + * - `repo:com.atmosphereaccount.registry.profile` - profile writes 44 + * - `repo:com.atmosphereaccount.registry.review` - review writes 45 + * - `repo:com.atmosphereaccount.registry.update` - update writes 46 + * - `blob:image/*` - avatar + 47 + * SVG icon 48 + * uploads 57 49 * 58 - * The `blob` scope is intentionally NOT bundled into the permission set 59 - * because the atproto permission spec explicitly disallows blob permissions 60 - * inside permission sets — they must always be requested separately. 61 - * See https://atproto.com/specs/permission ("Permission Sets"). 62 - * 63 - * The permission-set lexicon must be published to the DID that holds DNS 64 - * authority for `_lexicon.registry.atmosphereaccount.com` before this scope 65 - * will resolve. See `docs/PUBLISHING_LEXICONS.md` for setup steps. 50 + * We keep `com.atmosphereaccount.registry.fullPermissions` published as a 51 + * human-friendly permission set, but request direct repo scopes here so login 52 + * does not depend on every PDS correctly resolving DNS-backed permission sets. 66 53 * 67 54 * MUST stay in sync with `routes/oauth/client-metadata.json.ts`. 68 55 */ 69 56 const DEFAULT_SCOPE = 70 - "atproto include:com.atmosphereaccount.registry.fullPermissions blob:image/*"; 57 + "atproto repo:com.atmosphereaccount.registry.profile repo:com.atmosphereaccount.registry.review repo:com.atmosphereaccount.registry.update blob:image/*"; 71 58 const STATE_TTL_MS = 10 * 60 * 1000; 72 59 const ACCESS_TOKEN_REFRESH_THRESHOLD_MS = 60 * 1000; 73 60
+30
lib/pds.ts
··· 213 213 return await res.json() as { uri: string; cid: string; value: unknown }; 214 214 } 215 215 216 + export async function listRecordsPublic( 217 + pdsUrl: string, 218 + did: string, 219 + collection: string, 220 + opts: { limit?: number; reverse?: boolean } = {}, 221 + ): Promise< 222 + { 223 + cursor?: string; 224 + records: Array<{ uri: string; cid: string; value: unknown }>; 225 + } 226 + > { 227 + const url = new URL( 228 + `${pdsUrl.replace(/\/$/, "")}/xrpc/com.atproto.repo.listRecords`, 229 + ); 230 + url.searchParams.set("repo", did); 231 + url.searchParams.set("collection", collection); 232 + url.searchParams.set( 233 + "limit", 234 + String(Math.max(1, Math.min(opts.limit ?? 25, 100))), 235 + ); 236 + if (opts.reverse) url.searchParams.set("reverse", "true"); 237 + const res = await fetch(url.toString()); 238 + if (res.status === 404) return { records: [] }; 239 + if (!res.ok) throw new Error(`listRecords failed: HTTP ${res.status}`); 240 + return await res.json() as { 241 + cursor?: string; 242 + records: Array<{ uri: string; cid: string; value: unknown }>; 243 + }; 244 + } 245 + 216 246 /** Public: fetch app.bsky.actor.profile to pre-fill the create form. */ 217 247 export async function getBskyProfile( 218 248 pdsUrl: string,
+103
lib/profile-sync.ts
··· 1 + import { resolveIdentity } from "./identity.ts"; 2 + import { PROFILE_NSID, validateProfile } from "./lexicons.ts"; 3 + import { getRecordPublic, listRecordsPublic } from "./pds.ts"; 4 + import { getProfileByDid, upsertProfile } from "./registry.ts"; 5 + 6 + interface ProfileRecordEnvelope { 7 + uri: string; 8 + cid: string; 9 + value: unknown; 10 + rkey: string; 11 + } 12 + 13 + function rkeyFromAtUri(uri: string): string | null { 14 + const parts = uri.split("/"); 15 + return parts.length > 0 ? parts[parts.length - 1] || null : null; 16 + } 17 + 18 + export async function upsertProfileFromRecord(input: { 19 + did: string; 20 + handle: string; 21 + pdsUrl: string; 22 + record: ProfileRecordEnvelope; 23 + recordRev: string; 24 + }): Promise<boolean> { 25 + const validation = validateProfile(input.record.value); 26 + if (!validation.ok || !validation.value) { 27 + console.warn( 28 + `[profile-sync] invalid profile from ${input.did}/${input.record.rkey}: ${validation.error}`, 29 + ); 30 + return false; 31 + } 32 + 33 + const r = validation.value; 34 + await upsertProfile({ 35 + did: input.did, 36 + handle: input.handle, 37 + profileType: r.profileType, 38 + name: r.name, 39 + description: r.description, 40 + mainLink: r.mainLink ?? null, 41 + iosLink: r.iosLink ?? null, 42 + androidLink: r.androidLink ?? null, 43 + categories: r.categories ?? [], 44 + subcategories: r.subcategories ?? [], 45 + links: r.links ?? [], 46 + screenshots: r.screenshots ?? [], 47 + avatarCid: r.avatar?.ref.$link ?? null, 48 + avatarMime: r.avatar?.mimeType ?? null, 49 + iconCid: r.icon?.ref.$link ?? null, 50 + iconMime: r.icon?.mimeType ?? null, 51 + pdsUrl: input.pdsUrl, 52 + recordCid: input.record.cid, 53 + recordRev: input.recordRev, 54 + createdAt: Date.parse(r.createdAt) || Date.now(), 55 + }); 56 + return true; 57 + } 58 + 59 + export async function syncProfileByIdentifier( 60 + identifier: string, 61 + ): Promise<boolean> { 62 + const identity = await resolveIdentity(identifier); 63 + const canonical = await getRecordPublic( 64 + identity.pdsUrl, 65 + identity.did, 66 + PROFILE_NSID, 67 + "self", 68 + ); 69 + 70 + if (canonical) { 71 + return await upsertProfileFromRecord({ 72 + did: identity.did, 73 + handle: identity.handle, 74 + pdsUrl: identity.pdsUrl, 75 + record: { ...canonical, rkey: "self" }, 76 + recordRev: canonical.cid, 77 + }); 78 + } 79 + 80 + const listed = await listRecordsPublic( 81 + identity.pdsUrl, 82 + identity.did, 83 + PROFILE_NSID, 84 + { 85 + limit: 25, 86 + reverse: true, 87 + }, 88 + ); 89 + for (const record of listed.records) { 90 + const rkey = rkeyFromAtUri(record.uri); 91 + if (!rkey) continue; 92 + const synced = await upsertProfileFromRecord({ 93 + did: identity.did, 94 + handle: identity.handle, 95 + pdsUrl: identity.pdsUrl, 96 + record: { ...record, rkey }, 97 + recordRev: record.cid, 98 + }); 99 + if (synced) return true; 100 + } 101 + 102 + return (await getProfileByDid(identity.did).catch(() => null)) !== null; 103 + }
+11 -1
routes/explore/[handle].tsx
··· 32 32 listProfileUpdates, 33 33 type ProfileUpdateRow, 34 34 } from "../../lib/profile-updates.ts"; 35 + import { syncProfileByIdentifier } from "../../lib/profile-sync.ts"; 35 36 36 37 export const handler = define.handlers({ 37 38 async GET(ctx) { ··· 41 42 * user's own registry entry so the AccountMenu can deep-link to 42 43 * their public page. The lookups are cheap and trigger from the 43 44 * same DB connection. */ 44 - const [profile, ownerProfile] = await Promise.all([ 45 + let [profile, ownerProfile] = await Promise.all([ 45 46 getProfileByHandle(handle).catch(() => null), 46 47 user ? getProfileByDid(user.did).catch(() => null) : Promise.resolve( 47 48 null, 48 49 ), 49 50 ]); 51 + if (!profile) { 52 + const synced = await syncProfileByIdentifier(handle).catch((err) => { 53 + console.warn(`[explore] profile sync failed for ${handle}:`, err); 54 + return false; 55 + }); 56 + if (synced) { 57 + profile = await getProfileByHandle(handle).catch(() => null); 58 + } 59 + } 50 60 const [reviewSummary, reviews, ownReview, updates] = profile 51 61 ? await Promise.all([ 52 62 getReviewSummary(profile.did).catch(() => emptyReviewSummary()),
+3 -10
routes/oauth/client-metadata.json.ts
··· 24 24 response_types: ["code"], 25 25 redirect_uris: [redirectUri()], 26 26 /** 27 - * Minimum-permission scope. The `include:` half is resolved by the 28 - * authorization server via DNS-based atproto lexicon resolution 29 - * (TXT record at `_lexicon.registry.atmosphereaccount.com`) and 30 - * rendered in the consent dialog via its `title` / `detail`. 31 - * 32 - * `blob:image/*` is requested as a top-level scope because the 33 - * atproto permission spec explicitly disallows blob permissions 34 - * inside permission sets. 35 - * 36 27 * MUST stay in sync with `DEFAULT_SCOPE` in `lib/oauth.ts`. 28 + * Direct repo scopes avoid depending on every PDS correctly resolving 29 + * DNS-backed permission sets during login. 37 30 */ 38 31 scope: 39 - "atproto include:com.atmosphereaccount.registry.fullPermissions blob:image/*", 32 + "atproto repo:com.atmosphereaccount.registry.profile repo:com.atmosphereaccount.registry.review repo:com.atmosphereaccount.registry.update blob:image/*", 40 33 dpop_bound_access_tokens: true, 41 34 token_endpoint_auth_method: "private_key_jwt", 42 35 token_endpoint_auth_signing_alg: "ES256",
+5 -30
worker/indexer.ts
··· 18 18 REVIEW_NSID, 19 19 UPDATE_NSID, 20 20 validateFeatured, 21 - validateProfile, 22 21 validateReview, 23 22 validateUpdate, 24 23 } from "../lib/lexicons.ts"; ··· 28 27 getProfileByDid, 29 28 replaceFeatured, 30 29 setJetstreamCursor, 31 - upsertProfile, 32 30 } from "../lib/registry.ts"; 33 31 import { 34 32 createOrUpdateReview, ··· 43 41 import { findPdsEndpoint, resolveDidDocument } from "../lib/identity.ts"; 44 42 import { getRecordPublic } from "../lib/pds.ts"; 45 43 import { JETSTREAM_URL } from "../lib/env.ts"; 44 + import { upsertProfileFromRecord } from "../lib/profile-sync.ts"; 46 45 47 46 interface JetstreamCommit { 48 47 rev: string; ··· 106 105 pdsUrl, 107 106 event.did, 108 107 PROFILE_NSID, 109 - "self", 108 + commit.rkey, 110 109 ); 111 110 if (!fetched) return; 112 111 113 - const validation = validateProfile(fetched.value); 114 - if (!validation.ok || !validation.value) { 115 - console.warn( 116 - `[indexer] invalid profile from ${event.did}: ${validation.error}`, 117 - ); 118 - return; 119 - } 120 - const r = validation.value; 121 112 const handle = await resolveHandleFromDoc(event.did); 122 - 123 - await upsertProfile({ 113 + const synced = await upsertProfileFromRecord({ 124 114 did: event.did, 125 115 handle, 126 - profileType: r.profileType, 127 - name: r.name, 128 - description: r.description, 129 - mainLink: r.mainLink ?? null, 130 - iosLink: r.iosLink ?? null, 131 - androidLink: r.androidLink ?? null, 132 - categories: r.categories ?? [], 133 - subcategories: r.subcategories ?? [], 134 - links: r.links ?? [], 135 - screenshots: r.screenshots ?? [], 136 - avatarCid: r.avatar?.ref.$link ?? null, 137 - avatarMime: r.avatar?.mimeType ?? null, 138 - iconCid: r.icon?.ref.$link ?? null, 139 - iconMime: r.icon?.mimeType ?? null, 140 116 pdsUrl, 141 - recordCid: fetched.cid, 117 + record: { ...fetched, rkey: commit.rkey }, 142 118 recordRev: commit.rev, 143 - createdAt: Date.parse(r.createdAt) || Date.now(), 144 119 }); 145 - console.log(`[indexer] upsert profile ${handle} (${event.did})`); 120 + if (synced) console.log(`[indexer] upsert profile ${handle} (${event.did})`); 146 121 } 147 122 148 123 async function handleReviewEvent(event: JetstreamEvent): Promise<void> {