# The Ampelos profile, and how accounts.sticknife.com shows it Ampelos has a profile page at `https://ampelos.sticknife.com/profile`. It shows one person their Plex link, their Watch Now slots, and their watch history. The same panel is published in two other forms so that `accounts.sticknife.com` on charon can carry it as one section of the wider sticknife profile, next to the other services' sections: | Surface | Address | For | | --- | --- | --- | | Page | `/profile` | People coming to Ampelos directly | | Embed | `/embed/profile` | Charon framing the panel Ampelos renders | | API | `/api/profile` | Charon rendering the section itself | All three are the same data: the page, the embed and the JSON all come from `getProfile()` in `src/lib/profile.ts`. There is no second implementation to drift. --- ## The short version Put this in the Ampelos section of the sticknife profile: ```html ``` That is the whole integration. **No token, no secret, no configuration on charon's side.** Why it works is worth understanding, because it constrains where these services may live. --- ## Why no token is needed `accounts.sticknife.com` and `ampelos.sticknife.com` share the registrable domain `sticknife.com`. A request from one to the other is therefore **same-site**, even though it is cross-origin. The Ampelos session cookie is `SameSite=Lax`, and Lax cookies *are* sent on same-site subresource loads. So the person is simply already signed in inside the frame. Nothing is exchanged, nothing is impersonated, and no cookie had to be loosened to `SameSite=None`. > **This holds only while both services are under `sticknife.com`.** Moving > either to its own domain makes the pair cross-site, the cookie stops being > sent, and the embed will show the signed-out state to everybody. That would > need a real token flow — most likely charon forwarding the user's Authentik > ID token — and is a deliberate piece of work, not a config tweak. Both services already sign in against the same Authentik at `charon.sticknife.com`, so in practice a person signed in to the accounts page is signed in to Ampelos. ### The signed-out case If the visitor has no Ampelos session, the embed does **not** redirect to Authentik. Authentik refuses to be framed, so a redirect would render an `X-Frame-Options` error inside charon's panel — a broken box with no explanation. The embed instead renders a short "Sign in to Ampelos" link that targets `_top` and leaves the frame. For the same reason, every link inside the embed carries `target="_top"`, and the Plex link button becomes a link to the full profile page rather than starting a flow that would dead-end in the panel (plex.tv also refuses to be framed). --- ## Sizing the frame A cross-origin iframe cannot size itself and the parent cannot measure across the origin boundary. Ampelos therefore posts its height to the parent whenever it changes: ```js window.addEventListener("message", (event) => { if (event.origin !== "https://ampelos.sticknife.com") return; if (event.data?.type !== "ampelos:profile:height") return; iframe.style.height = `${event.data.height}px`; }); ``` The message is posted only to the origins in `AMPELOS_EMBED_ANCESTORS`, never to `*`. **Ignoring it is fine** — the panel then scrolls inside whatever height you give it. Check `event.origin` if you do listen; that check is charon's job and Ampelos cannot do it for you. --- ## The JSON, if charon would rather render it itself ``` GET https://ampelos.sticknife.com/api/profile ``` ```jsonc { "service": "ampelos", "schemaVersion": 2, "generatedAt": "2026-08-17T11:00:41.965Z", "user": { "id": "…", "displayName": "Ryan Potter", "email": "ryan@…" }, "plex": { "linked": true, "username": "…", "email": "…", "linkedAt": "2026-08-10T…", "isServerOwner": false, "librariesSharedAt": "2026-08-10T…", // null if the share failed "sharedLibraryCount": 3 }, // or simply: { "linked": false } "watchNow": { "television": { "quota": 5, "used": 1, "items": [{ "id": "…", "mediaItemId": "…", "title": "Law & Order- Special Victims Unit", "year": 1999, "posterUrl": "https://image.tmdb.org/t/p/w342/….jpg", "slotNumber": 1, "addedAt": "2026-08-09T…", "catalog": { "tmdbId": "2734", "kind": "television" } // or null }] }, "movies": { "quota": 10, "used": 6, "items": [ … ] } }, "history": { "total": 1668, // plays, counting repeats "distinctEpisodes": 993, // what `recent` is a window onto "playsLast30Days": 276, "lastWatchedAt": "2026-08-17T01:45:25.000Z", "recent": [{ // 25 most recent, ONE PER EPISODE "id": "…", "mediaItemId": "…", "mediaType": "tv_series", // or "movie" "title": "Gen V", "year": 2023, "posterUrl": "https://image.tmdb.org/t/p/w342/….jpg", "seasonNumber": 2, // null for a movie "episodeNumber": 8, // null for a movie "episodeTitle": "Trojan", // null if not in the database yet "watchedAt": "2026-08-17T01:45:25.000Z", // the MOST RECENT play "playCount": 3, // times this episode was played "source": "plex", "catalog": { "tmdbId": "205715", "kind": "television" } }] }, "links": { "self": "https://ampelos.sticknife.com/profile", "embed": "https://ampelos.sticknife.com/embed/profile", "api": "https://ampelos.sticknife.com/api/profile" } } ``` `posterUrl` is always absolute or null. Ampelos stores TMDB's bare path in some rows and a full URL in others, and resolves both before handing them over — a bare `/abc.jpg` would mean nothing on charon. `isServerOwner` matters for how you word the libraries line. Plex will not invite a server's owner to their own libraries ("You cannot send an invitation to yourself"), so an owner legitimately has `librariesSharedAt: null` while having access to everything. Do not render that as a failed share. ### Deduplication, and `catalog` `history.recent` is **one row per episode, not per play.** A rewatch, a resume after stopping, and a client that reports the same episode twice all land as separate plays, and left raw they bury everything else — this library's real numbers are 1,668 plays across 993 distinct episodes. Each row carries the most recent `watchedAt` and a `playCount` so collapsing repeats does not hide them. Count against `distinctEpisodes`, not `total`. `catalog` is what lets a row be clicked. Ampelos's own panel links it to `/?tab=&open=`, which opens the catalog detail panel on that title; charon can do the same, or use the TMDB id directly. It is **null** when the title has no TMDB link or has one a reviewer has *rejected* — a rejected id is a known-wrong id kept as evidence, so following it would open the wrong film. Render those rows as plain text. ### `schemaVersion` Part of the contract. Charon renders a panel it does not own from a service it cannot redeploy in step, so it should check this and degrade rather than break. - **Adding** a field does not bump it. Ignore keys you do not know. - **Changing what a field means** bumps it. **Version 2** (2026-08-17): `history.recent` became one row per episode rather than per play, and its `watchedAt` became the most recent play of that episode. A consumer written against version 1 would double-count. `playCount`, `distinctEpisodes` and `catalog` arrived at the same time but would not, alone, have earned the bump. ### Authenticating **From the browser** — same-site, so the session cookie carries it: ```js fetch("https://ampelos.sticknife.com/api/profile", { credentials: "include" }) ``` CORS allows exactly the origins in `AMPELOS_EMBED_ANCESTORS`, with `Access-Control-Allow-Credentials: true`. Any other origin gets no allow-origin header and the browser blocks the read. This mode answers for the signed-in user and **ignores the query string entirely** — a signed-in person cannot widen their own request by adding `?email=`. **From charon's server** — no browser, so no cookie: ``` GET /api/profile?email=someone@example.com Authorization: Bearer $AMPELOS_PROFILE_API_TOKEN ``` `?userId=` (the Ampelos uuid) works too. `email` is the useful one: it is unique in `users` and is the claim Authentik issues to every sticknife service, so charon already has it. > This mode is a **full read of anybody's profile, watch history included.** It > is off unless `AMPELOS_PROFILE_API_TOKEN` is set, and it deliberately does > **not** reuse `AMPELOS_AGENT_TOKEN` — that token is deployed to every > satellite host, and a leaked herald script should not also hand over > everyone's viewing. > > If charon only frames the panel, leave it unset. Responses are `Cache-Control: private, no-store`. --- ## What Ampelos needs configured In `.env.local` (see `.env.example`): ```sh # Who may frame /embed/* and call /api/profile from a browser. # ORIGINS, scheme included -- a bare hostname is ignored by the CSP parser. AMPELOS_EMBED_ANCESTORS=https://accounts.sticknife.com # Only if charon renders server-side. Off when unset. AMPELOS_PROFILE_API_TOKEN= ``` Everything outside `/embed/*` is served `frame-ancestors 'none'` and `X-Frame-Options: DENY`. Framing the catalog or the admin views has no legitimate use and is how a clickjacked "Remove" button gets pressed. --- ## Privacy Watch history is per-person and is shown **only to the person it belongs to.** This matters more than it looks. Ampelos reads history from Plex using the server owner's token, and that token can read *every* user's viewing. Being able to is not permission to: - History is recorded only for accounts that linked themselves at `/profile`. Everyone else on the Plex server is skipped and reported as unlinked. - Every read is scoped to one user id. `src/lib/profile.ts` has no "all users" mode, deliberately. - **There is no admin view of who watched what.** An administrator sees their own history and nobody else's. The one way to read another person's profile is the server-side token above, which is why it is a separate secret, off by default, and documented as being exactly as sensitive as it is. --- ## Where the data comes from | Section | Source | | --- | --- | | Plex link | `plex_accounts`, written by the PIN flow in `src/app/profile/actions.ts`. No Plex token is ever stored. | | Watch Now | `watching_now_items` + the per-user quotas on `users` | | Watch history | `watch_history`, filled by `plex:history` in ampelos-agent | History is **copied** out of Plex hourly rather than read live, because Plex prunes its own history and a rebuilt server starts empty — both of which have happened here. See `scripts/sync-plex-history.mjs` in ampelos-agent.