# loggedin.fyi A device-local DID hint service for AT Proto apps. Participating clients heartbeat a session cookie; other clients can list those DIDs and start a **normal OAuth login**. This is not SSO. - Chrome: hidden embed, silent `postMessage` list - Safari / Firefox / iOS: Storage Access API when possible; otherwise first-party `/save` popup, `/enroll` after login, and `/choose` - Runtime: Cloudflare Worker + one Durable Object per browser session ## Storage Access The `/embed` iframe and its XRPC calls use the unpartitioned `loggedin_session` cookie (`SameSite=None; Secure`). Chrome speaks [Storage Access Headers](https://privacycg.github.io/storage-access-headers/); Safari and Firefox use `document.requestStorageAccess()`. Permission is stored as `(embedder site, loggedin.fyi)` — a grant on Lightbox does not cover Cratewalk. Parent iframes must set `allow="storage-access"`. Use `Loggedin.prepareEmbed` / `Loggedin.mountEmbed` so the listener is attached before `src`. | `Sec-Fetch-Storage-Access` | Worker | | --- | --- | | `none` | Uncredentialed embed (Continue…). No `Set-Cookie` (that would orphan Durable Objects). | | `inactive` | `401` + `Activate-Storage-Access: retry; allowed-origin=""`. Never `allowed-origin=*`. | | `active` | Cookie attached; `Activate-Storage-Access: load`. | | missing + same-site / same-origin | First-party: attach cookie (`/choose`, `/save`, `/enroll`). | | missing + cross-site | Like `none` for cookie minting. | Embed boot: 1. If `hasStorageAccess()` is already true, list and heartbeat silently. 2. Else try `requestStorageAccess()` without a click (Chrome/Firefox can reactivate an existing grant) and reload. 3. Else show Continue. The click calls only `requestStorageAccess()` — not `hasStorageAccess()`, which can consume the gesture — then reloads. If that fails, `{ type: "popup" }` and first-party `/choose`. `GET /save?did=&origin=&action=save|logout` is the fallback when storage access is denied. `origin` must be the calling app, not this service. A hidden SessionBar iframe cannot collect a **first** grant; it can reuse one after a visible Continue on that same site. ## Local probe ```bash npm install npm run dev:probe ``` That starts: - Worker at `https://localhost:8787` - Honks (probe A) at `https://localhost:3001` - Blacksky (probe B) at `https://localhost:3002` The demo origins use a self-signed cert. Trust the warning once per origin. For HTTP instead: ```bash npx wrangler dev npm run probe -- --http ``` Then open `http://localhost:3001` and `http://localhost:3002`. ### Chrome 1. On Honks, click **Heartbeat**. 2. On Blacksky, the example renderer should list the DID (bsky.app’s DID resolves to a handle). 3. Click it to see a mocked OAuth start. ### Safari / Firefox / iOS 1. On Honks, click **Heartbeat**. If third-party cookies are blocked, a first-party `/save` window should open (or click **Enroll**, then **Save**). 2. On Blacksky, click **Continue with an AT Proto account you already use** in the embed (this may prompt for storage access). 3. Pick the DID in the `loggedin.fyi` popup. ## Native SDKs iOS and Android packages live in [`sdks/`](sdks/README.md). Native apps cannot hold the cookie directly, so they reach the browser's shared cookie jar once — `ASWebAuthenticationSession` on iOS, Custom Tabs on Android — and trade that for a signed token (`X-Loggedin-Token`) naming the same session. No device identifier, no permission, and the app lands on the same session as mobile Safari or Chrome. Set the signing secret to enable it: ```bash npx wrangler secret put DEVICE_TOKEN_SECRET ``` For local `wrangler dev`, copy [`.dev.vars.example`](.dev.vars.example) to gitignored `.dev.vars` and put a 32+ character value there. Without it, `GET /app/start` returns `501` and only the web embed works. ## API Unauthenticated XRPC. The cookie is the browser session key. Native clients send `X-Loggedin-Token` instead and never receive a cookie. Writes from the embed, enroll, and save pages also send the parent `origin`; token writes are pinned to the origin inside the token. | Lexicon / page | HTTP | | --- | --- | | `fyi.loggedin.account.loggedIn` | `POST /xrpc/fyi.loggedin.account.loggedIn` | | `fyi.loggedin.account.logout` | `POST /xrpc/fyi.loggedin.account.logout` | | `fyi.loggedin.account.list` | `GET /xrpc/fyi.loggedin.account.list` → `{ accounts: [{ did, origins }] }` | | first-party save | `GET /save?did=&origin=&action=save\|logout` | | native bootstrap | `GET /app/start?origin=&callback=&state=[&did=]` | | native bootstrap | `GET /app/start?origin=&callback=&state=[&did=]` | Cookie: `loggedin_session`, `HttpOnly`, 7-day TTL. HTTPS uses `Secure; SameSite=None` (Chrome silent embed). Local HTTP uses `SameSite=Lax` so first-party `/enroll`, `/save`, and `/choose` still work. Participation: any HTTPS origin that publishes valid OAuth client metadata at `/oauth-client-metadata.json`. Localhost stays live even if metadata cannot be fetched (self-signed HTTPS). Labels for hinted origins come from `GET /origins?origin=…`. Abuse controls: Cloudflare rate-limit bindings (IP / origin / metadata fetch), session write coalescing, and a per-session row cap. ## Deploy ```bash npx wrangler login npm run deploy ``` Point `loggedin.fyi` at the Worker in the Cloudflare dashboard. ## Val Town demos Lightbox (images + feed picker) and Cratewalk (Habbo-style conveyor) live in [`val-town/`](val-town/README.md). Deploy them as **two vals / two origins**, set `COOKIE_SECRET`, and keep each val’s `/oauth-client-metadata.json` reachable so the Worker can validate them.