Developed in a separate session; committed here alongside the calendar work
that shares this repository's migration journal.
A person can now see their own Plex link, their Watch Now slots and their watch
history at /profile. The same panel is published in two further forms so that
accounts.sticknife.com on charon can carry it as one section of a wider
sticknife profile, next to the other services' sections.
- watch_history (0024) records what has been played, keyed on the Plex
history id so a re-sync cannot duplicate a row. Partial unique index,
because that id is null for anything entered by hand.
- plex_accounts.is_server_owner (0025) marks the one account whose viewing
the server files under local account 1 rather than under its plex.tv id.
- The embed carries its own layout, origin allowlist and a frame-height
reporter, so the host page can size it without guessing.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
11 KiB
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:
<iframe
src="https://ampelos.sticknife.com/embed/profile"
title="Ampelos"
loading="lazy"
style="width:100%;border:0;height:640px"
></iframe>
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:
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
{
"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=<kind>&open=<tmdbId>, 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:
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_TOKENis set, and it deliberately does not reuseAMPELOS_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):
# 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.tshas 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.