1dfa2c0a04
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>
295 lines
11 KiB
Markdown
295 lines
11 KiB
Markdown
# 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
|
|
<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:
|
|
|
|
```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=<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:
|
|
|
|
```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.
|