chore: import upstream snapshot with attribution
CI / E2E Cloudflare (4/8) (push) Failing after 0s
CI / E2E Cloudflare (8/8) (push) Failing after 1s
CI / Lint (push) Failing after 1s
Auto Extract / Extract (push) Failing after 4s
CI / Version Check (push) Failing after 9s
CI / Integration Tests (push) Failing after 1s
CI / E2E tests (1/8) (push) Failing after 1s
CI / E2E tests (2/8) (push) Failing after 2s
CI / E2E tests (3/8) (push) Failing after 2s
CI / Browser Tests (push) Failing after 1s
CI / E2E tests (5/8) (push) Failing after 1s
CI / E2E Cloudflare (2/8) (push) Failing after 2s
CI / Typecheck (push) Failing after 1s
CI / Changeset Validation (push) Failing after 2s
CI / E2E Cloudflare (5/8) (push) Failing after 1s
CI / E2E Cloudflare (6/8) (push) Failing after 1s
CI / E2E Cloudflare (7/8) (push) Failing after 1s
CodeQL / Analyze (javascript-typescript) (push) Failing after 1s
Format / Format (push) Failing after 0s
CodeQL / Analyze (actions) (push) Failing after 4s
CI / E2E tests (4/8) (push) Failing after 1s
CI / E2E tests (6/8) (push) Failing after 1s
CI / E2E tests (7/8) (push) Failing after 2s
CI / E2E tests (8/8) (push) Failing after 1s
CI / E2E Cloudflare (1/8) (push) Failing after 1s
CI / E2E Cloudflare (3/8) (push) Failing after 2s
Preview Releases / Publish Preview (push) Failing after 0s
zizmor / Run zizmor (push) Failing after 1s
Release / Release (push) Failing after 2s
CI / Smoke Tests (push) Failing after 5m36s
CI / Tests (push) Failing after 6m36s
Release / Sync Templates (push) Has been skipped
CI / E2E Tests (push) Has been cancelled
CI / E2E Cloudflare (4/8) (push) Failing after 0s
CI / E2E Cloudflare (8/8) (push) Failing after 1s
CI / Lint (push) Failing after 1s
Auto Extract / Extract (push) Failing after 4s
CI / Version Check (push) Failing after 9s
CI / Integration Tests (push) Failing after 1s
CI / E2E tests (1/8) (push) Failing after 1s
CI / E2E tests (2/8) (push) Failing after 2s
CI / E2E tests (3/8) (push) Failing after 2s
CI / Browser Tests (push) Failing after 1s
CI / E2E tests (5/8) (push) Failing after 1s
CI / E2E Cloudflare (2/8) (push) Failing after 2s
CI / Typecheck (push) Failing after 1s
CI / Changeset Validation (push) Failing after 2s
CI / E2E Cloudflare (5/8) (push) Failing after 1s
CI / E2E Cloudflare (6/8) (push) Failing after 1s
CI / E2E Cloudflare (7/8) (push) Failing after 1s
CodeQL / Analyze (javascript-typescript) (push) Failing after 1s
Format / Format (push) Failing after 0s
CodeQL / Analyze (actions) (push) Failing after 4s
CI / E2E tests (4/8) (push) Failing after 1s
CI / E2E tests (6/8) (push) Failing after 1s
CI / E2E tests (7/8) (push) Failing after 2s
CI / E2E tests (8/8) (push) Failing after 1s
CI / E2E Cloudflare (1/8) (push) Failing after 1s
CI / E2E Cloudflare (3/8) (push) Failing after 2s
Preview Releases / Publish Preview (push) Failing after 0s
zizmor / Run zizmor (push) Failing after 1s
Release / Release (push) Failing after 2s
CI / Smoke Tests (push) Failing after 5m36s
CI / Tests (push) Failing after 6m36s
Release / Sync Templates (push) Has been skipped
CI / E2E Tests (push) Has been cancelled
This commit is contained in:
@@ -0,0 +1,90 @@
|
||||
/**
|
||||
* Resolves the publisher's atproto profile (display name, handle, PDS URL)
|
||||
* from a freshly-authenticated `OAuthSession`.
|
||||
*
|
||||
* Sources, in order of authority:
|
||||
*
|
||||
* 1. PDS URL: `session.getTokenInfo().aud`. The OAuth `aud` claim is the
|
||||
* resource server URL the session is bound to -- exactly the PDS the
|
||||
* session can actually talk to. Always populated for authenticated
|
||||
* sessions; never empty.
|
||||
* 2. Handle: resolved from the DID document via `LocalActorResolver`.
|
||||
* `alsoKnownAs` is read off the DID doc and the handle is verified to
|
||||
* round-trip back to the same DID before it's accepted. No PDS XRPC
|
||||
* involved -- this works regardless of how the PDS handles OAuth/DPoP
|
||||
* tokens, and doesn't need an `rpc:` scope. Falls back to the DID on
|
||||
* failure; the caller detects that via `isHandle()`.
|
||||
* 3. Display name: `app.bsky.actor.profile` (rkey `self`) via
|
||||
* `getRecord` -- a public repo read that doesn't require auth. Absent
|
||||
* profile records are not an error.
|
||||
*
|
||||
* The PDS URL is no longer best-effort: a session without a usable PDS
|
||||
* is unrecoverable, so we throw rather than persist an empty string and
|
||||
* lock the user out of subsequent commands.
|
||||
*/
|
||||
|
||||
import type { OAuthSession } from "@atcute/oauth-node-client";
|
||||
|
||||
import { createActorResolver } from "./oauth.js";
|
||||
|
||||
export interface AtprotoProfile {
|
||||
handle: string;
|
||||
displayName: string | null;
|
||||
pds: string;
|
||||
}
|
||||
|
||||
export async function resolveAtprotoProfile(session: OAuthSession): Promise<AtprotoProfile> {
|
||||
const did = session.sub;
|
||||
|
||||
// PDS URL: read directly from the OAuth token's `aud` claim. This is
|
||||
// the URL atcute itself uses for every authenticated request from the
|
||||
// session, so it's guaranteed populated. The previous implementation
|
||||
// tried `getSession.pdsUrl`, which doesn't exist in the Bluesky lexicon
|
||||
// -- the field is always undefined, leaving `pds` empty and corrupting
|
||||
// the credentials store on the next read.
|
||||
const tokenInfo = await session.getTokenInfo();
|
||||
const pds = tokenInfo.aud;
|
||||
if (typeof pds !== "string" || pds.length === 0) {
|
||||
// Defensive: should be impossible per atcute's session model, but if
|
||||
// it ever isn't, fail loudly here rather than persisting "" and
|
||||
// locking the user out.
|
||||
throw new Error(
|
||||
"OAuth session has no `aud` (PDS URL); cannot resolve publisher profile. This is a bug -- please report it.",
|
||||
);
|
||||
}
|
||||
|
||||
let handle: string = did;
|
||||
let displayName: string | null = null;
|
||||
|
||||
try {
|
||||
const resolved = await createActorResolver().resolve(did);
|
||||
// `LocalActorResolver` returns `'handle.invalid'` when the
|
||||
// alsoKnownAs handle doesn't round-trip back to this DID. Treat
|
||||
// that as "no handle" and fall back to the DID.
|
||||
if (resolved.handle && resolved.handle !== "handle.invalid") {
|
||||
handle = resolved.handle;
|
||||
}
|
||||
} catch {
|
||||
// best-effort; fall through to DID as handle
|
||||
}
|
||||
|
||||
try {
|
||||
const res = await session.handle(
|
||||
`/xrpc/com.atproto.repo.getRecord?repo=${encodeURIComponent(did)}&collection=app.bsky.actor.profile&rkey=self`,
|
||||
);
|
||||
if (res.ok) {
|
||||
displayName = pickDisplayName(await res.json());
|
||||
}
|
||||
} catch {
|
||||
// optional record; absence is fine
|
||||
}
|
||||
|
||||
return { handle, displayName, pds };
|
||||
}
|
||||
|
||||
function pickDisplayName(input: unknown): string | null {
|
||||
if (!input || typeof input !== "object") return null;
|
||||
if (!("value" in input) || !input.value || typeof input.value !== "object") return null;
|
||||
if (!("displayName" in input.value)) return null;
|
||||
return typeof input.value.displayName === "string" ? input.value.displayName : null;
|
||||
}
|
||||
Reference in New Issue
Block a user