Files
ampelos-dashboard/PROFILE-INTEGRATION.md
odin 1dfa2c0a04 Add the profile page, and publish it as an embeddable panel
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>
2026-08-17 15:42:17 +02:00

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.