Something went wrong. Try again.
The logic for serving and storing associations between users and apps
Something went wrong. Try again.
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587const CSS = ` :root { color-scheme: light; --ink: #1d1d1f; --muted: #6e6e73; --faint: #86868b; --bg: #ffffff; --band: #f5f5f7; --line: rgba(0, 0, 0, 0.08); --accent: #0066cc; --focus: #0071e3; --note: #e8f2fc; --note-ink: #003d73; --important: #fff4d6; --important-ink: #6b4f00; --sans: "SF Pro Text", "SF Pro Display", -apple-system, BlinkMacSystemFont, "Helvetica Neue", Helvetica, Arial, sans-serif; --mono: "SF Mono", ui-monospace, Menlo, Monaco, "Courier New", monospace; }
* { box-sizing: border-box; } html { scroll-behavior: smooth; } html, body { margin: 0; padding: 0; } body { font-family: var(--sans); background: var(--bg); color: var(--ink); line-height: 1.47; -webkit-font-smoothing: antialiased; } a { color: var(--accent); text-decoration: none; } a:hover { text-decoration: underline; text-underline-offset: 0.18em; } :focus-visible { outline: 2px solid var(--focus); outline-offset: 3px; }
.skip { position: absolute; left: 0.75rem; top: -3rem; z-index: 20; background: #fff; padding: 0.5rem 0.75rem; border-radius: 0.5rem; } .skip:focus { top: 0.75rem; }
.nav { position: sticky; top: 0; z-index: 10; display: flex; align-items: center; justify-content: space-between; gap: 1rem; min-height: 3.25rem; padding: 0 1.25rem; background: rgba(255, 255, 255, 0.72); backdrop-filter: saturate(180%) blur(20px); -webkit-backdrop-filter: saturate(180%) blur(20px); border-bottom: 1px solid var(--line); } .wordmark { font-size: 1.05rem; font-weight: 600; letter-spacing: -0.03em; color: var(--ink); text-decoration: none; } .wordmark:hover { text-decoration: none; } .nav-links { display: flex; gap: 1.4rem; font-size: 0.8rem; color: var(--muted); } .nav-links a { color: inherit; } .nav-links a[aria-current="page"] { color: var(--ink); font-weight: 600; }
.shell { display: grid; grid-template-columns: 1fr; width: min(72rem, calc(100% - 2.5rem)); margin: 0 auto; gap: 2rem; padding: 2rem 0 4.5rem; } .toc { display: none; } .toc h2 { margin: 0 0 0.75rem; font-size: 0.72rem; letter-spacing: 0.08em; text-transform: uppercase; color: var(--faint); border: 0; padding: 0; } .toc ol { margin: 0; padding: 0; list-style: none; } .toc a { display: block; padding: 0.28rem 0; color: var(--muted); font-size: 0.84rem; } .toc a:hover { color: var(--accent); }
article { min-width: 0; } .eyebrow { margin: 0 0 0.65rem; font-size: 0.75rem; font-weight: 600; letter-spacing: 0.12em; text-transform: uppercase; color: var(--faint); } h1 { margin: 0 0 1rem; font-size: clamp(2rem, 4vw, 2.75rem); font-weight: 600; letter-spacing: -0.035em; line-height: 1.1; } .lede { margin: 0 0 2rem; max-width: 42rem; font-size: 1.15rem; color: var(--muted); } h2 { margin: 2.75rem 0 0.85rem; padding-top: 1.25rem; border-top: 1px solid var(--line); font-size: 1.55rem; letter-spacing: -0.03em; scroll-margin-top: 4rem; } h3 { margin: 1.75rem 0 0.55rem; font-size: 1.15rem; letter-spacing: -0.02em; scroll-margin-top: 4rem; } p, li { font-size: 1.02rem; color: var(--ink); } p { margin: 0 0 0.9rem; } .muted { color: var(--muted); } ol.steps { margin: 0 0 1.25rem; padding-left: 1.35rem; } ol.steps > li { margin: 0.45rem 0; } ul { margin: 0 0 1.25rem; padding-left: 1.25rem; } code, .mono { font-family: var(--mono); font-size: 0.9em; } p code, li code { background: var(--band); padding: 0.1em 0.35em; border-radius: 0.3em; }
.listing { margin: 1.1rem 0 1.4rem; background: var(--band); border-radius: 0.7rem; overflow: hidden; } .listing figcaption { padding: 0.55rem 0.95rem 0; font-size: 0.72rem; font-weight: 600; letter-spacing: 0.06em; text-transform: uppercase; color: var(--faint); } .listing pre { margin: 0; padding: 0.7rem 0.95rem 1rem; overflow-x: auto; } .listing code { font-size: 0.82rem; line-height: 1.5; color: var(--ink); white-space: pre; }
.callout { margin: 1.15rem 0 1.4rem; padding: 0.9rem 1rem; border-radius: 0.7rem; font-size: 0.95rem; } .callout strong { display: block; margin-bottom: 0.25rem; } .callout p { margin: 0; font-size: inherit; } .callout.note { background: var(--note); color: var(--note-ink); } .callout.important { background: var(--important); color: var(--important-ink); }
table { width: 100%; border-collapse: collapse; margin: 0.75rem 0 1.4rem; font-size: 0.92rem; } th, td { text-align: left; padding: 0.55rem 0.65rem; border-bottom: 1px solid var(--line); vertical-align: top; } th { color: var(--muted); font-weight: 600; font-size: 0.75rem; letter-spacing: 0.04em; text-transform: uppercase; } td code { background: transparent; padding: 0; }
footer { padding: 1.75rem 0 2.5rem; border-top: 1px solid var(--line); color: var(--faint); font-size: 0.8rem; } footer .wrap { width: min(72rem, calc(100% - 2.5rem)); margin: 0 auto; display: flex; justify-content: space-between; gap: 1rem; flex-wrap: wrap; } footer a { color: inherit; }
@media (min-width: 980px) { .nav { padding: 0 2rem; } .shell { grid-template-columns: 14rem minmax(0, 46rem); gap: 3.5rem; padding-top: 2.75rem; } .toc { display: block; position: sticky; top: 4.5rem; align-self: start; } }
@media (prefers-reduced-motion: reduce) { html { scroll-behavior: auto; } }`;
const FAVICON = `data:image/svg+xml,${encodeURIComponent( `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32"><circle cx="13" cy="16" r="7.25" fill="none" stroke="#1d1d1f" stroke-width="1.8"/><circle cx="19" cy="16" r="7.25" fill="none" stroke="#1d1d1f" stroke-width="1.8"/></svg>`,)}`;
function escapeHtml(value: string): string { return value .replaceAll("&", "&") .replaceAll("<", "<") .replaceAll(">", ">") .replaceAll('"', """);}
function listing(caption: string, code: string): string { return `<figure class="listing"> <figcaption>${escapeHtml(caption)}</figcaption> <pre><code>${escapeHtml(code)}</code></pre> </figure>`;}
function callout(kind: "note" | "important", title: string, body: string): string { return `<aside class="callout ${kind}"><strong>${escapeHtml(title)}</strong><p>${body}</p></aside>`;}
export function developersPage(): string { return `<!doctype html><html lang="en"><head> <meta charset="utf-8"> <meta name="viewport" content="width=device-width, initial-scale=1"> <title>Integrating loggedin.fyi — Documentation</title> <meta name="description" content="Add your Atmosphere app to loggedin.fyi: publish client metadata, embed the interface, and start a normal OAuth login from a hinted DID."> <meta name="theme-color" content="#ffffff"> <link rel="icon" href="${FAVICON}"> <style>${CSS}</style></head><body> <a class="skip" href="#overview">Skip to content</a> <nav class="nav"> <a class="wordmark" href="/">loggedin.fyi</a> <div class="nav-links"> <a href="/">Overview</a> <a href="/developers" aria-current="page">Documentation</a> </div> </nav>
<div class="shell"> <nav class="toc" aria-label="On this page"> <h2>On This Page</h2> <ol> <li><a href="#overview">Overview</a></li> <li><a href="#getting-started">Getting Started</a></li> <li><a href="#metadata">Client Metadata</a></li> <li><a href="#embed">Embedding the Interface</a></li> <li><a href="#accounts">Displaying Accounts</a></li> <li><a href="#oauth">Starting OAuth</a></li> <li><a href="#heartbeat">Heartbeating a Session</a></li> <li><a href="#logout">Logging Out</a></li> <li><a href="#storage-access">Storage Access</a></li> <li><a href="#native">iOS and Android</a></li> <li><a href="#rate-limits">Rate Limits</a></li> <li><a href="#endpoints">Endpoints</a></li> </ol> </nav>
<article> <p class="eyebrow">Documentation</p> <h1 id="overview">Integrating loggedin.fyi</h1> <p class="lede">Use loggedin.fyi to fetch the Atmosphere accounts a user has recently used in their browser, from other Atmosphere apps participating with loggedin.fyi, and allow the user to quick-start the same OAuth login your app already implements. This is not single sign-on, and it is not a new identity provider.</p>
<p>loggedin.fyi stores a short-lived, record that a DID was used on a participating AT Proto app. Your AT Proto app can call to loggedin.fyi to retrieve that list. Then you can choose how to display that list of DIDs, and offer them as login hints to the user, showing them which accounts they have already logged into, across the Atmosphere (of apps that are participating with loggedin.fyi). Selecting an account does not create a session with loggedin.fyi. Your AT Proto app still must complete a normal AT Proto OAuth flow with the user’s PDS.</p> <p>In a browser, the session key is an <code>HttpOnly</code> cookie on the loggedin.fyi origin (<code>loggedin_session</code>, seven-day TTL). Cross-site embeds use <code>Secure; SameSite=None</code>. A grant of storage access is scoped to the pair <em>(your AT Proto app, loggedin.fyi)</em>. Native iOS and Android clients reach that same cookie through the system browser once, then carry a signed <code>X-Loggedin-Token</code> that names the same session.</p>
${callout( "note", "Note", "Your app decides when to call loggedin.fyi to confirm that the user has logged in to your app. loggedin.fyi does not opt people in on their behalf. You can choose to do this automatically or ask the user first. It's strongly recommended to prompt the user when they first sign in to your app. Use that same dialog to explain if your app will periodically call to loggedin.fyi to confirm that the user is still logged in to your app.", )}
<h2 id="getting-started">Getting Started</h2> <p>To add loggedin.fyi to your client, complete the following steps:</p> <ol class="steps"> <li>Publish your existing OAuth client-metadata.json document at <code>https://your-app.example/oauth-client-metadata.json</code> on your AT Proto app origin. If you have an AT Proto app already, you likely have this document; place it at that conventional path (or ensure it is reachable there).</li> <li>Load the loggedin.fyi JavaScript interface and embed <code>/embed</code> with the <code>storage-access</code> permission.</li> <li>Render the returned accounts and, when the user selects one, start your existing OAuth login with that DID.</li> <li>After sign-in, update loggedin.fyi with the DID of the active user, so other participating apps on this device can offer it. On logout, remove the hint.</li> <li>Optionally, you can also periodically call loggedin.fyi to confirm that the user is still logged in to your app, and update the list of accounts on loggedin.fyi.</li> <li>On iOS or Android, use the native SDK instead of the embed. Those clients bootstrap through the browser’s shared cookie jar once, then send <code>X-Loggedin-Token</code>.</li> </ol> <p class="muted">The sections below expand each step. Replace <code>https://your-app.example</code> with your production origin, and set <code>LOGGEDIN_ORIGIN</code> to the loggedin.fyi host you are targeting.</p>
<h2 id="metadata">Publishing Client Metadata</h2> <p>Any HTTPS origin that publishes valid OAuth client metadata at <code>/oauth-client-metadata.json</code> may participate. There is no static allowlist and no deploy gate. loggedin.fyi fetches that document when your app embeds, enrolls, saves, or heartbeats.</p> <p>Serve the document at that URL with an <code>application/json</code> content type and status <code>200</code> without a redirect. loggedin.fyi requires all of the following:</p> <ul> <li><code>client_id</code> in the document equals <code>https://your-app.example/oauth-client-metadata.json</code> exactly.</li> <li>If <code>client_uri</code> is present, its origin matches your app origin.</li> <li>At least one <code>redirect_uris</code> entry uses that same origin. HTTPS is required except on localhost.</li> </ul> <p>When validation succeeds, <code>client_name</code> and <code>logo_uri</code> appear as labels in the chooser and when clients call <code>GET /origins</code>. Localhost remains available even if metadata cannot be fetched, so you can develop against a self-signed certificate.</p> ${listing( "Listing 1", `{ "client_id": "https://your-app.example/oauth-client-metadata.json", "client_name": "Your App", "client_uri": "https://your-app.example", "logo_uri": "https://your-app.example/logo.png", "redirect_uris": [ "https://your-app.example/oauth/callback" ]}`, )} ${callout( "important", "Important", "A storage-access grant collected on one origin does not apply to another. Each participating app must obtain its own grant.", )}
<h2 id="embed">Embedding the Interface</h2> <p>Load the JavaScript interface from <code>/example-renderer.js</code>. The script defines <code>window.Loggedin</code>. Attach the message listener <em>before</em> you set the iframe <code>src</code>, and include the <code>storage-access</code> permission on the iframe.</p> ${listing( "Listing 2", `const LOGGEDIN_ORIGIN = "https://loggedin.fyi";
async function loadLoggedin() { if (window.Loggedin) return window.Loggedin; await new Promise((resolve, reject) => { const script = document.createElement("script"); script.src = LOGGEDIN_ORIGIN + "/example-renderer.js"; script.onload = resolve; script.onerror = () => reject(new Error("loggedin.fyi is unreachable")); document.head.appendChild(script); }); return window.Loggedin;}
const iframe = document.querySelector("#loggedin-embed");const Loggedin = await loadLoggedin();
Loggedin.mountEmbed(iframe, LOGGEDIN_ORIGIN + "/embed", { onReady() {}, onNeedsAccess() {}, onPopup() {}, onAccounts(accounts) { Loggedin.renderAccountList(list, accounts, { onPick(did, handle) { startOauth(handle.startsWith("did:") ? did : handle); }, }); }, onPicked(did) { startOauth(did); },});`, )} ${callout( "important", "Important", "Call <code>Loggedin.mountEmbed</code> (or <code>prepareEmbed</code> plus <code>listenToEmbed</code> before assigning <code>src</code>). If you set <code>src</code> first, the embed can post <code>accounts</code> before your listener exists.", )}
<h2 id="accounts">Displaying Hinted Accounts</h2> <p>When storage access is available, the embed posts <code>{ type: "accounts", accounts }</code>. Each account is a DID and the origins that loggedin.fyi has recorded for it:</p> ${listing( "Listing 3", `{ "type": "accounts", "accounts": [ { "did": "did:plc:example", "origins": ["https://other-app.example"] } ]}`, )} <p>Use <code>Loggedin.renderAccountList</code> to draw the list, or render it yourself. Fetch <code>GET /origins?origin=…</code> for the origins that appear on those accounts if you want human-readable names.</p> ${listing( "Listing 4", `const origins = [...new Set(accounts.flatMap((a) => a.origins || []))];const qs = origins.map((o) => "origin=" + encodeURIComponent(o)).join("&");const res = await fetch(LOGGEDIN_ORIGIN + "/origins?" + qs);const data = await res.json();const labels = {};for (const row of data.origins ?? []) labels[row.origin] = row.name;
Loggedin.renderAccountList(listElement, accounts, { originLabels: labels, onPick(did, handle) { startOauth(handle.startsWith("@") ? handle.slice(1) : did); },});`, )} <p>If the embed cannot use the cookie silently, it shows a Continue control and may post <code>needs-access</code> or <code>popup</code>. In the popup path, listen for <code>picked</code> and start OAuth with that DID.</p>
<h2 id="oauth">Starting a Normal OAuth Login</h2> <p>Treat a selected DID as a login hint for the OAuth flow you already ship. Do not exchange anything with loggedin.fyi for a session.</p> ${listing( "Listing 5", `function startOauth(identifier) { const url = new URL("/login", location.origin); url.searchParams.set("handle", identifier); location.href = url.toString();}`, )} ${callout( "note", "Note", "Retrieved accounts are only a suggestion for the end user. The user still authenticates with their PDS. If they decline the retrieved accounts, your app should still offer a handle field as you would without loggedin.fyi.", )}
<h2 id="heartbeat">Heartbeating a Signed-In Session</h2> <p>After the person is signed in, your app may choose to periodically send that user's DID to loggedin.fyi so other participating apps on this device can offer it. This heartbeat occurs when the person is actively using your client — for example, on load, or after an intentional action. loggedin.fyi stores one DID and origin pair with a last-seen timestamp. Records expire after seven days.</p> ${listing( "Listing 6", `Loggedin.heartbeat(iframe, session.did, LOGGEDIN_ORIGIN);`, )} <p>On Chrome, this posts <code>{ type: "heartbeat", did }</code> to the embed, which calls <code>POST /xrpc/fyi.loggedin.account.loggedIn</code>. If the embed is in popup mode, <code>heartbeat</code> opens a first-party <code>/save</code> window instead. The <code>origin</code> query parameter must be your app, not loggedin.fyi.</p> <p>You may also send people through <code>Loggedin.enrollUrl</code> after login if you want an explicit Save / Not now step on the loggedin.fyi origin.</p> ${listing( "Listing 7", `location.href = Loggedin.enrollUrl( LOGGEDIN_ORIGIN, session.did, location.href,);`, )}
<h2 id="logout">Removing a Hint on Logout</h2> <p>When the person signs out of your app, remove the hint for that DID so other clients stop offering it.</p> ${listing( "Listing 8", `Loggedin.logout(iframe, session.did, LOGGEDIN_ORIGIN);`, )} <p>This posts <code>{ type: "logout", did }</code> to the embed, or opens <code>/save?action=logout</code> when storage access is unavailable.</p>
<h2 id="native">iOS and Android SDKs</h2> <p>A native app cannot hold the loggedin.fyi cookie, and neither platform will give you a device identifier to use instead — Apple’s Developer Program License Agreement forbids deriving one, and Android restricts hardware ids to privileged callers. Both platforms do, however, expose the browser’s <em>shared cookie jar</em>: <code>ASWebAuthenticationSession</code> on iOS and Custom Tabs on Android. That is the same <code>loggedin_session</code> cookie mobile Safari or Chrome already holds, so a native client and the web share one device session.</p> <p>The SDKs make one visible trip through that sheet. The page is first-party on loggedin.fyi, so the cookie is attached; in exchange the worker mints a signed token carrying the same session id. Every call after that is a plain HTTPS request with <code>X-Loggedin-Token</code> and no browser.</p> ${callout( "note", "Note", "The system shows a consent sheet naming loggedin.fyi before the cookie is shared. That is the native counterpart of the Storage Access prompt Safari and Firefox already show on the web, it is scoped to your app, and it cannot be suppressed.", )} ${listing( "Listing 9", `{ "client_id": "https://your-app.example/oauth-client-metadata.json", "client_name": "Your App", "client_uri": "https://your-app.example", "redirect_uris": [ "https://your-app.example/oauth/callback", "example.your-app://loggedin" ]}`, )} <p>The callback you pass to the SDK must appear verbatim in <code>redirect_uris</code>. Anything else is refused with <code>403</code>, so a token can only ever be handed to a URL you published.</p> ${listing( "Listing 10", `import Loggedin
let client = try LoggedinClient( origin: URL(string: "https://your-app.example")!, callback: URL(string: "example.your-app://loggedin")!, presenter: SystemWebAuthPresenter { window })
// Once. Shows the consent sheet, then the account chooser.if let hintedDid = try await client.bootstrap() { startOAuth(with: hintedDid)}
// Afterwards — silent, no browser.let accounts = try await client.listAccounts()try await client.heartbeat(did: session.did)`, )} ${listing( "Listing 11", `val client = LoggedinClient( context, origin = "https://your-app.example", callback = "example.your-app://loggedin",)
client.bootstrap(activity) // Custom Tab// in your redirect Activity:val result = client.onBootstrapRedirect(intent.data!!)
val accounts = client.listAccounts()client.heartbeat(session.did)`, )} <p>Pass the signed-in DID to <code>bootstrap</code> to show a “remember this account?” confirmation instead of the chooser. A picked DID is a login hint: start the same OAuth flow you already ship.</p> <p>Tokens last at most seven days, matching the hint TTL. A <code>401</code> means the token expired or the person cleared their browser data — discard it and bootstrap again. The packages live in <code>sdks/ios</code> and <code>sdks/android</code>.</p>
<h2 id="storage-access">Storage Access Across Browsers</h2> <p>The embed and its XRPC calls use the unpartitioned <code>loggedin_session</code> cookie. Parent iframes must set <code>allow="storage-access"</code>.</p> <ul> <li>Chrome can activate storage access with Storage Access Headers and a silent embed.</li> <li>Safari and Firefox use <code>document.requestStorageAccess()</code>. If that fails, the embed opens a first-party <code>/choose</code> or <code>/save</code> window on loggedin.fyi.</li> </ul> <p>Do not call <code>hasStorageAccess()</code> inside the Continue click handler. That call can consume the user gesture. The embed requests storage access first; only then does it fall back to a popup.</p> <p>A hidden iframe can reuse an existing grant on that site. It cannot collect a first grant. Show a visible Continue control when the embed posts <code>needs-access</code>.</p>
<h2 id="rate-limits">Rate Limits</h2> <p>Open participation is gated by client metadata and edge rate limits, not a static list. Expect <code>429</code> with <code>RateLimitExceeded</code> when a client IP, app origin, or metadata fetch exceeds its budget. Heartbeats for the same DID and origin are coalesced for two minutes inside the session store. Each browser session keeps a bounded number of DID and origin pairs.</p>
<h2 id="endpoints">Endpoints</h2> <p>XRPC methods are unauthenticated. The cookie is the browser session key. Native SDKs send <code>X-Loggedin-Token</code> instead and never receive a session cookie; their writes are pinned to the origin inside the token. Write calls from the embed, enroll, and save pages also send the parent origin.</p> <table> <thead> <tr><th>Lexicon / page</th><th>HTTP</th></tr> </thead> <tbody> <tr><td><code>fyi.loggedin.account.loggedIn</code></td><td><code>POST /xrpc/fyi.loggedin.account.loggedIn</code></td></tr> <tr><td><code>fyi.loggedin.account.logout</code></td><td><code>POST /xrpc/fyi.loggedin.account.logout</code></td></tr> <tr><td><code>fyi.loggedin.account.list</code></td><td><code>GET /xrpc/fyi.loggedin.account.list</code></td></tr> <tr><td>JavaScript interface</td><td><code>GET /example-renderer.js</code></td></tr> <tr><td>Embed</td><td><code>GET /embed</code></td></tr> <tr><td>Origin labels</td><td><code>GET /origins?origin=…</code></td></tr> <tr><td>First-party save</td><td><code>GET /save?did=&origin=&action=save|logout</code></td></tr> <tr><td>Enroll</td><td><code>GET /enroll?did=&return=</code></td></tr> <tr><td>Chooser</td><td><code>GET /choose?parent=</code></td></tr> <tr><td>Native bootstrap</td><td><code>GET /app/start?origin=&callback=&state=</code></td></tr> <tr><td>Native session</td><td><code>X-Loggedin-Token</code> request header</td></tr> </tbody> </table> <p><code>GET /xrpc/fyi.loggedin.account.list</code> returns <code>{ accounts: [{ did, origins }] }</code>. See the Lightbox and Cratewalk demos for two complete clients that demonstrate how the hinted DIDs are used across different apps.</p> </article> </div>
<footer> <div class="wrap"> <a href="/">loggedin.fyi</a> <span>A device-local hint for Atmosphere accounts. Not a sign-in service.</span> </div> </footer></body></html>`;}