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>
This commit is contained in:
@@ -0,0 +1,294 @@
|
||||
# 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.
|
||||
Reference in New Issue
Block a user