diff --git a/AGENTS.md b/AGENTS.md index 9c8b3939..f6cde669 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -223,6 +223,7 @@ SQLX_OFFLINE=true cargo run -p cadet ``` - Serve Amethyst and proxy `/xrpc/*` to Aqua through the same public hostname. +- After completing a new feature or overhaul, publish the updated build to the stable public preview at `https://sigilyph.teal.fm` through the Cloudflare tunnel. - For temporary demos, a Cloudflare quick tunnel is acceptable. Record the active URL in `todo.md`. - Treat quick-tunnel URLs as ephemeral. A tunnel restart changes the hostname. diff --git a/apps/amethyst/lib/__tests__/oauthIssuer.test.ts b/apps/amethyst/lib/__tests__/oauthIssuer.test.ts new file mode 100644 index 00000000..f425e9ed --- /dev/null +++ b/apps/amethyst/lib/__tests__/oauthIssuer.test.ts @@ -0,0 +1,15 @@ +import { + DEFAULT_OAUTH_PDS_HOST, + pdsHostFromOAuthIssuer, +} from "../atp/oauthIssuer"; + +describe("OAuth issuer helpers", () => { + it("uses the issuer hostname as the PDS host", () => { + expect(pdsHostFromOAuthIssuer("https://evil.gay")).toBe("evil.gay"); + }); + + it("falls back when the issuer is missing or invalid", () => { + expect(pdsHostFromOAuthIssuer(null)).toBe(DEFAULT_OAUTH_PDS_HOST); + expect(pdsHostFromOAuthIssuer("not a url")).toBe(DEFAULT_OAUTH_PDS_HOST); + }); +}); diff --git a/apps/amethyst/lib/atp/oauthIssuer.ts b/apps/amethyst/lib/atp/oauthIssuer.ts new file mode 100644 index 00000000..38ac845c --- /dev/null +++ b/apps/amethyst/lib/atp/oauthIssuer.ts @@ -0,0 +1,15 @@ +export const DEFAULT_OAUTH_PDS_HOST = "bsky.social"; + +export function pdsHostFromOAuthIssuer( + issuer?: string | null, + fallback = DEFAULT_OAUTH_PDS_HOST, +) { + if (!issuer) return fallback; + + try { + const hostname = new URL(issuer).hostname.trim(); + return hostname || fallback; + } catch { + return fallback; + } +} diff --git a/apps/amethyst/stores/authenticationSlice.tsx b/apps/amethyst/stores/authenticationSlice.tsx index 587798d9..c55a829a 100644 --- a/apps/amethyst/stores/authenticationSlice.tsx +++ b/apps/amethyst/stores/authenticationSlice.tsx @@ -6,6 +6,7 @@ import * as Lexicons from "@teal/lexicons/src/lexicons"; import type { ProfileView } from "@teal/lexicons/src/types/fm/teal/alpha/actor/defs"; import createOAuthClient, { AquareumOAuthClient } from "../lib/atp/oauth"; +import { pdsHostFromOAuthIssuer } from "../lib/atp/oauthIssuer"; import { StateCreator } from "./mainStore"; export interface AllProfileViews { @@ -16,6 +17,7 @@ export interface AllProfileViews { export interface AuthenticationSlice { auth: AquareumOAuthClient; status: "start" | "loggedIn" | "loggedOut"; + oauthIssuer: null | string; oauthState: null | string; oauthSession: null | OAuthSession; pdsAgent: null | Agent; @@ -52,6 +54,7 @@ export const createAuthenticationSlice: StateCreator = ( return { auth: initialAuth, status: "start", + oauthIssuer: null, oauthState: null, oauthSession: null, pdsAgent: null, @@ -67,11 +70,12 @@ export const createAuthenticationSlice: StateCreator = ( getLoginUrl: async (handle: string) => { try { // resolve the handle to a PDS URL - const r = resolveFromIdentity(handle); - let auth = createOAuthClient(baseUrl, (await r).pds.hostname); + const resolvedIdentity = await resolveFromIdentity(handle); + const auth = createOAuthClient(baseUrl, resolvedIdentity.pds.hostname); const url = await auth.authorize(handle); set({ auth, + oauthIssuer: `https://${resolvedIdentity.pds.hostname}`, pds: { url: url.toString(), loading: false, @@ -94,12 +98,22 @@ export const createAuthenticationSlice: StateCreator = ( if (get().status === "loggedIn") { return; } + const oauthIssuer = state.get("iss"); + const callbackAuth = createOAuthClient( + baseUrl, + pdsHostFromOAuthIssuer( + oauthIssuer, + pdsHostFromOAuthIssuer(get().oauthIssuer), + ), + ); const { session, state: oauthState } = - await initialAuth.callback(state); + await callbackAuth.callback(state); const agent = new Agent(session); set({ + auth: callbackAuth, // TODO: fork or update auth lib oauthSession: session as any, + oauthIssuer: oauthIssuer ?? get().oauthIssuer, oauthState, status: "loggedIn", pdsAgent: addDocs(agent), @@ -128,7 +142,11 @@ export const createAuthenticationSlice: StateCreator = ( } try { // restore session - let sess = await initialAuth.restore(did); + const restoreAuth = createOAuthClient( + baseUrl, + pdsHostFromOAuthIssuer(get().oauthIssuer), + ); + let sess = await restoreAuth.restore(did); if (!sess) { throw new Error("Failed to restore session"); @@ -137,6 +155,7 @@ export const createAuthenticationSlice: StateCreator = ( const agent = new Agent(sess); set({ + auth: restoreAuth, pdsAgent: addDocs(agent), isAgentReady: true, status: "loggedIn", @@ -156,6 +175,7 @@ export const createAuthenticationSlice: StateCreator = ( set({ status: "loggedOut", oauthSession: null, + oauthIssuer: null, oauthState: null, profiles, pdsAgent: null, diff --git a/docs/development-oauth-tunnel.md b/docs/development-oauth-tunnel.md index 82f7254f..090facd9 100644 --- a/docs/development-oauth-tunnel.md +++ b/docs/development-oauth-tunnel.md @@ -49,6 +49,11 @@ pnpm tunnel:logs pnpm tunnel:verify ``` +`pnpm tunnel:verify` checks that the public metadata is reachable, that it +uses the active stable origin for `client_id`, `redirect_uris`, and +`client_uri`, that DPoP is enabled, and that latest-play XRPC returns a JSON +`plays` array. + Confirm the OAuth client metadata is served from the stable host: ```bash diff --git a/scripts/dev-tunnel.sh b/scripts/dev-tunnel.sh index a036c55e..8fe0f184 100755 --- a/scripts/dev-tunnel.sh +++ b/scripts/dev-tunnel.sh @@ -83,11 +83,58 @@ curl_preview() { curl --fail --show-error --silent --resolve "$TUNNEL_HOST:443:$ip" "$url" >/dev/null } +fetch_preview() { + local url="$1" + + if curl --fail --show-error --silent "$url"; then + return 0 + fi + + local ip + ip="$(dig +short "$TUNNEL_HOST" @1.1.1.1 | grep -E '^[0-9.]+$' | head -n 1 || true)" + if [[ -z "$ip" ]]; then + echo "Could not resolve $TUNNEL_HOST with the local resolver or Cloudflare DNS." >&2 + return 1 + fi + + echo "Local DNS has not caught up for $TUNNEL_HOST; retrying verification through $ip." >&2 + curl --fail --show-error --silent --resolve "$TUNNEL_HOST:443:$ip" "$url" +} + verify_preview() { echo "Verifying client metadata..." - curl_preview "$PUBLIC_ORIGIN/client-metadata.json" + fetch_preview "$PUBLIC_ORIGIN/client-metadata.json" | python3 -c ' +import json +import sys + +origin = sys.argv[1] +metadata = json.load(sys.stdin) +expected_client_id = f"{origin}/client-metadata.json" +expected_redirect_uri = f"{origin}/auth/callback" +actual_client_id = metadata.get("client_id") +actual_client_uri = metadata.get("client_uri") + +if actual_client_id != expected_client_id: + raise SystemExit(f"client_id mismatch: expected {expected_client_id!r}, got {actual_client_id!r}") +if expected_redirect_uri not in metadata.get("redirect_uris", []): + raise SystemExit(f"redirect_uris missing {expected_redirect_uri!r}") +if actual_client_uri != origin: + raise SystemExit(f"client_uri mismatch: expected {origin!r}, got {actual_client_uri!r}") +if metadata.get("token_endpoint_auth_method") != "none": + raise SystemExit("token_endpoint_auth_method must be none") +if metadata.get("dpop_bound_access_tokens") is not True: + raise SystemExit("dpop_bound_access_tokens must be true") +' "$PUBLIC_ORIGIN" echo "Verifying latest plays..." - curl_preview "$PUBLIC_ORIGIN/xrpc/fm.teal.alpha.stats.getLatest?limit=1" + fetch_preview "$PUBLIC_ORIGIN/xrpc/fm.teal.alpha.stats.getLatest?limit=1" | python3 -c ' +import json +import sys + +payload = json.load(sys.stdin) +plays = payload.get("plays") +if not isinstance(plays, list): + raise SystemExit("latest plays response must contain a plays array") +' } case "${1:-}" in diff --git a/todo.md b/todo.md index 4078f9af..4d074498 100644 --- a/todo.md +++ b/todo.md @@ -11,22 +11,18 @@ Last synced with GitHub and Linear issues: 2026-06-14. - The ignored local `.env` should keep `TUNNEL_HOST=sigilyph.teal.fm`, matching `EXPO_PUBLIC_BASE_URL`, `EXPO_PUBLIC_AQUA_URL`, and `CLOUDFLARED_TUNNEL_TOKEN`. - Use `pnpm tunnel:up`, `pnpm tunnel:down`, `pnpm tunnel:status`, `pnpm tunnel:logs`, and `pnpm tunnel:verify` for the stable preview. - The public Amethyst feed must use only live Aqua XRPC data. Do not add seeded, mocked, demo, or backup play data. +- Public preview refreshed on 2026-06-15 by rebuilding Amethyst, Aqua, and Cadet images, recreating the Compose preview stack, and verifying `https://sigilyph.teal.fm/client-metadata.json` plus latest plays XRPC. ## Local Open Work - [ ] Complete ATProto OAuth sign-in and callback QA through `https://sigilyph.teal.fm`. - - Verified again on 2026-06-15 that `https://sigilyph.teal.fm/client-metadata.json` serves the stable-origin `client_id` and callback URI, `pnpm tunnel:verify` passes, and latest plays XRPC returns live data. Remaining QA requires an interactive ATProto login/callback with a real account session. + - Verified again on 2026-06-15 that `pnpm tunnel:verify` validates the stable-origin `client_id`, callback URI, `client_uri`, DPoP setting, and latest plays XRPC response. Browser preflight on 2026-06-15 loaded the stable preview, started sign-in for `matt.evil.gay`, resolved the PDS as `evil.gay`, and reached the provider password page at `/oauth/authorize` with `client_id=https://sigilyph.teal.fm/client-metadata.json` plus a PAR `request_uri`. Amethyst now persists the resolved OAuth issuer and reconstructs callback/restore clients from the callback `iss` so non-`bsky.social` PDS sessions do not fall back to the initial client after redirect. Remaining QA requires entering a real account password/approval and confirming the callback returns to `/auth/callback`, creates a session, and restores after refresh. - [x] Drain the in-flight CAR import backfill queue for users with stale ingestion from the 2026-06-07 through 2026-06-10 Cadet outage. Redis/Garnet `LLEN car_import_jobs` returned `0` on 2026-06-14, local Cadet was running, and recent Cadet logs showed no CAR import job failures. - [x] Backfill the `fm.teal.alpha.feed.play` records present in `did:plc:tas6hj2xjrqben5653v5kohk`'s PDS repo but missing from the preview Postgres index. A focused CAR backfill completed on 2026-06-15 via `lightrail-backfill`; the preview index now has 10,215 plays for that DID, up from 10,172 immediately before the run and above the older 10,193-record comparison from 2026-06-11. - [x] Handle Jetstream account lifecycle events in Cadet, including deletes, takedowns, suspensions, activations, and tombstones. Cadet now tracks upstream account state, purges indexed public profile/social/play rows when an account becomes inactive, treats activation as the gate for future commit ingestion, and ignores legacy tombstone event kinds because modern Jetstream/account-hosting statuses replace them. ## Tracker Issues -- [ ] [#86](https://github.com/teal-fm/teal/issues/86) / [TEAL-31](https://linear.app/tealfm/issue/TEAL-31/log-unique-tracks-and-albums-to-popfeed) Log unique tracks and albums to Popfeed - - Linear: `Backlog`, no priority - - Create Popfeed records when a listener plays a track or album for the first time. - - Consider follow-on flows for completing Popfeed reviews of songs and albums from Teal. - - Blocked locally: GitHub discussion says this should be a user-enabled sync, potentially with a later history backfill, but no Popfeed NSID, lexicon, service endpoint, auth flow, or record schema are present in this repo yet. Web search on 2026-06-15 did not find an authoritative public Popfeed record schema to implement against. - [x] [#57](https://github.com/teal-fm/teal/issues/57) / [TEAL-30](https://linear.app/tealfm/issue/TEAL-30/top-albums-around-profile-pic) top albums around profile pic - Linear: `In Progress`, low priority - Labels: `API`, `Frontend`, `Legacy Songish Feature`