# 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.
