From 9272af285144df7c5dc883cc1373f3513278605e Mon Sep 17 00:00:00 2001 From: Sparky <1609870+sparkyfen@users.noreply.github.com> Date: Mon, 7 Sep 2026 19:00:19 -0700 Subject: [PATCH 01/22] feat(admin): entail.dev tag suggestion client (SONA-220) Add the server-side pieces for image tag suggestions: a client for entail.dev's public classifier (Bluesky post lookup plus a media-URL enqueue-and-poll path), an e621-to-Sona tag translator, and a resolver that turns a tweet URL into its first photo's pbs.twimg.com URL. Both fetchers are fail-soft: any non-2xx, timeout, or unexpected shape resolves to null, and no third-party response body is logged or stored. The tweet resolver reuses the guest-token flow twitter-avatar.ts already performs, so that helper and the public bearer are now exported. No UI, endpoint, or schema yet. --- src/lib/server/entail.test.ts | 255 +++++++++++++++++++++++++++ src/lib/server/entail.ts | 251 ++++++++++++++++++++++++++ src/lib/server/twitter-avatar.ts | 9 +- src/lib/server/twitter-media.test.ts | 141 +++++++++++++++ src/lib/server/twitter-media.ts | 165 +++++++++++++++++ 5 files changed, 818 insertions(+), 3 deletions(-) create mode 100644 src/lib/server/entail.test.ts create mode 100644 src/lib/server/entail.ts create mode 100644 src/lib/server/twitter-media.test.ts create mode 100644 src/lib/server/twitter-media.ts diff --git a/src/lib/server/entail.test.ts b/src/lib/server/entail.test.ts new file mode 100644 index 00000000..d01570bc --- /dev/null +++ b/src/lib/server/entail.test.ts @@ -0,0 +1,255 @@ +import { describe, expect, it, vi } from 'vitest'; +import { + classifySourceUrl, + classifyMediaUrl, + lookupBlueskyPost, + suggestionsFromResult, + translateTag +} from './entail'; + +const json = (body: unknown, status = 200) => + new Response(JSON.stringify(body), { status, headers: { 'Content-Type': 'application/json' } }); + +describe('classifySourceUrl', () => { + it('accepts the bluesky post shapes', () => { + expect(classifySourceUrl('https://bsky.app/profile/example.bsky.social/post/3abc')).toEqual({ + kind: 'bluesky', + url: 'https://bsky.app/profile/example.bsky.social/post/3abc' + }); + expect(classifySourceUrl('https://www.bsky.app/profile/did:plc:aaaa/post/3abc/')).toEqual({ + kind: 'bluesky', + url: 'https://bsky.app/profile/did:plc:aaaa/post/3abc' + }); + expect(classifySourceUrl('https://bsky.app/profile/did%3Aplc%3Aaaaa/post/3abc?ref=x')).toEqual({ + kind: 'bluesky', + url: 'https://bsky.app/profile/did:plc:aaaa/post/3abc' + }); + }); + + it('accepts the x/twitter status shapes and canonicalises them', () => { + expect(classifySourceUrl('https://x.com/examplefox/status/1234567890')).toEqual({ + kind: 'x', + url: 'https://x.com/examplefox/status/1234567890' + }); + expect(classifySourceUrl('https://twitter.com/examplefox/status/1234567890?s=21')).toEqual({ + kind: 'x', + url: 'https://x.com/examplefox/status/1234567890' + }); + expect(classifySourceUrl('https://x.com/i/status/1234567890')).toEqual({ + kind: 'x', + url: 'https://x.com/i/status/1234567890' + }); + expect(classifySourceUrl('https://x.com/examplefox/status/1234567890/photo/1')).toEqual({ + kind: 'x', + url: 'https://x.com/examplefox/status/1234567890' + }); + }); + + it('rejects anything else', () => { + expect(classifySourceUrl('https://bsky.app/profile/example.bsky.social')).toBeNull(); + expect(classifySourceUrl('https://bsky.app/profile/example/feed/3abc')).toBeNull(); + expect(classifySourceUrl('https://x.com/examplefox')).toBeNull(); + expect(classifySourceUrl('https://x.com/examplefox/status/notanid')).toBeNull(); + expect(classifySourceUrl('https://example.com/x.com/user/status/1')).toBeNull(); + expect(classifySourceUrl('https://pbs.twimg.com/media/abc.jpg')).toBeNull(); + expect(classifySourceUrl('javascript:alert(1)')).toBeNull(); + expect(classifySourceUrl('not a url')).toBeNull(); + }); +}); + +describe('translateTag', () => { + it('drops the trailing qualifier and hyphenates', () => { + expect(translateTag('digital_media_(artwork)')).toBe('digital-media'); + expect(translateTag('two_tone_fur')).toBe('two-tone-fur'); + expect(translateTag('Mammal')).toBe('mammal'); + }); + + it('leaves an inner parenthetical alone', () => { + expect(translateTag('a_(b)_c')).toBe('a-b-c'); + }); + + it('runs the result through the tag sanitizer', () => { + expect(translateTag('50%_fur')).toBe('50-fur'); + expect(translateTag(' Pink_Hair ')).toBe('pink-hair'); + }); + + it('returns null when nothing survives', () => { + expect(translateTag('(artwork)')).toBeNull(); + expect(translateTag('!!!')).toBeNull(); + expect(translateTag('')).toBeNull(); + }); +}); + +describe('suggestionsFromResult', () => { + it('keeps tags at or above the floor, in confidence order', () => { + expect( + suggestionsFromResult({ + rating: 'safe', + tags: [ + { name: 'mammal', confidence: 0.92 }, + { name: 'pink_hair', confidence: 0.8 }, + { name: 'canine', confidence: 0.79 } + ] + }) + ).toEqual({ tags: ['mammal', 'pink-hair'], rating: 'safe' }); + }); + + it('honours a custom floor', () => { + expect( + suggestionsFromResult({ tags: [{ name: 'canine', confidence: 0.5 }] }, 0.4).tags + ).toEqual(['canine']); + }); + + it('dedupes tags that translate to the same name', () => { + expect( + suggestionsFromResult({ + tags: [ + { name: 'digital_media_(artwork)', confidence: 0.95 }, + { name: 'digital media', confidence: 0.9 } + ] + }).tags + ).toEqual(['digital-media']); + }); + + it('drops junk entries and unknown ratings', () => { + expect( + suggestionsFromResult({ + rating: 'nsfw', + tags: [{ name: 42 }, { confidence: 0.99 }, { name: '(artwork)', confidence: 0.99 }] + }) + ).toEqual({ tags: [], rating: null }); + expect(suggestionsFromResult(null)).toEqual({ tags: [], rating: null }); + expect(suggestionsFromResult({ tags: 'nope' })).toEqual({ tags: [], rating: null }); + }); +}); + +describe('lookupBlueskyPost', () => { + const post = { + uri: 'at://did:plc:aaaa/app.bsky.feed.post/3abc', + images: [ + { cid: 'one', rating: 'explicit', tags: [{ name: 'mammal', confidence: 0.99 }] }, + { cid: 'two', rating: 'safe', tags: [{ name: 'canine', confidence: 0.99 }] } + ] + }; + + it('uses the first image only', async () => { + const fetchImpl = vi.fn(async (_url: string | URL | Request) => json(post)); + expect(await lookupBlueskyPost('https://bsky.app/profile/did:plc:aaaa/post/3abc', fetchImpl)).toEqual( + { tags: ['mammal'], rating: 'explicit' } + ); + const requested = String(fetchImpl.mock.calls[0]?.[0]); + expect(requested).toContain('min_confidence=0.8'); + expect(requested).toContain('wait=true'); + }); + + it('tolerates a bare array body', async () => { + const fetchImpl = vi.fn(async () => json(post.images)); + expect( + (await lookupBlueskyPost('https://bsky.app/profile/did:plc:aaaa/post/3abc', fetchImpl))?.tags + ).toEqual(['mammal']); + }); + + it('returns null without fetching for a non-bluesky URL', async () => { + const fetchImpl = vi.fn(async () => json(post)); + expect(await lookupBlueskyPost('https://x.com/examplefox/status/1', fetchImpl)).toBeNull(); + expect(fetchImpl).not.toHaveBeenCalled(); + }); + + it('returns null on 202, 429, malformed JSON, and network errors', async () => { + const url = 'https://bsky.app/profile/did:plc:aaaa/post/3abc'; + expect(await lookupBlueskyPost(url, vi.fn(async () => json({}, 202)))).toBeNull(); + expect(await lookupBlueskyPost(url, vi.fn(async () => new Response('slow down', { status: 429 })))).toBeNull(); + expect(await lookupBlueskyPost(url, vi.fn(async () => new Response('', { status: 200 })))).toBeNull(); + expect( + await lookupBlueskyPost( + url, + vi.fn(async () => { + throw new Error('TimeoutError'); + }) + ) + ).toBeNull(); + }); + + it('returns null when the post has no classified images', async () => { + expect( + await lookupBlueskyPost( + 'https://bsky.app/profile/did:plc:aaaa/post/3abc', + vi.fn(async () => json({ uri: 'at://x', images: [] })) + ) + ).toBeNull(); + }); +}); + +describe('classifyMediaUrl', () => { + const done = { + status: 'done', + content_sha256: 'abc', + rating: 'questionable', + tags: [{ name: 'mammal', confidence: 0.99 }] + }; + + it('refuses a host outside the allowlist without fetching', async () => { + const fetchImpl = vi.fn(async () => json(done)); + expect(await classifyMediaUrl('https://example.com/a.jpg', fetchImpl)).toBeNull(); + expect(await classifyMediaUrl('https://evil.pbs.twimg.com/a.jpg', fetchImpl)).toBeNull(); + expect(await classifyMediaUrl('http://pbs.twimg.com/a.jpg', fetchImpl)).toBeNull(); + expect(await classifyMediaUrl('nonsense', fetchImpl)).toBeNull(); + expect(fetchImpl).not.toHaveBeenCalled(); + }); + + it('enqueues then polls until the job is done', async () => { + let polls = 0; + const fetchImpl = vi.fn(async (url: string | URL | Request, init?: RequestInit) => { + if (init?.method === 'POST') return json({ job_id: 'job-1', status: 'enqueued' }, 202); + polls++; + expect(String(url)).toContain('/classify/job-1?wait=true'); + return polls === 1 ? json({ status: 'processing' }, 202) : json(done); + }); + expect(await classifyMediaUrl('https://pbs.twimg.com/media/abc?format=jpg', fetchImpl)).toEqual({ + tags: ['mammal'], + rating: 'questionable' + }); + expect(polls).toBe(2); + }); + + it('accepts cdn.bsky.app too', async () => { + const fetchImpl = vi.fn(async (_url: string | URL | Request, init?: RequestInit) => + init?.method === 'POST' ? json({ job_id: 'job-2' }, 202) : json(done) + ); + expect(await classifyMediaUrl('https://cdn.bsky.app/img/feed_fullsize/x.jpg', fetchImpl)).not.toBeNull(); + }); + + it('gives up after the poll cap', async () => { + let polls = 0; + const fetchImpl = vi.fn(async (_url: string | URL | Request, init?: RequestInit) => { + if (init?.method === 'POST') return json({ job_id: 'job-3' }, 202); + polls++; + return json({ status: 'processing' }, 202); + }); + expect(await classifyMediaUrl('https://pbs.twimg.com/media/abc', fetchImpl)).toBeNull(); + expect(polls).toBe(3); + }); + + it('returns null on a rate-limited enqueue, a missing job id, and errors', async () => { + const url = 'https://pbs.twimg.com/media/abc'; + expect(await classifyMediaUrl(url, vi.fn(async () => new Response('slow down', { status: 429 })))).toBeNull(); + expect(await classifyMediaUrl(url, vi.fn(async () => json({ status: 'enqueued' }, 202)))).toBeNull(); + expect( + await classifyMediaUrl( + url, + vi.fn(async () => { + throw new Error('TimeoutError'); + }) + ) + ).toBeNull(); + }); + + it('returns null when a poll fails outright', async () => { + const fetchImpl = vi.fn(async (_url: string | URL | Request, init?: RequestInit) => + init?.method === 'POST' + ? json({ job_id: 'job-4' }, 202) + : new Response('gone', { status: 404 }) + ); + expect(await classifyMediaUrl('https://pbs.twimg.com/media/abc', fetchImpl)).toBeNull(); + }); +}); diff --git a/src/lib/server/entail.ts b/src/lib/server/entail.ts new file mode 100644 index 00000000..a0ff58db --- /dev/null +++ b/src/lib/server/entail.ts @@ -0,0 +1,251 @@ +// entail.dev tag suggestions — a public, keyless classifier for furry artwork. +// Two entry points: a Bluesky post URL resolves through `/api/post` (the +// service has usually already classified it), and a raw media URL on an +// allowlisted CDN goes through the `/api/classify` enqueue-and-poll pair. +// +// Spec: https://entail.dev/api/openapi.json (docs at https://entail.dev/api/docs). +// Response shapes below were confirmed against the live API on 2026-09-08. +// Rate limiting is per client IP and there are no API keys, so a 429 is a +// normal outcome, not an error to surface. +// +// Everything here is fail-soft: any non-2xx, timeout, or unexpected shape +// resolves to null and the caller carries on without suggestions. Third-party +// response bodies are never logged and never stored. + +import { sanitizeTag } from './validate'; + +const ENTAIL_POST = 'https://entail.dev/api/post'; +const ENTAIL_CLASSIFY = 'https://entail.dev/api/classify'; + +/** The floor entail.dev's own docs recommend for Sona. */ +export const DEFAULT_CONFIDENCE_FLOOR = 0.8; + +// Budget: a /post lookup is one call. A classify is one POST plus at most +// three polls with a short pause between them, which keeps the worst case +// (3000 + 3 * 2000 + 2 * 250) just under ten seconds. +const POST_TIMEOUT_MS = 8000; +const CLASSIFY_TIMEOUT_MS = 3000; +const POLL_TIMEOUT_MS = 2000; +const POLL_PAUSE_MS = 250; +const POLL_ATTEMPTS = 3; + +export type EntailRating = 'safe' | 'questionable' | 'explicit'; + +export type Suggestions = { + tags: string[]; + rating: EntailRating | null; +}; + +/** One classification entry: an image inside a `/post` response, or the body + * of a finished `/classify/` poll. */ +export type ClassificationEntry = { + rating?: unknown; + tags?: unknown; +}; + +export type SourceKind = { kind: 'bluesky'; url: string } | { kind: 'x'; url: string }; + +const BLUESKY_ACTOR = /^[A-Za-z0-9._:%-]{1,256}$/; +const BLUESKY_RKEY = /^[A-Za-z0-9._~-]{1,64}$/; +const X_USER = /^[A-Za-z0-9_]{1,15}$/; +const STATUS_ID = /^\d{1,20}$/; + +/** + * Recognise a post URL we know how to get suggestions for, and return it in + * canonical form (no query string, no trailing slash, no `/photo/1` suffix). + * Anything else — including a bare media URL — returns null. Pure. + */ +export function classifySourceUrl(url: string): SourceKind | null { + let parsed: URL; + try { + parsed = new URL(url.trim()); + } catch { + return null; + } + if (parsed.protocol !== 'https:' && parsed.protocol !== 'http:') return null; + + const host = parsed.hostname.toLowerCase().replace(/^www\./, ''); + const parts = parsed.pathname.split('/').filter(Boolean); + + if (host === 'bsky.app') { + // /profile//post/ + if (parts.length !== 4 || parts[0] !== 'profile' || parts[2] !== 'post') return null; + const actor = decodeURIComponent(parts[1]); + const rkey = parts[3]; + if (!BLUESKY_ACTOR.test(parts[1]) || !BLUESKY_RKEY.test(rkey)) return null; + return { kind: 'bluesky', url: `https://bsky.app/profile/${actor}/post/${rkey}` }; + } + + if (host === 'x.com' || host === 'twitter.com' || host === 'mobile.x.com' || host === 'mobile.twitter.com') { + // //status/, /i/status/, either with a trailing /photo/N. + if (parts.length < 3) return null; + const [user, keyword, id] = parts; + if (keyword !== 'status' && keyword !== 'statuses') return null; + if (!STATUS_ID.test(id)) return null; + if (user !== 'i' && !X_USER.test(user)) return null; + return { kind: 'x', url: `https://x.com/${user}/status/${id}` }; + } + + return null; +} + +/** + * Translate one e621-vocabulary tag into a Sona tag. Drops the trailing + * qualifier e621 appends to disambiguate (`digital_media_(artwork)`), swaps + * underscores for hyphens, then runs the same sanitizer the tag inputs use. + * Returns null when nothing usable is left. Pure. + */ +export function translateTag(tag: string): string | null { + const translated = tag + .trim() + .toLowerCase() + .replace(/[\s_]*\([^()]*\)\s*$/, '') + .replace(/_/g, '-'); + const sanitized = sanitizeTag(translated); + return sanitized || null; +} + +function normalizeRating(rating: unknown): EntailRating | null { + return rating === 'safe' || rating === 'questionable' || rating === 'explicit' ? rating : null; +} + +/** + * Turn one classification entry into Sona tag suggestions: keep the tags at or + * above the confidence floor, translate them, and drop duplicates while + * preserving the confidence order the API returns. Pure. + */ +export function suggestionsFromResult( + result: ClassificationEntry | null | undefined, + floor = DEFAULT_CONFIDENCE_FLOOR +): Suggestions { + const rating = normalizeRating(result?.rating); + const raw = Array.isArray(result?.tags) ? result.tags : []; + const seen = new Set(); + const tags: string[] = []; + for (const entry of raw) { + const { name, confidence } = (entry ?? {}) as { name?: unknown; confidence?: unknown }; + if (typeof name !== 'string') continue; + if (typeof confidence !== 'number' || !(confidence >= floor)) continue; + const tag = translateTag(name); + if (!tag || seen.has(tag)) continue; + seen.add(tag); + tags.push(tag); + } + return { tags, rating }; +} + +/** `/post` answers with `{ uri, images: [...] }`; tolerate a bare array too. */ +function firstImage(body: unknown): ClassificationEntry | null { + const images = Array.isArray(body) + ? body + : Array.isArray((body as { images?: unknown })?.images) + ? ((body as { images: unknown[] }).images) + : null; + if (!images || images.length === 0) return null; + const first = images[0]; + return first && typeof first === 'object' ? (first as ClassificationEntry) : null; +} + +/** + * Suggestions for a Bluesky post. Uses the post's first classified image; a + * post whose images entail.dev hasn't classified yet answers 202, which we + * treat as "nothing to suggest" rather than waiting around. Never throws. + */ +export async function lookupBlueskyPost( + url: string, + fetchImpl: typeof fetch = fetch +): Promise { + const source = classifySourceUrl(url); + if (!source || source.kind !== 'bluesky') return null; + + const endpoint = `${ENTAIL_POST}?url=${encodeURIComponent(source.url)}&min_confidence=${DEFAULT_CONFIDENCE_FLOOR}&wait=true`; + try { + const res = await fetchImpl(endpoint, { signal: AbortSignal.timeout(POST_TIMEOUT_MS) }); + if (res.status === 202) { + // Queued for classification. Best effort: no retry loop. + console.warn('[entail] post not classified yet: status=202'); + return null; + } + if (!res.ok) { + console.warn(`[entail] post lookup failed: status=${res.status}`); + return null; + } + const image = firstImage(await res.json()); + if (!image) return null; + return suggestionsFromResult(image); + } catch (e) { + console.warn(`[entail] post lookup error: ${e instanceof Error ? e.message : String(e)}`); + return null; + } +} + +function jobIdFrom(body: unknown): string | null { + const { job_id: jobId, id } = (body ?? {}) as { job_id?: unknown; id?: unknown }; + if (typeof jobId === 'string' && jobId) return jobId; + if (typeof id === 'string' && id) return id; + return null; +} + +/** entail.dev fetches the URL itself, so only the two CDNs it allowlists are + * worth sending — anything else is refused there and never leaves here. */ +function isAllowedMediaHost(url: string): boolean { + try { + const { protocol, hostname } = new URL(url); + if (protocol !== 'https:') return false; + const host = hostname.toLowerCase(); + return host === 'pbs.twimg.com' || host === 'cdn.bsky.app'; + } catch { + return false; + } +} + +const pause = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms)); + +/** + * Suggestions for a single image URL on an allowlisted CDN: enqueue a + * classification job, then poll it a few times. Gives up (null) if the job + * isn't done by the attempt cap. Never throws. + */ +export async function classifyMediaUrl( + url: string, + fetchImpl: typeof fetch = fetch +): Promise { + if (!isAllowedMediaHost(url)) return null; + + try { + const enqueued = await fetchImpl(ENTAIL_CLASSIFY, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ url }), + signal: AbortSignal.timeout(CLASSIFY_TIMEOUT_MS) + }); + if (!enqueued.ok && enqueued.status !== 202) { + console.warn(`[entail] classify enqueue failed: status=${enqueued.status}`); + return null; + } + const jobId = jobIdFrom(await enqueued.json()); + if (!jobId) { + console.warn('[entail] classify enqueue returned no job id'); + return null; + } + + const poll = `${ENTAIL_CLASSIFY}/${encodeURIComponent(jobId)}?wait=true`; + for (let attempt = 0; attempt < POLL_ATTEMPTS; attempt++) { + if (attempt > 0) await pause(POLL_PAUSE_MS); + const res = await fetchImpl(poll, { signal: AbortSignal.timeout(POLL_TIMEOUT_MS) }); + if (res.status === 202) continue; + if (!res.ok) { + console.warn(`[entail] classify poll failed: status=${res.status}`); + return null; + } + const body = (await res.json()) as (ClassificationEntry & { status?: unknown }) | null; + if (body?.status !== 'done') continue; + return suggestionsFromResult(body); + } + console.warn(`[entail] classify job unfinished after ${POLL_ATTEMPTS} polls`); + return null; + } catch (e) { + console.warn(`[entail] classify error: ${e instanceof Error ? e.message : String(e)}`); + return null; + } +} diff --git a/src/lib/server/twitter-avatar.ts b/src/lib/server/twitter-avatar.ts index b7723579..11a6e925 100644 --- a/src/lib/server/twitter-avatar.ts +++ b/src/lib/server/twitter-avatar.ts @@ -11,7 +11,8 @@ // avatar. Registry-linked artists get theirs through the registry instead. // X web client's public bearer (shipped to every browser) — not a secret. -const X_BEARER = +// Exported so twitter-media.ts can reuse the same guest-token flow. +export const X_BEARER = 'Bearer AAAAAAAAAAAAAAAAAAAAANRILgAAAAAAnNwIzUejRCOuH5E6I8xnZz4puTs%3D1Zv7ttfk8LF81IUq16cHjhLTvJu4FA33AGWWjCpTnA'; const X_ACTIVATE = 'https://api.x.com/1.1/guest/activate.json'; const X_USER_BY_SCREEN_NAME = 'https://api.x.com/graphql/IGgvgiOx4QZndDHuD3x9TQ/UserByScreenName'; @@ -47,9 +48,11 @@ export function to400x400(url: string): string { return url.replace(/_normal(\.[a-z]+)$/i, '_400x400$1'); } -async function activateGuestToken(): Promise { +/** Activate a guest token against the public web bearer. Shared with + * twitter-media.ts; `fetchImpl` is only for tests. Never throws. */ +export async function activateGuestToken(fetchImpl: typeof fetch = fetch): Promise { try { - const res = await fetch(X_ACTIVATE, { + const res = await fetchImpl(X_ACTIVATE, { method: 'POST', headers: { Authorization: X_BEARER }, signal: AbortSignal.timeout(FETCH_TIMEOUT_MS) diff --git a/src/lib/server/twitter-media.test.ts b/src/lib/server/twitter-media.test.ts new file mode 100644 index 00000000..c0a6ab4b --- /dev/null +++ b/src/lib/server/twitter-media.test.ts @@ -0,0 +1,141 @@ +import { describe, expect, it, vi } from 'vitest'; +import { fetchTweetMediaUrl, parseTweetPhotoUrl, tweetIdFromUrl } from './twitter-media'; + +const json = (body: unknown, status = 200) => + new Response(JSON.stringify(body), { status, headers: { 'Content-Type': 'application/json' } }); + +/** Shaped like the real TweetResultByRestId response, trimmed to what we read. */ +const tweetWith = (media: unknown[]) => ({ + data: { + tweetResult: { + result: { + __typename: 'Tweet', + legacy: { extended_entities: { media } } + } + } + } +}); + +const photo = { type: 'photo', media_url_https: 'https://pbs.twimg.com/media/AbCdEf123.jpg' }; + +describe('tweetIdFromUrl', () => { + it('reads the id out of the accepted shapes', () => { + expect(tweetIdFromUrl('https://x.com/examplefox/status/1234567890')).toBe('1234567890'); + expect(tweetIdFromUrl('https://twitter.com/examplefox/status/1234567890?s=21')).toBe('1234567890'); + expect(tweetIdFromUrl('https://x.com/examplefox/status/1234567890/photo/1')).toBe('1234567890'); + expect(tweetIdFromUrl('https://x.com/i/status/1234567890')).toBe('1234567890'); + expect(tweetIdFromUrl('https://x.com/i/web/status/1234567890')).toBe('1234567890'); + expect(tweetIdFromUrl('https://mobile.twitter.com/examplefox/statuses/1234567890')).toBe('1234567890'); + }); + + it('returns null for anything else', () => { + expect(tweetIdFromUrl('https://x.com/examplefox')).toBeNull(); + expect(tweetIdFromUrl('https://x.com/examplefox/status/abc')).toBeNull(); + expect(tweetIdFromUrl('https://bsky.app/profile/a/post/3abc')).toBeNull(); + expect(tweetIdFromUrl('')).toBeNull(); + }); +}); + +describe('parseTweetPhotoUrl', () => { + it('upgrades the first photo to the largest variant', () => { + expect(parseTweetPhotoUrl(tweetWith([photo]))).toBe( + 'https://pbs.twimg.com/media/AbCdEf123?format=jpg&name=4096x4096' + ); + }); + + it('skips video and animated gif entries', () => { + expect(parseTweetPhotoUrl(tweetWith([{ type: 'video', media_url_https: 'https://pbs.twimg.com/x.jpg' }]))).toBeNull(); + expect( + parseTweetPhotoUrl(tweetWith([{ type: 'animated_gif', media_url_https: 'https://pbs.twimg.com/y.jpg' }])) + ).toBeNull(); + expect( + parseTweetPhotoUrl(tweetWith([{ type: 'video', media_url_https: 'https://pbs.twimg.com/x.jpg' }, photo])) + ).toContain('format=jpg'); + }); + + it('reads a tweet nested behind a visibility result', () => { + expect( + parseTweetPhotoUrl({ + data: { + tweetResult: { + result: { + __typename: 'TweetWithVisibilityResults', + tweet: { legacy: { extended_entities: { media: [photo] } } } + } + } + } + }) + ).toContain('AbCdEf123'); + }); + + it('falls back to entities.media when extended_entities is absent', () => { + expect( + parseTweetPhotoUrl({ + data: { tweetResult: { result: { legacy: { entities: { media: [photo] } } } } } + }) + ).toContain('AbCdEf123'); + }); + + it('returns null on a text-only tweet, a tombstone, and junk', () => { + expect(parseTweetPhotoUrl(tweetWith([]))).toBeNull(); + expect(parseTweetPhotoUrl({ data: { tweetResult: {} } })).toBeNull(); + expect(parseTweetPhotoUrl(null)).toBeNull(); + }); +}); + +describe('fetchTweetMediaUrl', () => { + const url = 'https://x.com/examplefox/status/1234567890'; + + const stub = (lookup: (n: number) => Response) => { + let lookups = 0; + const activations = { count: 0 }; + const fetchImpl = vi.fn(async (target: string | URL | Request) => { + if (String(target).includes('guest/activate')) { + activations.count++; + return json({ guest_token: `gt-${activations.count}` }); + } + return lookup(++lookups); + }); + return { fetchImpl, activations }; + }; + + it('activates a guest token and resolves the first photo', async () => { + const { fetchImpl } = stub(() => json(tweetWith([photo]))); + expect(await fetchTweetMediaUrl(url, fetchImpl)).toBe( + 'https://pbs.twimg.com/media/AbCdEf123?format=jpg&name=4096x4096' + ); + }); + + it('retries once with a fresh token on 401', async () => { + const { fetchImpl, activations } = stub((n) => + n === 1 ? new Response('nope', { status: 401 }) : json(tweetWith([photo])) + ); + expect(await fetchTweetMediaUrl(url, fetchImpl)).toContain('AbCdEf123'); + expect(activations.count).toBe(2); + }); + + it('returns null without fetching when the URL has no tweet id', async () => { + const fetchImpl = vi.fn(async () => json(tweetWith([photo]))); + expect(await fetchTweetMediaUrl('https://x.com/examplefox', fetchImpl)).toBeNull(); + expect(fetchImpl).not.toHaveBeenCalled(); + }); + + it('fails soft on refusal, a photoless tweet, malformed JSON, and network errors', async () => { + expect(await fetchTweetMediaUrl(url, stub(() => new Response('no', { status: 403 })).fetchImpl)).toBeNull(); + expect(await fetchTweetMediaUrl(url, stub(() => json(tweetWith([]))).fetchImpl)).toBeNull(); + expect(await fetchTweetMediaUrl(url, stub(() => new Response('')).fetchImpl)).toBeNull(); + expect( + await fetchTweetMediaUrl( + url, + vi.fn(async () => { + throw new Error('TimeoutError'); + }) + ) + ).toBeNull(); + }); + + it('returns null when the guest token cannot be activated', async () => { + const fetchImpl = vi.fn(async () => new Response('blocked', { status: 403 })); + expect(await fetchTweetMediaUrl(url, fetchImpl)).toBeNull(); + }); +}); diff --git a/src/lib/server/twitter-media.ts b/src/lib/server/twitter-media.ts new file mode 100644 index 00000000..37a5ffcb --- /dev/null +++ b/src/lib/server/twitter-media.ts @@ -0,0 +1,165 @@ +// Resolve the first photo attached to a public tweet, using the same guest-token +// flow as twitter-avatar.ts. The query id and the `features` map below are +// UNDOCUMENTED and rotate — both were lifted from FxEmbed's source +// (packages/atmosphere/src/providers/twitter/graphql/{queries,features}.ts, +// `TweetResultByRestIdQuery` plus its `rwebTweetFeatureKeys`) and verified +// against a real public tweet on 2026-09-08 (activate 200, GraphQL 200, photo +// present). If resolution goes uniformly null, refresh them from FxEmbed. +// +// Fail-soft throughout: any error resolves to null and the caller proceeds +// without a media URL. Videos and GIFs are skipped — only photos resolve. + +import { X_BEARER, activateGuestToken } from './twitter-avatar'; + +const X_TWEET_BY_REST_ID = 'https://api.x.com/graphql/f2sagi1jweVHFkTUIHzmMQ/TweetResultByRestId'; +const FETCH_TIMEOUT_MS = 5000; + +const QUERY_FEATURES = { + rweb_video_screen_enabled: false, + profile_label_improvements_pcf_label_in_post_enabled: true, + responsive_web_profile_redirect_enabled: false, + rweb_tipjar_consumption_enabled: false, + verified_phone_label_enabled: false, + creator_subscriptions_tweet_preview_api_enabled: true, + responsive_web_graphql_timeline_navigation_enabled: true, + responsive_web_graphql_skip_user_profile_image_extensions_enabled: false, + premium_content_api_read_enabled: false, + communities_web_enable_tweet_community_results_fetch: true, + c9s_tweet_anatomy_moderator_badge_enabled: true, + responsive_web_grok_analyze_button_fetch_trends_enabled: false, + responsive_web_grok_analyze_post_followups_enabled: true, + responsive_web_jetfuel_frame: true, + responsive_web_grok_share_attachment_enabled: true, + responsive_web_grok_annotations_enabled: true, + articles_preview_enabled: true, + responsive_web_edit_tweet_api_enabled: true, + graphql_is_translatable_rweb_tweet_is_translatable_enabled: true, + view_counts_everywhere_api_enabled: true, + longform_notetweets_consumption_enabled: true, + responsive_web_twitter_article_tweet_consumption_enabled: true, + content_disclosure_indicator_enabled: true, + content_disclosure_ai_generated_indicator_enabled: true, + responsive_web_grok_show_grok_translated_post: true, + responsive_web_grok_analysis_button_from_backend: true, + post_ctas_fetch_enabled: true, + freedom_of_speech_not_reach_fetch_enabled: true, + standardized_nudges_misinfo: true, + tweet_with_visibility_results_prefer_gql_limited_actions_policy_enabled: true, + longform_notetweets_rich_text_read_enabled: true, + longform_notetweets_inline_media_enabled: true, + responsive_web_grok_image_annotation_enabled: true, + responsive_web_grok_imagine_annotation_enabled: true, + responsive_web_grok_community_note_auto_translation_is_enabled: true, + responsive_web_enhance_cards_enabled: false, + tweet_awards_web_tipping_enabled: false +} as const; + +const QUERY_FIELD_TOGGLES = { + withArticleRichContentState: true, + withArticlePlainText: false, + withGrokAnalyze: false, + withDisallowedReplyControls: false +} as const; + +/** Pull the numeric status id out of any of the tweet URL shapes we accept + * ("x.com/user/status/1", "twitter.com/i/status/1", ".../status/1/photo/1"). */ +export function tweetIdFromUrl(url: string): string | null { + const match = url + .trim() + .match(/(?:^|\/\/|\.)(?:x|twitter)\.com\/(?:[A-Za-z0-9_]{1,15}|i\/web|i)\/status(?:es)?\/(\d{1,20})(?:[/?#]|$)/i); + return match ? match[1] : null; +} + +type TweetMedia = { type?: unknown; media_url_https?: unknown }; + +/** + * Extract the first photo from a TweetResultByRestId response and ask + * pbs.twimg.com for its largest variant. Returns null for a tweet with no + * photo (video- and GIF-only tweets included). Pure, so it's testable. + */ +export function parseTweetPhotoUrl(body: unknown): string | null { + const result = (body as { data?: { tweetResult?: { result?: Record } } })?.data + ?.tweetResult?.result; + if (!result) return null; + // A tweet behind a visibility interstitial nests the real tweet one level down. + const tweet = (result.tweet as Record | undefined) ?? result; + const legacy = tweet.legacy as + | { extended_entities?: { media?: unknown }; entities?: { media?: unknown } } + | undefined; + const media = legacy?.extended_entities?.media ?? legacy?.entities?.media; + if (!Array.isArray(media)) return null; + + for (const entry of media as TweetMedia[]) { + if (entry?.type !== 'photo') continue; + const url = entry.media_url_https; + if (typeof url !== 'string' || !url) continue; + const match = url.match(/^(.*)\.([a-z]+)$/i); + if (!match) return url; + return `${match[1]}?format=${match[2].toLowerCase()}&name=4096x4096`; + } + return null; +} + +function tweetLookup(tweetId: string, guestToken: string, fetchImpl: typeof fetch): Promise { + const csrf = [...crypto.getRandomValues(new Uint8Array(16))] + .map((b) => b.toString(16).padStart(2, '0')) + .join(''); + const variables = encodeURIComponent( + JSON.stringify({ + tweetId, + withCommunity: false, + includePromotedContent: false, + withVoice: false + }) + ); + const features = encodeURIComponent(JSON.stringify(QUERY_FEATURES)); + const fieldToggles = encodeURIComponent(JSON.stringify(QUERY_FIELD_TOGGLES)); + return fetchImpl( + `${X_TWEET_BY_REST_ID}?variables=${variables}&features=${features}&fieldToggles=${fieldToggles}`, + { + headers: { + Authorization: X_BEARER, + 'x-guest-token': guestToken, + 'x-csrf-token': csrf, + 'x-twitter-active-user': 'yes', + Cookie: `guest_id=v1%3A${guestToken}; ct0=${csrf};` + }, + signal: AbortSignal.timeout(FETCH_TIMEOUT_MS) + } + ); +} + +/** Resolve the first photo on a public tweet to a pbs.twimg.com URL. One guest + * token, one fresh-token retry if X refuses it (401/429), then null. Never throws. */ +export async function fetchTweetMediaUrl( + tweetUrl: string, + fetchImpl: typeof fetch = fetch +): Promise { + const tweetId = tweetIdFromUrl(tweetUrl); + if (!tweetId) return null; + try { + let token = await activateGuestToken(fetchImpl); + if (!token) return null; + let res = await tweetLookup(tweetId, token, fetchImpl); + if (res.status === 401 || res.status === 429) { + token = await activateGuestToken(fetchImpl); + if (!token) return null; + res = await tweetLookup(tweetId, token, fetchImpl); + } + if (!res.ok) { + console.warn(`[avatar] tweet media lookup failed: status=${res.status}`); + return null; + } + const photo = parseTweetPhotoUrl(await res.json()); + if (!photo) { + // 200 but no photo — a text/video tweet, a protected or deleted one, or + // the undocumented GraphQL shape rotated (see the file header). + console.warn('[avatar] tweet media lookup had no photo'); + return null; + } + return photo; + } catch (e) { + console.warn(`[avatar] tweet media lookup error: ${e instanceof Error ? e.message : String(e)}`); + return null; + } +} From c7c59dc42679cdfaa3cc78a3a5f62d89eb8ca1e6 Mon Sep 17 00:00:00 2001 From: Sparky <1609870+sparkyfen@users.noreply.github.com> Date: Mon, 7 Sep 2026 19:05:07 -0700 Subject: [PATCH 02/22] fix(admin): let the entail.dev poll outlast the server's wait hold (SONA-220) Both wait=true endpoints answer by holding the connection until the classifier finishes, measured at about five seconds for a fresh job. The poll timeout was 2000 ms, so it aborted the response it had just asked the server to hold, and every live classify returned null after two seconds. Raise the poll timeout to 8000 ms and drop to two attempts. The /post timeout was already 8000 ms and clears the hold as-is. A test now holds a poll open for 2.5 seconds and expects the suggestions rather than null. --- src/lib/server/entail.test.ts | 20 +++++++++++++++++++- src/lib/server/entail.ts | 13 ++++++++----- 2 files changed, 27 insertions(+), 6 deletions(-) diff --git a/src/lib/server/entail.test.ts b/src/lib/server/entail.test.ts index d01570bc..36922c7a 100644 --- a/src/lib/server/entail.test.ts +++ b/src/lib/server/entail.test.ts @@ -227,9 +227,27 @@ describe('classifyMediaUrl', () => { return json({ status: 'processing' }, 202); }); expect(await classifyMediaUrl('https://pbs.twimg.com/media/abc', fetchImpl)).toBeNull(); - expect(polls).toBe(3); + expect(polls).toBe(2); }); + // The poll endpoint answers `wait=true` by holding the connection until the + // classifier finishes — about five seconds for a fresh job. A poll timeout + // shorter than that hold aborts the response we asked to wait for, which is + // what a 2000 ms timeout did in the first cut of this module. + it('waits out a poll that the server holds open for seconds', async () => { + const heldFor = 2500; + const fetchImpl = vi.fn(async (_url: string | URL | Request, init?: RequestInit) => { + if (init?.method === 'POST') return json({ job_id: 'job-5' }, 202); + await new Promise((resolve) => setTimeout(resolve, heldFor)); + init?.signal?.throwIfAborted(); + return json(done); + }); + expect(await classifyMediaUrl('https://pbs.twimg.com/media/abc', fetchImpl)).toEqual({ + tags: ['mammal'], + rating: 'questionable' + }); + }, 10_000); + it('returns null on a rate-limited enqueue, a missing job id, and errors', async () => { const url = 'https://pbs.twimg.com/media/abc'; expect(await classifyMediaUrl(url, vi.fn(async () => new Response('slow down', { status: 429 })))).toBeNull(); diff --git a/src/lib/server/entail.ts b/src/lib/server/entail.ts index a0ff58db..d9aa7836 100644 --- a/src/lib/server/entail.ts +++ b/src/lib/server/entail.ts @@ -20,14 +20,17 @@ const ENTAIL_CLASSIFY = 'https://entail.dev/api/classify'; /** The floor entail.dev's own docs recommend for Sona. */ export const DEFAULT_CONFIDENCE_FLOOR = 0.8; -// Budget: a /post lookup is one call. A classify is one POST plus at most -// three polls with a short pause between them, which keeps the worst case -// (3000 + 3 * 2000 + 2 * 250) just under ten seconds. +// Both `wait=true` endpoints hold the connection open until the classifier +// finishes rather than answering 202 straight away. That hold was measured at +// roughly five seconds for a fresh job on 2026-09-08, so every timeout here +// has to clear it comfortably or we abort the very response we asked to wait +// for. A classify is one enqueue plus at most two polls, worst case about +// 3 + 8 + 0.25 + 8 seconds; the caller shows a pending state while it waits. const POST_TIMEOUT_MS = 8000; const CLASSIFY_TIMEOUT_MS = 3000; -const POLL_TIMEOUT_MS = 2000; +const POLL_TIMEOUT_MS = 8000; const POLL_PAUSE_MS = 250; -const POLL_ATTEMPTS = 3; +const POLL_ATTEMPTS = 2; export type EntailRating = 'safe' | 'questionable' | 'explicit'; From 5e0efdeec1502fff5391ae817a78d8e7ee5aa31b Mon Sep 17 00:00:00 2001 From: Sparky <1609870+sparkyfen@users.noreply.github.com> Date: Mon, 7 Sep 2026 19:08:54 -0700 Subject: [PATCH 03/22] feat(admin): tag suggestion endpoint (SONA-220) POST /api/admin/tag-suggestions takes either a stored image id or the source URL the operator is still typing, and answers with the tags entail.dev's classifier found in that post's image. Every request goes through classifySourceUrl before anything is fetched, so the only outbound URLs are ones this app built: the canonical bsky.app post URL, or the pbs.twimg.com media URL X's own API returned. A caller-supplied host is never fetched. The entail client collapsed every failure into null, which left the endpoint unable to tell a queued post from an outage. Add lookupBlueskyPostResult and classifyMediaUrlResult, which name the reason (not_ready, rate_limited, unavailable); the existing null-returning exports now wrap them and behave as before. --- src/lib/server/entail.test.ts | 33 ++++ src/lib/server/entail.ts | 84 ++++++-- .../api/admin/tag-suggestions/+server.ts | 108 +++++++++++ .../api/admin/tag-suggestions/server.test.ts | 183 ++++++++++++++++++ 4 files changed, 390 insertions(+), 18 deletions(-) create mode 100644 src/routes/api/admin/tag-suggestions/+server.ts create mode 100644 src/routes/api/admin/tag-suggestions/server.test.ts diff --git a/src/lib/server/entail.test.ts b/src/lib/server/entail.test.ts index 36922c7a..7609bfbb 100644 --- a/src/lib/server/entail.test.ts +++ b/src/lib/server/entail.test.ts @@ -2,7 +2,9 @@ import { describe, expect, it, vi } from 'vitest'; import { classifySourceUrl, classifyMediaUrl, + classifyMediaUrlResult, lookupBlueskyPost, + lookupBlueskyPostResult, suggestionsFromResult, translateTag } from './entail'; @@ -170,6 +172,20 @@ describe('lookupBlueskyPost', () => { ).toBeNull(); }); + it('names the reason a lookup produced nothing', async () => { + const url = 'https://bsky.app/profile/did:plc:aaaa/post/3abc'; + expect(await lookupBlueskyPostResult(url, vi.fn(async () => json({}, 202)))).toEqual({ + ok: false, + reason: 'not_ready' + }); + expect( + await lookupBlueskyPostResult(url, vi.fn(async () => new Response('slow down', { status: 429 }))) + ).toEqual({ ok: false, reason: 'rate_limited' }); + expect( + await lookupBlueskyPostResult(url, vi.fn(async () => new Response('boom', { status: 500 }))) + ).toEqual({ ok: false, reason: 'unavailable' }); + }); + it('returns null when the post has no classified images', async () => { expect( await lookupBlueskyPost( @@ -262,6 +278,23 @@ describe('classifyMediaUrl', () => { ).toBeNull(); }); + it('names a rate limit from either the enqueue or a poll', async () => { + const url = 'https://pbs.twimg.com/media/abc'; + expect( + await classifyMediaUrlResult(url, vi.fn(async () => new Response('slow down', { status: 429 }))) + ).toEqual({ ok: false, reason: 'rate_limited' }); + + const limitedPoll = vi.fn(async (_url: string | URL | Request, init?: RequestInit) => + init?.method === 'POST' + ? json({ job_id: 'job-6' }, 202) + : new Response('slow down', { status: 429 }) + ); + expect(await classifyMediaUrlResult(url, limitedPoll)).toEqual({ + ok: false, + reason: 'rate_limited' + }); + }); + it('returns null when a poll fails outright', async () => { const fetchImpl = vi.fn(async (_url: string | URL | Request, init?: RequestInit) => init?.method === 'POST' diff --git a/src/lib/server/entail.ts b/src/lib/server/entail.ts index d9aa7836..5205c25d 100644 --- a/src/lib/server/entail.ts +++ b/src/lib/server/entail.ts @@ -39,6 +39,19 @@ export type Suggestions = { rating: EntailRating | null; }; +/** Why a lookup produced nothing. `not_ready` is the one worth retrying: the + * post is queued but not classified yet. `rate_limited` is entail.dev's per-IP + * limit, which has no key to raise. Everything else — a timeout, a non-2xx, a + * job that never finished, a post with no furry images in it — is + * `unavailable`, because none of them tell the operator anything different. */ +export type LookupFailure = 'not_ready' | 'rate_limited' | 'unavailable'; + +export type LookupOutcome = + | { ok: true; suggestions: Suggestions } + | { ok: false; reason: LookupFailure }; + +const fail = (reason: LookupFailure): LookupOutcome => ({ ok: false, reason }); + /** One classification entry: an image inside a `/post` response, or the body * of a finished `/classify/` poll. */ export type ClassificationEntry = { @@ -154,34 +167,51 @@ function firstImage(body: unknown): ClassificationEntry | null { * post whose images entail.dev hasn't classified yet answers 202, which we * treat as "nothing to suggest" rather than waiting around. Never throws. */ -export async function lookupBlueskyPost( +export async function lookupBlueskyPostResult( url: string, fetchImpl: typeof fetch = fetch -): Promise { +): Promise { const source = classifySourceUrl(url); - if (!source || source.kind !== 'bluesky') return null; + if (!source || source.kind !== 'bluesky') return fail('unavailable'); const endpoint = `${ENTAIL_POST}?url=${encodeURIComponent(source.url)}&min_confidence=${DEFAULT_CONFIDENCE_FLOOR}&wait=true`; try { const res = await fetchImpl(endpoint, { signal: AbortSignal.timeout(POST_TIMEOUT_MS) }); if (res.status === 202) { - // Queued for classification. Best effort: no retry loop. + // Queued for classification. Best effort: no retry loop here — the + // caller decides whether to ask again. console.warn('[entail] post not classified yet: status=202'); - return null; + return fail('not_ready'); + } + if (res.status === 429) { + console.warn('[entail] post lookup rate limited: status=429'); + return fail('rate_limited'); } if (!res.ok) { console.warn(`[entail] post lookup failed: status=${res.status}`); - return null; + return fail('unavailable'); } const image = firstImage(await res.json()); - if (!image) return null; - return suggestionsFromResult(image); + // No images means entail.dev found no furry artwork in the post, which + // leaves nothing to suggest. + if (!image) return fail('unavailable'); + return { ok: true, suggestions: suggestionsFromResult(image) }; } catch (e) { console.warn(`[entail] post lookup error: ${e instanceof Error ? e.message : String(e)}`); - return null; + return fail('unavailable'); } } +/** Suggestions for a Bluesky post, or null for any failure. Use + * {@link lookupBlueskyPostResult} when the reason matters. */ +export async function lookupBlueskyPost( + url: string, + fetchImpl: typeof fetch = fetch +): Promise { + const outcome = await lookupBlueskyPostResult(url, fetchImpl); + return outcome.ok ? outcome.suggestions : null; +} + function jobIdFrom(body: unknown): string | null { const { job_id: jobId, id } = (body ?? {}) as { job_id?: unknown; id?: unknown }; if (typeof jobId === 'string' && jobId) return jobId; @@ -209,11 +239,11 @@ const pause = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms)); * classification job, then poll it a few times. Gives up (null) if the job * isn't done by the attempt cap. Never throws. */ -export async function classifyMediaUrl( +export async function classifyMediaUrlResult( url: string, fetchImpl: typeof fetch = fetch -): Promise { - if (!isAllowedMediaHost(url)) return null; +): Promise { + if (!isAllowedMediaHost(url)) return fail('unavailable'); try { const enqueued = await fetchImpl(ENTAIL_CLASSIFY, { @@ -222,14 +252,18 @@ export async function classifyMediaUrl( body: JSON.stringify({ url }), signal: AbortSignal.timeout(CLASSIFY_TIMEOUT_MS) }); + if (enqueued.status === 429) { + console.warn('[entail] classify enqueue rate limited: status=429'); + return fail('rate_limited'); + } if (!enqueued.ok && enqueued.status !== 202) { console.warn(`[entail] classify enqueue failed: status=${enqueued.status}`); - return null; + return fail('unavailable'); } const jobId = jobIdFrom(await enqueued.json()); if (!jobId) { console.warn('[entail] classify enqueue returned no job id'); - return null; + return fail('unavailable'); } const poll = `${ENTAIL_CLASSIFY}/${encodeURIComponent(jobId)}?wait=true`; @@ -237,18 +271,32 @@ export async function classifyMediaUrl( if (attempt > 0) await pause(POLL_PAUSE_MS); const res = await fetchImpl(poll, { signal: AbortSignal.timeout(POLL_TIMEOUT_MS) }); if (res.status === 202) continue; + if (res.status === 429) { + console.warn('[entail] classify poll rate limited: status=429'); + return fail('rate_limited'); + } if (!res.ok) { console.warn(`[entail] classify poll failed: status=${res.status}`); - return null; + return fail('unavailable'); } const body = (await res.json()) as (ClassificationEntry & { status?: unknown }) | null; if (body?.status !== 'done') continue; - return suggestionsFromResult(body); + return { ok: true, suggestions: suggestionsFromResult(body) }; } console.warn(`[entail] classify job unfinished after ${POLL_ATTEMPTS} polls`); - return null; + return fail('unavailable'); } catch (e) { console.warn(`[entail] classify error: ${e instanceof Error ? e.message : String(e)}`); - return null; + return fail('unavailable'); } } + +/** Suggestions for one media URL, or null for any failure. Use + * {@link classifyMediaUrlResult} when the reason matters. */ +export async function classifyMediaUrl( + url: string, + fetchImpl: typeof fetch = fetch +): Promise { + const outcome = await classifyMediaUrlResult(url, fetchImpl); + return outcome.ok ? outcome.suggestions : null; +} diff --git a/src/routes/api/admin/tag-suggestions/+server.ts b/src/routes/api/admin/tag-suggestions/+server.ts new file mode 100644 index 00000000..6b87290a --- /dev/null +++ b/src/routes/api/admin/tag-suggestions/+server.ts @@ -0,0 +1,108 @@ +import { json } from '@sveltejs/kit'; +import { eq } from 'drizzle-orm'; +import { getDb } from '$lib/server/db'; +import { images } from '$lib/server/db/schema'; +import { + classifySourceUrl, + classifyMediaUrlResult, + lookupBlueskyPostResult, + type LookupFailure, + type LookupOutcome +} from '$lib/server/entail'; +import { fetchTweetMediaUrl } from '$lib/server/twitter-media'; +import type { RequestHandler } from './$types'; + +// POST /api/admin/tag-suggestions (admin-only via hooks — everything under +// /api except /api/cron/ requires the admin session). +// +// Suggests tags for an image from its source post, by asking entail.dev's +// public classifier what is in the picture (SONA-220). Two request shapes: +// +// { imageId } — the edit page, where the source URL is already stored. +// { sourcePostUrl } — the upload page, where there is no image row yet and +// the URL is whatever the operator has typed so far. +// +// Either shape goes through classifySourceUrl first, so the only URLs that +// ever leave this app are ones we built: the canonical bsky.app post URL, or +// the pbs.twimg.com media URL that X's own API handed back. A caller-supplied +// host is never fetched, which is what keeps the sourcePostUrl shape from +// being an SSRF hole. +// +// Nothing here runs on its own — no cron, no render path. entail.dev's +// response body is never logged or stored; only the normalized fields below +// are returned, and the tags have been through the same sanitizer the tag +// inputs use. + +/** What the UI gets back for a failed lookup, and the status carrying it. */ +const FAILURE_STATUS: Record = { + // 502, not 401 or 503: the admin gate answers an expired session with its + // own 401 and a plain-text body, so a 401 here would read as a logged-out + // operator. The body's `error` field is what tells the cases apart. + not_ready: 502, + rate_limited: 429, + unavailable: 502 +}; + +const MAX_URL_LENGTH = 2048; + +const failure = (reason: LookupFailure) => + json({ error: reason }, { status: FAILURE_STATUS[reason] }); + +const invalid = () => json({ error: 'invalid_request' }, { status: 400 }); + +type Body = { imageId?: unknown; sourcePostUrl?: unknown }; + +export const POST: RequestHandler = async ({ request, platform }) => { + const body = (await request.json().catch(() => null)) as Body | null; + if (!body || typeof body !== 'object' || Array.isArray(body)) return invalid(); + + const hasImageId = body.imageId !== undefined && body.imageId !== null; + const hasUrl = body.sourcePostUrl !== undefined && body.sourcePostUrl !== null; + // Exactly one: two fields would leave it ambiguous which one the operator + // meant, and the answers can differ (the form's unsaved value against the + // stored one). + if (hasImageId === hasUrl) return invalid(); + + let sourcePostUrl: string; + if (hasImageId) { + const imageId = body.imageId; + if (typeof imageId !== 'number' || !Number.isInteger(imageId) || imageId <= 0) return invalid(); + + const row = await getDb(platform!.env.DB) + .select({ sourcePostUrl: images.sourcePostUrl }) + .from(images) + .where(eq(images.id, imageId)) + .get(); + if (!row) return json({ error: 'not_found' }, { status: 404 }); + sourcePostUrl = row.sourcePostUrl ?? ''; + } else { + if (typeof body.sourcePostUrl !== 'string') return invalid(); + if (body.sourcePostUrl.length > MAX_URL_LENGTH) return invalid(); + sourcePostUrl = body.sourcePostUrl; + } + + // An image with no source post, or one pointing somewhere we have no + // classifier for, is not a broken request — there is just nothing to ask. + const source = classifySourceUrl(sourcePostUrl); + if (!source) return json({ error: 'unsupported_source' }, { status: 422 }); + + let outcome: LookupOutcome; + if (source.kind === 'bluesky') { + outcome = await lookupBlueskyPostResult(source.url); + } else { + // entail.dev indexes Bluesky, not X, so an X post has to be classified + // from its image. X's API is the only thing that knows which image that + // is, and it hands back a pbs.twimg.com URL — one of the two hosts + // classifyMediaUrl will send on. + const mediaUrl = await fetchTweetMediaUrl(source.url); + if (!mediaUrl) return failure('unavailable'); + outcome = await classifyMediaUrlResult(mediaUrl); + } + + if (!outcome.ok) return failure(outcome.reason); + return json({ + source: source.kind, + tags: outcome.suggestions.tags, + rating: outcome.suggestions.rating + }); +}; diff --git a/src/routes/api/admin/tag-suggestions/server.test.ts b/src/routes/api/admin/tag-suggestions/server.test.ts new file mode 100644 index 00000000..26e866df --- /dev/null +++ b/src/routes/api/admin/tag-suggestions/server.test.ts @@ -0,0 +1,183 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; +// better-sqlite3 ships no bundled types and is a dev-only test dependency here. +// @ts-expect-error - no declaration file for 'better-sqlite3' +import Database from 'better-sqlite3'; +import type { LookupOutcome } from '$lib/server/entail'; +import { makeD1 } from '$lib/server/test/d1'; +import { POST } from './+server'; + +// Only the outbound calls are stubbed. classifySourceUrl stays real, so the +// URL recognition the endpoint depends on is exercised rather than mocked. +const lookupBlueskyPostResult = vi.hoisted(() => + vi.fn(async (_url: string): Promise => ({ ok: false, reason: 'unavailable' })) +); +const classifyMediaUrlResult = vi.hoisted(() => + vi.fn(async (_url: string): Promise => ({ ok: false, reason: 'unavailable' })) +); +const fetchTweetMediaUrl = vi.hoisted(() => vi.fn(async (_url: string): Promise => null)); + +vi.mock('$lib/server/entail', async (importOriginal) => { + const original = await importOriginal(); + return { ...original, lookupBlueskyPostResult, classifyMediaUrlResult }; +}); +vi.mock('$lib/server/twitter-media', () => ({ fetchTweetMediaUrl })); + +const DDL = `CREATE TABLE images (id INTEGER PRIMARY KEY AUTOINCREMENT, title TEXT NOT NULL, slug TEXT, + image_url TEXT NOT NULL, thumbnail_url TEXT, width INTEGER, height INTEGER, file_size INTEGER, + md5hash TEXT, nsfw INTEGER NOT NULL DEFAULT 0, published INTEGER NOT NULL DEFAULT 1, + source_post_url TEXT, artist_id INTEGER, collection_id INTEGER, commissioned_at TEXT, + parent_image_id INTEGER, variant_label TEXT, featured INTEGER NOT NULL DEFAULT 0, + featured_order INTEGER, created_at TEXT);`; + +const BSKY_POST = 'https://bsky.app/profile/example.bsky.social/post/3abc'; +const X_POST = 'https://x.com/examplefox/status/1234567890'; +const MEDIA_URL = 'https://pbs.twimg.com/media/AbCdEf123?format=jpg&name=4096x4096'; + +const suggestions: LookupOutcome = { + ok: true, + suggestions: { tags: ['mammal', 'pink-hair'], rating: 'safe' } +}; + +function makeEnv() { + const sqlite = new Database(':memory:'); + sqlite.exec(DDL); + const d1 = makeD1(sqlite); + return { sqlite, platform: { env: { DB: d1 } } as unknown as App.Platform }; +} + +function insertImage(sqlite: { prepare: (sql: string) => { run: (...args: unknown[]) => void } }, sourcePostUrl: string | null) { + sqlite + .prepare('INSERT INTO images (id, title, image_url, source_post_url) VALUES (?, ?, ?, ?)') + .run(7, 'A picture', 'https://example.com/a.png', sourcePostUrl); +} + +function event(platform: App.Platform, body: unknown, raw?: string) { + const request = new Request('http://localhost/api/admin/tag-suggestions', { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: raw ?? JSON.stringify(body) + }); + return { request, platform } as never; +} + +beforeEach(() => { + lookupBlueskyPostResult.mockReset(); + classifyMediaUrlResult.mockReset(); + fetchTweetMediaUrl.mockReset(); + lookupBlueskyPostResult.mockResolvedValue(suggestions); + classifyMediaUrlResult.mockResolvedValue(suggestions); + fetchTweetMediaUrl.mockResolvedValue(MEDIA_URL); +}); + +describe('POST /api/admin/tag-suggestions', () => { + it('suggests tags for a bluesky source URL', async () => { + const { platform } = makeEnv(); + const res = await POST(event(platform, { sourcePostUrl: BSKY_POST })); + expect(res.status).toBe(200); + expect(await res.json()).toEqual({ + source: 'bluesky', + tags: ['mammal', 'pink-hair'], + rating: 'safe' + }); + // The canonical URL, not the caller's string. + expect(lookupBlueskyPostResult).toHaveBeenCalledWith(BSKY_POST); + expect(fetchTweetMediaUrl).not.toHaveBeenCalled(); + }); + + it('resolves an X post to its media URL, then classifies that', async () => { + const { platform } = makeEnv(); + const res = await POST(event(platform, { sourcePostUrl: `${X_POST}/photo/1` })); + expect(res.status).toBe(200); + expect(await res.json()).toEqual({ + source: 'x', + tags: ['mammal', 'pink-hair'], + rating: 'safe' + }); + expect(fetchTweetMediaUrl).toHaveBeenCalledWith(X_POST); + // The media URL is what reaches entail.dev — never the tweet URL. + expect(classifyMediaUrlResult).toHaveBeenCalledWith(MEDIA_URL); + expect(lookupBlueskyPostResult).not.toHaveBeenCalled(); + }); + + it('reads the stored source URL for an imageId', async () => { + const { sqlite, platform } = makeEnv(); + insertImage(sqlite, BSKY_POST); + const res = await POST(event(platform, { imageId: 7 })); + expect(res.status).toBe(200); + expect((await res.json()).source).toBe('bluesky'); + expect(lookupBlueskyPostResult).toHaveBeenCalledWith(BSKY_POST); + }); + + it('404s an unknown imageId', async () => { + const { platform } = makeEnv(); + const res = await POST(event(platform, { imageId: 99 })); + expect(res.status).toBe(404); + expect(await res.json()).toEqual({ error: 'not_found' }); + expect(lookupBlueskyPostResult).not.toHaveBeenCalled(); + }); + + it('422s a source we have no classifier for, including a stored empty one', async () => { + const { sqlite, platform } = makeEnv(); + insertImage(sqlite, null); + for (const body of [ + { sourcePostUrl: 'https://furaffinity.net/view/12345/' }, + { sourcePostUrl: '' }, + { imageId: 7 } + ]) { + const res = await POST(event(platform, body)); + expect(res.status).toBe(422); + expect(await res.json()).toEqual({ error: 'unsupported_source' }); + } + expect(lookupBlueskyPostResult).not.toHaveBeenCalled(); + }); + + it('400s malformed and ambiguous request bodies', async () => { + const { platform } = makeEnv(); + const bad: Array<[unknown, string | undefined]> = [ + [undefined, 'not json'], + [undefined, '[1,2]'], + [{}, undefined], + [{ imageId: 7, sourcePostUrl: BSKY_POST }, undefined], + [{ imageId: '7' }, undefined], + [{ imageId: 1.5 }, undefined], + [{ imageId: 0 }, undefined], + [{ sourcePostUrl: 42 }, undefined], + [{ sourcePostUrl: `https://bsky.app/profile/a/post/${'x'.repeat(2100)}` }, undefined] + ]; + for (const [body, raw] of bad) { + const res = await POST(event(platform, body, raw)); + expect(res.status).toBe(400); + expect(await res.json()).toEqual({ error: 'invalid_request' }); + } + }); + + it('502s not_ready when the post is queued but unclassified', async () => { + const { platform } = makeEnv(); + lookupBlueskyPostResult.mockResolvedValue({ ok: false, reason: 'not_ready' }); + const res = await POST(event(platform, { sourcePostUrl: BSKY_POST })); + expect(res.status).toBe(502); + expect(await res.json()).toEqual({ error: 'not_ready' }); + }); + + it('502s unavailable for a failed lookup and for an unresolvable tweet', async () => { + const { platform } = makeEnv(); + lookupBlueskyPostResult.mockResolvedValue({ ok: false, reason: 'unavailable' }); + const bsky = await POST(event(platform, { sourcePostUrl: BSKY_POST })); + expect(bsky.status).toBe(502); + expect(await bsky.json()).toEqual({ error: 'unavailable' }); + + fetchTweetMediaUrl.mockResolvedValue(null); + const x = await POST(event(platform, { sourcePostUrl: X_POST })); + expect(x.status).toBe(502); + expect(await x.json()).toEqual({ error: 'unavailable' }); + expect(classifyMediaUrlResult).not.toHaveBeenCalled(); + }); + + it('429s when entail.dev rate limited us', async () => { + const { platform } = makeEnv(); + lookupBlueskyPostResult.mockResolvedValue({ ok: false, reason: 'rate_limited' }); + const res = await POST(event(platform, { sourcePostUrl: BSKY_POST })); + expect(res.status).toBe(429); + expect(await res.json()).toEqual({ error: 'rate_limited' }); + }); +}); From e33f4f4297eb07976c2ad789887e3211c91348a6 Mon Sep 17 00:00:00 2001 From: Sparky <1609870+sparkyfen@users.noreply.github.com> Date: Mon, 7 Sep 2026 19:10:32 -0700 Subject: [PATCH 04/22] fix(admin): empty entail.dev classification is a success with no tags (SONA-220) A post the classifier read and found no furry artwork in was coming back as unavailable, which reads to the operator as an outage. It is an answer: the lookup now succeeds with an empty tag list, keeping whatever rating the classifier gave, and the endpoint returns 200. The null-returning wrapper follows suit. Callers can now tell "nothing to suggest" from "no answer", which they could not before. --- src/lib/server/entail.test.ts | 13 ++++++----- src/lib/server/entail.ts | 22 ++++++++++++------- .../api/admin/tag-suggestions/server.test.ts | 13 +++++++++++ 3 files changed, 34 insertions(+), 14 deletions(-) diff --git a/src/lib/server/entail.test.ts b/src/lib/server/entail.test.ts index 7609bfbb..babe7465 100644 --- a/src/lib/server/entail.test.ts +++ b/src/lib/server/entail.test.ts @@ -186,13 +186,14 @@ describe('lookupBlueskyPost', () => { ).toEqual({ ok: false, reason: 'unavailable' }); }); - it('returns null when the post has no classified images', async () => { + it('succeeds with no tags when the post has no classified images', async () => { + const fetchImpl = vi.fn(async () => json({ uri: 'at://x', images: [] })); expect( - await lookupBlueskyPost( - 'https://bsky.app/profile/did:plc:aaaa/post/3abc', - vi.fn(async () => json({ uri: 'at://x', images: [] })) - ) - ).toBeNull(); + await lookupBlueskyPost('https://bsky.app/profile/did:plc:aaaa/post/3abc', fetchImpl) + ).toEqual({ tags: [], rating: null }); + expect( + await lookupBlueskyPostResult('https://bsky.app/profile/did:plc:aaaa/post/3abc', fetchImpl) + ).toEqual({ ok: true, suggestions: { tags: [], rating: null } }); }); }); diff --git a/src/lib/server/entail.ts b/src/lib/server/entail.ts index 5205c25d..d5805a3c 100644 --- a/src/lib/server/entail.ts +++ b/src/lib/server/entail.ts @@ -42,8 +42,9 @@ export type Suggestions = { /** Why a lookup produced nothing. `not_ready` is the one worth retrying: the * post is queued but not classified yet. `rate_limited` is entail.dev's per-IP * limit, which has no key to raise. Everything else — a timeout, a non-2xx, a - * job that never finished, a post with no furry images in it — is - * `unavailable`, because none of them tell the operator anything different. */ + * job that never finished — is `unavailable`, because none of them tell the + * operator anything different. A post the classifier read and found nothing in + * is not a failure at all; it succeeds with an empty tag list. */ export type LookupFailure = 'not_ready' | 'rate_limited' | 'unavailable'; export type LookupOutcome = @@ -191,19 +192,24 @@ export async function lookupBlueskyPostResult( console.warn(`[entail] post lookup failed: status=${res.status}`); return fail('unavailable'); } + // An empty `images` array means entail.dev looked and found no furry + // artwork in the post. That is an answer, not a failure: the caller gets + // an empty tag list rather than an error it would have to explain. const image = firstImage(await res.json()); - // No images means entail.dev found no furry artwork in the post, which - // leaves nothing to suggest. - if (!image) return fail('unavailable'); - return { ok: true, suggestions: suggestionsFromResult(image) }; + return { + ok: true, + suggestions: image ? suggestionsFromResult(image) : { tags: [], rating: null } + }; } catch (e) { console.warn(`[entail] post lookup error: ${e instanceof Error ? e.message : String(e)}`); return fail('unavailable'); } } -/** Suggestions for a Bluesky post, or null for any failure. Use - * {@link lookupBlueskyPostResult} when the reason matters. */ +/** Suggestions for a Bluesky post, or null if the lookup failed. A post with + * nothing to suggest resolves to an empty tag list, not null — "no suggestions" + * and "no answer" are different facts. Use {@link lookupBlueskyPostResult} when + * the reason for a failure matters. */ export async function lookupBlueskyPost( url: string, fetchImpl: typeof fetch = fetch diff --git a/src/routes/api/admin/tag-suggestions/server.test.ts b/src/routes/api/admin/tag-suggestions/server.test.ts index 26e866df..3d7a23b5 100644 --- a/src/routes/api/admin/tag-suggestions/server.test.ts +++ b/src/routes/api/admin/tag-suggestions/server.test.ts @@ -151,6 +151,19 @@ describe('POST /api/admin/tag-suggestions', () => { } }); + it('200s with no tags when the classifier found nothing to suggest', async () => { + const { platform } = makeEnv(); + // The classifier read the post and rated it; nothing cleared the + // confidence floor. That is an answer, not a failure. + lookupBlueskyPostResult.mockResolvedValue({ + ok: true, + suggestions: { tags: [], rating: 'safe' } + }); + const res = await POST(event(platform, { sourcePostUrl: BSKY_POST })); + expect(res.status).toBe(200); + expect(await res.json()).toEqual({ source: 'bluesky', tags: [], rating: 'safe' }); + }); + it('502s not_ready when the post is queued but unclassified', async () => { const { platform } = makeEnv(); lookupBlueskyPostResult.mockResolvedValue({ ok: false, reason: 'not_ready' }); From d91c21b2fc8c60a35ad3e85242ad28e895b011be Mon Sep 17 00:00:00 2001 From: Sparky <1609870+sparkyfen@users.noreply.github.com> Date: Mon, 7 Sep 2026 19:11:52 -0700 Subject: [PATCH 05/22] docs(architecture): add entail.dev tag classifier (SONA-220) The diagram gains an entail.dev node and the edge from the API layer that calls it. The Bluesky and X node is no longer only a profile-picture source: the same guest-token path now resolves a tweet to its image so entail.dev has something to classify, so its label and the API edge say so. --- docs/architecture.md | 11 +++++++++-- 1 file changed, 9 insertions(+), 2 deletions(-) diff --git a/docs/architecture.md b/docs/architecture.md index 0765f1b9..80904d5c 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -44,7 +44,8 @@ graph TB Resend[✉️ Resend] Turnstile[🧩 Cloudflare Turnstile] ConsFYI[📅 cons.fyi] - Avatars[🖼️ Bluesky + X — profile picture sources] + Avatars[🖼️ Bluesky + X — profile pictures, tweet media] + Entail[🏷️ entail.dev — image tag classifier] UT[☁️ UploadThing — optional] end @@ -89,6 +90,7 @@ graph TB Auth -->|reset email| Resend RateLimit --> Turnstile Public -->|convention dates| ConsFYI + API -->|tag suggestions for a source post| Entail RegClient -->|search / pull / submit| RegWorker RegWorker --> RegD1 @@ -96,7 +98,7 @@ graph TB CI --> Deploy Deploy -->|wrangler pages deploy| Hooks CronWF -->|POST /api/cron/* with CRON_SECRET| API - API -->|fetch profile pictures to re-host| Avatars + API -->|fetch profile pictures to re-host, resolve tweet media| Avatars Admin -->|fetch profile pictures to re-host| Avatars Release -.->|pull tagged releases| Forks ``` @@ -131,6 +133,11 @@ graph TB zone rather than the reader's or UTC. - Telegram, FurTrack, Resend, and Turnstile are optional integrations, keyed off secrets or settings (see `wrangler.toml.example` for the full list). +- entail.dev needs no key or secret. The app calls it only when an operator + asks for tag suggestions on an image whose source post is on Bluesky or X, + and never on a render path or a schedule. An X post takes one extra hop: + X's own API resolves the post to its image, and that image URL is what + entail.dev classifies. - GitHub Actions is part of the runtime, not just delivery: the scheduled workflows (`sticker-resync` daily 06:00 UTC, `artist-sync` 06:30, `avatar-refresh` 07:00, `cleanup-orphans` weekly, `backfill-animated` From a1b673a4f47ea998855af446c2f1e97e84cce777 Mon Sep 17 00:00:00 2001 From: Sparky <1609870+sparkyfen@users.noreply.github.com> Date: Mon, 7 Sep 2026 19:23:43 -0700 Subject: [PATCH 06/22] fix(admin): review round 1 for the entail.dev tag suggestion client (SONA-220) - Decode the Bluesky actor inside the URL guard and validate the decoded value, so malformed percent sequences return null instead of throwing and encoded slashes cannot reach the canonical URL. - Drop the null-returning wrappers; lookupBlueskyPost and classifyMediaUrl now return the discriminated outcome the endpoint consumes. - Carry the X status id on the classified source; fetchTweetMediaUrl takes the id and reports rate_limited separately from unavailable. - Cap suggestions at 40 tags, report imageCount, read only the documented images and job_id fields, and log error names rather than messages that quote a third-party body. - Update the AI disclosure and privacy policy for the classifier call and bump the policy date. - Tests for each of the above plus the mobile and statuses URL forms. --- src/lib/ai-disclosure.test.ts | 6 +- src/lib/ai-disclosure.ts | 7 +- src/lib/legal.test.ts | 9 +- src/lib/legal.ts | 6 +- src/lib/server/entail.test.ts | 203 +++++++++++------- src/lib/server/entail.ts | 102 ++++----- src/lib/server/twitter-media.test.ts | 106 +++++---- src/lib/server/twitter-media.ts | 51 +++-- .../api/admin/tag-suggestions/+server.ts | 17 +- .../api/admin/tag-suggestions/server.test.ts | 74 ++++--- 10 files changed, 347 insertions(+), 234 deletions(-) diff --git a/src/lib/ai-disclosure.test.ts b/src/lib/ai-disclosure.test.ts index 12a5ca2c..037f492c 100644 --- a/src/lib/ai-disclosure.test.ts +++ b/src/lib/ai-disclosure.test.ts @@ -33,7 +33,11 @@ describe('defaultAiDisclosure', () => { }); it('states the runtime boundary and the dev-time access plainly', () => { - expect(all).toMatch(/never calls an AI service, so nothing you do is sent to one as you browse/); + // SONA-220 added the one runtime call; the disclosure names it and keeps + // the browsing-time claim scoped so it stays true. + expect(all).toMatch(/calls an AI service in one place/); + expect(all).toContain('entail.dev'); + expect(all).toMatch(/Nothing you do is sent to an AI service as you browse/); expect(all).toMatch(/logs and database/); expect(all).toContain('CodeRabbit'); // Honesty about what dev-time log access can expose: no "your data never diff --git a/src/lib/ai-disclosure.ts b/src/lib/ai-disclosure.ts index e27c6f84..799cebc8 100644 --- a/src/lib/ai-disclosure.ts +++ b/src/lib/ai-disclosure.ts @@ -2,8 +2,9 @@ // // Wording approved by the operator 2026-08-12 after a peer review that set the // register: short, disclosure-only, no persuasion. Facts only — every claim -// here is backed by the codebase (no runtime AI calls anywhere), the repo -// policy (AI_POLICY.md), or the operator's own practice. An owner whose +// here is backed by the codebase (the one runtime AI call is the operator's +// tag-suggestion lookup against entail.dev, SONA-220), the repo policy +// (AI_POLICY.md), or the operator's own practice. An owner whose // practice differs (or who wants their own words) overrides the text via // Settings, or turns the page off entirely with the aiPageEnabled toggle. // @@ -70,7 +71,7 @@ export function defaultAiDisclosure(): AiDisclosure { }, { lead: 'Your data.', - body: "The running site never calls an AI service, so nothing you do is sent to one as you browse. When the software is being worked on, the developer's tools can read this site's logs and database, as any developer's could, and those logs can include visitors' IP addresses and the pages they requested. Code goes to Anthropic and to CodeRabbit, a review service. Model training is switched off on the accounts used, and CodeRabbit states that the data from its reviews is never used for training. The privacy policy has the details." + body: "The site calls an AI service in one place: when the site owner asks for tag suggestions on a piece of artwork, the public URL of its source post goes to entail.dev, an image classifier. Nothing you do is sent to an AI service as you browse. When the software is being worked on, the developer's tools can read this site's logs and database, as any developer's could, and those logs can include visitors' IP addresses and the pages they requested. Code goes to Anthropic and to CodeRabbit, a review service. Model training is switched off on the accounts used, and CodeRabbit states that the data from its reviews is never used for training. The privacy policy has the details." }, { lead: 'The model.', diff --git a/src/lib/legal.test.ts b/src/lib/legal.test.ts index ae307656..1a2a989a 100644 --- a/src/lib/legal.test.ts +++ b/src/lib/legal.test.ts @@ -104,7 +104,7 @@ describe('defaultPrivacyPolicy', () => { // The runtime boundary, honestly scoped: nothing browsing-time goes to the // tools, but shared diagnostic logs can carry request data — both halves // must stay, or the paragraph overclaims again. - expect(text).toMatch(/nothing you do here is sent to them as you browse/); + expect(text).toMatch(/nothing you do here is sent to those tools as you browse/); expect(text).toMatch(/can contain request data such as IP addresses/); }); @@ -181,6 +181,9 @@ describe('defaultPrivacyPolicy', () => { // The integrations list reads exhaustive, so it must actually be: every // remote service a feature calls out to is named (SONA-167 round 1). expect(text).toContain('Bluesky'); + expect(text).toMatch(/resolving a post to its image/); + // SONA-220: the tag-suggestion lookup sends a post URL to entail.dev. + expect(text).toContain('entail.dev'); expect(text).toContain('FurTrack'); expect(text).toMatch(/shared artist registry/); }); @@ -293,8 +296,8 @@ describe('LEGAL_DEFAULTS_UPDATED tracks the default text', () => { // privacy page would show a "Last updated" line older than its own text. // Deliberately two assertions, not a diff — the point is to force the date // bump, not to review the prose. - const RECORDED_TEXT_HASH = '1a2371801ad230c1791ca926588bdf7329e4844e9b6535742a6188ec7dd89385'; - const RECORDED_UPDATED = '2026-08-24'; + const RECORDED_TEXT_HASH = 'f3861cf345d472156be90e5cbe8bf54700dd69c19dca975c42bdb20065b0edb4'; + const RECORDED_UPDATED = '2026-09-08'; function defaultsText(): string { // Fixed opts so the hash depends on the prose alone, not the caller. Both diff --git a/src/lib/legal.ts b/src/lib/legal.ts index ea193c9b..40a77d0d 100644 --- a/src/lib/legal.ts +++ b/src/lib/legal.ts @@ -27,7 +27,7 @@ export interface LegalSection { // on every fork by construction (a build/deploy date would falsely advance on a // redeploy that didn't touch the text). Bump this whenever you edit // defaultPrivacyPolicy or defaultTerms. -export const LEGAL_DEFAULTS_UPDATED = '2026-08-24'; +export const LEGAL_DEFAULTS_UPDATED = '2026-09-08'; /** * Resolve the "Last updated" date to show on a legal page from a *stable* source @@ -150,13 +150,13 @@ export function defaultPrivacyPolicy(opts: LegalOptions): LegalSection[] { // paragraph with the toggle would delete a real processor // disclosure. Only the vendor NAMES follow the affirmation, since // those are the part a declining owner has not stood behind. - 'Sites running this software are typically built and maintained with AI development tools, which do not run as part of the site itself, so nothing you do here is sent to them as you browse. When the site owner or their developer is diagnosing a problem, the operational data they share with development or code-review tools can include server logs and database records, and those logs can contain request data such as IP addresses, page URLs, and browser user-agent strings.', + 'Sites running this software are typically built and maintained with AI development tools, which do not run as part of the site itself, so nothing you do here is sent to those tools as you browse. When the site owner or their developer is diagnosing a problem, the operational data they share with development or code-review tools can include server logs and database records, and those logs can contain request data such as IP addresses, page URLs, and browser user-agent strings.', ...(opts.aiToolsDisclosed === false ? [] : [ "For this site those tools are Anthropic's Claude, which writes and debugs code under the developer's direction, and CodeRabbit, a code review service that reads proposed changes." ]), - "For specific features the site also talks to Cloudflare Turnstile (bot protection on the sign-in page), Telegram (importing sticker packs), cons.fyi (convention listings), X (formerly Twitter) and Bluesky (fetching the profile pictures shown on this site), FurTrack (importing fursuit photos), and the shared artist registry (syncing artist credits; the registry receives this site's name and hostname as part of the sync). The site contacts these services to run the feature; they are not used to track visitors." + "For specific features the site also talks to Cloudflare Turnstile (bot protection on the sign-in page), Telegram (importing sticker packs), cons.fyi (convention listings), X (formerly Twitter) and Bluesky (fetching the profile pictures shown on this site, and resolving a post to its image), entail.dev (suggesting tags for artwork from its source post), FurTrack (importing fursuit photos), and the shared artist registry (syncing artist credits; the registry receives this site's name and hostname as part of the sync). The site contacts these services to run the feature; they are not used to track visitors." ] }, { diff --git a/src/lib/server/entail.test.ts b/src/lib/server/entail.test.ts index babe7465..c8756bf0 100644 --- a/src/lib/server/entail.test.ts +++ b/src/lib/server/entail.test.ts @@ -1,14 +1,17 @@ -import { describe, expect, it, vi } from 'vitest'; +import { afterEach, describe, expect, it, vi } from 'vitest'; import { + MAX_SUGGESTED_TAGS, classifySourceUrl, classifyMediaUrl, - classifyMediaUrlResult, lookupBlueskyPost, - lookupBlueskyPostResult, suggestionsFromResult, translateTag } from './entail'; +afterEach(() => { + vi.restoreAllMocks(); +}); + const json = (body: unknown, status = 200) => new Response(JSON.stringify(body), { status, headers: { 'Content-Type': 'application/json' } }); @@ -28,22 +31,27 @@ describe('classifySourceUrl', () => { }); }); + it('rejects a malformed or slash-smuggling percent-encoded bluesky actor', () => { + // A bad percent sequence used to throw URIError out of the classifier. + expect(classifySourceUrl('https://bsky.app/profile/100%/post/3abc')).toBeNull(); + // An encoded slash passes the raw-actor regex but decodes into a path + // separator inside the canonical URL. + expect(classifySourceUrl('https://bsky.app/profile/a%2f..%2fx/post/3abc')).toBeNull(); + }); + it('accepts the x/twitter status shapes and canonicalises them', () => { - expect(classifySourceUrl('https://x.com/examplefox/status/1234567890')).toEqual({ - kind: 'x', - url: 'https://x.com/examplefox/status/1234567890' - }); - expect(classifySourceUrl('https://twitter.com/examplefox/status/1234567890?s=21')).toEqual({ - kind: 'x', - url: 'https://x.com/examplefox/status/1234567890' - }); + const canonical = { kind: 'x', url: 'https://x.com/examplefox/status/1234567890', id: '1234567890' }; + expect(classifySourceUrl('https://x.com/examplefox/status/1234567890')).toEqual(canonical); + expect(classifySourceUrl('https://twitter.com/examplefox/status/1234567890?s=21')).toEqual(canonical); + expect(classifySourceUrl('https://x.com/examplefox/status/1234567890/photo/1')).toEqual(canonical); + expect(classifySourceUrl('https://mobile.twitter.com/examplefox/statuses/1234567890')).toEqual( + canonical + ); + expect(classifySourceUrl('https://mobile.x.com/examplefox/status/1234567890')).toEqual(canonical); expect(classifySourceUrl('https://x.com/i/status/1234567890')).toEqual({ kind: 'x', - url: 'https://x.com/i/status/1234567890' - }); - expect(classifySourceUrl('https://x.com/examplefox/status/1234567890/photo/1')).toEqual({ - kind: 'x', - url: 'https://x.com/examplefox/status/1234567890' + url: 'https://x.com/i/status/1234567890', + id: '1234567890' }); }); @@ -113,6 +121,19 @@ describe('suggestionsFromResult', () => { ).toEqual(['digital-media']); }); + it('caps the list after dedupe', () => { + const tags = Array.from({ length: MAX_SUGGESTED_TAGS + 5 }, (_, i) => ({ + name: `tag_${i}`, + confidence: 0.99 + })); + // A duplicate ahead of the cap must not count against it. + tags.unshift({ name: 'tag_0', confidence: 0.999 }); + const result = suggestionsFromResult({ tags }).tags; + expect(result).toHaveLength(MAX_SUGGESTED_TAGS); + expect(result[0]).toBe('tag-0'); + expect(new Set(result).size).toBe(MAX_SUGGESTED_TAGS); + }); + it('drops junk entries and unknown ratings', () => { expect( suggestionsFromResult({ @@ -126,6 +147,7 @@ describe('suggestionsFromResult', () => { }); describe('lookupBlueskyPost', () => { + const url = 'https://bsky.app/profile/did:plc:aaaa/post/3abc'; const post = { uri: 'at://did:plc:aaaa/app.bsky.feed.post/3abc', images: [ @@ -134,34 +156,38 @@ describe('lookupBlueskyPost', () => { ] }; - it('uses the first image only', async () => { + it('uses the first image only, and reports how many there were', async () => { const fetchImpl = vi.fn(async (_url: string | URL | Request) => json(post)); - expect(await lookupBlueskyPost('https://bsky.app/profile/did:plc:aaaa/post/3abc', fetchImpl)).toEqual( - { tags: ['mammal'], rating: 'explicit' } - ); + expect(await lookupBlueskyPost(url, fetchImpl)).toEqual({ + ok: true, + suggestions: { tags: ['mammal'], rating: 'explicit' }, + imageCount: 2 + }); const requested = String(fetchImpl.mock.calls[0]?.[0]); expect(requested).toContain('min_confidence=0.8'); expect(requested).toContain('wait=true'); }); - it('tolerates a bare array body', async () => { - const fetchImpl = vi.fn(async () => json(post.images)); - expect( - (await lookupBlueskyPost('https://bsky.app/profile/did:plc:aaaa/post/3abc', fetchImpl))?.tags - ).toEqual(['mammal']); - }); - - it('returns null without fetching for a non-bluesky URL', async () => { + it('fails without fetching for a non-bluesky URL', async () => { const fetchImpl = vi.fn(async () => json(post)); - expect(await lookupBlueskyPost('https://x.com/examplefox/status/1', fetchImpl)).toBeNull(); + expect(await lookupBlueskyPost('https://x.com/examplefox/status/1', fetchImpl)).toEqual({ + ok: false, + reason: 'unavailable' + }); expect(fetchImpl).not.toHaveBeenCalled(); }); - it('returns null on 202, 429, malformed JSON, and network errors', async () => { - const url = 'https://bsky.app/profile/did:plc:aaaa/post/3abc'; - expect(await lookupBlueskyPost(url, vi.fn(async () => json({}, 202)))).toBeNull(); - expect(await lookupBlueskyPost(url, vi.fn(async () => new Response('slow down', { status: 429 })))).toBeNull(); - expect(await lookupBlueskyPost(url, vi.fn(async () => new Response('', { status: 200 })))).toBeNull(); + it('names the reason a lookup produced nothing', async () => { + expect(await lookupBlueskyPost(url, vi.fn(async () => json({}, 202)))).toEqual({ + ok: false, + reason: 'not_ready' + }); + expect( + await lookupBlueskyPost(url, vi.fn(async () => new Response('slow down', { status: 429 }))) + ).toEqual({ ok: false, reason: 'rate_limited' }); + expect( + await lookupBlueskyPost(url, vi.fn(async () => new Response('boom', { status: 500 }))) + ).toEqual({ ok: false, reason: 'unavailable' }); expect( await lookupBlueskyPost( url, @@ -169,48 +195,59 @@ describe('lookupBlueskyPost', () => { throw new Error('TimeoutError'); }) ) - ).toBeNull(); + ).toEqual({ ok: false, reason: 'unavailable' }); }); - it('names the reason a lookup produced nothing', async () => { - const url = 'https://bsky.app/profile/did:plc:aaaa/post/3abc'; - expect(await lookupBlueskyPostResult(url, vi.fn(async () => json({}, 202)))).toEqual({ - ok: false, - reason: 'not_ready' - }); - expect( - await lookupBlueskyPostResult(url, vi.fn(async () => new Response('slow down', { status: 429 }))) - ).toEqual({ ok: false, reason: 'rate_limited' }); + it('logs a malformed body as a parse failure without quoting it', async () => { + const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}); expect( - await lookupBlueskyPostResult(url, vi.fn(async () => new Response('boom', { status: 500 }))) + await lookupBlueskyPost(url, vi.fn(async () => new Response('secret-body', { status: 200 }))) ).toEqual({ ok: false, reason: 'unavailable' }); + const logged = warn.mock.calls.map((c) => c.join(' ')).join('\n'); + expect(logged).toContain('SyntaxError'); + expect(logged).not.toContain('secret-body'); + expect(logged).not.toContain(''); + }); + + it('ignores a bare-array body the API never sends', async () => { + expect(await lookupBlueskyPost(url, vi.fn(async () => json(post.images)))).toEqual({ + ok: true, + suggestions: { tags: [], rating: null }, + imageCount: 0 + }); }); it('succeeds with no tags when the post has no classified images', async () => { const fetchImpl = vi.fn(async () => json({ uri: 'at://x', images: [] })); - expect( - await lookupBlueskyPost('https://bsky.app/profile/did:plc:aaaa/post/3abc', fetchImpl) - ).toEqual({ tags: [], rating: null }); - expect( - await lookupBlueskyPostResult('https://bsky.app/profile/did:plc:aaaa/post/3abc', fetchImpl) - ).toEqual({ ok: true, suggestions: { tags: [], rating: null } }); + expect(await lookupBlueskyPost(url, fetchImpl)).toEqual({ + ok: true, + suggestions: { tags: [], rating: null }, + imageCount: 0 + }); }); }); describe('classifyMediaUrl', () => { + const url = 'https://pbs.twimg.com/media/abc'; const done = { status: 'done', content_sha256: 'abc', rating: 'questionable', tags: [{ name: 'mammal', confidence: 0.99 }] }; + const success = { + ok: true, + suggestions: { tags: ['mammal'], rating: 'questionable' }, + imageCount: 1 + }; it('refuses a host outside the allowlist without fetching', async () => { const fetchImpl = vi.fn(async () => json(done)); - expect(await classifyMediaUrl('https://example.com/a.jpg', fetchImpl)).toBeNull(); - expect(await classifyMediaUrl('https://evil.pbs.twimg.com/a.jpg', fetchImpl)).toBeNull(); - expect(await classifyMediaUrl('http://pbs.twimg.com/a.jpg', fetchImpl)).toBeNull(); - expect(await classifyMediaUrl('nonsense', fetchImpl)).toBeNull(); + const refused = { ok: false, reason: 'unavailable' }; + expect(await classifyMediaUrl('https://example.com/a.jpg', fetchImpl)).toEqual(refused); + expect(await classifyMediaUrl('https://evil.pbs.twimg.com/a.jpg', fetchImpl)).toEqual(refused); + expect(await classifyMediaUrl('http://pbs.twimg.com/a.jpg', fetchImpl)).toEqual(refused); + expect(await classifyMediaUrl('nonsense', fetchImpl)).toEqual(refused); expect(fetchImpl).not.toHaveBeenCalled(); }); @@ -222,10 +259,9 @@ describe('classifyMediaUrl', () => { expect(String(url)).toContain('/classify/job-1?wait=true'); return polls === 1 ? json({ status: 'processing' }, 202) : json(done); }); - expect(await classifyMediaUrl('https://pbs.twimg.com/media/abc?format=jpg', fetchImpl)).toEqual({ - tags: ['mammal'], - rating: 'questionable' - }); + expect(await classifyMediaUrl('https://pbs.twimg.com/media/abc?format=jpg', fetchImpl)).toEqual( + success + ); expect(polls).toBe(2); }); @@ -233,7 +269,9 @@ describe('classifyMediaUrl', () => { const fetchImpl = vi.fn(async (_url: string | URL | Request, init?: RequestInit) => init?.method === 'POST' ? json({ job_id: 'job-2' }, 202) : json(done) ); - expect(await classifyMediaUrl('https://cdn.bsky.app/img/feed_fullsize/x.jpg', fetchImpl)).not.toBeNull(); + expect(await classifyMediaUrl('https://cdn.bsky.app/img/feed_fullsize/x.jpg', fetchImpl)).toEqual( + success + ); }); it('gives up after the poll cap', async () => { @@ -243,7 +281,7 @@ describe('classifyMediaUrl', () => { polls++; return json({ status: 'processing' }, 202); }); - expect(await classifyMediaUrl('https://pbs.twimg.com/media/abc', fetchImpl)).toBeNull(); + expect(await classifyMediaUrl(url, fetchImpl)).toEqual({ ok: false, reason: 'unavailable' }); expect(polls).toBe(2); }); @@ -259,16 +297,21 @@ describe('classifyMediaUrl', () => { init?.signal?.throwIfAborted(); return json(done); }); - expect(await classifyMediaUrl('https://pbs.twimg.com/media/abc', fetchImpl)).toEqual({ - tags: ['mammal'], - rating: 'questionable' - }); + expect(await classifyMediaUrl(url, fetchImpl)).toEqual(success); }, 10_000); - it('returns null on a rate-limited enqueue, a missing job id, and errors', async () => { - const url = 'https://pbs.twimg.com/media/abc'; - expect(await classifyMediaUrl(url, vi.fn(async () => new Response('slow down', { status: 429 })))).toBeNull(); - expect(await classifyMediaUrl(url, vi.fn(async () => json({ status: 'enqueued' }, 202)))).toBeNull(); + it('is unavailable on a failed enqueue, a missing job id, and errors', async () => { + const unavailable = { ok: false, reason: 'unavailable' }; + expect( + await classifyMediaUrl(url, vi.fn(async () => new Response('boom', { status: 500 }))) + ).toEqual(unavailable); + expect(await classifyMediaUrl(url, vi.fn(async () => json({ status: 'enqueued' }, 202)))).toEqual( + unavailable + ); + // The spec documents `job_id` only; a bare `id` is not a job id. + expect(await classifyMediaUrl(url, vi.fn(async () => json({ id: 'job-7' }, 202)))).toEqual( + unavailable + ); expect( await classifyMediaUrl( url, @@ -276,13 +319,23 @@ describe('classifyMediaUrl', () => { throw new Error('TimeoutError'); }) ) - ).toBeNull(); + ).toEqual(unavailable); + }); + + it('logs a malformed poll body as a parse failure without quoting it', async () => { + const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}); + const fetchImpl = vi.fn(async (_url: string | URL | Request, init?: RequestInit) => + init?.method === 'POST' ? json({ job_id: 'job-8' }, 202) : new Response('secret-body') + ); + expect(await classifyMediaUrl(url, fetchImpl)).toEqual({ ok: false, reason: 'unavailable' }); + const logged = warn.mock.calls.map((c) => c.join(' ')).join('\n'); + expect(logged).toContain('SyntaxError'); + expect(logged).not.toContain('secret-body'); }); it('names a rate limit from either the enqueue or a poll', async () => { - const url = 'https://pbs.twimg.com/media/abc'; expect( - await classifyMediaUrlResult(url, vi.fn(async () => new Response('slow down', { status: 429 }))) + await classifyMediaUrl(url, vi.fn(async () => new Response('slow down', { status: 429 }))) ).toEqual({ ok: false, reason: 'rate_limited' }); const limitedPoll = vi.fn(async (_url: string | URL | Request, init?: RequestInit) => @@ -290,18 +343,18 @@ describe('classifyMediaUrl', () => { ? json({ job_id: 'job-6' }, 202) : new Response('slow down', { status: 429 }) ); - expect(await classifyMediaUrlResult(url, limitedPoll)).toEqual({ + expect(await classifyMediaUrl(url, limitedPoll)).toEqual({ ok: false, reason: 'rate_limited' }); }); - it('returns null when a poll fails outright', async () => { + it('is unavailable when a poll fails outright', async () => { const fetchImpl = vi.fn(async (_url: string | URL | Request, init?: RequestInit) => init?.method === 'POST' ? json({ job_id: 'job-4' }, 202) : new Response('gone', { status: 404 }) ); - expect(await classifyMediaUrl('https://pbs.twimg.com/media/abc', fetchImpl)).toBeNull(); + expect(await classifyMediaUrl(url, fetchImpl)).toEqual({ ok: false, reason: 'unavailable' }); }); }); diff --git a/src/lib/server/entail.ts b/src/lib/server/entail.ts index d5805a3c..78384beb 100644 --- a/src/lib/server/entail.ts +++ b/src/lib/server/entail.ts @@ -9,8 +9,8 @@ // normal outcome, not an error to surface. // // Everything here is fail-soft: any non-2xx, timeout, or unexpected shape -// resolves to null and the caller carries on without suggestions. Third-party -// response bodies are never logged and never stored. +// resolves to a failed outcome and the caller carries on without suggestions. +// Third-party response bodies are never logged and never stored. import { sanitizeTag } from './validate'; @@ -20,6 +20,10 @@ const ENTAIL_CLASSIFY = 'https://entail.dev/api/classify'; /** The floor entail.dev's own docs recommend for Sona. */ export const DEFAULT_CONFIDENCE_FLOOR = 0.8; +/** Most tags a single lookup will suggest, counted after dedupe. The + * classifier can return hundreds; the UI shows a short list. */ +export const MAX_SUGGESTED_TAGS = 40; + // Both `wait=true` endpoints hold the connection open until the classifier // finishes rather than answering 202 straight away. That hold was measured at // roughly five seconds for a fresh job on 2026-09-08, so every timeout here @@ -47,8 +51,11 @@ export type Suggestions = { * is not a failure at all; it succeeds with an empty tag list. */ export type LookupFailure = 'not_ready' | 'rate_limited' | 'unavailable'; +/** `imageCount` is how many images the source post carried. Suggestions come + * from the first one only, so a count above 1 tells the UI the rest went + * unread. The X path always reports 1: it classifies a single media URL. */ export type LookupOutcome = - | { ok: true; suggestions: Suggestions } + | { ok: true; suggestions: Suggestions; imageCount: number } | { ok: false; reason: LookupFailure }; const fail = (reason: LookupFailure): LookupOutcome => ({ ok: false, reason }); @@ -60,7 +67,9 @@ export type ClassificationEntry = { tags?: unknown; }; -export type SourceKind = { kind: 'bluesky'; url: string } | { kind: 'x'; url: string }; +/** The `x` kind carries the status id so the tweet lookup never re-parses + * the URL. */ +export type SourceKind = { kind: 'bluesky'; url: string } | { kind: 'x'; url: string; id: string }; const BLUESKY_ACTOR = /^[A-Za-z0-9._:%-]{1,256}$/; const BLUESKY_RKEY = /^[A-Za-z0-9._~-]{1,64}$/; @@ -87,9 +96,17 @@ export function classifySourceUrl(url: string): SourceKind | null { if (host === 'bsky.app') { // /profile//post/ if (parts.length !== 4 || parts[0] !== 'profile' || parts[2] !== 'post') return null; - const actor = decodeURIComponent(parts[1]); + // Decode first, then validate the decoded actor: a malformed percent + // sequence throws, and an encoded slash would otherwise pass the regex + // and decode into a path separator in the canonical URL. + let actor: string; + try { + actor = decodeURIComponent(parts[1]); + } catch { + return null; + } const rkey = parts[3]; - if (!BLUESKY_ACTOR.test(parts[1]) || !BLUESKY_RKEY.test(rkey)) return null; + if (!BLUESKY_ACTOR.test(actor) || !BLUESKY_RKEY.test(rkey)) return null; return { kind: 'bluesky', url: `https://bsky.app/profile/${actor}/post/${rkey}` }; } @@ -100,7 +117,7 @@ export function classifySourceUrl(url: string): SourceKind | null { if (keyword !== 'status' && keyword !== 'statuses') return null; if (!STATUS_ID.test(id)) return null; if (user !== 'i' && !X_USER.test(user)) return null; - return { kind: 'x', url: `https://x.com/${user}/status/${id}` }; + return { kind: 'x', url: `https://x.com/${user}/status/${id}`, id }; } return null; @@ -129,7 +146,8 @@ function normalizeRating(rating: unknown): EntailRating | null { /** * Turn one classification entry into Sona tag suggestions: keep the tags at or * above the confidence floor, translate them, and drop duplicates while - * preserving the confidence order the API returns. Pure. + * preserving the confidence order the API returns, capped at + * {@link MAX_SUGGESTED_TAGS}. Pure. */ export function suggestionsFromResult( result: ClassificationEntry | null | undefined, @@ -147,20 +165,15 @@ export function suggestionsFromResult( if (!tag || seen.has(tag)) continue; seen.add(tag); tags.push(tag); + if (tags.length >= MAX_SUGGESTED_TAGS) break; } return { tags, rating }; } -/** `/post` answers with `{ uri, images: [...] }`; tolerate a bare array too. */ -function firstImage(body: unknown): ClassificationEntry | null { - const images = Array.isArray(body) - ? body - : Array.isArray((body as { images?: unknown })?.images) - ? ((body as { images: unknown[] }).images) - : null; - if (!images || images.length === 0) return null; - const first = images[0]; - return first && typeof first === 'object' ? (first as ClassificationEntry) : null; +/** `/post` answers with `{ uri, images: [...] }`. */ +function postImages(body: unknown): ClassificationEntry[] { + const images = (body as { images?: unknown })?.images; + return Array.isArray(images) ? (images as ClassificationEntry[]) : []; } /** @@ -168,7 +181,7 @@ function firstImage(body: unknown): ClassificationEntry | null { * post whose images entail.dev hasn't classified yet answers 202, which we * treat as "nothing to suggest" rather than waiting around. Never throws. */ -export async function lookupBlueskyPostResult( +export async function lookupBlueskyPost( url: string, fetchImpl: typeof fetch = fetch ): Promise { @@ -195,34 +208,31 @@ export async function lookupBlueskyPostResult( // An empty `images` array means entail.dev looked and found no furry // artwork in the post. That is an answer, not a failure: the caller gets // an empty tag list rather than an error it would have to explain. - const image = firstImage(await res.json()); + const images = postImages(await res.json()); + const first = images[0]; return { ok: true, - suggestions: image ? suggestionsFromResult(image) : { tags: [], rating: null } + suggestions: + first && typeof first === 'object' ? suggestionsFromResult(first) : { tags: [], rating: null }, + imageCount: images.length }; } catch (e) { - console.warn(`[entail] post lookup error: ${e instanceof Error ? e.message : String(e)}`); + console.warn(`[entail] post lookup error: ${errorLabel(e)}`); return fail('unavailable'); } } -/** Suggestions for a Bluesky post, or null if the lookup failed. A post with - * nothing to suggest resolves to an empty tag list, not null — "no suggestions" - * and "no answer" are different facts. Use {@link lookupBlueskyPostResult} when - * the reason for a failure matters. */ -export async function lookupBlueskyPost( - url: string, - fetchImpl: typeof fetch = fetch -): Promise { - const outcome = await lookupBlueskyPostResult(url, fetchImpl); - return outcome.ok ? outcome.suggestions : null; +/** What a caught error is safe to log. A JSON parse failure's message quotes + * a fragment of the body, and third-party bodies are never logged, so a + * SyntaxError is reduced to its name. */ +export function errorLabel(e: unknown): string { + if (e instanceof SyntaxError) return e.name; + return e instanceof Error ? e.message : String(e); } function jobIdFrom(body: unknown): string | null { - const { job_id: jobId, id } = (body ?? {}) as { job_id?: unknown; id?: unknown }; - if (typeof jobId === 'string' && jobId) return jobId; - if (typeof id === 'string' && id) return id; - return null; + const { job_id: jobId } = (body ?? {}) as { job_id?: unknown }; + return typeof jobId === 'string' && jobId ? jobId : null; } /** entail.dev fetches the URL itself, so only the two CDNs it allowlists are @@ -242,10 +252,10 @@ const pause = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms)); /** * Suggestions for a single image URL on an allowlisted CDN: enqueue a - * classification job, then poll it a few times. Gives up (null) if the job - * isn't done by the attempt cap. Never throws. + * classification job, then poll it a few times. Gives up (`unavailable`) if + * the job isn't done by the attempt cap. Never throws. */ -export async function classifyMediaUrlResult( +export async function classifyMediaUrl( url: string, fetchImpl: typeof fetch = fetch ): Promise { @@ -287,22 +297,12 @@ export async function classifyMediaUrlResult( } const body = (await res.json()) as (ClassificationEntry & { status?: unknown }) | null; if (body?.status !== 'done') continue; - return { ok: true, suggestions: suggestionsFromResult(body) }; + return { ok: true, suggestions: suggestionsFromResult(body), imageCount: 1 }; } console.warn(`[entail] classify job unfinished after ${POLL_ATTEMPTS} polls`); return fail('unavailable'); } catch (e) { - console.warn(`[entail] classify error: ${e instanceof Error ? e.message : String(e)}`); + console.warn(`[entail] classify error: ${errorLabel(e)}`); return fail('unavailable'); } } - -/** Suggestions for one media URL, or null for any failure. Use - * {@link classifyMediaUrlResult} when the reason matters. */ -export async function classifyMediaUrl( - url: string, - fetchImpl: typeof fetch = fetch -): Promise { - const outcome = await classifyMediaUrlResult(url, fetchImpl); - return outcome.ok ? outcome.suggestions : null; -} diff --git a/src/lib/server/twitter-media.test.ts b/src/lib/server/twitter-media.test.ts index c0a6ab4b..82cda5d8 100644 --- a/src/lib/server/twitter-media.test.ts +++ b/src/lib/server/twitter-media.test.ts @@ -1,5 +1,9 @@ -import { describe, expect, it, vi } from 'vitest'; -import { fetchTweetMediaUrl, parseTweetPhotoUrl, tweetIdFromUrl } from './twitter-media'; +import { afterEach, describe, expect, it, vi } from 'vitest'; +import { fetchTweetMediaUrl, parseTweetPhotoUrl } from './twitter-media'; + +afterEach(() => { + vi.restoreAllMocks(); +}); const json = (body: unknown, status = 200) => new Response(JSON.stringify(body), { status, headers: { 'Content-Type': 'application/json' } }); @@ -18,24 +22,6 @@ const tweetWith = (media: unknown[]) => ({ const photo = { type: 'photo', media_url_https: 'https://pbs.twimg.com/media/AbCdEf123.jpg' }; -describe('tweetIdFromUrl', () => { - it('reads the id out of the accepted shapes', () => { - expect(tweetIdFromUrl('https://x.com/examplefox/status/1234567890')).toBe('1234567890'); - expect(tweetIdFromUrl('https://twitter.com/examplefox/status/1234567890?s=21')).toBe('1234567890'); - expect(tweetIdFromUrl('https://x.com/examplefox/status/1234567890/photo/1')).toBe('1234567890'); - expect(tweetIdFromUrl('https://x.com/i/status/1234567890')).toBe('1234567890'); - expect(tweetIdFromUrl('https://x.com/i/web/status/1234567890')).toBe('1234567890'); - expect(tweetIdFromUrl('https://mobile.twitter.com/examplefox/statuses/1234567890')).toBe('1234567890'); - }); - - it('returns null for anything else', () => { - expect(tweetIdFromUrl('https://x.com/examplefox')).toBeNull(); - expect(tweetIdFromUrl('https://x.com/examplefox/status/abc')).toBeNull(); - expect(tweetIdFromUrl('https://bsky.app/profile/a/post/3abc')).toBeNull(); - expect(tweetIdFromUrl('')).toBeNull(); - }); -}); - describe('parseTweetPhotoUrl', () => { it('upgrades the first photo to the largest variant', () => { expect(parseTweetPhotoUrl(tweetWith([photo]))).toBe( @@ -43,6 +29,12 @@ describe('parseTweetPhotoUrl', () => { ); }); + it('passes a media URL with no extension through untouched', () => { + expect( + parseTweetPhotoUrl(tweetWith([{ type: 'photo', media_url_https: 'https://pbs.twimg.com/media/NoExt' }])) + ).toBe('https://pbs.twimg.com/media/NoExt'); + }); + it('skips video and animated gif entries', () => { expect(parseTweetPhotoUrl(tweetWith([{ type: 'video', media_url_https: 'https://pbs.twimg.com/x.jpg' }]))).toBeNull(); expect( @@ -84,58 +76,92 @@ describe('parseTweetPhotoUrl', () => { }); describe('fetchTweetMediaUrl', () => { - const url = 'https://x.com/examplefox/status/1234567890'; + const id = '1234567890'; + const unavailable = { ok: false, reason: 'unavailable' }; const stub = (lookup: (n: number) => Response) => { let lookups = 0; const activations = { count: 0 }; - const fetchImpl = vi.fn(async (target: string | URL | Request) => { + const fetchImpl = vi.fn(async (target: string | URL | Request, init?: RequestInit) => { if (String(target).includes('guest/activate')) { activations.count++; return json({ guest_token: `gt-${activations.count}` }); } + tokens.push(String(new Headers(init?.headers).get('x-guest-token'))); return lookup(++lookups); }); - return { fetchImpl, activations }; + const tokens: string[] = []; + return { fetchImpl, activations, tokens }; }; it('activates a guest token and resolves the first photo', async () => { - const { fetchImpl } = stub(() => json(tweetWith([photo]))); - expect(await fetchTweetMediaUrl(url, fetchImpl)).toBe( - 'https://pbs.twimg.com/media/AbCdEf123?format=jpg&name=4096x4096' - ); + const { fetchImpl, tokens } = stub(() => json(tweetWith([photo]))); + expect(await fetchTweetMediaUrl(id, fetchImpl)).toEqual({ + ok: true, + url: 'https://pbs.twimg.com/media/AbCdEf123?format=jpg&name=4096x4096' + }); + expect(tokens).toEqual(['gt-1']); + const lookup = String(fetchImpl.mock.calls.find(([t]) => !String(t).includes('guest/activate'))?.[0]); + expect(lookup).toContain(encodeURIComponent(`"tweetId":"${id}"`)); }); it('retries once with a fresh token on 401', async () => { - const { fetchImpl, activations } = stub((n) => + const { fetchImpl, activations, tokens } = stub((n) => n === 1 ? new Response('nope', { status: 401 }) : json(tweetWith([photo])) ); - expect(await fetchTweetMediaUrl(url, fetchImpl)).toContain('AbCdEf123'); + const outcome = await fetchTweetMediaUrl(id, fetchImpl); + expect(outcome.ok && outcome.url).toContain('AbCdEf123'); + expect(activations.count).toBe(2); + expect(tokens).toEqual(['gt-1', 'gt-2']); + }); + + it('retries once with a fresh token on 429', async () => { + const { fetchImpl, activations, tokens } = stub((n) => + n === 1 ? new Response('slow down', { status: 429 }) : json(tweetWith([photo])) + ); + const outcome = await fetchTweetMediaUrl(id, fetchImpl); + expect(outcome.ok && outcome.url).toContain('AbCdEf123'); + expect(activations.count).toBe(2); + expect(tokens).toEqual(['gt-1', 'gt-2']); + }); + + it('reports a rate limit that survives the retry', async () => { + const { fetchImpl, activations } = stub(() => new Response('slow down', { status: 429 })); + expect(await fetchTweetMediaUrl(id, fetchImpl)).toEqual({ ok: false, reason: 'rate_limited' }); expect(activations.count).toBe(2); }); - it('returns null without fetching when the URL has no tweet id', async () => { - const fetchImpl = vi.fn(async () => json(tweetWith([photo]))); - expect(await fetchTweetMediaUrl('https://x.com/examplefox', fetchImpl)).toBeNull(); - expect(fetchImpl).not.toHaveBeenCalled(); + it('is unavailable after a 401 that survives the retry', async () => { + const { fetchImpl } = stub(() => new Response('nope', { status: 401 })); + expect(await fetchTweetMediaUrl(id, fetchImpl)).toEqual(unavailable); }); it('fails soft on refusal, a photoless tweet, malformed JSON, and network errors', async () => { - expect(await fetchTweetMediaUrl(url, stub(() => new Response('no', { status: 403 })).fetchImpl)).toBeNull(); - expect(await fetchTweetMediaUrl(url, stub(() => json(tweetWith([]))).fetchImpl)).toBeNull(); - expect(await fetchTweetMediaUrl(url, stub(() => new Response('')).fetchImpl)).toBeNull(); + expect(await fetchTweetMediaUrl(id, stub(() => new Response('no', { status: 403 })).fetchImpl)).toEqual( + unavailable + ); + expect(await fetchTweetMediaUrl(id, stub(() => json(tweetWith([]))).fetchImpl)).toEqual(unavailable); + expect(await fetchTweetMediaUrl(id, stub(() => new Response('')).fetchImpl)).toEqual(unavailable); expect( await fetchTweetMediaUrl( - url, + id, vi.fn(async () => { throw new Error('TimeoutError'); }) ) - ).toBeNull(); + ).toEqual(unavailable); + }); + + it('logs a malformed body as a parse failure without quoting it', async () => { + const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}); + await fetchTweetMediaUrl(id, stub(() => new Response('secret-body')).fetchImpl); + const logged = warn.mock.calls.map((c) => c.join(' ')).join('\n'); + expect(logged).toContain('SyntaxError'); + expect(logged).not.toContain('secret-body'); }); - it('returns null when the guest token cannot be activated', async () => { + it('is unavailable when the guest token cannot be activated', async () => { const fetchImpl = vi.fn(async () => new Response('blocked', { status: 403 })); - expect(await fetchTweetMediaUrl(url, fetchImpl)).toBeNull(); + expect(await fetchTweetMediaUrl(id, fetchImpl)).toEqual(unavailable); }); }); diff --git a/src/lib/server/twitter-media.ts b/src/lib/server/twitter-media.ts index 37a5ffcb..cc974f53 100644 --- a/src/lib/server/twitter-media.ts +++ b/src/lib/server/twitter-media.ts @@ -6,9 +6,11 @@ // against a real public tweet on 2026-09-08 (activate 200, GraphQL 200, photo // present). If resolution goes uniformly null, refresh them from FxEmbed. // -// Fail-soft throughout: any error resolves to null and the caller proceeds -// without a media URL. Videos and GIFs are skipped — only photos resolve. +// Fail-soft throughout: any error resolves to a failed outcome and the caller +// proceeds without a media URL. Videos and GIFs are skipped — only photos +// resolve. +import { errorLabel } from './entail'; import { X_BEARER, activateGuestToken } from './twitter-avatar'; const X_TWEET_BY_REST_ID = 'https://api.x.com/graphql/f2sagi1jweVHFkTUIHzmMQ/TweetResultByRestId'; @@ -61,14 +63,13 @@ const QUERY_FIELD_TOGGLES = { withDisallowedReplyControls: false } as const; -/** Pull the numeric status id out of any of the tweet URL shapes we accept - * ("x.com/user/status/1", "twitter.com/i/status/1", ".../status/1/photo/1"). */ -export function tweetIdFromUrl(url: string): string | null { - const match = url - .trim() - .match(/(?:^|\/\/|\.)(?:x|twitter)\.com\/(?:[A-Za-z0-9_]{1,15}|i\/web|i)\/status(?:es)?\/(\d{1,20})(?:[/?#]|$)/i); - return match ? match[1] : null; -} +/** `rate_limited` is X refusing the guest token twice over with a 429; + * everything else that yields no photo is `unavailable`. */ +export type TweetMediaOutcome = + | { ok: true; url: string } + | { ok: false; reason: 'rate_limited' | 'unavailable' }; + +const fail = (reason: 'rate_limited' | 'unavailable'): TweetMediaOutcome => ({ ok: false, reason }); type TweetMedia = { type?: unknown; media_url_https?: unknown }; @@ -129,37 +130,41 @@ function tweetLookup(tweetId: string, guestToken: string, fetchImpl: typeof fetc ); } -/** Resolve the first photo on a public tweet to a pbs.twimg.com URL. One guest - * token, one fresh-token retry if X refuses it (401/429), then null. Never throws. */ +/** Resolve the first photo on a public tweet to a pbs.twimg.com URL, given the + * numeric status id classifySourceUrl already validated. One guest token, one + * fresh-token retry if X refuses it (401/429), then a failed outcome. Never + * throws. */ export async function fetchTweetMediaUrl( - tweetUrl: string, + tweetId: string, fetchImpl: typeof fetch = fetch -): Promise { - const tweetId = tweetIdFromUrl(tweetUrl); - if (!tweetId) return null; +): Promise { try { let token = await activateGuestToken(fetchImpl); - if (!token) return null; + if (!token) return fail('unavailable'); let res = await tweetLookup(tweetId, token, fetchImpl); if (res.status === 401 || res.status === 429) { token = await activateGuestToken(fetchImpl); - if (!token) return null; + if (!token) return fail('unavailable'); res = await tweetLookup(tweetId, token, fetchImpl); } + if (res.status === 429) { + console.warn('[avatar] tweet media lookup rate limited: status=429'); + return fail('rate_limited'); + } if (!res.ok) { console.warn(`[avatar] tweet media lookup failed: status=${res.status}`); - return null; + return fail('unavailable'); } const photo = parseTweetPhotoUrl(await res.json()); if (!photo) { // 200 but no photo — a text/video tweet, a protected or deleted one, or // the undocumented GraphQL shape rotated (see the file header). console.warn('[avatar] tweet media lookup had no photo'); - return null; + return fail('unavailable'); } - return photo; + return { ok: true, url: photo }; } catch (e) { - console.warn(`[avatar] tweet media lookup error: ${e instanceof Error ? e.message : String(e)}`); - return null; + console.warn(`[avatar] tweet media lookup error: ${errorLabel(e)}`); + return fail('unavailable'); } } diff --git a/src/routes/api/admin/tag-suggestions/+server.ts b/src/routes/api/admin/tag-suggestions/+server.ts index 6b87290a..153e56fa 100644 --- a/src/routes/api/admin/tag-suggestions/+server.ts +++ b/src/routes/api/admin/tag-suggestions/+server.ts @@ -4,8 +4,8 @@ import { getDb } from '$lib/server/db'; import { images } from '$lib/server/db/schema'; import { classifySourceUrl, - classifyMediaUrlResult, - lookupBlueskyPostResult, + classifyMediaUrl, + lookupBlueskyPost, type LookupFailure, type LookupOutcome } from '$lib/server/entail'; @@ -88,21 +88,22 @@ export const POST: RequestHandler = async ({ request, platform }) => { let outcome: LookupOutcome; if (source.kind === 'bluesky') { - outcome = await lookupBlueskyPostResult(source.url); + outcome = await lookupBlueskyPost(source.url); } else { // entail.dev indexes Bluesky, not X, so an X post has to be classified // from its image. X's API is the only thing that knows which image that // is, and it hands back a pbs.twimg.com URL — one of the two hosts - // classifyMediaUrl will send on. - const mediaUrl = await fetchTweetMediaUrl(source.url); - if (!mediaUrl) return failure('unavailable'); - outcome = await classifyMediaUrlResult(mediaUrl); + // classifyMediaUrl will send on. Only the validated status id goes out. + const media = await fetchTweetMediaUrl(source.id); + if (!media.ok) return failure(media.reason); + outcome = await classifyMediaUrl(media.url); } if (!outcome.ok) return failure(outcome.reason); return json({ source: source.kind, tags: outcome.suggestions.tags, - rating: outcome.suggestions.rating + rating: outcome.suggestions.rating, + imageCount: outcome.imageCount }); }; diff --git a/src/routes/api/admin/tag-suggestions/server.test.ts b/src/routes/api/admin/tag-suggestions/server.test.ts index 3d7a23b5..beedc338 100644 --- a/src/routes/api/admin/tag-suggestions/server.test.ts +++ b/src/routes/api/admin/tag-suggestions/server.test.ts @@ -3,22 +3,25 @@ import { describe, it, expect, vi, beforeEach } from 'vitest'; // @ts-expect-error - no declaration file for 'better-sqlite3' import Database from 'better-sqlite3'; import type { LookupOutcome } from '$lib/server/entail'; +import type { TweetMediaOutcome } from '$lib/server/twitter-media'; import { makeD1 } from '$lib/server/test/d1'; import { POST } from './+server'; // Only the outbound calls are stubbed. classifySourceUrl stays real, so the // URL recognition the endpoint depends on is exercised rather than mocked. -const lookupBlueskyPostResult = vi.hoisted(() => +const lookupBlueskyPost = vi.hoisted(() => vi.fn(async (_url: string): Promise => ({ ok: false, reason: 'unavailable' })) ); -const classifyMediaUrlResult = vi.hoisted(() => +const classifyMediaUrl = vi.hoisted(() => vi.fn(async (_url: string): Promise => ({ ok: false, reason: 'unavailable' })) ); -const fetchTweetMediaUrl = vi.hoisted(() => vi.fn(async (_url: string): Promise => null)); +const fetchTweetMediaUrl = vi.hoisted(() => + vi.fn(async (_id: string): Promise => ({ ok: false, reason: 'unavailable' })) +); vi.mock('$lib/server/entail', async (importOriginal) => { const original = await importOriginal(); - return { ...original, lookupBlueskyPostResult, classifyMediaUrlResult }; + return { ...original, lookupBlueskyPost, classifyMediaUrl }; }); vi.mock('$lib/server/twitter-media', () => ({ fetchTweetMediaUrl })); @@ -31,11 +34,13 @@ const DDL = `CREATE TABLE images (id INTEGER PRIMARY KEY AUTOINCREMENT, title TE const BSKY_POST = 'https://bsky.app/profile/example.bsky.social/post/3abc'; const X_POST = 'https://x.com/examplefox/status/1234567890'; +const X_ID = '1234567890'; const MEDIA_URL = 'https://pbs.twimg.com/media/AbCdEf123?format=jpg&name=4096x4096'; const suggestions: LookupOutcome = { ok: true, - suggestions: { tags: ['mammal', 'pink-hair'], rating: 'safe' } + suggestions: { tags: ['mammal', 'pink-hair'], rating: 'safe' }, + imageCount: 3 }; function makeEnv() { @@ -61,12 +66,12 @@ function event(platform: App.Platform, body: unknown, raw?: string) { } beforeEach(() => { - lookupBlueskyPostResult.mockReset(); - classifyMediaUrlResult.mockReset(); + lookupBlueskyPost.mockReset(); + classifyMediaUrl.mockReset(); fetchTweetMediaUrl.mockReset(); - lookupBlueskyPostResult.mockResolvedValue(suggestions); - classifyMediaUrlResult.mockResolvedValue(suggestions); - fetchTweetMediaUrl.mockResolvedValue(MEDIA_URL); + lookupBlueskyPost.mockResolvedValue(suggestions); + classifyMediaUrl.mockResolvedValue(suggestions); + fetchTweetMediaUrl.mockResolvedValue({ ok: true, url: MEDIA_URL }); }); describe('POST /api/admin/tag-suggestions', () => { @@ -77,10 +82,11 @@ describe('POST /api/admin/tag-suggestions', () => { expect(await res.json()).toEqual({ source: 'bluesky', tags: ['mammal', 'pink-hair'], - rating: 'safe' + rating: 'safe', + imageCount: 3 }); // The canonical URL, not the caller's string. - expect(lookupBlueskyPostResult).toHaveBeenCalledWith(BSKY_POST); + expect(lookupBlueskyPost).toHaveBeenCalledWith(BSKY_POST); expect(fetchTweetMediaUrl).not.toHaveBeenCalled(); }); @@ -91,12 +97,14 @@ describe('POST /api/admin/tag-suggestions', () => { expect(await res.json()).toEqual({ source: 'x', tags: ['mammal', 'pink-hair'], - rating: 'safe' + rating: 'safe', + imageCount: 3 }); - expect(fetchTweetMediaUrl).toHaveBeenCalledWith(X_POST); + // Only the validated status id goes to X, never the caller's string. + expect(fetchTweetMediaUrl).toHaveBeenCalledWith(X_ID); // The media URL is what reaches entail.dev — never the tweet URL. - expect(classifyMediaUrlResult).toHaveBeenCalledWith(MEDIA_URL); - expect(lookupBlueskyPostResult).not.toHaveBeenCalled(); + expect(classifyMediaUrl).toHaveBeenCalledWith(MEDIA_URL); + expect(lookupBlueskyPost).not.toHaveBeenCalled(); }); it('reads the stored source URL for an imageId', async () => { @@ -105,7 +113,7 @@ describe('POST /api/admin/tag-suggestions', () => { const res = await POST(event(platform, { imageId: 7 })); expect(res.status).toBe(200); expect((await res.json()).source).toBe('bluesky'); - expect(lookupBlueskyPostResult).toHaveBeenCalledWith(BSKY_POST); + expect(lookupBlueskyPost).toHaveBeenCalledWith(BSKY_POST); }); it('404s an unknown imageId', async () => { @@ -113,7 +121,7 @@ describe('POST /api/admin/tag-suggestions', () => { const res = await POST(event(platform, { imageId: 99 })); expect(res.status).toBe(404); expect(await res.json()).toEqual({ error: 'not_found' }); - expect(lookupBlueskyPostResult).not.toHaveBeenCalled(); + expect(lookupBlueskyPost).not.toHaveBeenCalled(); }); it('422s a source we have no classifier for, including a stored empty one', async () => { @@ -121,6 +129,8 @@ describe('POST /api/admin/tag-suggestions', () => { insertImage(sqlite, null); for (const body of [ { sourcePostUrl: 'https://furaffinity.net/view/12345/' }, + // A malformed percent sequence in the actor is unsupported, not a 500. + { sourcePostUrl: 'https://bsky.app/profile/100%/post/3abc' }, { sourcePostUrl: '' }, { imageId: 7 } ]) { @@ -128,7 +138,7 @@ describe('POST /api/admin/tag-suggestions', () => { expect(res.status).toBe(422); expect(await res.json()).toEqual({ error: 'unsupported_source' }); } - expect(lookupBlueskyPostResult).not.toHaveBeenCalled(); + expect(lookupBlueskyPost).not.toHaveBeenCalled(); }); it('400s malformed and ambiguous request bodies', async () => { @@ -155,18 +165,19 @@ describe('POST /api/admin/tag-suggestions', () => { const { platform } = makeEnv(); // The classifier read the post and rated it; nothing cleared the // confidence floor. That is an answer, not a failure. - lookupBlueskyPostResult.mockResolvedValue({ + lookupBlueskyPost.mockResolvedValue({ ok: true, - suggestions: { tags: [], rating: 'safe' } + suggestions: { tags: [], rating: 'safe' }, + imageCount: 3 }); const res = await POST(event(platform, { sourcePostUrl: BSKY_POST })); expect(res.status).toBe(200); - expect(await res.json()).toEqual({ source: 'bluesky', tags: [], rating: 'safe' }); + expect(await res.json()).toEqual({ source: 'bluesky', tags: [], rating: 'safe', imageCount: 3 }); }); it('502s not_ready when the post is queued but unclassified', async () => { const { platform } = makeEnv(); - lookupBlueskyPostResult.mockResolvedValue({ ok: false, reason: 'not_ready' }); + lookupBlueskyPost.mockResolvedValue({ ok: false, reason: 'not_ready' }); const res = await POST(event(platform, { sourcePostUrl: BSKY_POST })); expect(res.status).toBe(502); expect(await res.json()).toEqual({ error: 'not_ready' }); @@ -174,23 +185,32 @@ describe('POST /api/admin/tag-suggestions', () => { it('502s unavailable for a failed lookup and for an unresolvable tweet', async () => { const { platform } = makeEnv(); - lookupBlueskyPostResult.mockResolvedValue({ ok: false, reason: 'unavailable' }); + lookupBlueskyPost.mockResolvedValue({ ok: false, reason: 'unavailable' }); const bsky = await POST(event(platform, { sourcePostUrl: BSKY_POST })); expect(bsky.status).toBe(502); expect(await bsky.json()).toEqual({ error: 'unavailable' }); - fetchTweetMediaUrl.mockResolvedValue(null); + fetchTweetMediaUrl.mockResolvedValue({ ok: false, reason: 'unavailable' }); const x = await POST(event(platform, { sourcePostUrl: X_POST })); expect(x.status).toBe(502); expect(await x.json()).toEqual({ error: 'unavailable' }); - expect(classifyMediaUrlResult).not.toHaveBeenCalled(); + expect(classifyMediaUrl).not.toHaveBeenCalled(); }); it('429s when entail.dev rate limited us', async () => { const { platform } = makeEnv(); - lookupBlueskyPostResult.mockResolvedValue({ ok: false, reason: 'rate_limited' }); + lookupBlueskyPost.mockResolvedValue({ ok: false, reason: 'rate_limited' }); const res = await POST(event(platform, { sourcePostUrl: BSKY_POST })); expect(res.status).toBe(429); expect(await res.json()).toEqual({ error: 'rate_limited' }); }); + + it('429s when X rate limited the tweet lookup', async () => { + const { platform } = makeEnv(); + fetchTweetMediaUrl.mockResolvedValue({ ok: false, reason: 'rate_limited' }); + const res = await POST(event(platform, { sourcePostUrl: X_POST })); + expect(res.status).toBe(429); + expect(await res.json()).toEqual({ error: 'rate_limited' }); + expect(classifyMediaUrl).not.toHaveBeenCalled(); + }); }); From 97579b0d5d1080f74e2044d618873d91f25edb98 Mon Sep 17 00:00:00 2001 From: Sparky <1609870+sparkyfen@users.noreply.github.com> Date: Mon, 7 Sep 2026 19:36:32 -0700 Subject: [PATCH 07/22] fix(admin): review round 2 for the entail.dev tag suggestion client (SONA-220) - Describe both lookup paths on the AI disclosure page and plain-word the privacy policy entry; policy date matches the commit date. - Drop symbol-only tags instead of translating them to punctuation. - Report the real photo count for multi-photo tweets, and keep the X rate limit signal when the token retry cannot activate. - Accept the /i/web/status permalink form. - Treat a /post body without an images array as unavailable. - Cap the request body before parsing it. - Held-wait regression test for the Bluesky lookup, a direct errorLabel test, and the imageCount 0 endpoint case. --- src/lib/ai-disclosure.test.ts | 5 +- src/lib/ai-disclosure.ts | 2 +- src/lib/legal.test.ts | 6 +- src/lib/legal.ts | 4 +- src/lib/server/entail.test.ts | 94 ++++++++++++++----- src/lib/server/entail.ts | 48 ++++++---- src/lib/server/twitter-media.test.ts | 70 +++++++++----- src/lib/server/twitter-media.ts | 42 ++++++--- .../api/admin/tag-suggestions/+server.ts | 18 +++- .../api/admin/tag-suggestions/server.test.ts | 32 ++++++- 10 files changed, 226 insertions(+), 95 deletions(-) diff --git a/src/lib/ai-disclosure.test.ts b/src/lib/ai-disclosure.test.ts index 037f492c..e8f76f2a 100644 --- a/src/lib/ai-disclosure.test.ts +++ b/src/lib/ai-disclosure.test.ts @@ -37,7 +37,10 @@ describe('defaultAiDisclosure', () => { // the browsing-time claim scoped so it stays true. expect(all).toMatch(/calls an AI service in one place/); expect(all).toContain('entail.dev'); - expect(all).toMatch(/Nothing you do is sent to an AI service as you browse/); + // Both paths: the post link (Bluesky) and the picture link X hands back. + expect(all).toMatch(/or to the picture in that post/); + expect(all).toMatch(/X's own service is asked which picture the post carries first/); + expect(all).toMatch(/Only the site owner can start that, so nothing you do is sent to an AI service as you browse/); expect(all).toMatch(/logs and database/); expect(all).toContain('CodeRabbit'); // Honesty about what dev-time log access can expose: no "your data never diff --git a/src/lib/ai-disclosure.ts b/src/lib/ai-disclosure.ts index 799cebc8..b4c36f35 100644 --- a/src/lib/ai-disclosure.ts +++ b/src/lib/ai-disclosure.ts @@ -71,7 +71,7 @@ export function defaultAiDisclosure(): AiDisclosure { }, { lead: 'Your data.', - body: "The site calls an AI service in one place: when the site owner asks for tag suggestions on a piece of artwork, the public URL of its source post goes to entail.dev, an image classifier. Nothing you do is sent to an AI service as you browse. When the software is being worked on, the developer's tools can read this site's logs and database, as any developer's could, and those logs can include visitors' IP addresses and the pages they requested. Code goes to Anthropic and to CodeRabbit, a review service. Model training is switched off on the accounts used, and CodeRabbit states that the data from its reviews is never used for training. The privacy policy has the details." + body: "The site calls an AI service in one place: when the site owner asks for tag suggestions on a piece of artwork, a public link to its source post, or to the picture in that post, goes to entail.dev, an image classifier. For a post on X, X's own service is asked which picture the post carries first. Only the site owner can start that, so nothing you do is sent to an AI service as you browse. When the software is being worked on, the developer's tools can read this site's logs and database, as any developer's could, and those logs can include visitors' IP addresses and the pages they requested. Code goes to Anthropic and to CodeRabbit, a review service. Model training is switched off on the accounts used, and CodeRabbit states that the data from its reviews is never used for training. The privacy policy has the details." }, { lead: 'The model.', diff --git a/src/lib/legal.test.ts b/src/lib/legal.test.ts index 1a2a989a..6e4a27fc 100644 --- a/src/lib/legal.test.ts +++ b/src/lib/legal.test.ts @@ -181,7 +181,7 @@ describe('defaultPrivacyPolicy', () => { // The integrations list reads exhaustive, so it must actually be: every // remote service a feature calls out to is named (SONA-167 round 1). expect(text).toContain('Bluesky'); - expect(text).toMatch(/resolving a post to its image/); + expect(text).toMatch(/finding the picture in a post/); // SONA-220: the tag-suggestion lookup sends a post URL to entail.dev. expect(text).toContain('entail.dev'); expect(text).toContain('FurTrack'); @@ -296,8 +296,8 @@ describe('LEGAL_DEFAULTS_UPDATED tracks the default text', () => { // privacy page would show a "Last updated" line older than its own text. // Deliberately two assertions, not a diff — the point is to force the date // bump, not to review the prose. - const RECORDED_TEXT_HASH = 'f3861cf345d472156be90e5cbe8bf54700dd69c19dca975c42bdb20065b0edb4'; - const RECORDED_UPDATED = '2026-09-08'; + const RECORDED_TEXT_HASH = 'e016ba07a84e6b8f2523f393d7aacd2485789354598be8d5b70708974eae8032'; + const RECORDED_UPDATED = '2026-09-07'; function defaultsText(): string { // Fixed opts so the hash depends on the prose alone, not the caller. Both diff --git a/src/lib/legal.ts b/src/lib/legal.ts index 40a77d0d..90b81bad 100644 --- a/src/lib/legal.ts +++ b/src/lib/legal.ts @@ -27,7 +27,7 @@ export interface LegalSection { // on every fork by construction (a build/deploy date would falsely advance on a // redeploy that didn't touch the text). Bump this whenever you edit // defaultPrivacyPolicy or defaultTerms. -export const LEGAL_DEFAULTS_UPDATED = '2026-09-08'; +export const LEGAL_DEFAULTS_UPDATED = '2026-09-07'; /** * Resolve the "Last updated" date to show on a legal page from a *stable* source @@ -156,7 +156,7 @@ export function defaultPrivacyPolicy(opts: LegalOptions): LegalSection[] { : [ "For this site those tools are Anthropic's Claude, which writes and debugs code under the developer's direction, and CodeRabbit, a code review service that reads proposed changes." ]), - "For specific features the site also talks to Cloudflare Turnstile (bot protection on the sign-in page), Telegram (importing sticker packs), cons.fyi (convention listings), X (formerly Twitter) and Bluesky (fetching the profile pictures shown on this site, and resolving a post to its image), entail.dev (suggesting tags for artwork from its source post), FurTrack (importing fursuit photos), and the shared artist registry (syncing artist credits; the registry receives this site's name and hostname as part of the sync). The site contacts these services to run the feature; they are not used to track visitors." + "For specific features the site also talks to Cloudflare Turnstile (bot protection on the sign-in page), Telegram (importing sticker packs), cons.fyi (convention listings), X (formerly Twitter) and Bluesky (fetching the profile pictures shown on this site, and finding the picture in a post), entail.dev (suggesting tags for artwork from its source post), FurTrack (importing fursuit photos), and the shared artist registry (syncing artist credits; the registry receives this site's name and hostname as part of the sync). The site contacts these services to run the feature; they are not used to track visitors." ] }, { diff --git a/src/lib/server/entail.test.ts b/src/lib/server/entail.test.ts index c8756bf0..a5b5e951 100644 --- a/src/lib/server/entail.test.ts +++ b/src/lib/server/entail.test.ts @@ -3,6 +3,7 @@ import { MAX_SUGGESTED_TAGS, classifySourceUrl, classifyMediaUrl, + errorLabel, lookupBlueskyPost, suggestionsFromResult, translateTag @@ -53,6 +54,24 @@ describe('classifySourceUrl', () => { url: 'https://x.com/i/status/1234567890', id: '1234567890' }); + // The share-sheet permalink form. + expect(classifySourceUrl('https://x.com/i/web/status/1234567890')).toEqual({ + kind: 'x', + url: 'https://x.com/i/status/1234567890', + id: '1234567890' + }); + expect(classifySourceUrl('https://twitter.com/i/web/status/1234567890?s=20')).toEqual({ + kind: 'x', + url: 'https://x.com/i/status/1234567890', + id: '1234567890' + }); + // `web` only means something after `i`. + expect(classifySourceUrl('https://x.com/web/status/1234567890')).toEqual({ + kind: 'x', + url: 'https://x.com/web/status/1234567890', + id: '1234567890' + }); + expect(classifySourceUrl('https://x.com/i/web/1234567890')).toBeNull(); }); it('rejects anything else', () => { @@ -88,6 +107,16 @@ describe('translateTag', () => { expect(translateTag('!!!')).toBeNull(); expect(translateTag('')).toBeNull(); }); + + it('drops emoticon tags instead of leaving their debris', () => { + // e621 carries symbol-only tags whose sanitized remains ("3", "-", "---") + // would otherwise be suggested as if they were words. + expect(translateTag('<3')).toBeNull(); + expect(translateTag('^_^')).toBeNull(); + expect(translateTag('-_-')).toBeNull(); + expect(translateTag(':3')).toBeNull(); + expect(translateTag('digital_media_(artwork)')).toBe('digital-media'); + }); }); describe('suggestionsFromResult', () => { @@ -104,12 +133,6 @@ describe('suggestionsFromResult', () => { ).toEqual({ tags: ['mammal', 'pink-hair'], rating: 'safe' }); }); - it('honours a custom floor', () => { - expect( - suggestionsFromResult({ tags: [{ name: 'canine', confidence: 0.5 }] }, 0.4).tags - ).toEqual(['canine']); - }); - it('dedupes tags that translate to the same name', () => { expect( suggestionsFromResult({ @@ -146,6 +169,23 @@ describe('suggestionsFromResult', () => { }); }); +describe('errorLabel', () => { + it('reduces a parse failure to its name and keeps everything else readable', () => { + // Every fail-soft catch in this module and twitter-media.ts logs through + // this. A SyntaxError's message quotes the body that failed to parse. + let parseError: unknown; + try { + JSON.parse('secret-body'); + } catch (e) { + parseError = e; + } + expect(errorLabel(parseError)).toBe('SyntaxError'); + expect(errorLabel(new Error('TimeoutError'))).toBe('TimeoutError'); + expect(errorLabel('plain string')).toBe('plain string'); + expect(errorLabel(42)).toBe('42'); + }); +}); + describe('lookupBlueskyPost', () => { const url = 'https://bsky.app/profile/did:plc:aaaa/post/3abc'; const post = { @@ -209,12 +249,14 @@ describe('lookupBlueskyPost', () => { expect(logged).not.toContain(''); }); - it('ignores a bare-array body the API never sends', async () => { - expect(await lookupBlueskyPost(url, vi.fn(async () => json(post.images)))).toEqual({ - ok: true, - suggestions: { tags: [], rating: null }, - imageCount: 0 - }); + it('is unavailable on a 200 whose body has no images array', async () => { + // A bare array, or an object missing `images`, is a shape we don't know. + // It must not pass as "the classifier found nothing" (which is `images: []`). + const unavailable = { ok: false, reason: 'unavailable' }; + expect(await lookupBlueskyPost(url, vi.fn(async () => json(post.images)))).toEqual(unavailable); + expect(await lookupBlueskyPost(url, vi.fn(async () => json({ uri: 'at://x' })))).toEqual( + unavailable + ); }); it('succeeds with no tags when the post has no classified images', async () => { @@ -225,6 +267,23 @@ describe('lookupBlueskyPost', () => { imageCount: 0 }); }); + + // `/post?wait=true` holds the connection open while the classifier works, + // the same way the classify poll does (see the matching test below). A post + // timeout shorter than that hold would abort the answer we asked to wait for. + it('waits out a post lookup that the server holds open for seconds', async () => { + const heldFor = 2500; + const fetchImpl = vi.fn(async (_url: string | URL | Request, init?: RequestInit) => { + await new Promise((resolve) => setTimeout(resolve, heldFor)); + init?.signal?.throwIfAborted(); + return json(post); + }); + expect(await lookupBlueskyPost(url, fetchImpl)).toEqual({ + ok: true, + suggestions: { tags: ['mammal'], rating: 'explicit' }, + imageCount: 2 + }); + }, 10_000); }); describe('classifyMediaUrl', () => { @@ -322,17 +381,6 @@ describe('classifyMediaUrl', () => { ).toEqual(unavailable); }); - it('logs a malformed poll body as a parse failure without quoting it', async () => { - const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}); - const fetchImpl = vi.fn(async (_url: string | URL | Request, init?: RequestInit) => - init?.method === 'POST' ? json({ job_id: 'job-8' }, 202) : new Response('secret-body') - ); - expect(await classifyMediaUrl(url, fetchImpl)).toEqual({ ok: false, reason: 'unavailable' }); - const logged = warn.mock.calls.map((c) => c.join(' ')).join('\n'); - expect(logged).toContain('SyntaxError'); - expect(logged).not.toContain('secret-body'); - }); - it('names a rate limit from either the enqueue or a poll', async () => { expect( await classifyMediaUrl(url, vi.fn(async () => new Response('slow down', { status: 429 }))) diff --git a/src/lib/server/entail.ts b/src/lib/server/entail.ts index 78384beb..17021617 100644 --- a/src/lib/server/entail.ts +++ b/src/lib/server/entail.ts @@ -4,7 +4,7 @@ // allowlisted CDN goes through the `/api/classify` enqueue-and-poll pair. // // Spec: https://entail.dev/api/openapi.json (docs at https://entail.dev/api/docs). -// Response shapes below were confirmed against the live API on 2026-09-08. +// Response shapes below were confirmed against the live API on 2026-09-07. // Rate limiting is per client IP and there are no API keys, so a 429 is a // normal outcome, not an error to surface. // @@ -26,7 +26,7 @@ export const MAX_SUGGESTED_TAGS = 40; // Both `wait=true` endpoints hold the connection open until the classifier // finishes rather than answering 202 straight away. That hold was measured at -// roughly five seconds for a fresh job on 2026-09-08, so every timeout here +// roughly five seconds for a fresh job on 2026-09-07, so every timeout here // has to clear it comfortably or we abort the very response we asked to wait // for. A classify is one enqueue plus at most two polls, worst case about // 3 + 8 + 0.25 + 8 seconds; the caller shows a pending state while it waits. @@ -53,7 +53,8 @@ export type LookupFailure = 'not_ready' | 'rate_limited' | 'unavailable'; /** `imageCount` is how many images the source post carried. Suggestions come * from the first one only, so a count above 1 tells the UI the rest went - * unread. The X path always reports 1: it classifies a single media URL. */ + * unread. classifyMediaUrl has no post to count, so it reports 1 and the + * endpoint substitutes the tweet's photo count on the X path. */ export type LookupOutcome = | { ok: true; suggestions: Suggestions; imageCount: number } | { ok: false; reason: LookupFailure }; @@ -111,7 +112,10 @@ export function classifySourceUrl(url: string): SourceKind | null { } if (host === 'x.com' || host === 'twitter.com' || host === 'mobile.x.com' || host === 'mobile.twitter.com') { - // //status/, /i/status/, either with a trailing /photo/N. + // //status/, /i/status/, /i/web/status/, any with a + // trailing /photo/N. The `/i/web/` permalink is the form X's own share + // sheet hands out, so it canonicalises to /i/status/ like the rest. + if (parts[0] === 'i' && parts[1] === 'web') parts.splice(1, 1); if (parts.length < 3) return null; const [user, keyword, id] = parts; if (keyword !== 'status' && keyword !== 'statuses') return null; @@ -127,7 +131,9 @@ export function classifySourceUrl(url: string): SourceKind | null { * Translate one e621-vocabulary tag into a Sona tag. Drops the trailing * qualifier e621 appends to disambiguate (`digital_media_(artwork)`), swaps * underscores for hyphens, then runs the same sanitizer the tag inputs use. - * Returns null when nothing usable is left. Pure. + * Emoticon tags (`<3`, `^_^`, `-_-`, `:3`) sanitize down to bare digits or + * hyphens, so the result also needs a letter to count. Returns null when + * nothing usable is left. Pure. */ export function translateTag(tag: string): string | null { const translated = tag @@ -135,8 +141,10 @@ export function translateTag(tag: string): string | null { .toLowerCase() .replace(/[\s_]*\([^()]*\)\s*$/, '') .replace(/_/g, '-'); - const sanitized = sanitizeTag(translated); - return sanitized || null; + const sanitized = sanitizeTag(translated) + .replace(/-{2,}/g, '-') + .replace(/^-|-$/g, ''); + return /[a-z]/.test(sanitized) ? sanitized : null; } function normalizeRating(rating: unknown): EntailRating | null { @@ -149,10 +157,7 @@ function normalizeRating(rating: unknown): EntailRating | null { * preserving the confidence order the API returns, capped at * {@link MAX_SUGGESTED_TAGS}. Pure. */ -export function suggestionsFromResult( - result: ClassificationEntry | null | undefined, - floor = DEFAULT_CONFIDENCE_FLOOR -): Suggestions { +export function suggestionsFromResult(result: ClassificationEntry | null | undefined): Suggestions { const rating = normalizeRating(result?.rating); const raw = Array.isArray(result?.tags) ? result.tags : []; const seen = new Set(); @@ -160,7 +165,7 @@ export function suggestionsFromResult( for (const entry of raw) { const { name, confidence } = (entry ?? {}) as { name?: unknown; confidence?: unknown }; if (typeof name !== 'string') continue; - if (typeof confidence !== 'number' || !(confidence >= floor)) continue; + if (typeof confidence !== 'number' || !(confidence >= DEFAULT_CONFIDENCE_FLOOR)) continue; const tag = translateTag(name); if (!tag || seen.has(tag)) continue; seen.add(tag); @@ -170,10 +175,12 @@ export function suggestionsFromResult( return { tags, rating }; } -/** `/post` answers with `{ uri, images: [...] }`. */ -function postImages(body: unknown): ClassificationEntry[] { +/** `/post` answers with `{ uri, images: [...] }`. A body without an `images` + * array is a shape we don't know, so it resolves to null rather than to an + * empty list that would read as "nothing to suggest". */ +function postImages(body: unknown): ClassificationEntry[] | null { const images = (body as { images?: unknown })?.images; - return Array.isArray(images) ? (images as ClassificationEntry[]) : []; + return Array.isArray(images) ? (images as ClassificationEntry[]) : null; } /** @@ -207,13 +214,16 @@ export async function lookupBlueskyPost( } // An empty `images` array means entail.dev looked and found no furry // artwork in the post. That is an answer, not a failure: the caller gets - // an empty tag list rather than an error it would have to explain. + // an empty tag list rather than an error it would have to explain. A body + // with no `images` array at all is neither; it is unavailable. const images = postImages(await res.json()); - const first = images[0]; + if (!images) { + console.warn('[entail] post lookup returned an unexpected shape'); + return fail('unavailable'); + } return { ok: true, - suggestions: - first && typeof first === 'object' ? suggestionsFromResult(first) : { tags: [], rating: null }, + suggestions: suggestionsFromResult(images[0]), imageCount: images.length }; } catch (e) { diff --git a/src/lib/server/twitter-media.test.ts b/src/lib/server/twitter-media.test.ts index 82cda5d8..b8f4c088 100644 --- a/src/lib/server/twitter-media.test.ts +++ b/src/lib/server/twitter-media.test.ts @@ -1,5 +1,5 @@ import { afterEach, describe, expect, it, vi } from 'vitest'; -import { fetchTweetMediaUrl, parseTweetPhotoUrl } from './twitter-media'; +import { fetchTweetMediaUrl, parseTweetPhotos } from './twitter-media'; afterEach(() => { vi.restoreAllMocks(); @@ -22,32 +22,44 @@ const tweetWith = (media: unknown[]) => ({ const photo = { type: 'photo', media_url_https: 'https://pbs.twimg.com/media/AbCdEf123.jpg' }; -describe('parseTweetPhotoUrl', () => { +describe('parseTweetPhotos', () => { it('upgrades the first photo to the largest variant', () => { - expect(parseTweetPhotoUrl(tweetWith([photo]))).toBe( - 'https://pbs.twimg.com/media/AbCdEf123?format=jpg&name=4096x4096' - ); + expect(parseTweetPhotos(tweetWith([photo]))).toEqual({ + url: 'https://pbs.twimg.com/media/AbCdEf123?format=jpg&name=4096x4096', + photoCount: 1 + }); + }); + + it('counts every photo but resolves only the first', () => { + const second = { type: 'photo', media_url_https: 'https://pbs.twimg.com/media/Second.png' }; + const third = { type: 'photo', media_url_https: 'https://pbs.twimg.com/media/Third.png' }; + expect(parseTweetPhotos(tweetWith([photo, second, third]))).toEqual({ + url: 'https://pbs.twimg.com/media/AbCdEf123?format=jpg&name=4096x4096', + photoCount: 3 + }); }); it('passes a media URL with no extension through untouched', () => { expect( - parseTweetPhotoUrl(tweetWith([{ type: 'photo', media_url_https: 'https://pbs.twimg.com/media/NoExt' }])) + parseTweetPhotos(tweetWith([{ type: 'photo', media_url_https: 'https://pbs.twimg.com/media/NoExt' }])) + ?.url ).toBe('https://pbs.twimg.com/media/NoExt'); }); it('skips video and animated gif entries', () => { - expect(parseTweetPhotoUrl(tweetWith([{ type: 'video', media_url_https: 'https://pbs.twimg.com/x.jpg' }]))).toBeNull(); + expect(parseTweetPhotos(tweetWith([{ type: 'video', media_url_https: 'https://pbs.twimg.com/x.jpg' }]))).toBeNull(); expect( - parseTweetPhotoUrl(tweetWith([{ type: 'animated_gif', media_url_https: 'https://pbs.twimg.com/y.jpg' }])) + parseTweetPhotos(tweetWith([{ type: 'animated_gif', media_url_https: 'https://pbs.twimg.com/y.jpg' }])) ).toBeNull(); + // A video alongside a photo is not counted as a photo. expect( - parseTweetPhotoUrl(tweetWith([{ type: 'video', media_url_https: 'https://pbs.twimg.com/x.jpg' }, photo])) - ).toContain('format=jpg'); + parseTweetPhotos(tweetWith([{ type: 'video', media_url_https: 'https://pbs.twimg.com/x.jpg' }, photo])) + ).toEqual({ url: expect.stringContaining('format=jpg'), photoCount: 1 }); }); it('reads a tweet nested behind a visibility result', () => { expect( - parseTweetPhotoUrl({ + parseTweetPhotos({ data: { tweetResult: { result: { @@ -56,22 +68,22 @@ describe('parseTweetPhotoUrl', () => { } } } - }) + })?.url ).toContain('AbCdEf123'); }); it('falls back to entities.media when extended_entities is absent', () => { expect( - parseTweetPhotoUrl({ + parseTweetPhotos({ data: { tweetResult: { result: { legacy: { entities: { media: [photo] } } } } } - }) + })?.url ).toContain('AbCdEf123'); }); it('returns null on a text-only tweet, a tombstone, and junk', () => { - expect(parseTweetPhotoUrl(tweetWith([]))).toBeNull(); - expect(parseTweetPhotoUrl({ data: { tweetResult: {} } })).toBeNull(); - expect(parseTweetPhotoUrl(null)).toBeNull(); + expect(parseTweetPhotos(tweetWith([]))).toBeNull(); + expect(parseTweetPhotos({ data: { tweetResult: {} } })).toBeNull(); + expect(parseTweetPhotos(null)).toBeNull(); }); }); @@ -98,7 +110,8 @@ describe('fetchTweetMediaUrl', () => { const { fetchImpl, tokens } = stub(() => json(tweetWith([photo]))); expect(await fetchTweetMediaUrl(id, fetchImpl)).toEqual({ ok: true, - url: 'https://pbs.twimg.com/media/AbCdEf123?format=jpg&name=4096x4096' + url: 'https://pbs.twimg.com/media/AbCdEf123?format=jpg&name=4096x4096', + photoCount: 1 }); expect(tokens).toEqual(['gt-1']); const lookup = String(fetchImpl.mock.calls.find(([t]) => !String(t).includes('guest/activate'))?.[0]); @@ -131,6 +144,19 @@ describe('fetchTweetMediaUrl', () => { expect(activations.count).toBe(2); }); + it('stays rate_limited when the retry cannot even get a fresh token after a 429', async () => { + let activations = 0; + const fetchImpl = vi.fn(async (target: string | URL | Request) => { + if (String(target).includes('guest/activate')) { + activations++; + return activations === 1 ? json({ guest_token: 'gt-1' }) : new Response('slow down', { status: 429 }); + } + return new Response('slow down', { status: 429 }); + }); + expect(await fetchTweetMediaUrl(id, fetchImpl)).toEqual({ ok: false, reason: 'rate_limited' }); + expect(activations).toBe(2); + }); + it('is unavailable after a 401 that survives the retry', async () => { const { fetchImpl } = stub(() => new Response('nope', { status: 401 })); expect(await fetchTweetMediaUrl(id, fetchImpl)).toEqual(unavailable); @@ -152,14 +178,6 @@ describe('fetchTweetMediaUrl', () => { ).toEqual(unavailable); }); - it('logs a malformed body as a parse failure without quoting it', async () => { - const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}); - await fetchTweetMediaUrl(id, stub(() => new Response('secret-body')).fetchImpl); - const logged = warn.mock.calls.map((c) => c.join(' ')).join('\n'); - expect(logged).toContain('SyntaxError'); - expect(logged).not.toContain('secret-body'); - }); - it('is unavailable when the guest token cannot be activated', async () => { const fetchImpl = vi.fn(async () => new Response('blocked', { status: 403 })); expect(await fetchTweetMediaUrl(id, fetchImpl)).toEqual(unavailable); diff --git a/src/lib/server/twitter-media.ts b/src/lib/server/twitter-media.ts index cc974f53..247777a6 100644 --- a/src/lib/server/twitter-media.ts +++ b/src/lib/server/twitter-media.ts @@ -3,7 +3,7 @@ // UNDOCUMENTED and rotate — both were lifted from FxEmbed's source // (packages/atmosphere/src/providers/twitter/graphql/{queries,features}.ts, // `TweetResultByRestIdQuery` plus its `rwebTweetFeatureKeys`) and verified -// against a real public tweet on 2026-09-08 (activate 200, GraphQL 200, photo +// against a real public tweet on 2026-09-07 (activate 200, GraphQL 200, photo // present). If resolution goes uniformly null, refresh them from FxEmbed. // // Fail-soft throughout: any error resolves to a failed outcome and the caller @@ -66,19 +66,24 @@ const QUERY_FIELD_TOGGLES = { /** `rate_limited` is X refusing the guest token twice over with a 429; * everything else that yields no photo is `unavailable`. */ export type TweetMediaOutcome = - | { ok: true; url: string } + | { ok: true; url: string; photoCount: number } | { ok: false; reason: 'rate_limited' | 'unavailable' }; const fail = (reason: 'rate_limited' | 'unavailable'): TweetMediaOutcome => ({ ok: false, reason }); type TweetMedia = { type?: unknown; media_url_https?: unknown }; +/** The first photo on a tweet, upgraded to its largest variant, and how many + * photos the tweet carried in all. */ +export type TweetPhotos = { url: string; photoCount: number }; + /** - * Extract the first photo from a TweetResultByRestId response and ask - * pbs.twimg.com for its largest variant. Returns null for a tweet with no - * photo (video- and GIF-only tweets included). Pure, so it's testable. + * Extract the photos from a TweetResultByRestId response: the first one's + * URL, asking pbs.twimg.com for its largest variant, plus the photo count. + * Returns null for a tweet with no photo (video- and GIF-only tweets + * included). Pure, so it's testable. */ -export function parseTweetPhotoUrl(body: unknown): string | null { +export function parseTweetPhotos(body: unknown): TweetPhotos | null { const result = (body as { data?: { tweetResult?: { result?: Record } } })?.data ?.tweetResult?.result; if (!result) return null; @@ -90,15 +95,18 @@ export function parseTweetPhotoUrl(body: unknown): string | null { const media = legacy?.extended_entities?.media ?? legacy?.entities?.media; if (!Array.isArray(media)) return null; + let first: string | null = null; + let photoCount = 0; for (const entry of media as TweetMedia[]) { if (entry?.type !== 'photo') continue; const url = entry.media_url_https; if (typeof url !== 'string' || !url) continue; + photoCount++; + if (first) continue; const match = url.match(/^(.*)\.([a-z]+)$/i); - if (!match) return url; - return `${match[1]}?format=${match[2].toLowerCase()}&name=4096x4096`; + first = match ? `${match[1]}?format=${match[2].toLowerCase()}&name=4096x4096` : url; } - return null; + return first ? { url: first, photoCount } : null; } function tweetLookup(tweetId: string, guestToken: string, fetchImpl: typeof fetch): Promise { @@ -130,8 +138,9 @@ function tweetLookup(tweetId: string, guestToken: string, fetchImpl: typeof fetc ); } -/** Resolve the first photo on a public tweet to a pbs.twimg.com URL, given the - * numeric status id classifySourceUrl already validated. One guest token, one +/** Resolve the first photo on a public tweet to a pbs.twimg.com URL, and + * count the tweet's photos, given the numeric status id classifySourceUrl + * already validated. One guest token, one * fresh-token retry if X refuses it (401/429), then a failed outcome. Never * throws. */ export async function fetchTweetMediaUrl( @@ -143,8 +152,11 @@ export async function fetchTweetMediaUrl( if (!token) return fail('unavailable'); let res = await tweetLookup(tweetId, token, fetchImpl); if (res.status === 401 || res.status === 429) { + // If X was already rate limiting us and now refuses a fresh token + // too, that is still a rate limit, not an outage. + const limited = res.status === 429; token = await activateGuestToken(fetchImpl); - if (!token) return fail('unavailable'); + if (!token) return fail(limited ? 'rate_limited' : 'unavailable'); res = await tweetLookup(tweetId, token, fetchImpl); } if (res.status === 429) { @@ -155,14 +167,14 @@ export async function fetchTweetMediaUrl( console.warn(`[avatar] tweet media lookup failed: status=${res.status}`); return fail('unavailable'); } - const photo = parseTweetPhotoUrl(await res.json()); - if (!photo) { + const photos = parseTweetPhotos(await res.json()); + if (!photos) { // 200 but no photo — a text/video tweet, a protected or deleted one, or // the undocumented GraphQL shape rotated (see the file header). console.warn('[avatar] tweet media lookup had no photo'); return fail('unavailable'); } - return { ok: true, url: photo }; + return { ok: true, url: photos.url, photoCount: photos.photoCount }; } catch (e) { console.warn(`[avatar] tweet media lookup error: ${errorLabel(e)}`); return fail('unavailable'); diff --git a/src/routes/api/admin/tag-suggestions/+server.ts b/src/routes/api/admin/tag-suggestions/+server.ts index 153e56fa..85d1f2cd 100644 --- a/src/routes/api/admin/tag-suggestions/+server.ts +++ b/src/routes/api/admin/tag-suggestions/+server.ts @@ -44,6 +44,9 @@ const FAILURE_STATUS: Record = { }; const MAX_URL_LENGTH = 2048; +/** Read before parsing: a valid body is a short object with one field, so + * anything past this is refused without handing it to JSON.parse. */ +const MAX_BODY_BYTES = 4096; const failure = (reason: LookupFailure) => json({ error: reason }, { status: FAILURE_STATUS[reason] }); @@ -53,7 +56,14 @@ const invalid = () => json({ error: 'invalid_request' }, { status: 400 }); type Body = { imageId?: unknown; sourcePostUrl?: unknown }; export const POST: RequestHandler = async ({ request, platform }) => { - const body = (await request.json().catch(() => null)) as Body | null; + const raw = await request.arrayBuffer().catch(() => null); + if (!raw || raw.byteLength > MAX_BODY_BYTES) return invalid(); + let body: Body | null; + try { + body = JSON.parse(new TextDecoder().decode(raw)); + } catch { + body = null; + } if (!body || typeof body !== 'object' || Array.isArray(body)) return invalid(); const hasImageId = body.imageId !== undefined && body.imageId !== null; @@ -87,6 +97,7 @@ export const POST: RequestHandler = async ({ request, platform }) => { if (!source) return json({ error: 'unsupported_source' }, { status: 422 }); let outcome: LookupOutcome; + let photoCount = 0; if (source.kind === 'bluesky') { outcome = await lookupBlueskyPost(source.url); } else { @@ -97,6 +108,7 @@ export const POST: RequestHandler = async ({ request, platform }) => { const media = await fetchTweetMediaUrl(source.id); if (!media.ok) return failure(media.reason); outcome = await classifyMediaUrl(media.url); + photoCount = media.photoCount; } if (!outcome.ok) return failure(outcome.reason); @@ -104,6 +116,8 @@ export const POST: RequestHandler = async ({ request, platform }) => { source: source.kind, tags: outcome.suggestions.tags, rating: outcome.suggestions.rating, - imageCount: outcome.imageCount + // classifyMediaUrl sees one image; only the tweet lookup knows how many + // the post carried. + imageCount: source.kind === 'x' ? photoCount : outcome.imageCount }); }; diff --git a/src/routes/api/admin/tag-suggestions/server.test.ts b/src/routes/api/admin/tag-suggestions/server.test.ts index beedc338..da49cda9 100644 --- a/src/routes/api/admin/tag-suggestions/server.test.ts +++ b/src/routes/api/admin/tag-suggestions/server.test.ts @@ -71,7 +71,7 @@ beforeEach(() => { fetchTweetMediaUrl.mockReset(); lookupBlueskyPost.mockResolvedValue(suggestions); classifyMediaUrl.mockResolvedValue(suggestions); - fetchTweetMediaUrl.mockResolvedValue({ ok: true, url: MEDIA_URL }); + fetchTweetMediaUrl.mockResolvedValue({ ok: true, url: MEDIA_URL, photoCount: 1 }); }); describe('POST /api/admin/tag-suggestions', () => { @@ -98,7 +98,8 @@ describe('POST /api/admin/tag-suggestions', () => { source: 'x', tags: ['mammal', 'pink-hair'], rating: 'safe', - imageCount: 3 + // The tweet's photo count, not whatever classifyMediaUrl reports. + imageCount: 1 }); // Only the validated status id goes to X, never the caller's string. expect(fetchTweetMediaUrl).toHaveBeenCalledWith(X_ID); @@ -107,6 +108,15 @@ describe('POST /api/admin/tag-suggestions', () => { expect(lookupBlueskyPost).not.toHaveBeenCalled(); }); + it('reports how many photos a multi-photo tweet carried', async () => { + const { platform } = makeEnv(); + fetchTweetMediaUrl.mockResolvedValue({ ok: true, url: MEDIA_URL, photoCount: 3 }); + classifyMediaUrl.mockResolvedValue({ ...suggestions, imageCount: 1 }); + const res = await POST(event(platform, { sourcePostUrl: X_POST })); + expect(res.status).toBe(200); + expect((await res.json()).imageCount).toBe(3); + }); + it('reads the stored source URL for an imageId', async () => { const { sqlite, platform } = makeEnv(); insertImage(sqlite, BSKY_POST); @@ -152,7 +162,10 @@ describe('POST /api/admin/tag-suggestions', () => { [{ imageId: 1.5 }, undefined], [{ imageId: 0 }, undefined], [{ sourcePostUrl: 42 }, undefined], - [{ sourcePostUrl: `https://bsky.app/profile/a/post/${'x'.repeat(2100)}` }, undefined] + [{ sourcePostUrl: `https://bsky.app/profile/a/post/${'x'.repeat(2100)}` }, undefined], + // Over the body cap: refused before JSON.parse ever sees it, even though + // the URL inside is fine and the padding would otherwise be ignored. + [undefined, JSON.stringify({ sourcePostUrl: BSKY_POST, pad: 'x'.repeat(4100) })] ]; for (const [body, raw] of bad) { const res = await POST(event(platform, body, raw)); @@ -175,6 +188,19 @@ describe('POST /api/admin/tag-suggestions', () => { expect(await res.json()).toEqual({ source: 'bluesky', tags: [], rating: 'safe', imageCount: 3 }); }); + it('passes an imageCount of 0 through as a success', async () => { + const { platform } = makeEnv(); + // A post with no classified images: still 200, still zero, not a failure. + lookupBlueskyPost.mockResolvedValue({ + ok: true, + suggestions: { tags: [], rating: null }, + imageCount: 0 + }); + const res = await POST(event(platform, { sourcePostUrl: BSKY_POST })); + expect(res.status).toBe(200); + expect(await res.json()).toEqual({ source: 'bluesky', tags: [], rating: null, imageCount: 0 }); + }); + it('502s not_ready when the post is queued but unclassified', async () => { const { platform } = makeEnv(); lookupBlueskyPost.mockResolvedValue({ ok: false, reason: 'not_ready' }); From 2520a6c8212241fb7eb813ad1f88c2f4b567777c Mon Sep 17 00:00:00 2001 From: Sparky <1609870+sparkyfen@users.noreply.github.com> Date: Mon, 7 Sep 2026 20:03:39 -0700 Subject: [PATCH 08/22] fix(admin): review round 3 for the entail.dev tag suggestion client (SONA-220) - Reword the AI disclosure and privacy policy so each names what actually leaves the site, and attribute picture lookup to X only. - UPDATING.md section for owners who pasted their own privacy or /ai text, and the matching AI_POLICY.md sentence. - Send an explicit User-Agent on X requests; Node's default is refused. - Clamp the tweet photo count and the raw tag scan, read the request body as text with a byte cap, and move errorLabel to its own module. - Tests for the cap boundaries, the hyphen run at the tag length cap, the timeout floor, and the photo URL identity. --- AI_POLICY.md | 5 +++ UPDATING.md | 24 +++++++++++ src/lib/ai-disclosure.test.ts | 4 +- src/lib/ai-disclosure.ts | 2 +- src/lib/legal.test.ts | 10 ++--- src/lib/legal.ts | 2 +- src/lib/server/entail.test.ts | 41 ++++++++++++------- src/lib/server/entail.ts | 24 +++++------ src/lib/server/fetch-errors.test.ts | 19 +++++++++ src/lib/server/fetch-errors.ts | 8 ++++ src/lib/server/twitter-avatar.test.ts | 23 +++++++---- src/lib/server/twitter-avatar.ts | 7 +++- src/lib/server/twitter-media.test.ts | 20 ++++++++- src/lib/server/twitter-media.ts | 10 +++-- .../api/admin/tag-suggestions/+server.ts | 20 +++++---- .../api/admin/tag-suggestions/server.test.ts | 19 +++++++++ 16 files changed, 177 insertions(+), 61 deletions(-) create mode 100644 src/lib/server/fetch-errors.test.ts create mode 100644 src/lib/server/fetch-errors.ts diff --git a/AI_POLICY.md b/AI_POLICY.md index 3666e8ef..1dfad408 100644 --- a/AI_POLICY.md +++ b/AI_POLICY.md @@ -24,6 +24,11 @@ second AI model reads the first one's work, and CodeRabbit, a third-party service, reviews the diff. A human approves every merge and clicks every deploy. There is no review team here, just one maintainer and a set of tools. +The running site makes one AI call of its own: when the operator asks for tag +suggestions on a piece of artwork, the site sends a public link to the source +post, or to the picture in it, to entail.dev, an image classifier. Nothing a +visitor does reaches an AI service. + ## Rules the agents work under These rules predate this file, and every agent session on this project works diff --git a/UPDATING.md b/UPDATING.md index 24ac1773..262d667c 100644 --- a/UPDATING.md +++ b/UPDATING.md @@ -48,6 +48,30 @@ you sync — or to catch up on what shipped since you last did — read the the merged changes since the previous one. `git log --oneline ..upstream/main` after a fetch gives the same view from a clone. +## Read before upgrading: tag suggestions call entail.dev (SONA-220) + +This release adds an admin-only tag suggestion lookup for artwork. Only you can +start it, and nothing runs on its own. When you do, the site sends a public +link to entail.dev, an image classifier, and shows you the tags it returns. For a +Bluesky post that link is the post itself. For an X post the site first asks X's +own API which picture the post carries, then sends the picture link X hands back. +Nothing from either reply is stored; the suggestions are yours to accept or drop. + +The built-in privacy policy and the `/ai` page now name both services. **If you +pasted your own privacy text or your own `/ai` text in Settings, neither was +updated**, and your pages will not mention services your site now contacts. Add +these to your privacy policy's list of feature integrations: + +- `X (formerly Twitter) (fetching the profile pictures shown on this site, and finding the picture in a post)` +- `entail.dev (suggesting tags for artwork from its source post or the picture in it)` + +And add this to your `/ai` text: "The site calls an AI service in one place. When +the site owner asks for tag suggestions on a piece of artwork, the site sends a +public link to entail.dev, an image classifier. That link points at the artwork's +source post, or at the picture in that post. For a post on X, the site first asks +X's own service which picture the post carries. Only the site owner can start +that, so nothing you do is sent to an AI service as you browse." + ## One-time backfill: sticker animation flags (SONA-123) The release that adds the per-sticker download-format menu also adds a diff --git a/src/lib/ai-disclosure.test.ts b/src/lib/ai-disclosure.test.ts index e8f76f2a..f2319c27 100644 --- a/src/lib/ai-disclosure.test.ts +++ b/src/lib/ai-disclosure.test.ts @@ -38,8 +38,8 @@ describe('defaultAiDisclosure', () => { expect(all).toMatch(/calls an AI service in one place/); expect(all).toContain('entail.dev'); // Both paths: the post link (Bluesky) and the picture link X hands back. - expect(all).toMatch(/or to the picture in that post/); - expect(all).toMatch(/X's own service is asked which picture the post carries first/); + expect(all).toMatch(/points at the artwork's source post, or at the picture in that post/); + expect(all).toMatch(/the site first asks X's own service which picture the post carries/); expect(all).toMatch(/Only the site owner can start that, so nothing you do is sent to an AI service as you browse/); expect(all).toMatch(/logs and database/); expect(all).toContain('CodeRabbit'); diff --git a/src/lib/ai-disclosure.ts b/src/lib/ai-disclosure.ts index b4c36f35..8dd8fe93 100644 --- a/src/lib/ai-disclosure.ts +++ b/src/lib/ai-disclosure.ts @@ -71,7 +71,7 @@ export function defaultAiDisclosure(): AiDisclosure { }, { lead: 'Your data.', - body: "The site calls an AI service in one place: when the site owner asks for tag suggestions on a piece of artwork, a public link to its source post, or to the picture in that post, goes to entail.dev, an image classifier. For a post on X, X's own service is asked which picture the post carries first. Only the site owner can start that, so nothing you do is sent to an AI service as you browse. When the software is being worked on, the developer's tools can read this site's logs and database, as any developer's could, and those logs can include visitors' IP addresses and the pages they requested. Code goes to Anthropic and to CodeRabbit, a review service. Model training is switched off on the accounts used, and CodeRabbit states that the data from its reviews is never used for training. The privacy policy has the details." + body: "The site calls an AI service in one place. When the site owner asks for tag suggestions on a piece of artwork, the site sends a public link to entail.dev, an image classifier. That link points at the artwork's source post, or at the picture in that post. For a post on X, the site first asks X's own service which picture the post carries. Only the site owner can start that, so nothing you do is sent to an AI service as you browse. When the software is being worked on, the developer's tools can read this site's logs and database, as any developer's could, and those logs can include visitors' IP addresses and the pages they requested. Code goes to Anthropic and to CodeRabbit, a review service. Model training is switched off on the accounts used, and CodeRabbit states that the data from its reviews is never used for training. The privacy policy has the details." }, { lead: 'The model.', diff --git a/src/lib/legal.test.ts b/src/lib/legal.test.ts index 6e4a27fc..7b9ea4f7 100644 --- a/src/lib/legal.test.ts +++ b/src/lib/legal.test.ts @@ -180,10 +180,10 @@ describe('defaultPrivacyPolicy', () => { expect(text).toContain('cons.fyi'); // The integrations list reads exhaustive, so it must actually be: every // remote service a feature calls out to is named (SONA-167 round 1). - expect(text).toContain('Bluesky'); - expect(text).toMatch(/finding the picture in a post/); - // SONA-220: the tag-suggestion lookup sends a post URL to entail.dev. - expect(text).toContain('entail.dev'); + // Picture lookup is an X-only ask; Bluesky posts go to entail.dev whole. + expect(text).toMatch(/X \(formerly Twitter\) \(fetching the profile pictures shown on this site, and finding the picture in a post\), Bluesky \(fetching the profile pictures shown on this site\)/); + // SONA-220: the tag-suggestion lookup sends a post or picture URL to entail.dev. + expect(text).toMatch(/entail.dev \(suggesting tags for artwork from its source post or the picture in it\)/); expect(text).toContain('FurTrack'); expect(text).toMatch(/shared artist registry/); }); @@ -296,7 +296,7 @@ describe('LEGAL_DEFAULTS_UPDATED tracks the default text', () => { // privacy page would show a "Last updated" line older than its own text. // Deliberately two assertions, not a diff — the point is to force the date // bump, not to review the prose. - const RECORDED_TEXT_HASH = 'e016ba07a84e6b8f2523f393d7aacd2485789354598be8d5b70708974eae8032'; + const RECORDED_TEXT_HASH = '36982486de4edbea612682e28801fee08ecbccf41ec0942a32370daf5a9b0910'; const RECORDED_UPDATED = '2026-09-07'; function defaultsText(): string { diff --git a/src/lib/legal.ts b/src/lib/legal.ts index 90b81bad..a8a30ab9 100644 --- a/src/lib/legal.ts +++ b/src/lib/legal.ts @@ -156,7 +156,7 @@ export function defaultPrivacyPolicy(opts: LegalOptions): LegalSection[] { : [ "For this site those tools are Anthropic's Claude, which writes and debugs code under the developer's direction, and CodeRabbit, a code review service that reads proposed changes." ]), - "For specific features the site also talks to Cloudflare Turnstile (bot protection on the sign-in page), Telegram (importing sticker packs), cons.fyi (convention listings), X (formerly Twitter) and Bluesky (fetching the profile pictures shown on this site, and finding the picture in a post), entail.dev (suggesting tags for artwork from its source post), FurTrack (importing fursuit photos), and the shared artist registry (syncing artist credits; the registry receives this site's name and hostname as part of the sync). The site contacts these services to run the feature; they are not used to track visitors." + "For specific features the site also talks to Cloudflare Turnstile (bot protection on the sign-in page), Telegram (importing sticker packs), cons.fyi (convention listings), X (formerly Twitter) (fetching the profile pictures shown on this site, and finding the picture in a post), Bluesky (fetching the profile pictures shown on this site), entail.dev (suggesting tags for artwork from its source post or the picture in it), FurTrack (importing fursuit photos), and the shared artist registry (syncing artist credits; the registry receives this site's name and hostname as part of the sync). The site contacts these services to run the feature; they are not used to track visitors." ] }, { diff --git a/src/lib/server/entail.test.ts b/src/lib/server/entail.test.ts index a5b5e951..9133342e 100644 --- a/src/lib/server/entail.test.ts +++ b/src/lib/server/entail.test.ts @@ -1,9 +1,11 @@ import { afterEach, describe, expect, it, vi } from 'vitest'; import { + MAX_RAW_ENTRIES, MAX_SUGGESTED_TAGS, + POLL_TIMEOUT_MS, + POST_TIMEOUT_MS, classifySourceUrl, classifyMediaUrl, - errorLabel, lookupBlueskyPost, suggestionsFromResult, translateTag @@ -108,6 +110,14 @@ describe('translateTag', () => { expect(translateTag('')).toBeNull(); }); + it('leaves no doubled or trailing hyphen when the cap cuts a hyphen run', () => { + // The sanitizer slices at 50 characters. A run of hyphens straddling the + // cut would otherwise survive as a doubled or trailing hyphen. + const tag = translateTag(`${'a'.repeat(48)}___bbb`); + expect(tag).toBe('a'.repeat(48)); + expect(tag).not.toMatch(/--|-$/); + }); + it('drops emoticon tags instead of leaving their debris', () => { // e621 carries symbol-only tags whose sanitized remains ("3", "-", "---") // would otherwise be suggested as if they were words. @@ -157,6 +167,15 @@ describe('suggestionsFromResult', () => { expect(new Set(result).size).toBe(MAX_SUGGESTED_TAGS); }); + it('stops reading a hostile tag array after the entry cap', () => { + const low = { name: 'noise', confidence: 0.1 }; + const tags = Array.from({ length: MAX_RAW_ENTRIES + 1 }, () => ({ ...low })); + // The last entry inside the cap is read; the first one past it is not. + tags[MAX_RAW_ENTRIES - 1] = { name: 'canine', confidence: 0.99 }; + tags[MAX_RAW_ENTRIES] = { name: 'mammal', confidence: 0.99 }; + expect(suggestionsFromResult({ tags }).tags).toEqual(['canine']); + }); + it('drops junk entries and unknown ratings', () => { expect( suggestionsFromResult({ @@ -169,20 +188,12 @@ describe('suggestionsFromResult', () => { }); }); -describe('errorLabel', () => { - it('reduces a parse failure to its name and keeps everything else readable', () => { - // Every fail-soft catch in this module and twitter-media.ts logs through - // this. A SyntaxError's message quotes the body that failed to parse. - let parseError: unknown; - try { - JSON.parse('secret-body'); - } catch (e) { - parseError = e; - } - expect(errorLabel(parseError)).toBe('SyntaxError'); - expect(errorLabel(new Error('TimeoutError'))).toBe('TimeoutError'); - expect(errorLabel('plain string')).toBe('plain string'); - expect(errorLabel(42)).toBe('42'); +describe('timeouts', () => { + it('outlast the wait=true hold the server puts on a fresh job', () => { + // About five seconds measured on 2026-09-07; anything at or above six + // clears it. The comment above the constants explains why this matters. + expect(POST_TIMEOUT_MS).toBeGreaterThanOrEqual(6000); + expect(POLL_TIMEOUT_MS).toBeGreaterThanOrEqual(6000); }); }); diff --git a/src/lib/server/entail.ts b/src/lib/server/entail.ts index 17021617..52d4ebf9 100644 --- a/src/lib/server/entail.ts +++ b/src/lib/server/entail.ts @@ -12,6 +12,7 @@ // resolves to a failed outcome and the caller carries on without suggestions. // Third-party response bodies are never logged and never stored. +import { errorLabel } from './fetch-errors'; import { sanitizeTag } from './validate'; const ENTAIL_POST = 'https://entail.dev/api/post'; @@ -24,15 +25,19 @@ export const DEFAULT_CONFIDENCE_FLOOR = 0.8; * classifier can return hundreds; the UI shows a short list. */ export const MAX_SUGGESTED_TAGS = 40; +/** Most raw entries one classification is read for. A body is third-party + * input, so the walk stops here rather than following an array of any size. */ +export const MAX_RAW_ENTRIES = 200; + // Both `wait=true` endpoints hold the connection open until the classifier // finishes rather than answering 202 straight away. That hold was measured at // roughly five seconds for a fresh job on 2026-09-07, so every timeout here // has to clear it comfortably or we abort the very response we asked to wait // for. A classify is one enqueue plus at most two polls, worst case about // 3 + 8 + 0.25 + 8 seconds; the caller shows a pending state while it waits. -const POST_TIMEOUT_MS = 8000; +export const POST_TIMEOUT_MS = 8000; const CLASSIFY_TIMEOUT_MS = 3000; -const POLL_TIMEOUT_MS = 8000; +export const POLL_TIMEOUT_MS = 8000; const POLL_PAUSE_MS = 250; const POLL_ATTEMPTS = 2; @@ -120,7 +125,8 @@ export function classifySourceUrl(url: string): SourceKind | null { const [user, keyword, id] = parts; if (keyword !== 'status' && keyword !== 'statuses') return null; if (!STATUS_ID.test(id)) return null; - if (user !== 'i' && !X_USER.test(user)) return null; + // `i` (the /i/status form) is a valid user segment by this pattern too. + if (!X_USER.test(user)) return null; return { kind: 'x', url: `https://x.com/${user}/status/${id}`, id }; } @@ -155,14 +161,14 @@ function normalizeRating(rating: unknown): EntailRating | null { * Turn one classification entry into Sona tag suggestions: keep the tags at or * above the confidence floor, translate them, and drop duplicates while * preserving the confidence order the API returns, capped at - * {@link MAX_SUGGESTED_TAGS}. Pure. + * {@link MAX_SUGGESTED_TAGS}. Reads at most {@link MAX_RAW_ENTRIES} entries. Pure. */ export function suggestionsFromResult(result: ClassificationEntry | null | undefined): Suggestions { const rating = normalizeRating(result?.rating); const raw = Array.isArray(result?.tags) ? result.tags : []; const seen = new Set(); const tags: string[] = []; - for (const entry of raw) { + for (const entry of raw.slice(0, MAX_RAW_ENTRIES)) { const { name, confidence } = (entry ?? {}) as { name?: unknown; confidence?: unknown }; if (typeof name !== 'string') continue; if (typeof confidence !== 'number' || !(confidence >= DEFAULT_CONFIDENCE_FLOOR)) continue; @@ -232,14 +238,6 @@ export async function lookupBlueskyPost( } } -/** What a caught error is safe to log. A JSON parse failure's message quotes - * a fragment of the body, and third-party bodies are never logged, so a - * SyntaxError is reduced to its name. */ -export function errorLabel(e: unknown): string { - if (e instanceof SyntaxError) return e.name; - return e instanceof Error ? e.message : String(e); -} - function jobIdFrom(body: unknown): string | null { const { job_id: jobId } = (body ?? {}) as { job_id?: unknown }; return typeof jobId === 'string' && jobId ? jobId : null; diff --git a/src/lib/server/fetch-errors.test.ts b/src/lib/server/fetch-errors.test.ts new file mode 100644 index 00000000..65460c49 --- /dev/null +++ b/src/lib/server/fetch-errors.test.ts @@ -0,0 +1,19 @@ +import { describe, expect, it } from 'vitest'; +import { errorLabel } from './fetch-errors'; + +describe('errorLabel', () => { + it('reduces a parse failure to its name and keeps everything else readable', () => { + // Every fail-soft catch in entail.ts and twitter-media.ts logs through + // this. A SyntaxError's message quotes the body that failed to parse. + let parseError: unknown; + try { + JSON.parse('secret-body'); + } catch (e) { + parseError = e; + } + expect(errorLabel(parseError)).toBe('SyntaxError'); + expect(errorLabel(new Error('TimeoutError'))).toBe('TimeoutError'); + expect(errorLabel('plain string')).toBe('plain string'); + expect(errorLabel(42)).toBe('42'); + }); +}); diff --git a/src/lib/server/fetch-errors.ts b/src/lib/server/fetch-errors.ts new file mode 100644 index 00000000..ac6cc8d3 --- /dev/null +++ b/src/lib/server/fetch-errors.ts @@ -0,0 +1,8 @@ +/** What a caught error is safe to log. A JSON parse failure's message quotes + * a fragment of the body, and third-party bodies are never logged, so a + * SyntaxError is reduced to its name. Shared by the fail-soft fetch clients + * (entail.ts, twitter-media.ts). */ +export function errorLabel(e: unknown): string { + if (e instanceof SyntaxError) return e.name; + return e instanceof Error ? e.message : String(e); +} diff --git a/src/lib/server/twitter-avatar.test.ts b/src/lib/server/twitter-avatar.test.ts index 35543aec..5ea61bb8 100644 --- a/src/lib/server/twitter-avatar.test.ts +++ b/src/lib/server/twitter-avatar.test.ts @@ -1,5 +1,6 @@ import { afterEach, describe, expect, it, vi } from 'vitest'; import { + X_USER_AGENT, twitterHandleFromUrl, parseUserAvatar, to400x400, @@ -66,18 +67,22 @@ describe('fetchTwitterAvatar', () => { }; it('activates a guest token and resolves the 400x400 avatar', async () => { - vi.stubGlobal( - 'fetch', - vi.fn(async (url: string | URL) => { - if (String(url).includes('guest/activate')) { - return new Response(JSON.stringify({ guest_token: 'gt' }), { status: 200 }); - } - return new Response(JSON.stringify(userBody), { status: 200 }); - }) - ); + const fetchImpl = vi.fn(async (url: string | URL, _init?: RequestInit) => { + if (String(url).includes('guest/activate')) { + return new Response(JSON.stringify({ guest_token: 'gt' }), { status: 200 }); + } + return new Response(JSON.stringify(userBody), { status: 200 }); + }); + vi.stubGlobal('fetch', fetchImpl); expect(await fetchTwitterAvatar('https://x.com/examplefox')).toBe( 'https://pbs.twimg.com/profile_images/9/pic_400x400.jpg' ); + // api.x.com 404s Node's default `User-Agent: node`; the activation and + // the lookup both send the shared one. + expect(fetchImpl).toHaveBeenCalledTimes(2); + for (const [, init] of fetchImpl.mock.calls) { + expect(new Headers(init?.headers).get('user-agent')).toBe(X_USER_AGENT); + } }); it('retries once with a fresh token on 429, then succeeds', async () => { diff --git a/src/lib/server/twitter-avatar.ts b/src/lib/server/twitter-avatar.ts index 11a6e925..2a784525 100644 --- a/src/lib/server/twitter-avatar.ts +++ b/src/lib/server/twitter-avatar.ts @@ -15,6 +15,10 @@ export const X_BEARER = 'Bearer AAAAAAAAAAAAAAAAAAAAANRILgAAAAAAnNwIzUejRCOuH5E6I8xnZz4puTs%3D1Zv7ttfk8LF81IUq16cHjhLTvJu4FA33AGWWjCpTnA'; const X_ACTIVATE = 'https://api.x.com/1.1/guest/activate.json'; +/** api.x.com answers 404 to Node's default `User-Agent: node` and 200 to any + * other value (verified 2026-09-07), so every request names itself. Exported + * so twitter-media.ts sends the same one. */ +export const X_USER_AGENT = 'Mozilla/5.0 (compatible; Sona; +https://github.com/sona-fast/sona)'; const X_USER_BY_SCREEN_NAME = 'https://api.x.com/graphql/IGgvgiOx4QZndDHuD3x9TQ/UserByScreenName'; const FETCH_TIMEOUT_MS = 5000; @@ -54,7 +58,7 @@ export async function activateGuestToken(fetchImpl: typeof fetch = fetch): Promi try { const res = await fetchImpl(X_ACTIVATE, { method: 'POST', - headers: { Authorization: X_BEARER }, + headers: { Authorization: X_BEARER, 'User-Agent': X_USER_AGENT }, signal: AbortSignal.timeout(FETCH_TIMEOUT_MS) }); if (!res.ok) { @@ -79,6 +83,7 @@ async function userLookup(handle: string, guestToken: string): Promise return fetch(`${X_USER_BY_SCREEN_NAME}?variables=${variables}`, { headers: { Authorization: X_BEARER, + 'User-Agent': X_USER_AGENT, 'x-guest-token': guestToken, 'x-csrf-token': csrf, 'x-twitter-active-user': 'yes', diff --git a/src/lib/server/twitter-media.test.ts b/src/lib/server/twitter-media.test.ts index b8f4c088..56942624 100644 --- a/src/lib/server/twitter-media.test.ts +++ b/src/lib/server/twitter-media.test.ts @@ -1,4 +1,5 @@ import { afterEach, describe, expect, it, vi } from 'vitest'; +import { X_USER_AGENT } from './twitter-avatar'; import { fetchTweetMediaUrl, parseTweetPhotos } from './twitter-media'; afterEach(() => { @@ -39,6 +40,16 @@ describe('parseTweetPhotos', () => { }); }); + it('clamps the photo count to the four X allows', () => { + // X attaches at most four photos; a body claiming more is not trusted. + const six = Array.from({ length: 6 }, (_, i) => ({ + type: 'photo', + media_url_https: `https://pbs.twimg.com/media/P${i}.jpg` + })); + expect(parseTweetPhotos(tweetWith(six))?.photoCount).toBe(4); + expect(parseTweetPhotos(tweetWith(six.slice(0, 4)))?.photoCount).toBe(4); + }); + it('passes a media URL with no extension through untouched', () => { expect( parseTweetPhotos(tweetWith([{ type: 'photo', media_url_https: 'https://pbs.twimg.com/media/NoExt' }])) @@ -51,10 +62,11 @@ describe('parseTweetPhotos', () => { expect( parseTweetPhotos(tweetWith([{ type: 'animated_gif', media_url_https: 'https://pbs.twimg.com/y.jpg' }])) ).toBeNull(); - // A video alongside a photo is not counted as a photo. + // A video alongside a photo is not counted as a photo, and the photo, not + // the video's poster, is what resolves. expect( parseTweetPhotos(tweetWith([{ type: 'video', media_url_https: 'https://pbs.twimg.com/x.jpg' }, photo])) - ).toEqual({ url: expect.stringContaining('format=jpg'), photoCount: 1 }); + ).toEqual({ url: 'https://pbs.twimg.com/media/AbCdEf123?format=jpg&name=4096x4096', photoCount: 1 }); }); it('reads a tweet nested behind a visibility result', () => { @@ -116,6 +128,10 @@ describe('fetchTweetMediaUrl', () => { expect(tokens).toEqual(['gt-1']); const lookup = String(fetchImpl.mock.calls.find(([t]) => !String(t).includes('guest/activate'))?.[0]); expect(lookup).toContain(encodeURIComponent(`"tweetId":"${id}"`)); + // api.x.com 404s Node's default `User-Agent: node`, so both calls name themselves. + for (const [, init] of fetchImpl.mock.calls) { + expect(new Headers(init?.headers).get('user-agent')).toBe(X_USER_AGENT); + } }); it('retries once with a fresh token on 401', async () => { diff --git a/src/lib/server/twitter-media.ts b/src/lib/server/twitter-media.ts index 247777a6..9e91632b 100644 --- a/src/lib/server/twitter-media.ts +++ b/src/lib/server/twitter-media.ts @@ -10,11 +10,14 @@ // proceeds without a media URL. Videos and GIFs are skipped — only photos // resolve. -import { errorLabel } from './entail'; -import { X_BEARER, activateGuestToken } from './twitter-avatar'; +import { errorLabel } from './fetch-errors'; +import { X_BEARER, X_USER_AGENT, activateGuestToken } from './twitter-avatar'; const X_TWEET_BY_REST_ID = 'https://api.x.com/graphql/f2sagi1jweVHFkTUIHzmMQ/TweetResultByRestId'; const FETCH_TIMEOUT_MS = 5000; +/** X attaches at most four photos to a post; a count past that is a response + * we do not trust, so it is clamped rather than reported. */ +const MAX_TWEET_PHOTOS = 4; const QUERY_FEATURES = { rweb_video_screen_enabled: false, @@ -106,7 +109,7 @@ export function parseTweetPhotos(body: unknown): TweetPhotos | null { const match = url.match(/^(.*)\.([a-z]+)$/i); first = match ? `${match[1]}?format=${match[2].toLowerCase()}&name=4096x4096` : url; } - return first ? { url: first, photoCount } : null; + return first ? { url: first, photoCount: Math.min(photoCount, MAX_TWEET_PHOTOS) } : null; } function tweetLookup(tweetId: string, guestToken: string, fetchImpl: typeof fetch): Promise { @@ -128,6 +131,7 @@ function tweetLookup(tweetId: string, guestToken: string, fetchImpl: typeof fetc { headers: { Authorization: X_BEARER, + 'User-Agent': X_USER_AGENT, 'x-guest-token': guestToken, 'x-csrf-token': csrf, 'x-twitter-active-user': 'yes', diff --git a/src/routes/api/admin/tag-suggestions/+server.ts b/src/routes/api/admin/tag-suggestions/+server.ts index 85d1f2cd..817f0cf2 100644 --- a/src/routes/api/admin/tag-suggestions/+server.ts +++ b/src/routes/api/admin/tag-suggestions/+server.ts @@ -56,11 +56,11 @@ const invalid = () => json({ error: 'invalid_request' }, { status: 400 }); type Body = { imageId?: unknown; sourcePostUrl?: unknown }; export const POST: RequestHandler = async ({ request, platform }) => { - const raw = await request.arrayBuffer().catch(() => null); - if (!raw || raw.byteLength > MAX_BODY_BYTES) return invalid(); + const text = await request.text().catch(() => null); + if (text === null || new TextEncoder().encode(text).length > MAX_BODY_BYTES) return invalid(); let body: Body | null; try { - body = JSON.parse(new TextDecoder().decode(raw)); + body = JSON.parse(text); } catch { body = null; } @@ -97,9 +97,13 @@ export const POST: RequestHandler = async ({ request, platform }) => { if (!source) return json({ error: 'unsupported_source' }, { status: 422 }); let outcome: LookupOutcome; - let photoCount = 0; + // How many images the post carried. Only the Bluesky lookup and the tweet + // lookup see the post; classifyMediaUrl sees one image. + let imageCount: number; if (source.kind === 'bluesky') { outcome = await lookupBlueskyPost(source.url); + if (!outcome.ok) return failure(outcome.reason); + imageCount = outcome.imageCount; } else { // entail.dev indexes Bluesky, not X, so an X post has to be classified // from its image. X's API is the only thing that knows which image that @@ -108,16 +112,14 @@ export const POST: RequestHandler = async ({ request, platform }) => { const media = await fetchTweetMediaUrl(source.id); if (!media.ok) return failure(media.reason); outcome = await classifyMediaUrl(media.url); - photoCount = media.photoCount; + if (!outcome.ok) return failure(outcome.reason); + imageCount = media.photoCount; } - if (!outcome.ok) return failure(outcome.reason); return json({ source: source.kind, tags: outcome.suggestions.tags, rating: outcome.suggestions.rating, - // classifyMediaUrl sees one image; only the tweet lookup knows how many - // the post carried. - imageCount: source.kind === 'x' ? photoCount : outcome.imageCount + imageCount }); }; diff --git a/src/routes/api/admin/tag-suggestions/server.test.ts b/src/routes/api/admin/tag-suggestions/server.test.ts index da49cda9..a3a4e53c 100644 --- a/src/routes/api/admin/tag-suggestions/server.test.ts +++ b/src/routes/api/admin/tag-suggestions/server.test.ts @@ -174,6 +174,25 @@ describe('POST /api/admin/tag-suggestions', () => { } }); + it('accepts a body of exactly the cap and refuses one byte more', async () => { + const { platform } = makeEnv(); + // ASCII throughout, so characters are bytes. Pad to the cap exactly. + const shell = JSON.stringify({ sourcePostUrl: BSKY_POST, pad: '' }); + const atCap = JSON.stringify({ sourcePostUrl: BSKY_POST, pad: 'x'.repeat(4096 - shell.length) }); + expect(new TextEncoder().encode(atCap).length).toBe(4096); + expect((await POST(event(platform, undefined, atCap))).status).toBe(200); + + const overCap = JSON.stringify({ sourcePostUrl: BSKY_POST, pad: 'x'.repeat(4097 - shell.length) }); + expect(new TextEncoder().encode(overCap).length).toBe(4097); + const res = await POST(event(platform, undefined, overCap)); + expect(res.status).toBe(400); + expect(await res.json()).toEqual({ error: 'invalid_request' }); + // Multi-byte characters count as bytes, not characters: 2048 two-byte + // characters fit in the pad's character budget but not its byte budget. + const wide = JSON.stringify({ sourcePostUrl: BSKY_POST, pad: 'é'.repeat(2048) }); + expect((await POST(event(platform, undefined, wide))).status).toBe(400); + }); + it('200s with no tags when the classifier found nothing to suggest', async () => { const { platform } = makeEnv(); // The classifier read the post and rated it; nothing cleared the From 8d247e9d42ebd07e6b42f9e19cd64be6d4c537b1 Mon Sep 17 00:00:00 2001 From: Sparky <1609870+sparkyfen@users.noreply.github.com> Date: Mon, 7 Sep 2026 20:18:02 -0700 Subject: [PATCH 09/22] fix(admin): review round 4 for the entail.dev tag suggestion client (SONA-220) - Fix the upgrade note so owners replace their X and Bluesky entry rather than add a duplicate, and name entail.dev as an image classifier in the privacy policy and AI policy. - One 20 second deadline per lookup, threaded into every outbound call. - Take the validated source into the Bluesky lookup so the actor is never decoded twice, and refuse double-encoded actors up front. - Accept a done poll body without a status field, per the spec. - Share the X GraphQL header builder and route every catch through errorLabel. - Tests for the job id encoding, the enqueue body, the retry headers, and the deadline. --- AI_POLICY.md | 8 +-- UPDATING.md | 15 ++-- src/lib/legal.test.ts | 6 +- src/lib/legal.ts | 2 +- src/lib/server/entail.test.ts | 63 ++++++++++++++-- src/lib/server/entail.ts | 36 +++++++--- src/lib/server/fetch-errors.ts | 8 +++ src/lib/server/twitter-avatar.ts | 41 +++++++---- src/lib/server/twitter-media.test.ts | 12 ++++ src/lib/server/twitter-media.ts | 37 +++++----- .../api/admin/tag-suggestions/+server.ts | 17 +++-- .../api/admin/tag-suggestions/server.test.ts | 72 +++++++++++++------ 12 files changed, 230 insertions(+), 87 deletions(-) diff --git a/AI_POLICY.md b/AI_POLICY.md index 1dfad408..75f1e37a 100644 --- a/AI_POLICY.md +++ b/AI_POLICY.md @@ -24,10 +24,10 @@ second AI model reads the first one's work, and CodeRabbit, a third-party service, reviews the diff. A human approves every merge and clicks every deploy. There is no review team here, just one maintainer and a set of tools. -The running site makes one AI call of its own: when the operator asks for tag -suggestions on a piece of artwork, the site sends a public link to the source -post, or to the picture in it, to entail.dev, an image classifier. Nothing a -visitor does reaches an AI service. +The running site makes one AI call of its own. When the operator asks for tag +suggestions on a piece of artwork, the site sends entail.dev, an image +classifier, a public link to the artwork's source post or to the picture in +that post. Nothing a visitor does reaches an AI service. ## Rules the agents work under diff --git a/UPDATING.md b/UPDATING.md index 262d667c..e59c2494 100644 --- a/UPDATING.md +++ b/UPDATING.md @@ -57,13 +57,14 @@ Bluesky post that link is the post itself. For an X post the site first asks X's own API which picture the post carries, then sends the picture link X hands back. Nothing from either reply is stored; the suggestions are yours to accept or drop. -The built-in privacy policy and the `/ai` page now name both services. **If you -pasted your own privacy text or your own `/ai` text in Settings, neither was -updated**, and your pages will not mention services your site now contacts. Add -these to your privacy policy's list of feature integrations: - -- `X (formerly Twitter) (fetching the profile pictures shown on this site, and finding the picture in a post)` -- `entail.dev (suggesting tags for artwork from its source post or the picture in it)` +The built-in privacy policy and the `/ai` page describe this call already. If +you pasted your own privacy text or your own `/ai` text in Settings, **neither +was updated**, and your pages will not mention services your site now contacts. +In your privacy policy's list of feature integrations, replace your X and +Bluesky entry with the first line below and add the second: + +- `X (formerly Twitter) and Bluesky (fetching the profile pictures shown on this site; X is also asked which picture a post carries)` +- `entail.dev (an image classifier that suggests tags for artwork from its source post or the picture in it)` And add this to your `/ai` text: "The site calls an AI service in one place. When the site owner asks for tag suggestions on a piece of artwork, the site sends a diff --git a/src/lib/legal.test.ts b/src/lib/legal.test.ts index 7b9ea4f7..539f3280 100644 --- a/src/lib/legal.test.ts +++ b/src/lib/legal.test.ts @@ -181,9 +181,9 @@ describe('defaultPrivacyPolicy', () => { // The integrations list reads exhaustive, so it must actually be: every // remote service a feature calls out to is named (SONA-167 round 1). // Picture lookup is an X-only ask; Bluesky posts go to entail.dev whole. - expect(text).toMatch(/X \(formerly Twitter\) \(fetching the profile pictures shown on this site, and finding the picture in a post\), Bluesky \(fetching the profile pictures shown on this site\)/); + expect(text).toMatch(/X \(formerly Twitter\) and Bluesky \(fetching the profile pictures shown on this site; X is also asked which picture a post carries\)/); // SONA-220: the tag-suggestion lookup sends a post or picture URL to entail.dev. - expect(text).toMatch(/entail.dev \(suggesting tags for artwork from its source post or the picture in it\)/); + expect(text).toMatch(/entail.dev \(an image classifier that suggests tags for artwork from its source post or the picture in it\)/); expect(text).toContain('FurTrack'); expect(text).toMatch(/shared artist registry/); }); @@ -296,7 +296,7 @@ describe('LEGAL_DEFAULTS_UPDATED tracks the default text', () => { // privacy page would show a "Last updated" line older than its own text. // Deliberately two assertions, not a diff — the point is to force the date // bump, not to review the prose. - const RECORDED_TEXT_HASH = '36982486de4edbea612682e28801fee08ecbccf41ec0942a32370daf5a9b0910'; + const RECORDED_TEXT_HASH = '3920a2040e2b5aa90608844acd275dcbb92b69439da22d1e07e8d7ec403cddfd'; const RECORDED_UPDATED = '2026-09-07'; function defaultsText(): string { diff --git a/src/lib/legal.ts b/src/lib/legal.ts index a8a30ab9..67a152f5 100644 --- a/src/lib/legal.ts +++ b/src/lib/legal.ts @@ -156,7 +156,7 @@ export function defaultPrivacyPolicy(opts: LegalOptions): LegalSection[] { : [ "For this site those tools are Anthropic's Claude, which writes and debugs code under the developer's direction, and CodeRabbit, a code review service that reads proposed changes." ]), - "For specific features the site also talks to Cloudflare Turnstile (bot protection on the sign-in page), Telegram (importing sticker packs), cons.fyi (convention listings), X (formerly Twitter) (fetching the profile pictures shown on this site, and finding the picture in a post), Bluesky (fetching the profile pictures shown on this site), entail.dev (suggesting tags for artwork from its source post or the picture in it), FurTrack (importing fursuit photos), and the shared artist registry (syncing artist credits; the registry receives this site's name and hostname as part of the sync). The site contacts these services to run the feature; they are not used to track visitors." + "For specific features the site also talks to Cloudflare Turnstile (bot protection on the sign-in page), Telegram (importing sticker packs), cons.fyi (convention listings), X (formerly Twitter) and Bluesky (fetching the profile pictures shown on this site; X is also asked which picture a post carries), entail.dev (an image classifier that suggests tags for artwork from its source post or the picture in it), FurTrack (importing fursuit photos), and the shared artist registry (syncing artist credits; the registry receives this site's name and hostname as part of the sync). The site contacts these services to run the feature; they are not used to track visitors." ] }, { diff --git a/src/lib/server/entail.test.ts b/src/lib/server/entail.test.ts index 9133342e..08f489d0 100644 --- a/src/lib/server/entail.test.ts +++ b/src/lib/server/entail.test.ts @@ -7,6 +7,7 @@ import { classifySourceUrl, classifyMediaUrl, lookupBlueskyPost, + lookupBlueskySource, suggestionsFromResult, translateTag } from './entail'; @@ -40,6 +41,10 @@ describe('classifySourceUrl', () => { // An encoded slash passes the raw-actor regex but decodes into a path // separator inside the canonical URL. expect(classifySourceUrl('https://bsky.app/profile/a%2f..%2fx/post/3abc')).toBeNull(); + // A double-encoded actor decodes to one that still carries a `%`; the + // endpoint used to decode that again downstream. + expect(classifySourceUrl('https://bsky.app/profile/a%252Fb/post/3abc')).toBeNull(); + expect(classifySourceUrl('https://bsky.app/profile/foo%252ebar/post/3abc')).toBeNull(); }); it('accepts the x/twitter status shapes and canonicalises them', () => { @@ -228,6 +233,23 @@ describe('lookupBlueskyPost', () => { expect(fetchImpl).not.toHaveBeenCalled(); }); + it('sends a validated source as-is through lookupBlueskySource', async () => { + const fetchImpl = vi.fn(async (_url: string | URL | Request) => json(post)); + const source = { kind: 'bluesky' as const, url: 'https://bsky.app/profile/did:plc:aaaa/post/3abc' }; + expect((await lookupBlueskySource(source, fetchImpl)).ok).toBe(true); + expect(String(fetchImpl.mock.calls[0]?.[0])).toContain(encodeURIComponent(source.url)); + }); + + it('returns unavailable without fetching when the deadline has already passed', async () => { + const fetchImpl = vi.fn(async () => json(post)); + const source = { kind: 'bluesky' as const, url }; + expect(await lookupBlueskySource(source, fetchImpl, AbortSignal.abort())).toEqual({ + ok: false, + reason: 'unavailable' + }); + expect(fetchImpl).not.toHaveBeenCalled(); + }); + it('names the reason a lookup produced nothing', async () => { expect(await lookupBlueskyPost(url, vi.fn(async () => json({}, 202)))).toEqual({ ok: false, @@ -323,18 +345,51 @@ describe('classifyMediaUrl', () => { it('enqueues then polls until the job is done', async () => { let polls = 0; + const mediaUrl = 'https://pbs.twimg.com/media/abc?format=jpg'; const fetchImpl = vi.fn(async (url: string | URL | Request, init?: RequestInit) => { - if (init?.method === 'POST') return json({ job_id: 'job-1', status: 'enqueued' }, 202); + if (init?.method === 'POST') { + expect(new Headers(init.headers).get('content-type')).toBe('application/json'); + expect(JSON.parse(String(init.body))).toEqual({ url: mediaUrl }); + return json({ job_id: 'job-1', status: 'enqueued' }, 202); + } polls++; expect(String(url)).toContain('/classify/job-1?wait=true'); return polls === 1 ? json({ status: 'processing' }, 202) : json(done); }); - expect(await classifyMediaUrl('https://pbs.twimg.com/media/abc?format=jpg', fetchImpl)).toEqual( - success - ); + expect(await classifyMediaUrl(mediaUrl, fetchImpl)).toEqual(success); expect(polls).toBe(2); }); + it('encodes the job id into the poll path', async () => { + let polled = ''; + const fetchImpl = vi.fn(async (url: string | URL | Request, init?: RequestInit) => { + if (init?.method === 'POST') return json({ job_id: '../post' }, 202); + polled = String(url); + return json(done); + }); + await classifyMediaUrl(url, fetchImpl); + expect(polled.startsWith('https://entail.dev/api/classify/')).toBe(true); + expect(polled).toContain('%2F'); + }); + + it('accepts a 200 poll body that carries no status field', async () => { + const { status: _status, ...bare } = done; + const fetchImpl = vi.fn(async (_url: string | URL | Request, init?: RequestInit) => + init?.method === 'POST' ? json({ job_id: 'job-8' }, 202) : json(bare) + ); + expect(await classifyMediaUrl(url, fetchImpl)).toEqual(success); + expect(fetchImpl).toHaveBeenCalledTimes(2); + }); + + it('returns unavailable without fetching when the deadline has already passed', async () => { + const fetchImpl = vi.fn(async () => json(done)); + expect(await classifyMediaUrl(url, fetchImpl, AbortSignal.abort())).toEqual({ + ok: false, + reason: 'unavailable' + }); + expect(fetchImpl).not.toHaveBeenCalled(); + }); + it('accepts cdn.bsky.app too', async () => { const fetchImpl = vi.fn(async (_url: string | URL | Request, init?: RequestInit) => init?.method === 'POST' ? json({ job_id: 'job-2' }, 202) : json(done) diff --git a/src/lib/server/entail.ts b/src/lib/server/entail.ts index 52d4ebf9..c16e769b 100644 --- a/src/lib/server/entail.ts +++ b/src/lib/server/entail.ts @@ -12,7 +12,7 @@ // resolves to a failed outcome and the caller carries on without suggestions. // Third-party response bodies are never logged and never stored. -import { errorLabel } from './fetch-errors'; +import { errorLabel, timeoutSignal } from './fetch-errors'; import { sanitizeTag } from './validate'; const ENTAIL_POST = 'https://entail.dev/api/post'; @@ -77,7 +77,9 @@ export type ClassificationEntry = { * the URL. */ export type SourceKind = { kind: 'bluesky'; url: string } | { kind: 'x'; url: string; id: string }; -const BLUESKY_ACTOR = /^[A-Za-z0-9._:%-]{1,256}$/; +// Checked after percent-decoding, so a `%` that survives (a double-encoded +// actor) is rejected rather than decoded again downstream. +const BLUESKY_ACTOR = /^[A-Za-z0-9._:-]{1,256}$/; const BLUESKY_RKEY = /^[A-Za-z0-9._~-]{1,64}$/; const X_USER = /^[A-Za-z0-9_]{1,15}$/; const STATUS_ID = /^\d{1,20}$/; @@ -196,14 +198,28 @@ function postImages(body: unknown): ClassificationEntry[] | null { */ export async function lookupBlueskyPost( url: string, - fetchImpl: typeof fetch = fetch + fetchImpl: typeof fetch = fetch, + signal?: AbortSignal ): Promise { const source = classifySourceUrl(url); if (!source || source.kind !== 'bluesky') return fail('unavailable'); + return lookupBlueskySource(source, fetchImpl, signal); +} +/** + * The same lookup for a source classifySourceUrl has already validated. The + * endpoint calls this directly so the canonical URL is not run through the + * classifier (and percent-decoded) a second time. Never throws. + */ +export async function lookupBlueskySource( + source: Extract, + fetchImpl: typeof fetch = fetch, + signal?: AbortSignal +): Promise { const endpoint = `${ENTAIL_POST}?url=${encodeURIComponent(source.url)}&min_confidence=${DEFAULT_CONFIDENCE_FLOOR}&wait=true`; try { - const res = await fetchImpl(endpoint, { signal: AbortSignal.timeout(POST_TIMEOUT_MS) }); + signal?.throwIfAborted(); + const res = await fetchImpl(endpoint, { signal: timeoutSignal(POST_TIMEOUT_MS, signal) }); if (res.status === 202) { // Queued for classification. Best effort: no retry loop here — the // caller decides whether to ask again. @@ -265,16 +281,18 @@ const pause = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms)); */ export async function classifyMediaUrl( url: string, - fetchImpl: typeof fetch = fetch + fetchImpl: typeof fetch = fetch, + signal?: AbortSignal ): Promise { if (!isAllowedMediaHost(url)) return fail('unavailable'); try { + signal?.throwIfAborted(); const enqueued = await fetchImpl(ENTAIL_CLASSIFY, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ url }), - signal: AbortSignal.timeout(CLASSIFY_TIMEOUT_MS) + signal: timeoutSignal(CLASSIFY_TIMEOUT_MS, signal) }); if (enqueued.status === 429) { console.warn('[entail] classify enqueue rate limited: status=429'); @@ -293,7 +311,7 @@ export async function classifyMediaUrl( const poll = `${ENTAIL_CLASSIFY}/${encodeURIComponent(jobId)}?wait=true`; for (let attempt = 0; attempt < POLL_ATTEMPTS; attempt++) { if (attempt > 0) await pause(POLL_PAUSE_MS); - const res = await fetchImpl(poll, { signal: AbortSignal.timeout(POLL_TIMEOUT_MS) }); + const res = await fetchImpl(poll, { signal: timeoutSignal(POLL_TIMEOUT_MS, signal) }); if (res.status === 202) continue; if (res.status === 429) { console.warn('[entail] classify poll rate limited: status=429'); @@ -303,8 +321,10 @@ export async function classifyMediaUrl( console.warn(`[entail] classify poll failed: status=${res.status}`); return fail('unavailable'); } + // The spec documents a 200 as "job done" and does not type a status + // field, so only an explicit contradiction sends us back to poll. const body = (await res.json()) as (ClassificationEntry & { status?: unknown }) | null; - if (body?.status !== 'done') continue; + if (body?.status && body.status !== 'done') continue; return { ok: true, suggestions: suggestionsFromResult(body), imageCount: 1 }; } console.warn(`[entail] classify job unfinished after ${POLL_ATTEMPTS} polls`); diff --git a/src/lib/server/fetch-errors.ts b/src/lib/server/fetch-errors.ts index ac6cc8d3..303600be 100644 --- a/src/lib/server/fetch-errors.ts +++ b/src/lib/server/fetch-errors.ts @@ -6,3 +6,11 @@ export function errorLabel(e: unknown): string { if (e instanceof SyntaxError) return e.name; return e instanceof Error ? e.message : String(e); } + +/** A per-call timeout, joined with the caller's overall deadline when it has + * one, so a chain of fail-soft fetches cannot outlive the request that asked + * for them. */ +export function timeoutSignal(ms: number, signal?: AbortSignal): AbortSignal { + const timeout = AbortSignal.timeout(ms); + return signal ? AbortSignal.any([timeout, signal]) : timeout; +} diff --git a/src/lib/server/twitter-avatar.ts b/src/lib/server/twitter-avatar.ts index 2a784525..1656af3a 100644 --- a/src/lib/server/twitter-avatar.ts +++ b/src/lib/server/twitter-avatar.ts @@ -10,6 +10,8 @@ // and fail-soft: any error resolves to null and the save proceeds without an // avatar. Registry-linked artists get theirs through the registry instead. +import { errorLabel, timeoutSignal } from './fetch-errors'; + // X web client's public bearer (shipped to every browser) — not a secret. // Exported so twitter-media.ts can reuse the same guest-token flow. export const X_BEARER = @@ -53,13 +55,17 @@ export function to400x400(url: string): string { } /** Activate a guest token against the public web bearer. Shared with - * twitter-media.ts; `fetchImpl` is only for tests. Never throws. */ -export async function activateGuestToken(fetchImpl: typeof fetch = fetch): Promise { + * twitter-media.ts; `fetchImpl` is only for tests, `signal` is the caller's + * overall deadline. Never throws. */ +export async function activateGuestToken( + fetchImpl: typeof fetch = fetch, + signal?: AbortSignal +): Promise { try { const res = await fetchImpl(X_ACTIVATE, { method: 'POST', headers: { Authorization: X_BEARER, 'User-Agent': X_USER_AGENT }, - signal: AbortSignal.timeout(FETCH_TIMEOUT_MS) + signal: timeoutSignal(FETCH_TIMEOUT_MS, signal) }); if (!res.ok) { // A non-2xx here (esp. 403) is the signal that X is blocking Workers egress — @@ -70,25 +76,32 @@ export async function activateGuestToken(fetchImpl: typeof fetch = fetch): Promi const body = (await res.json()) as { guest_token?: unknown }; return typeof body.guest_token === 'string' ? body.guest_token : null; } catch (e) { - console.warn(`[avatar] twitter guest-token activation error: ${e instanceof Error ? e.message : String(e)}`); + console.warn(`[avatar] twitter guest-token activation error: ${errorLabel(e)}`); return null; } } -async function userLookup(handle: string, guestToken: string): Promise { +/** The header set every X GraphQL call carries: the bearer, the guest token, + * and a fresh random csrf value mirrored into the cookie the way the web + * client does it. Shared with twitter-media.ts. */ +export function xGraphqlHeaders(guestToken: string): Record { const csrf = [...crypto.getRandomValues(new Uint8Array(16))] .map((b) => b.toString(16).padStart(2, '0')) .join(''); + return { + Authorization: X_BEARER, + 'User-Agent': X_USER_AGENT, + 'x-guest-token': guestToken, + 'x-csrf-token': csrf, + 'x-twitter-active-user': 'yes', + Cookie: `guest_id=v1%3A${guestToken}; ct0=${csrf};` + }; +} + +async function userLookup(handle: string, guestToken: string): Promise { const variables = encodeURIComponent(JSON.stringify({ screen_name: handle })); return fetch(`${X_USER_BY_SCREEN_NAME}?variables=${variables}`, { - headers: { - Authorization: X_BEARER, - 'User-Agent': X_USER_AGENT, - 'x-guest-token': guestToken, - 'x-csrf-token': csrf, - 'x-twitter-active-user': 'yes', - Cookie: `guest_id=v1%3A${guestToken}; ct0=${csrf};` - }, + headers: xGraphqlHeaders(guestToken), signal: AbortSignal.timeout(FETCH_TIMEOUT_MS) }); } @@ -120,7 +133,7 @@ export async function fetchTwitterAvatar(twitterUrl: string): Promise { expect(outcome.ok && outcome.url).toContain('AbCdEf123'); expect(activations.count).toBe(2); expect(tokens).toEqual(['gt-1', 'gt-2']); + for (const [, init] of fetchImpl.mock.calls) { + expect(new Headers(init?.headers).get('user-agent')).toBe(X_USER_AGENT); + } }); it('retries once with a fresh token on 429', async () => { @@ -152,6 +155,15 @@ describe('fetchTweetMediaUrl', () => { expect(outcome.ok && outcome.url).toContain('AbCdEf123'); expect(activations.count).toBe(2); expect(tokens).toEqual(['gt-1', 'gt-2']); + for (const [, init] of fetchImpl.mock.calls) { + expect(new Headers(init?.headers).get('user-agent')).toBe(X_USER_AGENT); + } + }); + + it('returns unavailable without fetching when the deadline has already passed', async () => { + const { fetchImpl } = stub(() => json(tweetWith([photo]))); + expect(await fetchTweetMediaUrl(id, fetchImpl, AbortSignal.abort())).toEqual(unavailable); + expect(fetchImpl).not.toHaveBeenCalled(); }); it('reports a rate limit that survives the retry', async () => { diff --git a/src/lib/server/twitter-media.ts b/src/lib/server/twitter-media.ts index 9e91632b..eaedbfbc 100644 --- a/src/lib/server/twitter-media.ts +++ b/src/lib/server/twitter-media.ts @@ -10,8 +10,8 @@ // proceeds without a media URL. Videos and GIFs are skipped — only photos // resolve. -import { errorLabel } from './fetch-errors'; -import { X_BEARER, X_USER_AGENT, activateGuestToken } from './twitter-avatar'; +import { errorLabel, timeoutSignal } from './fetch-errors'; +import { activateGuestToken, xGraphqlHeaders } from './twitter-avatar'; const X_TWEET_BY_REST_ID = 'https://api.x.com/graphql/f2sagi1jweVHFkTUIHzmMQ/TweetResultByRestId'; const FETCH_TIMEOUT_MS = 5000; @@ -112,10 +112,12 @@ export function parseTweetPhotos(body: unknown): TweetPhotos | null { return first ? { url: first, photoCount: Math.min(photoCount, MAX_TWEET_PHOTOS) } : null; } -function tweetLookup(tweetId: string, guestToken: string, fetchImpl: typeof fetch): Promise { - const csrf = [...crypto.getRandomValues(new Uint8Array(16))] - .map((b) => b.toString(16).padStart(2, '0')) - .join(''); +function tweetLookup( + tweetId: string, + guestToken: string, + fetchImpl: typeof fetch, + signal?: AbortSignal +): Promise { const variables = encodeURIComponent( JSON.stringify({ tweetId, @@ -129,15 +131,8 @@ function tweetLookup(tweetId: string, guestToken: string, fetchImpl: typeof fetc return fetchImpl( `${X_TWEET_BY_REST_ID}?variables=${variables}&features=${features}&fieldToggles=${fieldToggles}`, { - headers: { - Authorization: X_BEARER, - 'User-Agent': X_USER_AGENT, - 'x-guest-token': guestToken, - 'x-csrf-token': csrf, - 'x-twitter-active-user': 'yes', - Cookie: `guest_id=v1%3A${guestToken}; ct0=${csrf};` - }, - signal: AbortSignal.timeout(FETCH_TIMEOUT_MS) + headers: xGraphqlHeaders(guestToken), + signal: timeoutSignal(FETCH_TIMEOUT_MS, signal) } ); } @@ -149,19 +144,21 @@ function tweetLookup(tweetId: string, guestToken: string, fetchImpl: typeof fetc * throws. */ export async function fetchTweetMediaUrl( tweetId: string, - fetchImpl: typeof fetch = fetch + fetchImpl: typeof fetch = fetch, + signal?: AbortSignal ): Promise { try { - let token = await activateGuestToken(fetchImpl); + signal?.throwIfAborted(); + let token = await activateGuestToken(fetchImpl, signal); if (!token) return fail('unavailable'); - let res = await tweetLookup(tweetId, token, fetchImpl); + let res = await tweetLookup(tweetId, token, fetchImpl, signal); if (res.status === 401 || res.status === 429) { // If X was already rate limiting us and now refuses a fresh token // too, that is still a rate limit, not an outage. const limited = res.status === 429; - token = await activateGuestToken(fetchImpl); + token = await activateGuestToken(fetchImpl, signal); if (!token) return fail(limited ? 'rate_limited' : 'unavailable'); - res = await tweetLookup(tweetId, token, fetchImpl); + res = await tweetLookup(tweetId, token, fetchImpl, signal); } if (res.status === 429) { console.warn('[avatar] tweet media lookup rate limited: status=429'); diff --git a/src/routes/api/admin/tag-suggestions/+server.ts b/src/routes/api/admin/tag-suggestions/+server.ts index 817f0cf2..37e4c80b 100644 --- a/src/routes/api/admin/tag-suggestions/+server.ts +++ b/src/routes/api/admin/tag-suggestions/+server.ts @@ -5,7 +5,7 @@ import { images } from '$lib/server/db/schema'; import { classifySourceUrl, classifyMediaUrl, - lookupBlueskyPost, + lookupBlueskySource, type LookupFailure, type LookupOutcome } from '$lib/server/entail'; @@ -44,6 +44,10 @@ const FAILURE_STATUS: Record = { }; const MAX_URL_LENGTH = 2048; +/** Ceiling on the whole lookup chain. The X path is three fetches plus an + * enqueue and two polls, each with its own timeout, so without this the worst + * case ran close to forty seconds. */ +const LOOKUP_DEADLINE_MS = 20_000; /** Read before parsing: a valid body is a short object with one field, so * anything past this is refused without handing it to JSON.parse. */ const MAX_BODY_BYTES = 4096; @@ -100,8 +104,13 @@ export const POST: RequestHandler = async ({ request, platform }) => { // How many images the post carried. Only the Bluesky lookup and the tweet // lookup see the post; classifyMediaUrl sees one image. let imageCount: number; + // One deadline for every outbound call below; each lookup returns + // `unavailable` when it fires. + const signal = AbortSignal.timeout(LOOKUP_DEADLINE_MS); if (source.kind === 'bluesky') { - outcome = await lookupBlueskyPost(source.url); + // The validated source goes straight in, so the canonical URL is not + // percent-decoded a second time by a re-run of classifySourceUrl. + outcome = await lookupBlueskySource(source, fetch, signal); if (!outcome.ok) return failure(outcome.reason); imageCount = outcome.imageCount; } else { @@ -109,9 +118,9 @@ export const POST: RequestHandler = async ({ request, platform }) => { // from its image. X's API is the only thing that knows which image that // is, and it hands back a pbs.twimg.com URL — one of the two hosts // classifyMediaUrl will send on. Only the validated status id goes out. - const media = await fetchTweetMediaUrl(source.id); + const media = await fetchTweetMediaUrl(source.id, fetch, signal); if (!media.ok) return failure(media.reason); - outcome = await classifyMediaUrl(media.url); + outcome = await classifyMediaUrl(media.url, fetch, signal); if (!outcome.ok) return failure(outcome.reason); imageCount = media.photoCount; } diff --git a/src/routes/api/admin/tag-suggestions/server.test.ts b/src/routes/api/admin/tag-suggestions/server.test.ts index a3a4e53c..6edbc90c 100644 --- a/src/routes/api/admin/tag-suggestions/server.test.ts +++ b/src/routes/api/admin/tag-suggestions/server.test.ts @@ -2,26 +2,41 @@ import { describe, it, expect, vi, beforeEach } from 'vitest'; // better-sqlite3 ships no bundled types and is a dev-only test dependency here. // @ts-expect-error - no declaration file for 'better-sqlite3' import Database from 'better-sqlite3'; -import type { LookupOutcome } from '$lib/server/entail'; +import type { LookupOutcome, SourceKind } from '$lib/server/entail'; import type { TweetMediaOutcome } from '$lib/server/twitter-media'; import { makeD1 } from '$lib/server/test/d1'; import { POST } from './+server'; // Only the outbound calls are stubbed. classifySourceUrl stays real, so the // URL recognition the endpoint depends on is exercised rather than mocked. -const lookupBlueskyPost = vi.hoisted(() => - vi.fn(async (_url: string): Promise => ({ ok: false, reason: 'unavailable' })) +const lookupBlueskySource = vi.hoisted(() => + vi.fn( + async (_source: SourceKind, _fetch?: typeof fetch, _signal?: AbortSignal): Promise => ({ + ok: false, + reason: 'unavailable' + }) + ) ); const classifyMediaUrl = vi.hoisted(() => - vi.fn(async (_url: string): Promise => ({ ok: false, reason: 'unavailable' })) + vi.fn( + async (_url: string, _fetch?: typeof fetch, _signal?: AbortSignal): Promise => ({ + ok: false, + reason: 'unavailable' + }) + ) ); const fetchTweetMediaUrl = vi.hoisted(() => - vi.fn(async (_id: string): Promise => ({ ok: false, reason: 'unavailable' })) + vi.fn( + async (_id: string, _fetch?: typeof fetch, _signal?: AbortSignal): Promise => ({ + ok: false, + reason: 'unavailable' + }) + ) ); vi.mock('$lib/server/entail', async (importOriginal) => { const original = await importOriginal(); - return { ...original, lookupBlueskyPost, classifyMediaUrl }; + return { ...original, lookupBlueskySource, classifyMediaUrl }; }); vi.mock('$lib/server/twitter-media', () => ({ fetchTweetMediaUrl })); @@ -66,10 +81,10 @@ function event(platform: App.Platform, body: unknown, raw?: string) { } beforeEach(() => { - lookupBlueskyPost.mockReset(); + lookupBlueskySource.mockReset(); classifyMediaUrl.mockReset(); fetchTweetMediaUrl.mockReset(); - lookupBlueskyPost.mockResolvedValue(suggestions); + lookupBlueskySource.mockResolvedValue(suggestions); classifyMediaUrl.mockResolvedValue(suggestions); fetchTweetMediaUrl.mockResolvedValue({ ok: true, url: MEDIA_URL, photoCount: 1 }); }); @@ -85,8 +100,12 @@ describe('POST /api/admin/tag-suggestions', () => { rating: 'safe', imageCount: 3 }); - // The canonical URL, not the caller's string. - expect(lookupBlueskyPost).toHaveBeenCalledWith(BSKY_POST); + // The validated source, not the caller's string. + expect(lookupBlueskySource).toHaveBeenCalledWith( + { kind: 'bluesky', url: BSKY_POST }, + fetch, + expect.any(AbortSignal) + ); expect(fetchTweetMediaUrl).not.toHaveBeenCalled(); }); @@ -101,11 +120,13 @@ describe('POST /api/admin/tag-suggestions', () => { // The tweet's photo count, not whatever classifyMediaUrl reports. imageCount: 1 }); - // Only the validated status id goes to X, never the caller's string. - expect(fetchTweetMediaUrl).toHaveBeenCalledWith(X_ID); + // Only the validated status id goes to X, never the caller's string, and + // both calls share the endpoint's one deadline. + expect(fetchTweetMediaUrl).toHaveBeenCalledWith(X_ID, fetch, expect.any(AbortSignal)); // The media URL is what reaches entail.dev — never the tweet URL. - expect(classifyMediaUrl).toHaveBeenCalledWith(MEDIA_URL); - expect(lookupBlueskyPost).not.toHaveBeenCalled(); + expect(classifyMediaUrl).toHaveBeenCalledWith(MEDIA_URL, fetch, expect.any(AbortSignal)); + expect(classifyMediaUrl.mock.calls[0]?.[2]).toBe(fetchTweetMediaUrl.mock.calls[0]?.[2]); + expect(lookupBlueskySource).not.toHaveBeenCalled(); }); it('reports how many photos a multi-photo tweet carried', async () => { @@ -123,7 +144,11 @@ describe('POST /api/admin/tag-suggestions', () => { const res = await POST(event(platform, { imageId: 7 })); expect(res.status).toBe(200); expect((await res.json()).source).toBe('bluesky'); - expect(lookupBlueskyPost).toHaveBeenCalledWith(BSKY_POST); + expect(lookupBlueskySource).toHaveBeenCalledWith( + { kind: 'bluesky', url: BSKY_POST }, + fetch, + expect.any(AbortSignal) + ); }); it('404s an unknown imageId', async () => { @@ -131,7 +156,7 @@ describe('POST /api/admin/tag-suggestions', () => { const res = await POST(event(platform, { imageId: 99 })); expect(res.status).toBe(404); expect(await res.json()).toEqual({ error: 'not_found' }); - expect(lookupBlueskyPost).not.toHaveBeenCalled(); + expect(lookupBlueskySource).not.toHaveBeenCalled(); }); it('422s a source we have no classifier for, including a stored empty one', async () => { @@ -141,6 +166,9 @@ describe('POST /api/admin/tag-suggestions', () => { { sourcePostUrl: 'https://furaffinity.net/view/12345/' }, // A malformed percent sequence in the actor is unsupported, not a 500. { sourcePostUrl: 'https://bsky.app/profile/100%/post/3abc' }, + // A double-encoded actor is refused up front rather than decoded a + // second time on its way to entail.dev. + { sourcePostUrl: 'https://bsky.app/profile/a%252Fb/post/3abc' }, { sourcePostUrl: '' }, { imageId: 7 } ]) { @@ -148,7 +176,7 @@ describe('POST /api/admin/tag-suggestions', () => { expect(res.status).toBe(422); expect(await res.json()).toEqual({ error: 'unsupported_source' }); } - expect(lookupBlueskyPost).not.toHaveBeenCalled(); + expect(lookupBlueskySource).not.toHaveBeenCalled(); }); it('400s malformed and ambiguous request bodies', async () => { @@ -197,7 +225,7 @@ describe('POST /api/admin/tag-suggestions', () => { const { platform } = makeEnv(); // The classifier read the post and rated it; nothing cleared the // confidence floor. That is an answer, not a failure. - lookupBlueskyPost.mockResolvedValue({ + lookupBlueskySource.mockResolvedValue({ ok: true, suggestions: { tags: [], rating: 'safe' }, imageCount: 3 @@ -210,7 +238,7 @@ describe('POST /api/admin/tag-suggestions', () => { it('passes an imageCount of 0 through as a success', async () => { const { platform } = makeEnv(); // A post with no classified images: still 200, still zero, not a failure. - lookupBlueskyPost.mockResolvedValue({ + lookupBlueskySource.mockResolvedValue({ ok: true, suggestions: { tags: [], rating: null }, imageCount: 0 @@ -222,7 +250,7 @@ describe('POST /api/admin/tag-suggestions', () => { it('502s not_ready when the post is queued but unclassified', async () => { const { platform } = makeEnv(); - lookupBlueskyPost.mockResolvedValue({ ok: false, reason: 'not_ready' }); + lookupBlueskySource.mockResolvedValue({ ok: false, reason: 'not_ready' }); const res = await POST(event(platform, { sourcePostUrl: BSKY_POST })); expect(res.status).toBe(502); expect(await res.json()).toEqual({ error: 'not_ready' }); @@ -230,7 +258,7 @@ describe('POST /api/admin/tag-suggestions', () => { it('502s unavailable for a failed lookup and for an unresolvable tweet', async () => { const { platform } = makeEnv(); - lookupBlueskyPost.mockResolvedValue({ ok: false, reason: 'unavailable' }); + lookupBlueskySource.mockResolvedValue({ ok: false, reason: 'unavailable' }); const bsky = await POST(event(platform, { sourcePostUrl: BSKY_POST })); expect(bsky.status).toBe(502); expect(await bsky.json()).toEqual({ error: 'unavailable' }); @@ -244,7 +272,7 @@ describe('POST /api/admin/tag-suggestions', () => { it('429s when entail.dev rate limited us', async () => { const { platform } = makeEnv(); - lookupBlueskyPost.mockResolvedValue({ ok: false, reason: 'rate_limited' }); + lookupBlueskySource.mockResolvedValue({ ok: false, reason: 'rate_limited' }); const res = await POST(event(platform, { sourcePostUrl: BSKY_POST })); expect(res.status).toBe(429); expect(await res.json()).toEqual({ error: 'rate_limited' }); From fbcc1d4567863bd96849a71e85935eac59cd4d4d Mon Sep 17 00:00:00 2001 From: Sparky <1609870+sparkyfen@users.noreply.github.com> Date: Mon, 7 Sep 2026 20:28:14 -0700 Subject: [PATCH 10/22] fix(admin): review round 5 for the entail.dev tag suggestion client (SONA-220) - Drop the unused string-taking Bluesky wrapper and the unused bearer export. - Reject a finished classify poll whose body is not a classification entry. - Tests for the deadline join, the csrf header and cookie mirror, and the poll shape guard. --- src/lib/server/entail.test.ts | 49 +++++++++++++++++++++------ src/lib/server/entail.ts | 34 +++++++++---------- src/lib/server/fetch-errors.test.ts | 19 ++++++++++- src/lib/server/twitter-avatar.test.ts | 15 +++++++- src/lib/server/twitter-avatar.ts | 3 +- 5 files changed, 87 insertions(+), 33 deletions(-) diff --git a/src/lib/server/entail.test.ts b/src/lib/server/entail.test.ts index 08f489d0..26f471fc 100644 --- a/src/lib/server/entail.test.ts +++ b/src/lib/server/entail.test.ts @@ -6,7 +6,6 @@ import { POST_TIMEOUT_MS, classifySourceUrl, classifyMediaUrl, - lookupBlueskyPost, lookupBlueskySource, suggestionsFromResult, translateTag @@ -202,8 +201,15 @@ describe('timeouts', () => { }); }); -describe('lookupBlueskyPost', () => { +describe('lookupBlueskySource', () => { const url = 'https://bsky.app/profile/did:plc:aaaa/post/3abc'; + // The endpoint validates the URL with classifySourceUrl and hands the + // result straight to lookupBlueskySource; this does the same in one step. + const lookupBlueskyPost = (postUrl: string, fetchImpl: typeof fetch, signal?: AbortSignal) => { + const source = classifySourceUrl(postUrl); + if (!source || source.kind !== 'bluesky') throw new Error(`not a bluesky post: ${postUrl}`); + return lookupBlueskySource(source, fetchImpl, signal); + }; const post = { uri: 'at://did:plc:aaaa/app.bsky.feed.post/3abc', images: [ @@ -224,15 +230,6 @@ describe('lookupBlueskyPost', () => { expect(requested).toContain('wait=true'); }); - it('fails without fetching for a non-bluesky URL', async () => { - const fetchImpl = vi.fn(async () => json(post)); - expect(await lookupBlueskyPost('https://x.com/examplefox/status/1', fetchImpl)).toEqual({ - ok: false, - reason: 'unavailable' - }); - expect(fetchImpl).not.toHaveBeenCalled(); - }); - it('sends a validated source as-is through lookupBlueskySource', async () => { const fetchImpl = vi.fn(async (_url: string | URL | Request) => json(post)); const source = { kind: 'bluesky' as const, url: 'https://bsky.app/profile/did:plc:aaaa/post/3abc' }; @@ -390,6 +387,36 @@ describe('classifyMediaUrl', () => { expect(fetchImpl).not.toHaveBeenCalled(); }); + it('is unavailable when the caller\'s deadline passes during a fetch', async () => { + // The per-call timeout is joined with the caller's signal, so an abort + // from outside reaches the in-flight fetch through init.signal. + const controller = new AbortController(); + const fetchImpl = vi.fn( + (_url: string | URL | Request, init?: RequestInit) => + new Promise((_resolve, reject) => { + init?.signal?.addEventListener('abort', () => reject(init.signal?.reason), { once: true }); + }) + ); + const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}); + const pending = classifyMediaUrl(url, fetchImpl, controller.signal); + controller.abort(new Error('caller deadline')); + expect(await pending).toEqual({ ok: false, reason: 'unavailable' }); + expect(fetchImpl).toHaveBeenCalledTimes(1); + // The caller's reason, not the per-call TimeoutError, is what ended it. + expect(warn.mock.calls.map((c) => c.join(' ')).join('\n')).toContain('caller deadline'); + }); + + it('is unavailable on a 200 poll body that is not a classification entry', async () => { + // null, a string, or an error envelope must not read as "no tags found". + const unavailable = { ok: false, reason: 'unavailable' }; + for (const body of [null, 'done', { detail: 'x' }]) { + const fetchImpl = vi.fn(async (_url: string | URL | Request, init?: RequestInit) => + init?.method === 'POST' ? json({ job_id: 'job-9' }, 202) : json(body) + ); + expect(await classifyMediaUrl(url, fetchImpl)).toEqual(unavailable); + } + }); + it('accepts cdn.bsky.app too', async () => { const fetchImpl = vi.fn(async (_url: string | URL | Request, init?: RequestInit) => init?.method === 'POST' ? json({ job_id: 'job-2' }, 202) : json(done) diff --git a/src/lib/server/entail.ts b/src/lib/server/entail.ts index c16e769b..961acff7 100644 --- a/src/lib/server/entail.ts +++ b/src/lib/server/entail.ts @@ -192,24 +192,10 @@ function postImages(body: unknown): ClassificationEntry[] | null { } /** - * Suggestions for a Bluesky post. Uses the post's first classified image; a - * post whose images entail.dev hasn't classified yet answers 202, which we - * treat as "nothing to suggest" rather than waiting around. Never throws. - */ -export async function lookupBlueskyPost( - url: string, - fetchImpl: typeof fetch = fetch, - signal?: AbortSignal -): Promise { - const source = classifySourceUrl(url); - if (!source || source.kind !== 'bluesky') return fail('unavailable'); - return lookupBlueskySource(source, fetchImpl, signal); -} - -/** - * The same lookup for a source classifySourceUrl has already validated. The - * endpoint calls this directly so the canonical URL is not run through the - * classifier (and percent-decoded) a second time. Never throws. + * Suggestions for a Bluesky post classifySourceUrl has already validated. + * Uses the post's first classified image; a post whose images entail.dev + * hasn't classified yet answers 202, which we treat as "nothing to suggest" + * rather than waiting around. Never throws. */ export async function lookupBlueskySource( source: Extract, @@ -274,6 +260,12 @@ function isAllowedMediaHost(url: string): boolean { const pause = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms)); +/** A finished `/classify/` body is an object with a `tags` array, the + * same way a `/post` body has an `images` array. */ +function pollEntry(body: unknown): body is ClassificationEntry { + return typeof body === 'object' && body !== null && Array.isArray((body as ClassificationEntry).tags); +} + /** * Suggestions for a single image URL on an allowlisted CDN: enqueue a * classification job, then poll it a few times. Gives up (`unavailable`) if @@ -325,6 +317,12 @@ export async function classifyMediaUrl( // field, so only an explicit contradiction sends us back to poll. const body = (await res.json()) as (ClassificationEntry & { status?: unknown }) | null; if (body?.status && body.status !== 'done') continue; + // A finished job carries a `tags` array. Anything else (null, a string, + // an error envelope) is a shape we don't know, not an empty result. + if (!pollEntry(body)) { + console.warn('[entail] classify poll returned an unexpected shape'); + return fail('unavailable'); + } return { ok: true, suggestions: suggestionsFromResult(body), imageCount: 1 }; } console.warn(`[entail] classify job unfinished after ${POLL_ATTEMPTS} polls`); diff --git a/src/lib/server/fetch-errors.test.ts b/src/lib/server/fetch-errors.test.ts index 65460c49..f483d022 100644 --- a/src/lib/server/fetch-errors.test.ts +++ b/src/lib/server/fetch-errors.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from 'vitest'; -import { errorLabel } from './fetch-errors'; +import { errorLabel, timeoutSignal } from './fetch-errors'; describe('errorLabel', () => { it('reduces a parse failure to its name and keeps everything else readable', () => { @@ -17,3 +17,20 @@ describe('errorLabel', () => { expect(errorLabel(42)).toBe('42'); }); }); + +describe('timeoutSignal', () => { + it('aborts when the caller\'s signal aborts, well before the timeout', () => { + const controller = new AbortController(); + const signal = timeoutSignal(60_000, controller.signal); + expect(signal.aborted).toBe(false); + controller.abort(); + expect(signal.aborted).toBe(true); + }); + + it('aborts from the timeout side while the caller\'s signal is still live', async () => { + const signal = timeoutSignal(5, new AbortController().signal); + await new Promise((resolve) => setTimeout(resolve, 50)); + expect(signal.aborted).toBe(true); + expect((signal.reason as Error).name).toBe('TimeoutError'); + }); +}); diff --git a/src/lib/server/twitter-avatar.test.ts b/src/lib/server/twitter-avatar.test.ts index 5ea61bb8..ee6908ae 100644 --- a/src/lib/server/twitter-avatar.test.ts +++ b/src/lib/server/twitter-avatar.test.ts @@ -4,7 +4,8 @@ import { twitterHandleFromUrl, parseUserAvatar, to400x400, - fetchTwitterAvatar + fetchTwitterAvatar, + xGraphqlHeaders } from './twitter-avatar'; describe('twitterHandleFromUrl', () => { @@ -53,6 +54,18 @@ describe('to400x400', () => { }); }); +describe('xGraphqlHeaders', () => { + it('mirrors a fresh csrf value into the cookie next to the guest id', () => { + const first = xGraphqlHeaders('gt-1'); + const csrf = first['x-csrf-token']; + expect(csrf).toMatch(/^[0-9a-f]{32}$/); + expect(first.Cookie).toContain(`ct0=${csrf}`); + expect(first.Cookie).toContain('guest_id=v1%3Agt-1'); + expect(first['x-guest-token']).toBe('gt-1'); + expect(xGraphqlHeaders('gt-1')['x-csrf-token']).not.toBe(csrf); + }); +}); + describe('fetchTwitterAvatar', () => { afterEach(() => { vi.unstubAllGlobals(); diff --git a/src/lib/server/twitter-avatar.ts b/src/lib/server/twitter-avatar.ts index 1656af3a..f23858bf 100644 --- a/src/lib/server/twitter-avatar.ts +++ b/src/lib/server/twitter-avatar.ts @@ -13,8 +13,7 @@ import { errorLabel, timeoutSignal } from './fetch-errors'; // X web client's public bearer (shipped to every browser) — not a secret. -// Exported so twitter-media.ts can reuse the same guest-token flow. -export const X_BEARER = +const X_BEARER = 'Bearer AAAAAAAAAAAAAAAAAAAAANRILgAAAAAAnNwIzUejRCOuH5E6I8xnZz4puTs%3D1Zv7ttfk8LF81IUq16cHjhLTvJu4FA33AGWWjCpTnA'; const X_ACTIVATE = 'https://api.x.com/1.1/guest/activate.json'; /** api.x.com answers 404 to Node's default `User-Agent: node` and 200 to any From 9c3b99d8d949878cbc2607b423add633f06c0294 Mon Sep 17 00:00:00 2001 From: Sparky <1609870+sparkyfen@users.noreply.github.com> Date: Mon, 7 Sep 2026 20:38:32 -0700 Subject: [PATCH 11/22] fix(admin): review round 6 polish for the entail.dev tag suggestion client (SONA-220) - Sort raw classifier entries by confidence before the scan cap. - Cap raw tag names before the qualifier regex; tighten the poll guard. - Report an unknown photo count when only the legacy media array is present. - Deadline floor test, comment fixes, and a no-rating poll case. --- src/lib/server/entail.test.ts | 64 +++++++++++++++++-- src/lib/server/entail.ts | 41 +++++++++--- src/lib/server/twitter-avatar.ts | 4 +- src/lib/server/twitter-media.test.ts | 8 ++- src/lib/server/twitter-media.ts | 15 +++-- .../api/admin/tag-suggestions/+server.ts | 15 +++-- .../api/admin/tag-suggestions/server.test.ts | 25 +++++++- 7 files changed, 141 insertions(+), 31 deletions(-) diff --git a/src/lib/server/entail.test.ts b/src/lib/server/entail.test.ts index 26f471fc..550a5a5b 100644 --- a/src/lib/server/entail.test.ts +++ b/src/lib/server/entail.test.ts @@ -122,6 +122,14 @@ describe('translateTag', () => { expect(tag).not.toMatch(/--|-$/); }); + it('returns quickly and null for a very long underscore run', () => { + // The qualifier-stripping regex backtracks quadratically on `_` runs; the + // input is cut to 200 characters before it runs, so 100k stays cheap. + const started = performance.now(); + expect(translateTag('_'.repeat(100_000))).toBeNull(); + expect(performance.now() - started).toBeLessThan(100); + }); + it('drops emoticon tags instead of leaving their debris', () => { // e621 carries symbol-only tags whose sanitized remains ("3", "-", "---") // would otherwise be suggested as if they were words. @@ -171,13 +179,32 @@ describe('suggestionsFromResult', () => { expect(new Set(result).size).toBe(MAX_SUGGESTED_TAGS); }); - it('stops reading a hostile tag array after the entry cap', () => { + it('keeps the most confident entries when a hostile array exceeds the cap', () => { + // The classify path sends no confidence floor, so a body can list its + // tags in any order. The cap has to drop the least confident, not the + // last-listed: a qualifying tag deep past the cap survives, a lower one + // inside it does not. const low = { name: 'noise', confidence: 0.1 }; - const tags = Array.from({ length: MAX_RAW_ENTRIES + 1 }, () => ({ ...low })); - // The last entry inside the cap is read; the first one past it is not. - tags[MAX_RAW_ENTRIES - 1] = { name: 'canine', confidence: 0.99 }; - tags[MAX_RAW_ENTRIES] = { name: 'mammal', confidence: 0.99 }; - expect(suggestionsFromResult({ tags }).tags).toEqual(['canine']); + const tags: unknown[] = Array.from({ length: MAX_RAW_ENTRIES + 100 }, () => ({ ...low })); + tags[0] = { name: 'canine', confidence: 0.85 }; + tags[MAX_RAW_ENTRIES + 50] = { name: 'mammal', confidence: 0.99 }; + // Entries with no usable confidence sort last rather than throwing. + tags[5] = { name: 'junk', confidence: 'high' }; + tags[6] = null; + tags[7] = { name: 'nan', confidence: Number.NaN }; + expect(suggestionsFromResult({ tags }).tags).toEqual(['mammal', 'canine']); + // Ties keep the API's order (a stable sort). + const tied = [ + { name: 'zebra', confidence: 0.9 }, + { name: 'ant', confidence: 0.9 } + ]; + expect(suggestionsFromResult({ tags: tied }).tags).toEqual(['zebra', 'ant']); + // Past the cap, everything that remains is below what was kept. + const many = Array.from({ length: MAX_RAW_ENTRIES + 1 }, (_, i) => ({ + name: `tag_${i}`, + confidence: i === MAX_RAW_ENTRIES ? 0.5 : 0.9 + })); + expect(suggestionsFromResult({ tags: many }).tags).not.toContain(`tag-${MAX_RAW_ENTRIES}`); }); it('drops junk entries and unknown ratings', () => { @@ -406,10 +433,33 @@ describe('classifyMediaUrl', () => { expect(warn.mock.calls.map((c) => c.join(' ')).join('\n')).toContain('caller deadline'); }); + it('succeeds with a null rating when the poll body carries none', async () => { + const fetchImpl = vi.fn(async (_url: string | URL | Request, init?: RequestInit) => + init?.method === 'POST' + ? json({ job_id: 'job-10' }, 202) + : json({ tags: [{ name: 'mammal', confidence: 0.99 }] }) + ); + expect(await classifyMediaUrl(url, fetchImpl)).toEqual({ + ok: true, + suggestions: { tags: ['mammal'], rating: null }, + imageCount: 1 + }); + }); + it('is unavailable on a 200 poll body that is not a classification entry', async () => { // null, a string, or an error envelope must not read as "no tags found". + // An envelope whose `tags` holds strings is an unknown shape too, while + // an empty `tags` array is a real (empty) result. const unavailable = { ok: false, reason: 'unavailable' }; - for (const body of [null, 'done', { detail: 'x' }]) { + const emptyFetch = vi.fn(async (_url: string | URL | Request, init?: RequestInit) => + init?.method === 'POST' ? json({ job_id: 'job-9' }, 202) : json({ tags: [] }) + ); + expect(await classifyMediaUrl(url, emptyFetch)).toEqual({ + ok: true, + suggestions: { tags: [], rating: null }, + imageCount: 1 + }); + for (const body of [null, 'done', { detail: 'x' }, { tags: ['a'] }, { tags: [null] }]) { const fetchImpl = vi.fn(async (_url: string | URL | Request, init?: RequestInit) => init?.method === 'POST' ? json({ job_id: 'job-9' }, 202) : json(body) ); diff --git a/src/lib/server/entail.ts b/src/lib/server/entail.ts index 961acff7..296b3b0f 100644 --- a/src/lib/server/entail.ts +++ b/src/lib/server/entail.ts @@ -25,10 +25,16 @@ export const DEFAULT_CONFIDENCE_FLOOR = 0.8; * classifier can return hundreds; the UI shows a short list. */ export const MAX_SUGGESTED_TAGS = 40; -/** Most raw entries one classification is read for. A body is third-party - * input, so the walk stops here rather than following an array of any size. */ +/** Most raw entries one classification is read for, taken from the top after + * a sort by confidence so the cap drops the least confident. A body is + * third-party input, so the walk stops here rather than following an array of + * any size. */ export const MAX_RAW_ENTRIES = 200; +/** Longest raw tag name translateTag looks at. e621 tags run well under this; + * the cut keeps the qualifier-stripping regex off a very long input. */ +const MAX_RAW_TAG_LENGTH = 200; + // Both `wait=true` endpoints hold the connection open until the classifier // finishes rather than answering 202 straight away. That hold was measured at // roughly five seconds for a fresh job on 2026-09-07, so every timeout here @@ -145,6 +151,7 @@ export function classifySourceUrl(url: string): SourceKind | null { */ export function translateTag(tag: string): string | null { const translated = tag + .slice(0, MAX_RAW_TAG_LENGTH) .trim() .toLowerCase() .replace(/[\s_]*\([^()]*\)\s*$/, '') @@ -159,18 +166,31 @@ function normalizeRating(rating: unknown): EntailRating | null { return rating === 'safe' || rating === 'questionable' || rating === 'explicit' ? rating : null; } +/** Sort key for a raw tag entry: its confidence, or -Infinity when it has + * none, so junk entries sort last instead of unsettling the order. */ +function confidenceOf(entry: unknown): number { + const confidence = (entry as { confidence?: unknown } | null)?.confidence; + return typeof confidence === 'number' && !Number.isNaN(confidence) ? confidence : -Infinity; +} + /** * Turn one classification entry into Sona tag suggestions: keep the tags at or - * above the confidence floor, translate them, and drop duplicates while - * preserving the confidence order the API returns, capped at - * {@link MAX_SUGGESTED_TAGS}. Reads at most {@link MAX_RAW_ENTRIES} entries. Pure. + * above the confidence floor, translate them, and drop duplicates, in + * confidence order (a stable sort, so ties keep the API's order), capped at + * {@link MAX_SUGGESTED_TAGS}. Reads at most {@link MAX_RAW_ENTRIES} entries, + * the most confident ones. Pure. */ export function suggestionsFromResult(result: ClassificationEntry | null | undefined): Suggestions { const rating = normalizeRating(result?.rating); const raw = Array.isArray(result?.tags) ? result.tags : []; const seen = new Set(); const tags: string[] = []; - for (const entry of raw.slice(0, MAX_RAW_ENTRIES)) { + const ordered = raw.slice().sort((a, b) => { + const ca = confidenceOf(a); + const cb = confidenceOf(b); + return ca === cb ? 0 : cb > ca ? 1 : -1; + }); + for (const entry of ordered.slice(0, MAX_RAW_ENTRIES)) { const { name, confidence } = (entry ?? {}) as { name?: unknown; confidence?: unknown }; if (typeof name !== 'string') continue; if (typeof confidence !== 'number' || !(confidence >= DEFAULT_CONFIDENCE_FLOOR)) continue; @@ -261,9 +281,14 @@ function isAllowedMediaHost(url: string): boolean { const pause = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms)); /** A finished `/classify/` body is an object with a `tags` array, the - * same way a `/post` body has an `images` array. */ + * same way a `/post` body has an `images` array. The array's first entry, when + * there is one, has to be an object too: an error envelope that happens to + * carry `tags: ['...']` would otherwise read as an empty success. */ function pollEntry(body: unknown): body is ClassificationEntry { - return typeof body === 'object' && body !== null && Array.isArray((body as ClassificationEntry).tags); + if (typeof body !== 'object' || body === null) return false; + const tags = (body as ClassificationEntry).tags; + if (!Array.isArray(tags)) return false; + return tags.length === 0 || (typeof tags[0] === 'object' && tags[0] !== null); } /** diff --git a/src/lib/server/twitter-avatar.ts b/src/lib/server/twitter-avatar.ts index f23858bf..78ea6f5d 100644 --- a/src/lib/server/twitter-avatar.ts +++ b/src/lib/server/twitter-avatar.ts @@ -17,8 +17,8 @@ const X_BEARER = 'Bearer AAAAAAAAAAAAAAAAAAAAANRILgAAAAAAnNwIzUejRCOuH5E6I8xnZz4puTs%3D1Zv7ttfk8LF81IUq16cHjhLTvJu4FA33AGWWjCpTnA'; const X_ACTIVATE = 'https://api.x.com/1.1/guest/activate.json'; /** api.x.com answers 404 to Node's default `User-Agent: node` and 200 to any - * other value (verified 2026-09-07), so every request names itself. Exported - * so twitter-media.ts sends the same one. */ + * other value (verified 2026-09-07), so every request names itself. + * xGraphqlHeaders carries it; exported so the tests can assert the header. */ export const X_USER_AGENT = 'Mozilla/5.0 (compatible; Sona; +https://github.com/sona-fast/sona)'; const X_USER_BY_SCREEN_NAME = 'https://api.x.com/graphql/IGgvgiOx4QZndDHuD3x9TQ/UserByScreenName'; const FETCH_TIMEOUT_MS = 5000; diff --git a/src/lib/server/twitter-media.test.ts b/src/lib/server/twitter-media.test.ts index 30b880b7..6d3f3c53 100644 --- a/src/lib/server/twitter-media.test.ts +++ b/src/lib/server/twitter-media.test.ts @@ -84,12 +84,14 @@ describe('parseTweetPhotos', () => { ).toContain('AbCdEf123'); }); - it('falls back to entities.media when extended_entities is absent', () => { + it('falls back to entities.media when extended_entities is absent, with no count', () => { + // X truncates entities.media to one item, so the photo resolves but the + // count is unknown rather than 1. expect( parseTweetPhotos({ data: { tweetResult: { result: { legacy: { entities: { media: [photo] } } } } } - })?.url - ).toContain('AbCdEf123'); + }) + ).toEqual({ url: 'https://pbs.twimg.com/media/AbCdEf123?format=jpg&name=4096x4096', photoCount: null }); }); it('returns null on a text-only tweet, a tombstone, and junk', () => { diff --git a/src/lib/server/twitter-media.ts b/src/lib/server/twitter-media.ts index eaedbfbc..0b8725f0 100644 --- a/src/lib/server/twitter-media.ts +++ b/src/lib/server/twitter-media.ts @@ -69,7 +69,7 @@ const QUERY_FIELD_TOGGLES = { /** `rate_limited` is X refusing the guest token twice over with a 429; * everything else that yields no photo is `unavailable`. */ export type TweetMediaOutcome = - | { ok: true; url: string; photoCount: number } + | { ok: true; url: string; photoCount: number | null } | { ok: false; reason: 'rate_limited' | 'unavailable' }; const fail = (reason: 'rate_limited' | 'unavailable'): TweetMediaOutcome => ({ ok: false, reason }); @@ -77,8 +77,9 @@ const fail = (reason: 'rate_limited' | 'unavailable'): TweetMediaOutcome => ({ o type TweetMedia = { type?: unknown; media_url_https?: unknown }; /** The first photo on a tweet, upgraded to its largest variant, and how many - * photos the tweet carried in all. */ -export type TweetPhotos = { url: string; photoCount: number }; + * photos the tweet carried in all. The count is null when it came from the + * `entities.media` fallback, which X truncates to one item. */ +export type TweetPhotos = { url: string; photoCount: number | null }; /** * Extract the photos from a TweetResultByRestId response: the first one's @@ -95,7 +96,11 @@ export function parseTweetPhotos(body: unknown): TweetPhotos | null { const legacy = tweet.legacy as | { extended_entities?: { media?: unknown }; entities?: { media?: unknown } } | undefined; - const media = legacy?.extended_entities?.media ?? legacy?.entities?.media; + const extended = legacy?.extended_entities?.media; + // `entities.media` only ever lists one item, so a count read from it is not + // a count; only `extended_entities` is authoritative. + const counted = Array.isArray(extended); + const media = extended ?? legacy?.entities?.media; if (!Array.isArray(media)) return null; let first: string | null = null; @@ -109,7 +114,7 @@ export function parseTweetPhotos(body: unknown): TweetPhotos | null { const match = url.match(/^(.*)\.([a-z]+)$/i); first = match ? `${match[1]}?format=${match[2].toLowerCase()}&name=4096x4096` : url; } - return first ? { url: first, photoCount: Math.min(photoCount, MAX_TWEET_PHOTOS) } : null; + return first ? { url: first, photoCount: counted ? Math.min(photoCount, MAX_TWEET_PHOTOS) : null } : null; } function tweetLookup( diff --git a/src/routes/api/admin/tag-suggestions/+server.ts b/src/routes/api/admin/tag-suggestions/+server.ts index 37e4c80b..757cb506 100644 --- a/src/routes/api/admin/tag-suggestions/+server.ts +++ b/src/routes/api/admin/tag-suggestions/+server.ts @@ -44,10 +44,14 @@ const FAILURE_STATUS: Record = { }; const MAX_URL_LENGTH = 2048; -/** Ceiling on the whole lookup chain. The X path is three fetches plus an - * enqueue and two polls, each with its own timeout, so without this the worst - * case ran close to forty seconds. */ +/** Ceiling on the whole lookup chain. The X path is up to four fetches (the + * activate and the tweet lookup can each run twice) plus an enqueue and two + * polls, each with its own timeout, so without this the worst case ran close + * to forty seconds. */ const LOOKUP_DEADLINE_MS = 20_000; +// SvelteKit rejects any other named export from a +server file unless it +// starts with an underscore; the tests read it under this name. +export { LOOKUP_DEADLINE_MS as _LOOKUP_DEADLINE_MS }; /** Read before parsing: a valid body is a short object with one field, so * anything past this is refused without handing it to JSON.parse. */ const MAX_BODY_BYTES = 4096; @@ -102,8 +106,9 @@ export const POST: RequestHandler = async ({ request, platform }) => { let outcome: LookupOutcome; // How many images the post carried. Only the Bluesky lookup and the tweet - // lookup see the post; classifyMediaUrl sees one image. - let imageCount: number; + // lookup see the post; classifyMediaUrl sees one image. Null when the + // tweet lookup could not count (see TweetPhotos). + let imageCount: number | null; // One deadline for every outbound call below; each lookup returns // `unavailable` when it fires. const signal = AbortSignal.timeout(LOOKUP_DEADLINE_MS); diff --git a/src/routes/api/admin/tag-suggestions/server.test.ts b/src/routes/api/admin/tag-suggestions/server.test.ts index 6edbc90c..33a0efa0 100644 --- a/src/routes/api/admin/tag-suggestions/server.test.ts +++ b/src/routes/api/admin/tag-suggestions/server.test.ts @@ -5,7 +5,7 @@ import Database from 'better-sqlite3'; import type { LookupOutcome, SourceKind } from '$lib/server/entail'; import type { TweetMediaOutcome } from '$lib/server/twitter-media'; import { makeD1 } from '$lib/server/test/d1'; -import { POST } from './+server'; +import { POST, _LOOKUP_DEADLINE_MS } from './+server'; // Only the outbound calls are stubbed. classifySourceUrl stays real, so the // URL recognition the endpoint depends on is exercised rather than mocked. @@ -138,6 +138,29 @@ describe('POST /api/admin/tag-suggestions', () => { expect((await res.json()).imageCount).toBe(3); }); + it('reports a null imageCount when the tweet lookup could not count', async () => { + const { platform } = makeEnv(); + // The entities.media fallback lists one item whatever the tweet carried, + // so the count is unknown, not 1. + fetchTweetMediaUrl.mockResolvedValue({ ok: true, url: MEDIA_URL, photoCount: null }); + classifyMediaUrl.mockResolvedValue({ ...suggestions, imageCount: 1 }); + const res = await POST(event(platform, { sourcePostUrl: X_POST })); + expect(res.status).toBe(200); + expect(await res.json()).toEqual({ + source: 'x', + tags: ['mammal', 'pink-hair'], + rating: 'safe', + imageCount: null + }); + }); + + it('gives the lookup chain a deadline that clears one full X round', async () => { + // One activate (5 s) + one tweet lookup (5 s) + one enqueue (3 s) + one + // poll (8 s) at their own timeouts is 21 s; the deadline exists to stop + // the retry paths, not to cut that chain short of its first attempt. + expect(_LOOKUP_DEADLINE_MS).toBeGreaterThanOrEqual(15_000); + }); + it('reads the stored source URL for an imageId', async () => { const { sqlite, platform } = makeEnv(); insertImage(sqlite, BSKY_POST); From c1345dbd4d8ccb2ff37aa71be3dfecc69fac8c2d Mon Sep 17 00:00:00 2001 From: Sparky <1609870+sparkyfen@users.noreply.github.com> Date: Mon, 7 Sep 2026 20:43:08 -0700 Subject: [PATCH 12/22] fix(admin): lookup deadline clears one full first attempt (SONA-220) The 20 s ceiling sat below the 21 s sum of one activate, one tweet lookup, one enqueue, and one poll at their own timeouts. Raise it to 22 s and pin the floor test to that sum. --- src/routes/api/admin/tag-suggestions/+server.ts | 6 ++++-- src/routes/api/admin/tag-suggestions/server.test.ts | 3 ++- 2 files changed, 6 insertions(+), 3 deletions(-) diff --git a/src/routes/api/admin/tag-suggestions/+server.ts b/src/routes/api/admin/tag-suggestions/+server.ts index 757cb506..d5caef76 100644 --- a/src/routes/api/admin/tag-suggestions/+server.ts +++ b/src/routes/api/admin/tag-suggestions/+server.ts @@ -47,8 +47,10 @@ const MAX_URL_LENGTH = 2048; /** Ceiling on the whole lookup chain. The X path is up to four fetches (the * activate and the tweet lookup can each run twice) plus an enqueue and two * polls, each with its own timeout, so without this the worst case ran close - * to forty seconds. */ -const LOOKUP_DEADLINE_MS = 20_000; + * to forty seconds. One first attempt at every timeout is 21 s (5 + 5 + 3 + 8), + * so the ceiling sits just above that: it cuts the retry paths, never a chain + * that is merely slow. */ +const LOOKUP_DEADLINE_MS = 22_000; // SvelteKit rejects any other named export from a +server file unless it // starts with an underscore; the tests read it under this name. export { LOOKUP_DEADLINE_MS as _LOOKUP_DEADLINE_MS }; diff --git a/src/routes/api/admin/tag-suggestions/server.test.ts b/src/routes/api/admin/tag-suggestions/server.test.ts index 33a0efa0..e9f28c33 100644 --- a/src/routes/api/admin/tag-suggestions/server.test.ts +++ b/src/routes/api/admin/tag-suggestions/server.test.ts @@ -158,7 +158,8 @@ describe('POST /api/admin/tag-suggestions', () => { // One activate (5 s) + one tweet lookup (5 s) + one enqueue (3 s) + one // poll (8 s) at their own timeouts is 21 s; the deadline exists to stop // the retry paths, not to cut that chain short of its first attempt. - expect(_LOOKUP_DEADLINE_MS).toBeGreaterThanOrEqual(15_000); + const firstAttempt = 5_000 + 5_000 + 3_000 + 8_000; + expect(_LOOKUP_DEADLINE_MS).toBeGreaterThanOrEqual(firstAttempt); }); it('reads the stored source URL for an imageId', async () => { From 40fc749ea39e0fc835240c99c88dc2a6afb2ea50 Mon Sep 17 00:00:00 2001 From: Sparky <1609870+sparkyfen@users.noreply.github.com> Date: Mon, 7 Sep 2026 20:46:34 -0700 Subject: [PATCH 13/22] fix(admin): final review polish for the tag suggestion endpoint (SONA-220) - Refuse an over-cap Content-Length before reading the body. - Skip the poll pause once the deadline has fired. - Log tweet media failures under their own prefix. --- src/lib/server/entail.ts | 2 +- src/lib/server/twitter-media.ts | 8 ++++---- src/routes/api/admin/tag-suggestions/+server.ts | 2 ++ src/routes/api/admin/tag-suggestions/server.test.ts | 13 +++++++++++++ 4 files changed, 20 insertions(+), 5 deletions(-) diff --git a/src/lib/server/entail.ts b/src/lib/server/entail.ts index 296b3b0f..ff41f77c 100644 --- a/src/lib/server/entail.ts +++ b/src/lib/server/entail.ts @@ -327,7 +327,7 @@ export async function classifyMediaUrl( const poll = `${ENTAIL_CLASSIFY}/${encodeURIComponent(jobId)}?wait=true`; for (let attempt = 0; attempt < POLL_ATTEMPTS; attempt++) { - if (attempt > 0) await pause(POLL_PAUSE_MS); + if (attempt > 0 && !signal?.aborted) await pause(POLL_PAUSE_MS); const res = await fetchImpl(poll, { signal: timeoutSignal(POLL_TIMEOUT_MS, signal) }); if (res.status === 202) continue; if (res.status === 429) { diff --git a/src/lib/server/twitter-media.ts b/src/lib/server/twitter-media.ts index 0b8725f0..cfda3edc 100644 --- a/src/lib/server/twitter-media.ts +++ b/src/lib/server/twitter-media.ts @@ -166,23 +166,23 @@ export async function fetchTweetMediaUrl( res = await tweetLookup(tweetId, token, fetchImpl, signal); } if (res.status === 429) { - console.warn('[avatar] tweet media lookup rate limited: status=429'); + console.warn('[tweet-media] tweet media lookup rate limited: status=429'); return fail('rate_limited'); } if (!res.ok) { - console.warn(`[avatar] tweet media lookup failed: status=${res.status}`); + console.warn(`[tweet-media] tweet media lookup failed: status=${res.status}`); return fail('unavailable'); } const photos = parseTweetPhotos(await res.json()); if (!photos) { // 200 but no photo — a text/video tweet, a protected or deleted one, or // the undocumented GraphQL shape rotated (see the file header). - console.warn('[avatar] tweet media lookup had no photo'); + console.warn('[tweet-media] tweet media lookup had no photo'); return fail('unavailable'); } return { ok: true, url: photos.url, photoCount: photos.photoCount }; } catch (e) { - console.warn(`[avatar] tweet media lookup error: ${errorLabel(e)}`); + console.warn(`[tweet-media] tweet media lookup error: ${errorLabel(e)}`); return fail('unavailable'); } } diff --git a/src/routes/api/admin/tag-suggestions/+server.ts b/src/routes/api/admin/tag-suggestions/+server.ts index d5caef76..b1ab7fb0 100644 --- a/src/routes/api/admin/tag-suggestions/+server.ts +++ b/src/routes/api/admin/tag-suggestions/+server.ts @@ -66,6 +66,8 @@ const invalid = () => json({ error: 'invalid_request' }, { status: 400 }); type Body = { imageId?: unknown; sourcePostUrl?: unknown }; export const POST: RequestHandler = async ({ request, platform }) => { + const declared = Number(request.headers.get('content-length')); + if (Number.isFinite(declared) && declared > MAX_BODY_BYTES) return invalid(); const text = await request.text().catch(() => null); if (text === null || new TextEncoder().encode(text).length > MAX_BODY_BYTES) return invalid(); let body: Body | null; diff --git a/src/routes/api/admin/tag-suggestions/server.test.ts b/src/routes/api/admin/tag-suggestions/server.test.ts index e9f28c33..d854d328 100644 --- a/src/routes/api/admin/tag-suggestions/server.test.ts +++ b/src/routes/api/admin/tag-suggestions/server.test.ts @@ -162,6 +162,19 @@ describe('POST /api/admin/tag-suggestions', () => { expect(_LOOKUP_DEADLINE_MS).toBeGreaterThanOrEqual(firstAttempt); }); + it('refuses a declared Content-Length over the cap before reading the body', async () => { + const { platform } = makeEnv(); + const request = new Request('http://localhost/api/admin/tag-suggestions', { + method: 'POST', + headers: { 'content-type': 'application/json', 'content-length': '5000' }, + body: JSON.stringify({ sourcePostUrl: BSKY_POST }) + }); + const res = await POST({ request, platform } as never); + expect(res.status).toBe(400); + expect(await res.json()).toEqual({ error: 'invalid_request' }); + expect(lookupBlueskySource).not.toHaveBeenCalled(); + }); + it('reads the stored source URL for an imageId', async () => { const { sqlite, platform } = makeEnv(); insertImage(sqlite, BSKY_POST); From 2194aeb0f7558e5684866ccfcb8cda0ff916eb6a Mon Sep 17 00:00:00 2001 From: Sparky <1609870+sparkyfen@users.noreply.github.com> Date: Mon, 7 Sep 2026 20:53:47 -0700 Subject: [PATCH 14/22] fix(admin): keep expected lookup outcomes out of the error metric (SONA-220) - A queued, unclassified post answers 202 not_ready instead of 502, since hooks count every 5xx into the site error rollup. - A tweet with no photo is a success with no tags, matching the Bluesky empty case, and the legacy media fallback that could pick a video poster frame is gone. - Bound the sort of a hostile tag array before the entry cap. --- src/lib/server/entail.test.ts | 15 ++++++ src/lib/server/entail.ts | 16 +++--- src/lib/server/twitter-media.test.ts | 45 ++++++++++++---- src/lib/server/twitter-media.ts | 53 +++++++++++-------- .../api/admin/tag-suggestions/+server.ts | 17 ++++-- .../api/admin/tag-suggestions/server.test.ts | 24 ++++----- 6 files changed, 114 insertions(+), 56 deletions(-) diff --git a/src/lib/server/entail.test.ts b/src/lib/server/entail.test.ts index 550a5a5b..e159cb00 100644 --- a/src/lib/server/entail.test.ts +++ b/src/lib/server/entail.test.ts @@ -1,6 +1,7 @@ import { afterEach, describe, expect, it, vi } from 'vitest'; import { MAX_RAW_ENTRIES, + MAX_SORTED_ENTRIES, MAX_SUGGESTED_TAGS, POLL_TIMEOUT_MS, POST_TIMEOUT_MS, @@ -207,6 +208,20 @@ describe('suggestionsFromResult', () => { expect(suggestionsFromResult({ tags: many }).tags).not.toContain(`tag-${MAX_RAW_ENTRIES}`); }); + it('sorts only the first MAX_SORTED_ENTRIES of a hostile array', () => { + // The sort itself is bounded, not just the walk after it: a body far + // past what entail.dev ever returns is cut before it is copied and + // sorted, so a qualifying tag in the dropped tail is never seen, while + // one inside the bound but past MAX_RAW_ENTRIES still sorts to the top. + const low = { name: 'noise', confidence: 0.1 }; + const tags: unknown[] = Array.from({ length: 5000 }, () => ({ ...low })); + tags[250] = { name: 'canine', confidence: 0.99 }; + tags[4999] = { name: 'mammal', confidence: 0.99 }; + expect(MAX_RAW_ENTRIES).toBeLessThan(250); + expect(MAX_SORTED_ENTRIES).toBeLessThan(4999); + expect(suggestionsFromResult({ tags }).tags).toEqual(['canine']); + }); + it('drops junk entries and unknown ratings', () => { expect( suggestionsFromResult({ diff --git a/src/lib/server/entail.ts b/src/lib/server/entail.ts index ff41f77c..be8d962d 100644 --- a/src/lib/server/entail.ts +++ b/src/lib/server/entail.ts @@ -26,11 +26,15 @@ export const DEFAULT_CONFIDENCE_FLOOR = 0.8; export const MAX_SUGGESTED_TAGS = 40; /** Most raw entries one classification is read for, taken from the top after - * a sort by confidence so the cap drops the least confident. A body is - * third-party input, so the walk stops here rather than following an array of - * any size. */ + * a sort by confidence so the cap drops the least confident. */ export const MAX_RAW_ENTRIES = 200; +/** Most raw entries the confidence sort ever sees. A body is third-party + * input, so the array is cut to this length before it is copied and sorted; + * entail.dev returns at most a few hundred entries, anything beyond this is + * hostile, and the tail past the bound is dropped unsorted. */ +export const MAX_SORTED_ENTRIES = 2000; + /** Longest raw tag name translateTag looks at. e621 tags run well under this; * the cut keeps the qualifier-stripping regex off a very long input. */ const MAX_RAW_TAG_LENGTH = 200; @@ -177,15 +181,15 @@ function confidenceOf(entry: unknown): number { * Turn one classification entry into Sona tag suggestions: keep the tags at or * above the confidence floor, translate them, and drop duplicates, in * confidence order (a stable sort, so ties keep the API's order), capped at - * {@link MAX_SUGGESTED_TAGS}. Reads at most {@link MAX_RAW_ENTRIES} entries, - * the most confident ones. Pure. + * {@link MAX_SUGGESTED_TAGS}. Sorts at most the first {@link MAX_SORTED_ENTRIES} + * entries and reads the {@link MAX_RAW_ENTRIES} most confident of those. Pure. */ export function suggestionsFromResult(result: ClassificationEntry | null | undefined): Suggestions { const rating = normalizeRating(result?.rating); const raw = Array.isArray(result?.tags) ? result.tags : []; const seen = new Set(); const tags: string[] = []; - const ordered = raw.slice().sort((a, b) => { + const ordered = raw.slice(0, MAX_SORTED_ENTRIES).sort((a, b) => { const ca = confidenceOf(a); const cb = confidenceOf(b); return ca === cb ? 0 : cb > ca ? 1 : -1; diff --git a/src/lib/server/twitter-media.test.ts b/src/lib/server/twitter-media.test.ts index 6d3f3c53..548cdca5 100644 --- a/src/lib/server/twitter-media.test.ts +++ b/src/lib/server/twitter-media.test.ts @@ -22,6 +22,8 @@ const tweetWith = (media: unknown[]) => ({ }); const photo = { type: 'photo', media_url_https: 'https://pbs.twimg.com/media/AbCdEf123.jpg' }; +/** What parseTweetPhotos reports for a tweet that resolved without a photo. */ +const noPhoto = { url: null, photoCount: 0 }; describe('parseTweetPhotos', () => { it('upgrades the first photo to the largest variant', () => { @@ -58,10 +60,12 @@ describe('parseTweetPhotos', () => { }); it('skips video and animated gif entries', () => { - expect(parseTweetPhotos(tweetWith([{ type: 'video', media_url_https: 'https://pbs.twimg.com/x.jpg' }]))).toBeNull(); + expect(parseTweetPhotos(tweetWith([{ type: 'video', media_url_https: 'https://pbs.twimg.com/x.jpg' }]))).toEqual( + noPhoto + ); expect( parseTweetPhotos(tweetWith([{ type: 'animated_gif', media_url_https: 'https://pbs.twimg.com/y.jpg' }])) - ).toBeNull(); + ).toEqual(noPhoto); // A video alongside a photo is not counted as a photo, and the photo, not // the video's poster, is what resolves. expect( @@ -84,18 +88,24 @@ describe('parseTweetPhotos', () => { ).toContain('AbCdEf123'); }); - it('falls back to entities.media when extended_entities is absent, with no count', () => { - // X truncates entities.media to one item, so the photo resolves but the - // count is unknown rather than 1. + it('ignores entities.media, which cannot tell a video poster from a photo', () => { + // Only extended_entities carries the real media type; entities.media + // types a video's poster frame as a photo, so it is never read, and a + // tweet with nothing else is a tweet with no photo. expect( parseTweetPhotos({ data: { tweetResult: { result: { legacy: { entities: { media: [photo] } } } } } }) - ).toEqual({ url: 'https://pbs.twimg.com/media/AbCdEf123?format=jpg&name=4096x4096', photoCount: null }); + ).toEqual(noPhoto); + }); + + it('reports no photo on a text-only tweet', () => { + expect(parseTweetPhotos(tweetWith([]))).toEqual(noPhoto); + expect(parseTweetPhotos({ data: { tweetResult: { result: { legacy: {} } } } })).toEqual(noPhoto); }); - it('returns null on a text-only tweet, a tombstone, and junk', () => { - expect(parseTweetPhotos(tweetWith([]))).toBeNull(); + it('returns null on a tombstone and junk', () => { + expect(parseTweetPhotos({ data: { tweetResult: { result: { __typename: 'TweetTombstone' } } } })).toBeNull(); expect(parseTweetPhotos({ data: { tweetResult: {} } })).toBeNull(); expect(parseTweetPhotos(null)).toBeNull(); }); @@ -192,11 +202,26 @@ describe('fetchTweetMediaUrl', () => { expect(await fetchTweetMediaUrl(id, fetchImpl)).toEqual(unavailable); }); - it('fails soft on refusal, a photoless tweet, malformed JSON, and network errors', async () => { + it('resolves a photoless tweet as ok with no URL, not as an outage', async () => { + // A text, video or GIF tweet is a real answer; the endpoint turns it into + // "no tags" rather than an unavailable classifier. + expect(await fetchTweetMediaUrl(id, stub(() => json(tweetWith([]))).fetchImpl)).toEqual({ + ok: true, + url: null, + photoCount: 0 + }); + }); + + it('fails soft on refusal, a tombstone, malformed JSON, and network errors', async () => { expect(await fetchTweetMediaUrl(id, stub(() => new Response('no', { status: 403 })).fetchImpl)).toEqual( unavailable ); - expect(await fetchTweetMediaUrl(id, stub(() => json(tweetWith([]))).fetchImpl)).toEqual(unavailable); + expect( + await fetchTweetMediaUrl( + id, + stub(() => json({ data: { tweetResult: { result: { __typename: 'TweetTombstone' } } } })).fetchImpl + ) + ).toEqual(unavailable); expect(await fetchTweetMediaUrl(id, stub(() => new Response('')).fetchImpl)).toEqual(unavailable); expect( await fetchTweetMediaUrl( diff --git a/src/lib/server/twitter-media.ts b/src/lib/server/twitter-media.ts index cfda3edc..cc0edcbe 100644 --- a/src/lib/server/twitter-media.ts +++ b/src/lib/server/twitter-media.ts @@ -8,7 +8,10 @@ // // Fail-soft throughout: any error resolves to a failed outcome and the caller // proceeds without a media URL. Videos and GIFs are skipped — only photos -// resolve. +// resolve, and only from `extended_entities.media`: the `entities.media` +// fallback is not read, because it cannot tell a video's poster frame from a +// photo. A tweet that resolves but carries no photo is a success with no +// URL, not a failure. import { errorLabel, timeoutSignal } from './fetch-errors'; import { activateGuestToken, xGraphqlHeaders } from './twitter-avatar'; @@ -66,10 +69,12 @@ const QUERY_FIELD_TOGGLES = { withDisallowedReplyControls: false } as const; -/** `rate_limited` is X refusing the guest token twice over with a 429; - * everything else that yields no photo is `unavailable`. */ +/** `rate_limited` is X refusing the guest token twice over with a 429; a + * tweet that resolved with no photo is ok with a null URL; everything else + * is `unavailable`. */ export type TweetMediaOutcome = - | { ok: true; url: string; photoCount: number | null } + | { ok: true; url: string; photoCount: number } + | { ok: true; url: null; photoCount: 0 } | { ok: false; reason: 'rate_limited' | 'unavailable' }; const fail = (reason: 'rate_limited' | 'unavailable'): TweetMediaOutcome => ({ ok: false, reason }); @@ -77,15 +82,16 @@ const fail = (reason: 'rate_limited' | 'unavailable'): TweetMediaOutcome => ({ o type TweetMedia = { type?: unknown; media_url_https?: unknown }; /** The first photo on a tweet, upgraded to its largest variant, and how many - * photos the tweet carried in all. The count is null when it came from the - * `entities.media` fallback, which X truncates to one item. */ -export type TweetPhotos = { url: string; photoCount: number | null }; + * photos the tweet carried in all; or no URL and a count of zero for a tweet + * that resolved without a photo. */ +export type TweetPhotos = { url: string; photoCount: number } | { url: null; photoCount: 0 }; /** * Extract the photos from a TweetResultByRestId response: the first one's * URL, asking pbs.twimg.com for its largest variant, plus the photo count. - * Returns null for a tweet with no photo (video- and GIF-only tweets - * included). Pure, so it's testable. + * A tweet with no photo (text-, video- and GIF-only tweets included) has a + * null URL. Returns null when there is no tweet to read: a tombstone, junk, + * or a rotated GraphQL shape. Pure, so it's testable. */ export function parseTweetPhotos(body: unknown): TweetPhotos | null { const result = (body as { data?: { tweetResult?: { result?: Record } } })?.data @@ -93,15 +99,14 @@ export function parseTweetPhotos(body: unknown): TweetPhotos | null { if (!result) return null; // A tweet behind a visibility interstitial nests the real tweet one level down. const tweet = (result.tweet as Record | undefined) ?? result; - const legacy = tweet.legacy as - | { extended_entities?: { media?: unknown }; entities?: { media?: unknown } } - | undefined; - const extended = legacy?.extended_entities?.media; - // `entities.media` only ever lists one item, so a count read from it is not - // a count; only `extended_entities` is authoritative. - const counted = Array.isArray(extended); - const media = extended ?? legacy?.entities?.media; - if (!Array.isArray(media)) return null; + const legacy = tweet.legacy as { extended_entities?: { media?: unknown } } | undefined; + // `legacy` is what marks a resolved tweet; a tombstone has none. + if (!legacy || typeof legacy !== 'object') return null; + // Only `extended_entities` is read. `entities.media` is truncated to one + // item and types a video's poster frame as a photo, so it can neither + // count nor tell a photo from a video thumbnail. + const media = legacy.extended_entities?.media; + if (!Array.isArray(media)) return { url: null, photoCount: 0 }; let first: string | null = null; let photoCount = 0; @@ -114,7 +119,7 @@ export function parseTweetPhotos(body: unknown): TweetPhotos | null { const match = url.match(/^(.*)\.([a-z]+)$/i); first = match ? `${match[1]}?format=${match[2].toLowerCase()}&name=4096x4096` : url; } - return first ? { url: first, photoCount: counted ? Math.min(photoCount, MAX_TWEET_PHOTOS) : null } : null; + return first ? { url: first, photoCount: Math.min(photoCount, MAX_TWEET_PHOTOS) } : { url: null, photoCount: 0 }; } function tweetLookup( @@ -175,11 +180,15 @@ export async function fetchTweetMediaUrl( } const photos = parseTweetPhotos(await res.json()); if (!photos) { - // 200 but no photo — a text/video tweet, a protected or deleted one, or - // the undocumented GraphQL shape rotated (see the file header). - console.warn('[tweet-media] tweet media lookup had no photo'); + // 200 but no tweet — a protected or deleted one, or the undocumented + // GraphQL shape rotated (see the file header). + console.warn('[tweet-media] tweet media lookup had no tweet'); return fail('unavailable'); } + if (photos.url === null) { + // A resolved text, video or GIF tweet: nothing to classify, not an outage. + return { ok: true, url: null, photoCount: 0 }; + } return { ok: true, url: photos.url, photoCount: photos.photoCount }; } catch (e) { console.warn(`[tweet-media] tweet media lookup error: ${errorLabel(e)}`); diff --git a/src/routes/api/admin/tag-suggestions/+server.ts b/src/routes/api/admin/tag-suggestions/+server.ts index b1ab7fb0..42532b40 100644 --- a/src/routes/api/admin/tag-suggestions/+server.ts +++ b/src/routes/api/admin/tag-suggestions/+server.ts @@ -35,10 +35,14 @@ import type { RequestHandler } from './$types'; /** What the UI gets back for a failed lookup, and the status carrying it. */ const FAILURE_STATUS: Record = { - // 502, not 401 or 503: the admin gate answers an expired session with its + // 202 for not_ready: the classifier has the post queued, nothing is broken, + // and hooks.server.ts counts every 5xx into the site's error metric, so a + // 502 here would book a server error against the site on each retry. + // unavailable stays 502, not 401 or 503: a real upstream failure belongs in + // that error rollup, and the admin gate answers an expired session with its // own 401 and a plain-text body, so a 401 here would read as a logged-out // operator. The body's `error` field is what tells the cases apart. - not_ready: 502, + not_ready: 202, rate_limited: 429, unavailable: 502 }; @@ -110,9 +114,8 @@ export const POST: RequestHandler = async ({ request, platform }) => { let outcome: LookupOutcome; // How many images the post carried. Only the Bluesky lookup and the tweet - // lookup see the post; classifyMediaUrl sees one image. Null when the - // tweet lookup could not count (see TweetPhotos). - let imageCount: number | null; + // lookup see the post; classifyMediaUrl sees one image. + let imageCount: number; // One deadline for every outbound call below; each lookup returns // `unavailable` when it fires. const signal = AbortSignal.timeout(LOOKUP_DEADLINE_MS); @@ -129,6 +132,10 @@ export const POST: RequestHandler = async ({ request, platform }) => { // classifyMediaUrl will send on. Only the validated status id goes out. const media = await fetchTweetMediaUrl(source.id, fetch, signal); if (!media.ok) return failure(media.reason); + // A tweet with no photo (text, video, GIF) has nothing to classify. That + // is a success with no tags, the same answer a Bluesky post with no + // classified image gets, not an outage. + if (media.url === null) return json({ source: source.kind, tags: [], rating: null, imageCount: 0 }); outcome = await classifyMediaUrl(media.url, fetch, signal); if (!outcome.ok) return failure(outcome.reason); imageCount = media.photoCount; diff --git a/src/routes/api/admin/tag-suggestions/server.test.ts b/src/routes/api/admin/tag-suggestions/server.test.ts index d854d328..7fa9cb1b 100644 --- a/src/routes/api/admin/tag-suggestions/server.test.ts +++ b/src/routes/api/admin/tag-suggestions/server.test.ts @@ -138,20 +138,16 @@ describe('POST /api/admin/tag-suggestions', () => { expect((await res.json()).imageCount).toBe(3); }); - it('reports a null imageCount when the tweet lookup could not count', async () => { + it('answers a photoless tweet with no tags rather than an outage', async () => { const { platform } = makeEnv(); - // The entities.media fallback lists one item whatever the tweet carried, - // so the count is unknown, not 1. - fetchTweetMediaUrl.mockResolvedValue({ ok: true, url: MEDIA_URL, photoCount: null }); - classifyMediaUrl.mockResolvedValue({ ...suggestions, imageCount: 1 }); + // A text, video or GIF tweet has nothing to classify: the same 200 with + // no tags a Bluesky post with no classified image gets, and no + // classifier call. + fetchTweetMediaUrl.mockResolvedValue({ ok: true, url: null, photoCount: 0 }); const res = await POST(event(platform, { sourcePostUrl: X_POST })); expect(res.status).toBe(200); - expect(await res.json()).toEqual({ - source: 'x', - tags: ['mammal', 'pink-hair'], - rating: 'safe', - imageCount: null - }); + expect(await res.json()).toEqual({ source: 'x', tags: [], rating: null, imageCount: 0 }); + expect(classifyMediaUrl).not.toHaveBeenCalled(); }); it('gives the lookup chain a deadline that clears one full X round', async () => { @@ -285,11 +281,13 @@ describe('POST /api/admin/tag-suggestions', () => { expect(await res.json()).toEqual({ source: 'bluesky', tags: [], rating: null, imageCount: 0 }); }); - it('502s not_ready when the post is queued but unclassified', async () => { + it('202s not_ready when the post is queued but unclassified', async () => { const { platform } = makeEnv(); + // Not a 5xx: hooks.server.ts books every 5xx into the site's error + // metric, and a queued post is not a server error. lookupBlueskySource.mockResolvedValue({ ok: false, reason: 'not_ready' }); const res = await POST(event(platform, { sourcePostUrl: BSKY_POST })); - expect(res.status).toBe(502); + expect(res.status).toBe(202); expect(await res.json()).toEqual({ error: 'not_ready' }); }); From a29e9014d9966586c9127b00bced25f32971ef10 Mon Sep 17 00:00:00 2001 From: Sparky <1609870+sparkyfen@users.noreply.github.com> Date: Mon, 7 Sep 2026 21:07:01 -0700 Subject: [PATCH 15/22] fix(admin): answer 404 and 202 for declined or pending lookups (SONA-220) An unreadable tweet, a post the classifier declines, and a job that is still running are operator-input or pending outcomes, not upstream failures, so they no longer answer 502 and no longer count toward the site error metric. The media host rejection now logs. --- src/lib/server/entail.test.ts | 50 ++++++++++++++++--- src/lib/server/entail.ts | 39 +++++++++++---- src/lib/server/twitter-media.test.ts | 22 ++++++-- src/lib/server/twitter-media.ts | 21 +++++--- .../api/admin/tag-suggestions/+server.ts | 29 ++++++----- .../api/admin/tag-suggestions/server.test.ts | 30 +++++++++++ 6 files changed, 149 insertions(+), 42 deletions(-) diff --git a/src/lib/server/entail.test.ts b/src/lib/server/entail.test.ts index e159cb00..b881b1d4 100644 --- a/src/lib/server/entail.test.ts +++ b/src/lib/server/entail.test.ts @@ -310,6 +310,19 @@ describe('lookupBlueskySource', () => { ).toEqual({ ok: false, reason: 'unavailable' }); }); + it('reports a non-429 4xx from /post as not_found, with the status in the log', async () => { + // entail.dev declining the input is the operator's problem, not an + // outage: the endpoint answers 404, which hooks.server.ts does not count + // as a site error the way it counts a 502. + const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}); + for (const status of [404, 400]) { + expect( + await lookupBlueskyPost(url, vi.fn(async () => new Response('no', { status }))) + ).toEqual({ ok: false, reason: 'not_found' }); + expect(warn.mock.calls.map((c) => c.join(' ')).join('\n')).toContain(`status=${status}`); + } + }); + it('logs a malformed body as a parse failure without quoting it', async () => { const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}); expect( @@ -372,14 +385,24 @@ describe('classifyMediaUrl', () => { imageCount: 1 }; - it('refuses a host outside the allowlist without fetching', async () => { + it('refuses a host outside the allowlist without fetching, and says so', async () => { + const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}); const fetchImpl = vi.fn(async () => json(done)); const refused = { ok: false, reason: 'unavailable' }; - expect(await classifyMediaUrl('https://example.com/a.jpg', fetchImpl)).toEqual(refused); - expect(await classifyMediaUrl('https://evil.pbs.twimg.com/a.jpg', fetchImpl)).toEqual(refused); - expect(await classifyMediaUrl('http://pbs.twimg.com/a.jpg', fetchImpl)).toEqual(refused); - expect(await classifyMediaUrl('nonsense', fetchImpl)).toEqual(refused); + const rejected = [ + 'https://example.com/a.jpg', + 'https://evil.pbs.twimg.com/a.jpg', + 'http://pbs.twimg.com/a.jpg', + 'nonsense' + ]; + for (const bad of rejected) expect(await classifyMediaUrl(bad, fetchImpl)).toEqual(refused); expect(fetchImpl).not.toHaveBeenCalled(); + // One warning per refusal, naming the reason but never the URL: a media + // URL is third-party data. + expect(warn).toHaveBeenCalledTimes(rejected.length); + const logged = warn.mock.calls.map((c) => c.join(' ')).join('\n'); + expect(logged).toContain('host not allowed'); + for (const bad of rejected) expect(logged).not.toContain(bad); }); it('enqueues then polls until the job is done', async () => { @@ -491,14 +514,16 @@ describe('classifyMediaUrl', () => { ); }); - it('gives up after the poll cap', async () => { + it('reports a job still running at the poll cap as not_ready, not an outage', async () => { + // The same retry-later answer a queued Bluesky post gets: nothing broke, + // so the endpoint answers 202 rather than a 502 the site counts as an error. let polls = 0; const fetchImpl = vi.fn(async (_url: string | URL | Request, init?: RequestInit) => { if (init?.method === 'POST') return json({ job_id: 'job-3' }, 202); polls++; return json({ status: 'processing' }, 202); }); - expect(await classifyMediaUrl(url, fetchImpl)).toEqual({ ok: false, reason: 'unavailable' }); + expect(await classifyMediaUrl(url, fetchImpl)).toEqual({ ok: false, reason: 'not_ready' }); expect(polls).toBe(2); }); @@ -539,6 +564,17 @@ describe('classifyMediaUrl', () => { ).toEqual(unavailable); }); + it('reports a non-429 4xx from the classify enqueue as not_found', async () => { + const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}); + for (const status of [404, 400]) { + expect(await classifyMediaUrl(url, vi.fn(async () => new Response('no', { status })))).toEqual({ + ok: false, + reason: 'not_found' + }); + expect(warn.mock.calls.map((c) => c.join(' ')).join('\n')).toContain(`status=${status}`); + } + }); + it('names a rate limit from either the enqueue or a poll', async () => { expect( await classifyMediaUrl(url, vi.fn(async () => new Response('slow down', { status: 429 }))) diff --git a/src/lib/server/entail.ts b/src/lib/server/entail.ts index be8d962d..52d9bf7e 100644 --- a/src/lib/server/entail.ts +++ b/src/lib/server/entail.ts @@ -59,12 +59,17 @@ export type Suggestions = { }; /** Why a lookup produced nothing. `not_ready` is the one worth retrying: the - * post is queued but not classified yet. `rate_limited` is entail.dev's per-IP - * limit, which has no key to raise. Everything else — a timeout, a non-2xx, a - * job that never finished — is `unavailable`, because none of them tell the - * operator anything different. A post the classifier read and found nothing in - * is not a failure at all; it succeeds with an empty tag list. */ -export type LookupFailure = 'not_ready' | 'rate_limited' | 'unavailable'; + * post is queued, or the job is still running past our poll cap. `not_found` + * is entail.dev declining the input with a non-429 4xx (an unknown post, or a + * URL it will not fetch): the operator's input, not an outage. `rate_limited` + * is entail.dev's per-IP limit, which has no key to raise. Everything else — a + * timeout, a 5xx, an unexpected shape — is `unavailable`, an upstream failure. + * A post the classifier read and found nothing in is not a failure at all; it + * succeeds with an empty tag list. */ +export type LookupFailure = 'not_ready' | 'not_found' | 'rate_limited' | 'unavailable'; + +/** A non-429 4xx is entail.dev declining what we sent, not failing. */ +const declined = (status: number) => status >= 400 && status < 500; /** `imageCount` is how many images the source post carried. Suggestions come * from the first one only, so a count above 1 tells the UI the rest went @@ -240,6 +245,10 @@ export async function lookupBlueskySource( console.warn('[entail] post lookup rate limited: status=429'); return fail('rate_limited'); } + if (declined(res.status)) { + console.warn(`[entail] post lookup declined: status=${res.status}`); + return fail('not_found'); + } if (!res.ok) { console.warn(`[entail] post lookup failed: status=${res.status}`); return fail('unavailable'); @@ -297,7 +306,7 @@ function pollEntry(body: unknown): body is ClassificationEntry { /** * Suggestions for a single image URL on an allowlisted CDN: enqueue a - * classification job, then poll it a few times. Gives up (`unavailable`) if + * classification job, then poll it a few times. Gives up (`not_ready`) if * the job isn't done by the attempt cap. Never throws. */ export async function classifyMediaUrl( @@ -305,7 +314,11 @@ export async function classifyMediaUrl( fetchImpl: typeof fetch = fetch, signal?: AbortSignal ): Promise { - if (!isAllowedMediaHost(url)) return fail('unavailable'); + if (!isAllowedMediaHost(url)) { + // The host, not the URL: a media URL is third-party data. + console.warn('[entail] media url host not allowed'); + return fail('unavailable'); + } try { signal?.throwIfAborted(); @@ -319,6 +332,10 @@ export async function classifyMediaUrl( console.warn('[entail] classify enqueue rate limited: status=429'); return fail('rate_limited'); } + if (declined(enqueued.status)) { + console.warn(`[entail] classify enqueue declined: status=${enqueued.status}`); + return fail('not_found'); + } if (!enqueued.ok && enqueued.status !== 202) { console.warn(`[entail] classify enqueue failed: status=${enqueued.status}`); return fail('unavailable'); @@ -354,8 +371,10 @@ export async function classifyMediaUrl( } return { ok: true, suggestions: suggestionsFromResult(body), imageCount: 1 }; } - console.warn(`[entail] classify job unfinished after ${POLL_ATTEMPTS} polls`); - return fail('unavailable'); + // Still running, not broken: the same retry-later answer a queued + // Bluesky post gets. + console.warn(`[entail] classify job not ready after ${POLL_ATTEMPTS} polls`); + return fail('not_ready'); } catch (e) { console.warn(`[entail] classify error: ${errorLabel(e)}`); return fail('unavailable'); diff --git a/src/lib/server/twitter-media.test.ts b/src/lib/server/twitter-media.test.ts index 548cdca5..4dc1c5f5 100644 --- a/src/lib/server/twitter-media.test.ts +++ b/src/lib/server/twitter-media.test.ts @@ -212,16 +212,28 @@ describe('fetchTweetMediaUrl', () => { }); }); - it('fails soft on refusal, a tombstone, malformed JSON, and network errors', async () => { - expect(await fetchTweetMediaUrl(id, stub(() => new Response('no', { status: 403 })).fetchImpl)).toEqual( - unavailable - ); + it('reports a tweet the guest token cannot read as not_found, not an outage', async () => { + // Deleted, protected, or a tombstone: X answers 200 with no tweet in it. + // That is the operator's input, so the endpoint answers 404, not 502. + const notFound = { ok: false, reason: 'not_found' }; expect( await fetchTweetMediaUrl( id, stub(() => json({ data: { tweetResult: { result: { __typename: 'TweetTombstone' } } } })).fetchImpl ) - ).toEqual(unavailable); + ).toEqual(notFound); + expect(await fetchTweetMediaUrl(id, stub(() => json({ data: { tweetResult: {} } })).fetchImpl)).toEqual( + notFound + ); + }); + + it('fails soft on refusal, a server error, malformed JSON, and network errors', async () => { + expect(await fetchTweetMediaUrl(id, stub(() => new Response('no', { status: 403 })).fetchImpl)).toEqual( + unavailable + ); + expect(await fetchTweetMediaUrl(id, stub(() => new Response('boom', { status: 500 })).fetchImpl)).toEqual( + unavailable + ); expect(await fetchTweetMediaUrl(id, stub(() => new Response('')).fetchImpl)).toEqual(unavailable); expect( await fetchTweetMediaUrl( diff --git a/src/lib/server/twitter-media.ts b/src/lib/server/twitter-media.ts index cc0edcbe..747a73a2 100644 --- a/src/lib/server/twitter-media.ts +++ b/src/lib/server/twitter-media.ts @@ -69,15 +69,19 @@ const QUERY_FIELD_TOGGLES = { withDisallowedReplyControls: false } as const; -/** `rate_limited` is X refusing the guest token twice over with a 429; a - * tweet that resolved with no photo is ok with a null URL; everything else - * is `unavailable`. */ +/** `rate_limited` is X refusing the guest token twice over with a 429; + * `not_found` is a 200 with no tweet in it (deleted, protected, or a + * tombstone: nothing a guest token can read); a tweet that resolved with no + * photo is ok with a null URL; everything else (a non-2xx, a body that does + * not parse, a timeout) is `unavailable`. */ export type TweetMediaOutcome = | { ok: true; url: string; photoCount: number } | { ok: true; url: null; photoCount: 0 } - | { ok: false; reason: 'rate_limited' | 'unavailable' }; + | { ok: false; reason: TweetMediaFailure }; -const fail = (reason: 'rate_limited' | 'unavailable'): TweetMediaOutcome => ({ ok: false, reason }); +export type TweetMediaFailure = 'not_found' | 'rate_limited' | 'unavailable'; + +const fail = (reason: TweetMediaFailure): TweetMediaOutcome => ({ ok: false, reason }); type TweetMedia = { type?: unknown; media_url_https?: unknown }; @@ -180,10 +184,11 @@ export async function fetchTweetMediaUrl( } const photos = parseTweetPhotos(await res.json()); if (!photos) { - // 200 but no tweet — a protected or deleted one, or the undocumented - // GraphQL shape rotated (see the file header). + // 200 but no tweet: a protected or deleted one, which is the operator's + // input and not an outage. If the undocumented GraphQL shape rotated + // (see the file header) this fires for every tweet, so the log stays. console.warn('[tweet-media] tweet media lookup had no tweet'); - return fail('unavailable'); + return fail('not_found'); } if (photos.url === null) { // A resolved text, video or GIF tweet: nothing to classify, not an outage. diff --git a/src/routes/api/admin/tag-suggestions/+server.ts b/src/routes/api/admin/tag-suggestions/+server.ts index 42532b40..28845f64 100644 --- a/src/routes/api/admin/tag-suggestions/+server.ts +++ b/src/routes/api/admin/tag-suggestions/+server.ts @@ -9,7 +9,7 @@ import { type LookupFailure, type LookupOutcome } from '$lib/server/entail'; -import { fetchTweetMediaUrl } from '$lib/server/twitter-media'; +import { fetchTweetMediaUrl, type TweetMediaFailure } from '$lib/server/twitter-media'; import type { RequestHandler } from './$types'; // POST /api/admin/tag-suggestions (admin-only via hooks — everything under @@ -33,16 +33,21 @@ import type { RequestHandler } from './$types'; // are returned, and the tags have been through the same sanitizer the tag // inputs use. -/** What the UI gets back for a failed lookup, and the status carrying it. */ -const FAILURE_STATUS: Record = { - // 202 for not_ready: the classifier has the post queued, nothing is broken, - // and hooks.server.ts counts every 5xx into the site's error metric, so a - // 502 here would book a server error against the site on each retry. - // unavailable stays 502, not 401 or 503: a real upstream failure belongs in - // that error rollup, and the admin gate answers an expired session with its - // own 401 and a plain-text body, so a 401 here would read as a logged-out - // operator. The body's `error` field is what tells the cases apart. +/** What the UI gets back for a failed lookup, and the status carrying it. + * hooks.server.ts counts every 5xx into the site's error metric, so only a + * real upstream failure gets a 5xx; the rest are outcomes, not errors. + * 202 not_ready: queued, or still classifying past our poll cap. Retry. + * 404 not_found: an unknown imageId, a source post the guest token cannot + * read (deleted, protected), or the classifier declined the input. + * 429 rate_limited: entail.dev's or X's per-IP limit. + * 502 unavailable: an upstream failure only (a 5xx, a timeout, a body we + * cannot read). Not 401 or 503: the admin gate answers an expired + * session with its own 401 and a plain-text body, so a 401 here would + * read as a logged-out operator. + * The body's `error` field is what tells the cases apart. */ +const FAILURE_STATUS: Record = { not_ready: 202, + not_found: 404, rate_limited: 429, unavailable: 502 }; @@ -62,7 +67,7 @@ export { LOOKUP_DEADLINE_MS as _LOOKUP_DEADLINE_MS }; * anything past this is refused without handing it to JSON.parse. */ const MAX_BODY_BYTES = 4096; -const failure = (reason: LookupFailure) => +const failure = (reason: LookupFailure | TweetMediaFailure) => json({ error: reason }, { status: FAILURE_STATUS[reason] }); const invalid = () => json({ error: 'invalid_request' }, { status: 400 }); @@ -99,7 +104,7 @@ export const POST: RequestHandler = async ({ request, platform }) => { .from(images) .where(eq(images.id, imageId)) .get(); - if (!row) return json({ error: 'not_found' }, { status: 404 }); + if (!row) return failure('not_found'); sourcePostUrl = row.sourcePostUrl ?? ''; } else { if (typeof body.sourcePostUrl !== 'string') return invalid(); diff --git a/src/routes/api/admin/tag-suggestions/server.test.ts b/src/routes/api/admin/tag-suggestions/server.test.ts index 7fa9cb1b..542bbc78 100644 --- a/src/routes/api/admin/tag-suggestions/server.test.ts +++ b/src/routes/api/admin/tag-suggestions/server.test.ts @@ -305,6 +305,36 @@ describe('POST /api/admin/tag-suggestions', () => { expect(classifyMediaUrl).not.toHaveBeenCalled(); }); + it('404s not_found when the post cannot be read or the classifier declined it', async () => { + const { platform } = makeEnv(); + // The operator's input, not an outage: the same 404 an unknown imageId + // gets, and not a 5xx the site's error metric would count. + lookupBlueskySource.mockResolvedValue({ ok: false, reason: 'not_found' }); + const bsky = await POST(event(platform, { sourcePostUrl: BSKY_POST })); + expect(bsky.status).toBe(404); + expect(await bsky.json()).toEqual({ error: 'not_found' }); + + fetchTweetMediaUrl.mockResolvedValue({ ok: false, reason: 'not_found' }); + const x = await POST(event(platform, { sourcePostUrl: X_POST })); + expect(x.status).toBe(404); + expect(await x.json()).toEqual({ error: 'not_found' }); + expect(classifyMediaUrl).not.toHaveBeenCalled(); + + fetchTweetMediaUrl.mockResolvedValue({ ok: true, url: MEDIA_URL, photoCount: 1 }); + classifyMediaUrl.mockResolvedValue({ ok: false, reason: 'not_found' }); + const declined = await POST(event(platform, { sourcePostUrl: X_POST })); + expect(declined.status).toBe(404); + expect(await declined.json()).toEqual({ error: 'not_found' }); + }); + + it('202s not_ready when a classify job is still running at the poll cap', async () => { + const { platform } = makeEnv(); + classifyMediaUrl.mockResolvedValue({ ok: false, reason: 'not_ready' }); + const res = await POST(event(platform, { sourcePostUrl: X_POST })); + expect(res.status).toBe(202); + expect(await res.json()).toEqual({ error: 'not_ready' }); + }); + it('429s when entail.dev rate limited us', async () => { const { platform } = makeEnv(); lookupBlueskySource.mockResolvedValue({ ok: false, reason: 'rate_limited' }); From 3419ae6fb78367a248f749ebdddcfce8e4a219ae Mon Sep 17 00:00:00 2001 From: Sparky <1609870+sparkyfen@users.noreply.github.com> Date: Mon, 7 Sep 2026 21:10:18 -0700 Subject: [PATCH 16/22] fix(admin): only input-rejection codes count as the classifier declining (SONA-220) A 401, 403, or 408 from entail.dev means the integration is broken, so it stays an upstream failure rather than reading as a declined post. Fix the Bluesky lookup docstring that still described the old 202 handling. --- src/lib/server/entail.test.ts | 9 +++++++++ src/lib/server/entail.ts | 14 +++++++++----- 2 files changed, 18 insertions(+), 5 deletions(-) diff --git a/src/lib/server/entail.test.ts b/src/lib/server/entail.test.ts index b881b1d4..2f921918 100644 --- a/src/lib/server/entail.test.ts +++ b/src/lib/server/entail.test.ts @@ -321,6 +321,11 @@ describe('lookupBlueskySource', () => { ).toEqual({ ok: false, reason: 'not_found' }); expect(warn.mock.calls.map((c) => c.join(' ')).join('\n')).toContain(`status=${status}`); } + // A 403 is an edge block or an auth failure, not a declined post: the + // integration is broken, and that has to surface as an outage. + expect( + await lookupBlueskyPost(url, vi.fn(async () => new Response('blocked', { status: 403 }))) + ).toEqual({ ok: false, reason: 'unavailable' }); }); it('logs a malformed body as a parse failure without quoting it', async () => { @@ -573,6 +578,10 @@ describe('classifyMediaUrl', () => { }); expect(warn.mock.calls.map((c) => c.join(' ')).join('\n')).toContain(`status=${status}`); } + expect(await classifyMediaUrl(url, vi.fn(async () => new Response('blocked', { status: 403 })))).toEqual({ + ok: false, + reason: 'unavailable' + }); }); it('names a rate limit from either the enqueue or a poll', async () => { diff --git a/src/lib/server/entail.ts b/src/lib/server/entail.ts index 52d9bf7e..00d542ad 100644 --- a/src/lib/server/entail.ts +++ b/src/lib/server/entail.ts @@ -68,8 +68,12 @@ export type Suggestions = { * succeeds with an empty tag list. */ export type LookupFailure = 'not_ready' | 'not_found' | 'rate_limited' | 'unavailable'; -/** A non-429 4xx is entail.dev declining what we sent, not failing. */ -const declined = (status: number) => status >= 400 && status < 500; +/** The 4xx codes that mean entail.dev will not take this input (a post it + * does not index, a URL it rejects). Auth, quota, and edge-block codes such as + * 401, 403, and 408 are not in the set: those mean the integration is broken, + * and they fall through to `unavailable` so an outage stays visible. */ +const DECLINED_STATUSES = new Set([400, 404, 410, 415, 422]); +const declined = (status: number) => DECLINED_STATUSES.has(status); /** `imageCount` is how many images the source post carried. Suggestions come * from the first one only, so a count above 1 tells the UI the rest went @@ -222,9 +226,9 @@ function postImages(body: unknown): ClassificationEntry[] | null { /** * Suggestions for a Bluesky post classifySourceUrl has already validated. - * Uses the post's first classified image; a post whose images entail.dev - * hasn't classified yet answers 202, which we treat as "nothing to suggest" - * rather than waiting around. Never throws. + * Uses the post's first classified image; a post entail.dev has queued but + * not classified yet answers 202, which comes back as the `not_ready` failure + * for the caller to retry later. Never throws. */ export async function lookupBlueskySource( source: Extract, From b05a7426101c768b3bdd320478ff3c498a2da292 Mon Sep 17 00:00:00 2001 From: Sparky <1609870+sparkyfen@users.noreply.github.com> Date: Mon, 7 Sep 2026 21:12:55 -0700 Subject: [PATCH 17/22] docs(admin): describe not_found by the declined status set (SONA-220) --- src/lib/server/entail.test.ts | 4 ++-- src/lib/server/entail.ts | 5 +++-- 2 files changed, 5 insertions(+), 4 deletions(-) diff --git a/src/lib/server/entail.test.ts b/src/lib/server/entail.test.ts index 2f921918..4f008530 100644 --- a/src/lib/server/entail.test.ts +++ b/src/lib/server/entail.test.ts @@ -310,7 +310,7 @@ describe('lookupBlueskySource', () => { ).toEqual({ ok: false, reason: 'unavailable' }); }); - it('reports a non-429 4xx from /post as not_found, with the status in the log', async () => { + it('reports an input-rejection 4xx from /post as not_found, with the status in the log', async () => { // entail.dev declining the input is the operator's problem, not an // outage: the endpoint answers 404, which hooks.server.ts does not count // as a site error the way it counts a 502. @@ -569,7 +569,7 @@ describe('classifyMediaUrl', () => { ).toEqual(unavailable); }); - it('reports a non-429 4xx from the classify enqueue as not_found', async () => { + it('reports an input-rejection 4xx from the classify enqueue as not_found', async () => { const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}); for (const status of [404, 400]) { expect(await classifyMediaUrl(url, vi.fn(async () => new Response('no', { status })))).toEqual({ diff --git a/src/lib/server/entail.ts b/src/lib/server/entail.ts index 00d542ad..b5e107f4 100644 --- a/src/lib/server/entail.ts +++ b/src/lib/server/entail.ts @@ -60,8 +60,9 @@ export type Suggestions = { /** Why a lookup produced nothing. `not_ready` is the one worth retrying: the * post is queued, or the job is still running past our poll cap. `not_found` - * is entail.dev declining the input with a non-429 4xx (an unknown post, or a - * URL it will not fetch): the operator's input, not an outage. `rate_limited` + * is entail.dev refusing the input with one of DECLINED_STATUSES (an unknown + * post, or a URL it will not fetch): the operator's input, not an outage; auth + * and edge-block codes are `unavailable` instead. `rate_limited` * is entail.dev's per-IP limit, which has no key to raise. Everything else — a * timeout, a 5xx, an unexpected shape — is `unavailable`, an upstream failure. * A post the classifier read and found nothing in is not a failure at all; it From d84dbe098bab44a74223708409817372d918e467 Mon Sep 17 00:00:00 2001 From: Sparky <1609870+sparkyfen@users.noreply.github.com> Date: Mon, 7 Sep 2026 21:16:23 -0700 Subject: [PATCH 18/22] fix(admin): clamp the Bluesky image count and state the 202 caller contract (SONA-220) --- src/lib/server/entail.test.ts | 7 +++++++ src/lib/server/entail.ts | 6 +++++- src/routes/api/admin/tag-suggestions/+server.ts | 4 ++++ 3 files changed, 16 insertions(+), 1 deletion(-) diff --git a/src/lib/server/entail.test.ts b/src/lib/server/entail.test.ts index 4f008530..a8b4aeae 100644 --- a/src/lib/server/entail.test.ts +++ b/src/lib/server/entail.test.ts @@ -260,6 +260,13 @@ describe('lookupBlueskySource', () => { ] }; + it('clamps the image count to what a post can carry', async () => { + const many = { images: Array.from({ length: 9 }, () => post.images[0]) }; + const fetchImpl = vi.fn(async (_url: string | URL | Request) => json(many)); + const outcome = await lookupBlueskyPost(url, fetchImpl); + expect(outcome.ok && outcome.imageCount).toBe(4); + }); + it('uses the first image only, and reports how many there were', async () => { const fetchImpl = vi.fn(async (_url: string | URL | Request) => json(post)); expect(await lookupBlueskyPost(url, fetchImpl)).toEqual({ diff --git a/src/lib/server/entail.ts b/src/lib/server/entail.ts index b5e107f4..10b86b97 100644 --- a/src/lib/server/entail.ts +++ b/src/lib/server/entail.ts @@ -35,6 +35,10 @@ export const MAX_RAW_ENTRIES = 200; * hostile, and the tail past the bound is dropped unsorted. */ export const MAX_SORTED_ENTRIES = 2000; +/** Bluesky allows four images per post; anything above that in a reply is a + * hostile or broken body, so the count shown to the operator is clamped. */ +export const MAX_POST_IMAGES = 4; + /** Longest raw tag name translateTag looks at. e621 tags run well under this; * the cut keeps the qualifier-stripping regex off a very long input. */ const MAX_RAW_TAG_LENGTH = 200; @@ -270,7 +274,7 @@ export async function lookupBlueskySource( return { ok: true, suggestions: suggestionsFromResult(images[0]), - imageCount: images.length + imageCount: Math.min(images.length, MAX_POST_IMAGES) }; } catch (e) { console.warn(`[entail] post lookup error: ${errorLabel(e)}`); diff --git a/src/routes/api/admin/tag-suggestions/+server.ts b/src/routes/api/admin/tag-suggestions/+server.ts index 28845f64..1148db5a 100644 --- a/src/routes/api/admin/tag-suggestions/+server.ts +++ b/src/routes/api/admin/tag-suggestions/+server.ts @@ -18,6 +18,10 @@ import type { RequestHandler } from './$types'; // Suggests tags for an image from its source post, by asking entail.dev's // public classifier what is in the picture (SONA-220). Two request shapes: // +// Caller contract: a 202 is `not_ready` and carries `{ error }` with no tags, +// even though `res.ok` is true. Branch on `res.status === 202` (retry later) +// before treating an ok response as a suggestion payload. +// // { imageId } — the edit page, where the source URL is already stored. // { sourcePostUrl } — the upload page, where there is no image row yet and // the URL is whatever the operator has typed so far. From 4e51be90382699452b9086104c855cd440f11285 Mon Sep 17 00:00:00 2001 From: Sparky <1609870+sparkyfen@users.noreply.github.com> Date: Mon, 7 Sep 2026 21:19:45 -0700 Subject: [PATCH 19/22] fix(admin): terminal jobs, X error envelopes, and slashed tags (SONA-220) - A classify job that reports a terminal status is unavailable, not pending, so the operator is not told to retry a job that will never finish. - An X reply carrying a GraphQL errors array is an outage, not a post that cannot be read. - Slashes and colons in a classifier tag become hyphens instead of vanishing. --- src/lib/server/entail.test.ts | 35 ++++++++++++++++++++++++++++ src/lib/server/entail.ts | 14 +++++++++-- src/lib/server/twitter-media.test.ts | 14 +++++++++++ src/lib/server/twitter-media.ts | 12 +++++++++- 4 files changed, 72 insertions(+), 3 deletions(-) diff --git a/src/lib/server/entail.test.ts b/src/lib/server/entail.test.ts index a8b4aeae..71329622 100644 --- a/src/lib/server/entail.test.ts +++ b/src/lib/server/entail.test.ts @@ -109,6 +109,15 @@ describe('translateTag', () => { expect(translateTag(' Pink_Hair ')).toBe('pink-hair'); }); + it('hyphenates a slash or colon instead of gluing the words together', () => { + // The sanitizer drops anything outside [a-z0-9 -], so without this + // `male/female` would collapse into `malefemale`. + expect(translateTag('male/female')).toBe('male-female'); + expect(translateTag('canine/wolf_(species)')).toBe('canine-wolf'); + // Still needs a letter: `2:1` is a ratio tag, not a word. + expect(translateTag('2:1')).toBeNull(); + }); + it('returns null when nothing survives', () => { expect(translateTag('(artwork)')).toBeNull(); expect(translateTag('!!!')).toBeNull(); @@ -539,6 +548,32 @@ describe('classifyMediaUrl', () => { expect(polls).toBe(2); }); + it('keeps polling on a running status but gives up on a terminal one', async () => { + // `running` is pending, so it goes the full poll cap and answers + // not_ready. `failed` will never finish: the retry-later answer would only + // have the operator retry a dead job, so it is unavailable after one poll. + const pollsWith = (status: string) => { + let polls = 0; + const fetchImpl = vi.fn(async (_url: string | URL | Request, init?: RequestInit) => { + if (init?.method === 'POST') return json({ job_id: 'job-9' }, 202); + polls++; + return json({ status }); + }); + return { fetchImpl, polls: () => polls }; + }; + const running = pollsWith('running'); + expect(await classifyMediaUrl(url, running.fetchImpl)).toEqual({ ok: false, reason: 'not_ready' }); + expect(running.polls()).toBe(2); + + const failed = pollsWith('failed'); + expect(await classifyMediaUrl(url, failed.fetchImpl)).toEqual({ ok: false, reason: 'unavailable' }); + expect(failed.polls()).toBe(1); + + const shouting = pollsWith('ERROR'); + expect(await classifyMediaUrl(url, shouting.fetchImpl)).toEqual({ ok: false, reason: 'unavailable' }); + expect(shouting.polls()).toBe(1); + }); + // The poll endpoint answers `wait=true` by holding the connection until the // classifier finishes — about five seconds for a fresh job. A poll timeout // shorter than that hold aborts the response we asked to wait for, which is diff --git a/src/lib/server/entail.ts b/src/lib/server/entail.ts index 10b86b97..9845c511 100644 --- a/src/lib/server/entail.ts +++ b/src/lib/server/entail.ts @@ -79,6 +79,9 @@ export type LookupFailure = 'not_ready' | 'not_found' | 'rate_limited' | 'unavai * and they fall through to `unavailable` so an outage stays visible. */ const DECLINED_STATUSES = new Set([400, 404, 410, 415, 422]); const declined = (status: number) => DECLINED_STATUSES.has(status); +/** Poll-body statuses that mean the job will never finish. Any other non-done + * status (queued, running, processing) is still pending. */ +const TERMINAL_STATUSES = new Set(['failed', 'error', 'cancelled', 'canceled']); /** `imageCount` is how many images the source post carried. Suggestions come * from the first one only, so a count above 1 tells the UI the rest went @@ -162,7 +165,8 @@ export function classifySourceUrl(url: string): SourceKind | null { /** * Translate one e621-vocabulary tag into a Sona tag. Drops the trailing * qualifier e621 appends to disambiguate (`digital_media_(artwork)`), swaps - * underscores for hyphens, then runs the same sanitizer the tag inputs use. + * underscores, slashes and colons (`male/female`, `2:1`) for hyphens, then + * runs the same sanitizer the tag inputs use. * Emoticon tags (`<3`, `^_^`, `-_-`, `:3`) sanitize down to bare digits or * hyphens, so the result also needs a letter to count. Returns null when * nothing usable is left. Pure. @@ -173,7 +177,7 @@ export function translateTag(tag: string): string | null { .trim() .toLowerCase() .replace(/[\s_]*\([^()]*\)\s*$/, '') - .replace(/_/g, '-'); + .replace(/[_/:]/g, '-'); const sanitized = sanitizeTag(translated) .replace(/-{2,}/g, '-') .replace(/^-|-$/g, ''); @@ -371,6 +375,12 @@ export async function classifyMediaUrl( // The spec documents a 200 as "job done" and does not type a status // field, so only an explicit contradiction sends us back to poll. const body = (await res.json()) as (ClassificationEntry & { status?: unknown }) | null; + if (typeof body?.status === 'string' && TERMINAL_STATUSES.has(body.status.toLowerCase())) { + // A job that will never finish: polling on would only have the + // operator retry it. The status word is the API's, not user input. + console.warn(`[entail] classify job failed: status=${body.status}`); + return fail('unavailable'); + } if (body?.status && body.status !== 'done') continue; // A finished job carries a `tags` array. Anything else (null, a string, // an error envelope) is a shape we don't know, not an empty result. diff --git a/src/lib/server/twitter-media.test.ts b/src/lib/server/twitter-media.test.ts index 4dc1c5f5..9882c21a 100644 --- a/src/lib/server/twitter-media.test.ts +++ b/src/lib/server/twitter-media.test.ts @@ -227,6 +227,20 @@ describe('fetchTweetMediaUrl', () => { ); }); + it('reports a 200 that carries an errors array as unavailable, not not_found', async () => { + // A rotated query id or features map: X answers 200 with `errors` and no + // tweet. That is the integration, not the operator's post, so 502 not 404. + const errors = [{ message: 'Query not found', code: 42 }]; + expect(await fetchTweetMediaUrl(id, stub(() => json({ errors })).fetchImpl)).toEqual(unavailable); + expect( + await fetchTweetMediaUrl(id, stub(() => json({ errors, data: { tweetResult: {} } })).fetchImpl) + ).toEqual(unavailable); + // An empty errors array carries no error: still the not_found path. + expect( + await fetchTweetMediaUrl(id, stub(() => json({ errors: [], data: { tweetResult: {} } })).fetchImpl) + ).toEqual({ ok: false, reason: 'not_found' }); + }); + it('fails soft on refusal, a server error, malformed JSON, and network errors', async () => { expect(await fetchTweetMediaUrl(id, stub(() => new Response('no', { status: 403 })).fetchImpl)).toEqual( unavailable diff --git a/src/lib/server/twitter-media.ts b/src/lib/server/twitter-media.ts index 747a73a2..a518e001 100644 --- a/src/lib/server/twitter-media.ts +++ b/src/lib/server/twitter-media.ts @@ -182,7 +182,17 @@ export async function fetchTweetMediaUrl( console.warn(`[tweet-media] tweet media lookup failed: status=${res.status}`); return fail('unavailable'); } - const photos = parseTweetPhotos(await res.json()); + const body: unknown = await res.json(); + const errors = (body as { errors?: unknown } | null)?.errors; + if (Array.isArray(errors) && errors.length > 0) { + // X answers 200 with an `errors` array when the undocumented query id + // or features map rotates. That is our integration, not the tweet, so + // it must not read as "post not found". Count only: the messages are + // X's, and can carry the request back at us. + console.warn(`[tweet-media] tweet lookup returned errors: count=${errors.length}`); + return fail('unavailable'); + } + const photos = parseTweetPhotos(body); if (!photos) { // 200 but no tweet: a protected or deleted one, which is the operator's // input and not an outage. If the undocumented GraphQL shape rotated From d48a4162edd13f952312a7ce931fd73b08387125 Mon Sep 17 00:00:00 2001 From: Sparky <1609870+sparkyfen@users.noreply.github.com> Date: Mon, 7 Sep 2026 22:47:33 -0700 Subject: [PATCH 20/22] fix(admin): read the request body up to the cap instead of buffering it (SONA-220) A chunked body with no Content-Length was buffered whole before the 4096-byte check. Read it chunk by chunk and cancel at the first byte over. Exercise the lookup deadline in the endpoint test, and scope the AI policy's visitor sentence to normal operation. --- AI_POLICY.md | 5 +- .../api/admin/tag-suggestions/+server.ts | 37 ++++++++++- .../api/admin/tag-suggestions/server.test.ts | 62 +++++++++++++++++++ 3 files changed, 100 insertions(+), 4 deletions(-) diff --git a/AI_POLICY.md b/AI_POLICY.md index 75f1e37a..a76d9f62 100644 --- a/AI_POLICY.md +++ b/AI_POLICY.md @@ -27,7 +27,10 @@ deploy. There is no review team here, just one maintainer and a set of tools. The running site makes one AI call of its own. When the operator asks for tag suggestions on a piece of artwork, the site sends entail.dev, an image classifier, a public link to the artwork's source post or to the picture in -that post. Nothing a visitor does reaches an AI service. +that post. In normal operation, nothing a visitor does reaches an AI service. +The one exception is the diagnostic case the /ai page describes: when the +maintainer is debugging, logs that can include visitor IP addresses and page +URLs may pass through the development tools. ## Rules the agents work under diff --git a/src/routes/api/admin/tag-suggestions/+server.ts b/src/routes/api/admin/tag-suggestions/+server.ts index 1148db5a..bd2f3508 100644 --- a/src/routes/api/admin/tag-suggestions/+server.ts +++ b/src/routes/api/admin/tag-suggestions/+server.ts @@ -76,16 +76,47 @@ const failure = (reason: LookupFailure | TweetMediaFailure) => const invalid = () => json({ error: 'invalid_request' }, { status: 400 }); +/** Reads the body chunk by chunk and stops at the first byte past the cap, so + * a chunked request with no Content-Length is refused without being buffered + * whole first. Null for no body, a read error, or too many bytes. */ +async function readBody(stream: ReadableStream | null): Promise { + if (!stream) return null; + const reader = stream.getReader(); + const chunks: Uint8Array[] = []; + let total = 0; + try { + for (;;) { + const { done, value } = await reader.read(); + if (done) break; + total += value.length; + if (total > MAX_BODY_BYTES) { + await reader.cancel(); + return null; + } + chunks.push(value); + } + } catch { + return null; + } + const bytes = new Uint8Array(total); + let offset = 0; + for (const chunk of chunks) { + bytes.set(chunk, offset); + offset += chunk.length; + } + return bytes; +} + type Body = { imageId?: unknown; sourcePostUrl?: unknown }; export const POST: RequestHandler = async ({ request, platform }) => { const declared = Number(request.headers.get('content-length')); if (Number.isFinite(declared) && declared > MAX_BODY_BYTES) return invalid(); - const text = await request.text().catch(() => null); - if (text === null || new TextEncoder().encode(text).length > MAX_BODY_BYTES) return invalid(); + const bytes = await readBody(request.body); + if (bytes === null) return invalid(); let body: Body | null; try { - body = JSON.parse(text); + body = JSON.parse(new TextDecoder().decode(bytes)); } catch { body = null; } diff --git a/src/routes/api/admin/tag-suggestions/server.test.ts b/src/routes/api/admin/tag-suggestions/server.test.ts index 542bbc78..39286581 100644 --- a/src/routes/api/admin/tag-suggestions/server.test.ts +++ b/src/routes/api/admin/tag-suggestions/server.test.ts @@ -158,6 +158,32 @@ describe('POST /api/admin/tag-suggestions', () => { expect(_LOOKUP_DEADLINE_MS).toBeGreaterThanOrEqual(firstAttempt); }); + it('502s unavailable when the deadline fires mid-lookup', async () => { + const { platform } = makeEnv(); + // Own the deadline's signal so the test can fire it, and make the lookup + // answer only once it has: that is how a real lookup reports an abort. + const controller = new AbortController(); + const timeout = vi.spyOn(AbortSignal, 'timeout').mockReturnValue(controller.signal); + try { + lookupBlueskySource.mockImplementation( + (_source, _fetch, signal) => + new Promise((resolve) => { + signal!.addEventListener('abort', () => resolve({ ok: false, reason: 'unavailable' })); + }) + ); + const pending = POST(event(platform, { sourcePostUrl: BSKY_POST })); + // The body read takes a few ticks; wait until the lookup is in flight. + await vi.waitFor(() => expect(lookupBlueskySource).toHaveBeenCalledTimes(1)); + expect(lookupBlueskySource.mock.calls[0]?.[2]).toBe(controller.signal); + controller.abort(); + const res = await pending; + expect(res.status).toBe(502); + expect(await res.json()).toEqual({ error: 'unavailable' }); + } finally { + timeout.mockRestore(); + } + }); + it('refuses a declared Content-Length over the cap before reading the body', async () => { const { platform } = makeEnv(); const request = new Request('http://localhost/api/admin/tag-suggestions', { @@ -254,6 +280,42 @@ describe('POST /api/admin/tag-suggestions', () => { expect((await POST(event(platform, undefined, wide))).status).toBe(400); }); + it('stops reading a chunked body at the cap instead of buffering it whole', async () => { + const { platform } = makeEnv(); + // No Content-Length, so the precheck cannot refuse it. Ten 1 KiB chunks: + // the read should give up once it passes 4096 bytes and cancel the + // stream, never pulling the rest. + const chunk = new TextEncoder().encode('x'.repeat(1024)); + let pulled = 0; + let cancelled = false; + const body = new ReadableStream({ + pull(controller) { + if (pulled === 10) { + controller.close(); + return; + } + pulled += 1; + controller.enqueue(chunk); + }, + cancel() { + cancelled = true; + } + }); + const request = new Request('http://localhost/api/admin/tag-suggestions', { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body, + // Node requires this for a streaming request body. + duplex: 'half' + } as RequestInit); + const res = await POST({ request, platform } as never); + expect(res.status).toBe(400); + expect(await res.json()).toEqual({ error: 'invalid_request' }); + expect(cancelled).toBe(true); + expect(pulled).toBeLessThan(10); + expect(lookupBlueskySource).not.toHaveBeenCalled(); + }); + it('200s with no tags when the classifier found nothing to suggest', async () => { const { platform } = makeEnv(); // The classifier read the post and rated it; nothing cleared the From 145519d06f0054686fdd484987b378c224087bae Mon Sep 17 00:00:00 2001 From: Sparky <1609870+sparkyfen@users.noreply.github.com> Date: Mon, 7 Sep 2026 23:49:27 -0700 Subject: [PATCH 21/22] fix(admin): send no User-Agent on X guest-token requests (SONA-220) Guest tokens are meant to come from browsers, so the site should not name itself on these requests. Drop the explicit header and go back to the runtime default, which X accepts from Workers. Operator decision. --- src/lib/server/twitter-avatar.test.ts | 6 ------ src/lib/server/twitter-avatar.ts | 7 +------ src/lib/server/twitter-media.test.ts | 11 ----------- 3 files changed, 1 insertion(+), 23 deletions(-) diff --git a/src/lib/server/twitter-avatar.test.ts b/src/lib/server/twitter-avatar.test.ts index ee6908ae..593aca01 100644 --- a/src/lib/server/twitter-avatar.test.ts +++ b/src/lib/server/twitter-avatar.test.ts @@ -1,6 +1,5 @@ import { afterEach, describe, expect, it, vi } from 'vitest'; import { - X_USER_AGENT, twitterHandleFromUrl, parseUserAvatar, to400x400, @@ -90,12 +89,7 @@ describe('fetchTwitterAvatar', () => { expect(await fetchTwitterAvatar('https://x.com/examplefox')).toBe( 'https://pbs.twimg.com/profile_images/9/pic_400x400.jpg' ); - // api.x.com 404s Node's default `User-Agent: node`; the activation and - // the lookup both send the shared one. expect(fetchImpl).toHaveBeenCalledTimes(2); - for (const [, init] of fetchImpl.mock.calls) { - expect(new Headers(init?.headers).get('user-agent')).toBe(X_USER_AGENT); - } }); it('retries once with a fresh token on 429, then succeeds', async () => { diff --git a/src/lib/server/twitter-avatar.ts b/src/lib/server/twitter-avatar.ts index 78ea6f5d..185a749f 100644 --- a/src/lib/server/twitter-avatar.ts +++ b/src/lib/server/twitter-avatar.ts @@ -16,10 +16,6 @@ import { errorLabel, timeoutSignal } from './fetch-errors'; const X_BEARER = 'Bearer AAAAAAAAAAAAAAAAAAAAANRILgAAAAAAnNwIzUejRCOuH5E6I8xnZz4puTs%3D1Zv7ttfk8LF81IUq16cHjhLTvJu4FA33AGWWjCpTnA'; const X_ACTIVATE = 'https://api.x.com/1.1/guest/activate.json'; -/** api.x.com answers 404 to Node's default `User-Agent: node` and 200 to any - * other value (verified 2026-09-07), so every request names itself. - * xGraphqlHeaders carries it; exported so the tests can assert the header. */ -export const X_USER_AGENT = 'Mozilla/5.0 (compatible; Sona; +https://github.com/sona-fast/sona)'; const X_USER_BY_SCREEN_NAME = 'https://api.x.com/graphql/IGgvgiOx4QZndDHuD3x9TQ/UserByScreenName'; const FETCH_TIMEOUT_MS = 5000; @@ -63,7 +59,7 @@ export async function activateGuestToken( try { const res = await fetchImpl(X_ACTIVATE, { method: 'POST', - headers: { Authorization: X_BEARER, 'User-Agent': X_USER_AGENT }, + headers: { Authorization: X_BEARER }, signal: timeoutSignal(FETCH_TIMEOUT_MS, signal) }); if (!res.ok) { @@ -89,7 +85,6 @@ export function xGraphqlHeaders(guestToken: string): Record { .join(''); return { Authorization: X_BEARER, - 'User-Agent': X_USER_AGENT, 'x-guest-token': guestToken, 'x-csrf-token': csrf, 'x-twitter-active-user': 'yes', diff --git a/src/lib/server/twitter-media.test.ts b/src/lib/server/twitter-media.test.ts index 9882c21a..0b33ac50 100644 --- a/src/lib/server/twitter-media.test.ts +++ b/src/lib/server/twitter-media.test.ts @@ -1,5 +1,4 @@ import { afterEach, describe, expect, it, vi } from 'vitest'; -import { X_USER_AGENT } from './twitter-avatar'; import { fetchTweetMediaUrl, parseTweetPhotos } from './twitter-media'; afterEach(() => { @@ -140,10 +139,6 @@ describe('fetchTweetMediaUrl', () => { expect(tokens).toEqual(['gt-1']); const lookup = String(fetchImpl.mock.calls.find(([t]) => !String(t).includes('guest/activate'))?.[0]); expect(lookup).toContain(encodeURIComponent(`"tweetId":"${id}"`)); - // api.x.com 404s Node's default `User-Agent: node`, so both calls name themselves. - for (const [, init] of fetchImpl.mock.calls) { - expect(new Headers(init?.headers).get('user-agent')).toBe(X_USER_AGENT); - } }); it('retries once with a fresh token on 401', async () => { @@ -154,9 +149,6 @@ describe('fetchTweetMediaUrl', () => { expect(outcome.ok && outcome.url).toContain('AbCdEf123'); expect(activations.count).toBe(2); expect(tokens).toEqual(['gt-1', 'gt-2']); - for (const [, init] of fetchImpl.mock.calls) { - expect(new Headers(init?.headers).get('user-agent')).toBe(X_USER_AGENT); - } }); it('retries once with a fresh token on 429', async () => { @@ -167,9 +159,6 @@ describe('fetchTweetMediaUrl', () => { expect(outcome.ok && outcome.url).toContain('AbCdEf123'); expect(activations.count).toBe(2); expect(tokens).toEqual(['gt-1', 'gt-2']); - for (const [, init] of fetchImpl.mock.calls) { - expect(new Headers(init?.headers).get('user-agent')).toBe(X_USER_AGENT); - } }); it('returns unavailable without fetching when the deadline has already passed', async () => { From 2fbe43e52f15eefb5465e1983722812ae98ba6f9 Mon Sep 17 00:00:00 2001 From: Sparky <1609870+sparkyfen@users.noreply.github.com> Date: Mon, 14 Sep 2026 13:02:35 -0700 Subject: [PATCH 22/22] Let the lookup deadline clear both polls of a first attempt The deadline was set from a sum that counted one poll. A job still running answers the first poll 202 and takes a second, so at every timeout a first attempt on an X post runs 29.25 seconds: activate, tweet lookup, enqueue, and two polls with the pause between them. At 22 seconds the deadline aborted that second poll and the operator saw a 502 "unavailable", counted as a site error, where the poll cap exists to answer 202 "not ready". The deadline is 32 seconds, the pin sums the real chain from the exported constants, and a client test shows the 502 a short deadline produces. --- src/lib/server/entail.test.ts | 22 ++++++++++++ src/lib/server/entail.ts | 6 ++-- src/lib/server/twitter-avatar.ts | 2 +- src/lib/server/twitter-media.ts | 2 +- .../api/admin/tag-suggestions/+server.ts | 15 +++++--- .../api/admin/tag-suggestions/server.test.ts | 36 ++++++++++++++----- 6 files changed, 65 insertions(+), 18 deletions(-) diff --git a/src/lib/server/entail.test.ts b/src/lib/server/entail.test.ts index 71329622..1ba1e959 100644 --- a/src/lib/server/entail.test.ts +++ b/src/lib/server/entail.test.ts @@ -548,6 +548,28 @@ describe('classifyMediaUrl', () => { expect(polls).toBe(2); }); + it('loses that not_ready when the caller deadline cuts the second poll', async () => { + // The reason the endpoint's ceiling has to clear both polls and the pause + // between them: a job still running answers the first poll 202, and a + // deadline that fires during the second one aborts the fetch, so the + // operator gets a 502 instead of the 202 the poll cap is there to give. + const deadline = new AbortController(); + let polls = 0; + const fetchImpl = vi.fn(async (_url: string | URL | Request, init?: RequestInit) => { + if (init?.method === 'POST') return json({ job_id: 'job-4' }, 202); + polls++; + if (polls === 1) return json({ status: 'processing' }, 202); + deadline.abort(); + init?.signal?.throwIfAborted(); + return json(done); + }); + expect(await classifyMediaUrl(url, fetchImpl, deadline.signal)).toEqual({ + ok: false, + reason: 'unavailable' + }); + expect(polls).toBe(2); + }); + it('keeps polling on a running status but gives up on a terminal one', async () => { // `running` is pending, so it goes the full poll cap and answers // not_ready. `failed` will never finish: the retry-later answer would only diff --git a/src/lib/server/entail.ts b/src/lib/server/entail.ts index 9845c511..f3ad075d 100644 --- a/src/lib/server/entail.ts +++ b/src/lib/server/entail.ts @@ -50,10 +50,10 @@ const MAX_RAW_TAG_LENGTH = 200; // for. A classify is one enqueue plus at most two polls, worst case about // 3 + 8 + 0.25 + 8 seconds; the caller shows a pending state while it waits. export const POST_TIMEOUT_MS = 8000; -const CLASSIFY_TIMEOUT_MS = 3000; +export const CLASSIFY_TIMEOUT_MS = 3000; export const POLL_TIMEOUT_MS = 8000; -const POLL_PAUSE_MS = 250; -const POLL_ATTEMPTS = 2; +export const POLL_PAUSE_MS = 250; +export const POLL_ATTEMPTS = 2; export type EntailRating = 'safe' | 'questionable' | 'explicit'; diff --git a/src/lib/server/twitter-avatar.ts b/src/lib/server/twitter-avatar.ts index 185a749f..2b85eabd 100644 --- a/src/lib/server/twitter-avatar.ts +++ b/src/lib/server/twitter-avatar.ts @@ -17,7 +17,7 @@ const X_BEARER = 'Bearer AAAAAAAAAAAAAAAAAAAAANRILgAAAAAAnNwIzUejRCOuH5E6I8xnZz4puTs%3D1Zv7ttfk8LF81IUq16cHjhLTvJu4FA33AGWWjCpTnA'; const X_ACTIVATE = 'https://api.x.com/1.1/guest/activate.json'; const X_USER_BY_SCREEN_NAME = 'https://api.x.com/graphql/IGgvgiOx4QZndDHuD3x9TQ/UserByScreenName'; -const FETCH_TIMEOUT_MS = 5000; +export const FETCH_TIMEOUT_MS = 5000; /** Extract a bare handle from the stored twitter URL formats * ("https://x.com/@ExampleFox/", "twitter.com/examplefox", "@examplefox"). */ diff --git a/src/lib/server/twitter-media.ts b/src/lib/server/twitter-media.ts index a518e001..02096114 100644 --- a/src/lib/server/twitter-media.ts +++ b/src/lib/server/twitter-media.ts @@ -17,7 +17,7 @@ import { errorLabel, timeoutSignal } from './fetch-errors'; import { activateGuestToken, xGraphqlHeaders } from './twitter-avatar'; const X_TWEET_BY_REST_ID = 'https://api.x.com/graphql/f2sagi1jweVHFkTUIHzmMQ/TweetResultByRestId'; -const FETCH_TIMEOUT_MS = 5000; +export const FETCH_TIMEOUT_MS = 5000; /** X attaches at most four photos to a post; a count past that is a response * we do not trust, so it is clamped rather than reported. */ const MAX_TWEET_PHOTOS = 4; diff --git a/src/routes/api/admin/tag-suggestions/+server.ts b/src/routes/api/admin/tag-suggestions/+server.ts index bd2f3508..f62feff2 100644 --- a/src/routes/api/admin/tag-suggestions/+server.ts +++ b/src/routes/api/admin/tag-suggestions/+server.ts @@ -59,11 +59,16 @@ const FAILURE_STATUS: Record = { const MAX_URL_LENGTH = 2048; /** Ceiling on the whole lookup chain. The X path is up to four fetches (the * activate and the tweet lookup can each run twice) plus an enqueue and two - * polls, each with its own timeout, so without this the worst case ran close - * to forty seconds. One first attempt at every timeout is 21 s (5 + 5 + 3 + 8), - * so the ceiling sits just above that: it cuts the retry paths, never a chain - * that is merely slow. */ -const LOOKUP_DEADLINE_MS = 22_000; + * polls, each with its own timeout, so without this the worst case ran well + * past a minute. The invariant: the deadline never cuts a first attempt short. + * That attempt is the activate (5 s) + the tweet lookup (5 s) + the classify + * enqueue (3 s) + both polls with the pause between them (8 + 0.25 + 8), which + * is 29.25 s — the second poll is the normal path for a job still running, so + * a deadline below this turns the 202 `not_ready` the poll cap exists to + * produce into a 502 the site counts as an error. The ceiling sits just above + * that: it cuts the retry paths, never a chain that is merely slow. The UI's + * own client-side timeout has to stay above this. */ +const LOOKUP_DEADLINE_MS = 32_000; // SvelteKit rejects any other named export from a +server file unless it // starts with an underscore; the tests read it under this name. export { LOOKUP_DEADLINE_MS as _LOOKUP_DEADLINE_MS }; diff --git a/src/routes/api/admin/tag-suggestions/server.test.ts b/src/routes/api/admin/tag-suggestions/server.test.ts index 39286581..0c5e977c 100644 --- a/src/routes/api/admin/tag-suggestions/server.test.ts +++ b/src/routes/api/admin/tag-suggestions/server.test.ts @@ -2,8 +2,16 @@ import { describe, it, expect, vi, beforeEach } from 'vitest'; // better-sqlite3 ships no bundled types and is a dev-only test dependency here. // @ts-expect-error - no declaration file for 'better-sqlite3' import Database from 'better-sqlite3'; -import type { LookupOutcome, SourceKind } from '$lib/server/entail'; -import type { TweetMediaOutcome } from '$lib/server/twitter-media'; +import { + CLASSIFY_TIMEOUT_MS, + POLL_ATTEMPTS, + POLL_PAUSE_MS, + POLL_TIMEOUT_MS, + type LookupOutcome, + type SourceKind +} from '$lib/server/entail'; +import { FETCH_TIMEOUT_MS as ACTIVATE_TIMEOUT_MS } from '$lib/server/twitter-avatar'; +import { FETCH_TIMEOUT_MS as TWEET_TIMEOUT_MS, type TweetMediaOutcome } from '$lib/server/twitter-media'; import { makeD1 } from '$lib/server/test/d1'; import { POST, _LOOKUP_DEADLINE_MS } from './+server'; @@ -38,7 +46,12 @@ vi.mock('$lib/server/entail', async (importOriginal) => { const original = await importOriginal(); return { ...original, lookupBlueskySource, classifyMediaUrl }; }); -vi.mock('$lib/server/twitter-media', () => ({ fetchTweetMediaUrl })); +// Spread the original so the real timeout constants the deadline pin below +// sums stay readable through the mock. +vi.mock('$lib/server/twitter-media', async (importOriginal) => { + const original = await importOriginal(); + return { ...original, fetchTweetMediaUrl }; +}); const DDL = `CREATE TABLE images (id INTEGER PRIMARY KEY AUTOINCREMENT, title TEXT NOT NULL, slug TEXT, image_url TEXT NOT NULL, thumbnail_url TEXT, width INTEGER, height INTEGER, file_size INTEGER, @@ -151,11 +164,18 @@ describe('POST /api/admin/tag-suggestions', () => { }); it('gives the lookup chain a deadline that clears one full X round', async () => { - // One activate (5 s) + one tweet lookup (5 s) + one enqueue (3 s) + one - // poll (8 s) at their own timeouts is 21 s; the deadline exists to stop - // the retry paths, not to cut that chain short of its first attempt. - const firstAttempt = 5_000 + 5_000 + 3_000 + 8_000; - expect(_LOOKUP_DEADLINE_MS).toBeGreaterThanOrEqual(firstAttempt); + // One activate + one tweet lookup + one enqueue + every poll the cap + // allows, with the pause between them, each at its own timeout. Both + // polls count: a job still running answers the first one 202, so cutting + // the second turns a 202 not_ready into a 502 the site counts as an + // error. Summed from the real constants so the pin tracks them. + const firstAttempt = + ACTIVATE_TIMEOUT_MS + + TWEET_TIMEOUT_MS + + CLASSIFY_TIMEOUT_MS + + POLL_ATTEMPTS * POLL_TIMEOUT_MS + + (POLL_ATTEMPTS - 1) * POLL_PAUSE_MS; + expect(_LOOKUP_DEADLINE_MS).toBeGreaterThan(firstAttempt); }); it('502s unavailable when the deadline fires mid-lookup', async () => {