A dame.is & pds.dad production bsky38.com

allows cors for xrpc endpoints and moves to @atproto/lex in more places #79

Merged pds.dad
targeting main from bugfix/clean-ups
Merged opened by and assigned to pds.dad
targeting main from bugfix/clean-ups

Install the third-party lexicons we already talk to

Every raw /xrpc/ fetch in the app targets a schema someone else publishes: Slingshot's resolveMiniDoc, atproto's repo/sync methods, and five Bluesky appview queries. lex install pins each one by CID in lexicons.json and follows its refs, so pnpm lex:build now generates typed schemas for them alongside our own — the groundwork for replacing those hand-built URLs with @atproto/lex clients.

lex:verify passes --no-save: the CID comparison uses the parsed document, so re-formatting is safe, but --save (on by default) rewrites the manifest as 2-space JSON and would leave pnpm lint failing after every verification run.

Co-Authored-By: Claude Fable 5.1 noreply@anthropic.com Claude-Session: https://claude.ai/code/session_01CSSeSL9Exx7SHMFwPnXcAj

Add the shared lex client helpers

Every raw /xrpc/ fetch in the app repeats the same four things: build a URL by hand, check res.ok, parse, then re-validate against the schema it already imported. These are the two pieces that replace that plumbing, so the call sites can shrink to a schema and its params.

atproto/xrpc.ts says how a service is addressed. Third-party hosts get a base URL and an injectable fetch — the seam the isomorphic modules already test through. Our own appview gets a bare relative-path handler instead: lex needs no origin, so a .svelte.ts singleton can construct one at module scope while SSR evaluates it, and the tests that read the /xrpc/… string still see it.

server/slingshot.ts is actors.ts's slingshot() grown a schema. Same philosophy — only 200 and 400 are answers, everything else is retry-later rather than a row blanked to nulls — with a lexicon mismatch now landing on the retry side too, which is why validation stays strict. actors.ts keeps its own copy until its callers move over.

Co-Authored-By: Claude Fable 5.1 noreply@anthropic.com Claude-Session: https://claude.ai/code/session_01CSSeSL9Exx7SHMFwPnXcAj

Move browser stores' own-appview reads onto typed lex clients

records.svelte.ts, opt-outs.svelte.ts and optout.svelte.ts each hand-built a /xrpc/ URL and manually parsed the response against the output schema. Route them through localXrpc/localXrpcFresh's Client.call instead, which validates both the request params and the response body, and let failureStatus supply the "(status)" suffix on the one message that carries it.

Co-Authored-By: Claude Fable 5.1 noreply@anthropic.com Claude-Session: https://claude.ai/code/session_01CSSeSL9Exx7SHMFwPnXcAj

Read the appview and the typeahead through lex

The four browser-side reads of someone else's appview — a page of follows, the follow count under it, the profiles behind an engagement ranking, and the handle typeahead — were hand-built URLs and hand-written shape checks. They go through xrpc() and the installed lexicons now, so the schema decides what a valid answer looks like and an off-lexicon one lands in the error path each call site already had.

parseFollowsPage becomes toFollowActors: a validated page in, rows out. What is left is editorial — drop handle.invalid, treat a blank display name as none, re-point avatars at the blob proxy — since a row without a did or a handle can no longer reach it. followsUrl and FollowsPage go with it, and so does engagement's URLSearchParams workaround.

The typeahead's default host is a third-party implementation of app.bsky.actor.searchActorsTypeahead, so a comment says where the strictness knob is should it ever answer off-lexicon.

Aborts needed a second look: lex reports a cancelled request as an ordinary failure, so HandleInput asks its own signal instead of matching on DOMException.

Co-Authored-By: Claude Fable 5.1 noreply@anthropic.com Claude-Session: https://claude.ai/code/session_01CSSeSL9Exx7SHMFwPnXcAj

Move leaderboard, vote, and ballot pages onto the typed lex client

Replace the raw /xrpc/ fetch + manual output.schema.parse calls in +page.svelte, vote/+page.svelte, and me/+page.svelte with localXrpc.call, picking up param parsing and output validation from the lexicon schemas. Fixes an abort-detection bug along the way: lex wraps a cancelled request in XrpcInternalError rather than a DOMException, so an aborted keystroke in the leaderboard search now checks the request's own captured signal instead of matching on an error class that no longer applies.

Co-Authored-By: Claude Fable 5.1 noreply@anthropic.com Claude-Session: https://claude.ai/code/session_01CSSeSL9Exx7SHMFwPnXcAj

Move topPosts/counts.ts's getPosts call onto @atproto/lex

fetchBatch now calls xrpcSafe(getPostsQuery) instead of hand-rolling the query string and status/body checks; the count() coercion helper and the "no posts array" hand-check are gone now that lex validates the response against the schema. Test stubs need a real CID and the postView fields (author/record/indexedAt) the old hand-rolled parser never checked.

Co-Authored-By: Claude Fable 5.1 noreply@anthropic.com Claude-Session: https://claude.ai/code/session_01CSSeSL9Exx7SHMFwPnXcAj

Move the engagement scan's listRecords reads onto @atproto/lex

fetchPage now calls xrpc(serviceAgent(...), listRecordsQuery, ...) instead of building the URL by hand and loosely parsing the JSON body; strict lex parsing validates each page against the listRecords schema instead. Drops listRecordsUrl/parseListRecordsPage and their tests; fixture records gain the cid the schema requires.

Co-Authored-By: Claude Fable 5.1 noreply@anthropic.com Claude-Session: https://claude.ai/code/session_01CSSeSL9Exx7SHMFwPnXcAj

Fix fetchVoterStatus docstring to match what fresh actually does

The previous wording claimed both the poll and the post-write re-sync need the server's now, but only refreshStatus() (the poll) passes fresh: true; refresh()'s post-write re-sync passes false and does not bypass the cache. Point at the call site's existing accurate comment instead of duplicating (and contradicting) it.

Co-Authored-By: Claude Fable 5.1 noreply@anthropic.com Claude-Session: https://claude.ai/code/session_01CSSeSL9Exx7SHMFwPnXcAj

Ask Slingshot through the lexicons, not by hand-built URL

The four server-side Slingshot callers now go through Task 1's slingshot(url, schema, params): getRecord and resolveMiniDoc build their own query strings from the generated schemas, and their answers arrive validated. That deletes the local slingshot()/SlingshotAnswer in actors.ts, serviceAuth.ts's hand-rolled isMiniDoc, and identity.ts's isDid re-check — the lexicon's format: did is the guard that keeps a handle-shaped answer from looping the profile redirect.

Validation stays strict, so a 200 the lexicon refuses is a no-answer rather than a half-read row: a minidoc missing pds/signing_key now leaves a cached profile alone instead of overwriting it. The one distinction the code still draws by hand is RecordNotFound vs. any other 400 — "no profile record" against "the repo is gone".

getRepo stays a raw fetch, with only its URL built from the schema: lex-client reads a binary body with arrayBuffer(), which would buffer a whole 512 MiB export and undo the streaming byte-cap the walk needs.

Co-Authored-By: Claude Fable 5.1 noreply@anthropic.com Claude-Session: https://claude.ai/code/session_01CSSeSL9Exx7SHMFwPnXcAj

Move Slingshot browser reads onto typed lex xrpcSafe calls

fetchBskyProfile and resolveHandleToDid now build their two Slingshot requests through xrpcSafe against resolveMiniDocQuery/getRecordQuery instead of hand-built URLs and a raw fetch/JSON parse, picking up lexicon-driven param encoding and response validation. Each call keeps its existing tolerate-failure behavior; fetchBskyProfile additionally guards the one gap in xrpcSafe's "never throws" contract (a signal that is already aborted before the request is built rejects instead of resolving to a failure), matching the pattern slingshot.ts already uses server-side.

Co-Authored-By: Claude Fable 5.1 noreply@anthropic.com Claude-Session: https://claude.ai/code/session_01CSSeSL9Exx7SHMFwPnXcAj

Fix stale module-header prose in engagement-scan.ts

The header still described the listRecords read as "a plain fetch" after it moved onto xrpc(serviceAgent(...)); reword to say what it is now — unauthenticated, schema-validated through the lex client — and clarify that fetch is still injectable via serviceAgent.

Co-Authored-By: Claude Fable 5.1 noreply@anthropic.com Claude-Session: https://claude.ai/code/session_01CSSeSL9Exx7SHMFwPnXcAj

Fix: retry, don't throw, on an already-aborted signal in fetchBatch

xrpcSafe throws on a signal aborted before the request starts, so a shutdown mid-pull was bubbling past fetchCounts/pull.ts into start.ts's "pull threw" error log instead of the usual warn-level retry path. Wrap the xrpcSafe call in try/catch, matching the guard slingshot.ts already has for the same gap, and pin it with a test.

Co-Authored-By: Claude Fable 5.1 noreply@anthropic.com Claude-Session: https://claude.ai/code/session_01CSSeSL9Exx7SHMFwPnXcAj

Fix misleading comment on fetchBskyProfile's branded did cast

The cast comment claimed the did is "always this app's own" — wrong: /profile/[did]/+page.svelte calls fetchBskyProfile with an arbitrary profile subject's did, not just the signed-in user's own (auth.svelte.ts).

Co-Authored-By: Claude Fable 5.1 noreply@anthropic.com Claude-Session: https://claude.ai/code/session_01CSSeSL9Exx7SHMFwPnXcAj

Document the @atproto/lex migration in README and CLAUDE.md

Covers lex:install/lex:verify and the pnpm format step, the installed third-party lexicons and their bsky*-prefixed re-exports, the two client helper modules (xrpc.ts, slingshot.ts), the getRepo streaming exception, and the abort/test-stub gotchas for future lex call sites.

Co-Authored-By: Claude Fable 5.1 noreply@anthropic.com Claude-Session: https://claude.ai/code/session_01CSSeSL9Exx7SHMFwPnXcAj

Document the strict-parsing Cid/Uint8Array gotcha in CLAUDE.md

Review fix round 1: the third lex call-site gotcha (a record value read through lex turns { $link } into a Cid and { $bytes } into a Uint8Array, which is why blobCidOf accepts the parsed shape) was missing from the "Client helpers" section. Cross-referenced from the pre-existing blobCidOf sentence, and rewrapped a README.md line that had grown past the paragraph's per-sentence line-break convention.

Co-Authored-By: Claude Fable 5.1 noreply@anthropic.com Claude-Session: https://claude.ai/code/session_01CSSeSL9Exx7SHMFwPnXcAj

Say where a lex answer can be refused, and log what refused it

Strict validation gave every call site a fifth way to fail — a 200 whose body the lexicon no longer accepts — and the comments still listed four. Name it in ActorAnswer.retry, and give CLAUDE.md the gotcha that matters in practice: one drifted declared field in postView or a profileView* fails a whole batch at the four sites that read the live appview or typeahead, strictResponseProcessing: false is the escape hatch, and additive change is safe either way.

blobCidOf's three shapes were mislabelled after the move: Slingshot's getRecord now arrives with ref already parsed to a Cid, and only the Jetstream path still sees the raw { $link } JSON.

The diagnostics lost ground too. getPosts's failure warn dropped the error object, so a network fault logged InternalServerError with an undefined status and no cause; and slingshot's pre-flight catch shared its message with the in-flight one, so a quiet shutdown read like Slingshot going wrong. The engagement hydrate's empty catch now says which batch it dropped, since it fails soft into a screen of raw DIDs.

Co-Authored-By: Claude Fable 5.1 noreply@anthropic.com Claude-Session: https://claude.ai/code/session_01CSSeSL9Exx7SHMFwPnXcAj

Build the "(503)" suffix in one place, and cover the xrpc helpers

Seven call sites spelled the same status suffix out by hand, in two inverted spellings of the one conditional. withStatus(copy, err) is the whole line — copy, status when there is one, full stop — and every site now produces the string it produced before, character for character.

Two things fell out of collecting them. engagement-scan was throwing a fresh Error and dropping last, so the real failure never reached the console; it rides along as the cause now. And HandleInput guarded only its catch: a superseded typeahead response still wrote suggestions, could re-fire select, and flipped the spinner off under the request that replaced it. It now checks signal.aborted on both paths, as FollowSampler already did.

xrpc.test.ts pins what the helpers are for and had no coverage at all: that localXrpc reaches our own appview at a bare relative path with the browser cache untouched, that localXrpcFresh adds no-store, that serviceAgent routes through the injected fetch with an absolute URL at the base, and what failureStatus/withStatus make of a 503 and of a fault nobody answered. serviceAgent's doc now says that lex resolves against the base's origin, so a base carrying a path would lose it.

Co-Authored-By: Claude Fable 5.1 noreply@anthropic.com Claude-Session: https://claude.ai/code/session_01CSSeSL9Exx7SHMFwPnXcAj

Put the typeahead spinner out when the field empties mid-flight

The abort guard added with the rest of the fix wave took FollowSampler's finally without its counterpart: FollowSampler's restart clears loading itself before starting the successor, and onInput's short-input branch has no successor at all. So typing two characters and then clearing the field while the request was in flight left "Searching…" under an empty box until some later fetch happened along. That branch now clears the flag next to the rest of the state it resets; it is the only abort here that nothing follows.

Two corrections to the docs that came with the wave. The new CLAUDE.md gotcha claimed strictResponseProcessing: false as the escape hatch for a drifted declared field, which it is not — it is forwarded as { strict } into payload parsing and schema.safeParse, so it relaxes data-model strictness (a non-RFC-3339 datetime, a legacy blob ref, a float) and nothing else. A field that changed shape or vanished wants the installed lexicon updated instead, and the paragraph now says which is which. And config.ts's {@link serviceAgent} pointed at a symbol the module does not import, so it resolved to nothing; plain backticks.

Co-Authored-By: Claude Fable 5.1 noreply@anthropic.com Claude-Session: https://claude.ai/code/session_01CSSeSL9Exx7SHMFwPnXcAj

Commits 0