Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand Down
15 changes: 15 additions & 0 deletions apps/amethyst/lib/__tests__/oauthIssuer.test.ts
Original file line number Diff line number Diff line change
@@ -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);
});
});
15 changes: 15 additions & 0 deletions apps/amethyst/lib/atp/oauthIssuer.ts
Original file line number Diff line number Diff line change
@@ -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;
}
}
28 changes: 24 additions & 4 deletions apps/amethyst/stores/authenticationSlice.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand All @@ -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;
Expand Down Expand Up @@ -52,6 +54,7 @@ export const createAuthenticationSlice: StateCreator<AuthenticationSlice> = (
return {
auth: initialAuth,
status: "start",
oauthIssuer: null,
oauthState: null,
oauthSession: null,
pdsAgent: null,
Expand All @@ -67,11 +70,12 @@ export const createAuthenticationSlice: StateCreator<AuthenticationSlice> = (
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,
Expand All @@ -94,12 +98,22 @@ export const createAuthenticationSlice: StateCreator<AuthenticationSlice> = (
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),
Expand Down Expand Up @@ -128,7 +142,11 @@ export const createAuthenticationSlice: StateCreator<AuthenticationSlice> = (
}
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");
Expand All @@ -137,6 +155,7 @@ export const createAuthenticationSlice: StateCreator<AuthenticationSlice> = (
const agent = new Agent(sess);

set({
auth: restoreAuth,
pdsAgent: addDocs(agent),
isAgentReady: true,
status: "loggedIn",
Expand All @@ -156,6 +175,7 @@ export const createAuthenticationSlice: StateCreator<AuthenticationSlice> = (
set({
status: "loggedOut",
oauthSession: null,
oauthIssuer: null,
oauthState: null,
profiles,
pdsAgent: null,
Expand Down
5 changes: 5 additions & 0 deletions docs/development-oauth-tunnel.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
51 changes: 49 additions & 2 deletions scripts/dev-tunnel.sh
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
8 changes: 2 additions & 6 deletions todo.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`
Expand Down
Loading