Files
emdash-cms--emdash/packages/plugin-cli/src/profile.ts
T
wehub-resource-sync b3a7f98e5a
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
chore: import upstream snapshot with attribution
2026-07-13 12:23:53 +08:00

91 lines
3.5 KiB
TypeScript

/**
* 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;
}