# @leumas/embed-catalog — the built-in embeddable pages

The built-in embeddable-page catalogue — games and tools, generated from shared/knowledge/iframe-urls, verified live and frameable at generate time, and hydrated into the iframes collection. Taxonomy...


The games and tools Studio's **Dynamic → Iframes** page opens out of the box, plus the schema and the
boot-time hydration that put them there. The embed POLICY itself is not here — that is
`@leumas/arcade` (`buildEmbedAttrs`, `sanitizeSandbox`, `isSafeUrl`), and it stays in one place.

## What is in the catalogue, and what is not

`src/catalog.json` is **generated** from `shared/knowledge/iframe-urls/{games,tools}.txt` by
`ops/tools/embed-catalog/generate.mjs`. Do not hand-edit it — add a URL to the `.txt` and regenerate.

The generator does not trust the source lists. It **contacts every URL once** and keeps an entry only
if it answers 2xx, permits cross-origin framing, **and serves the thing the line claimed**. As of the
last run that was **500 of 1,233** — 360 games and 140 tools:

| dropped | why |
|---:|---|
| 509 | `http-404` — the URL does not exist. Mostly guessed slugs: 164 of 225 `retrogames.cc` entries, 245 of 508 `miniplay` |
| 180 | `x-frame-options: sameorigin` — loads in a browser, can never load in a frame. **Every Coolmath Games entry** |
| 114 | `duplicate-url` — the same embed listed twice under two names |
| 36 | `serves something else` — 200, frameable, and **the wrong game** (see below) |
| 8 | `unreachable` — `retrogamesonline.io` does not resolve |

### [critical] Why a 200 is not enough

`miniplay.com/embed/2048` answers 200, frames happily, and plays **Get Set Boogie**. Twenty-seven
`retrogames.cc` slugs do the same with a different ROM each. These portals substitute another game
for a delisted slug instead of 404'ing, so a catalogue built on status codes alone ships tiles that
lie about what they open — and nothing catches it except loading one and looking at it.

So the generator reads the served `<title>` and compares. Two rules keep that from over-firing:

- **a generic title is not evidence.** GameFlare prints `Free Online Games | Gameflare.com` on every
  embed, which identifies nothing; 30 good entries would otherwise have been dropped.
- **the check runs on games only.** Substitution is a games-portal behaviour, while a tool's title is
  marketing copy rather than a name — `Draw.io Diagram Editor` serves "Flowchart Maker & Online
  Diagram Software", `Minesweeper` serves "Buscaminas". Holding tools to a name match dropped six
  good ones on the first run.

## Pictures

Every row carries one `thumb` field with two possible sources, and a consumer renders it the same
either way:

- **a game's cover art**, hotlinked from the provider's own CDN. The portals publish it in
  `og:image`, so it costs nothing to collect — the same fetch that reads `<title>` reads this.
  GameFlare keeps it only on `/online-game/<slug>/` rather than on the embed page, so that one
  provider gets a second request. **353 of 360 games** get real artwork this way.
- **a screenshot of the tool**, at `/embeds/<id>.webp`, taken by `pnpm embeds:thumbs` and served out
  of Studio's `public/`. This is what the tools half gets, and it is the better preview anyway: you
  see the actual tool rather than a logo.

[critical] **A generic card is not a cover.** utilitydocker serves `og-default.png` for all 78 of its pages
and quill.tools renders one "138 Free Online Tools" banner for all 48 of theirs. Honouring those
would put the identical picture on 126 cards, which reads as a rendering bug rather than as a missing
thumbnail — so `coverFrom` rejects them and they fall through to a screenshot.

**Hotlinked art can rot**, and the consumer must expect it: a provider can move, expire or start
refusing the file. Studio's card stacks the image over a glyph and hides it on `error`, so a dead
cover degrades to an icon with no reflow rather than to a torn-image box.

Every one is kept, with its reason, in `src/rejects.json`. A 404 today can be a live URL next month;
`pnpm embeds:verify` re-probes both files and reports the drift without writing.

**Why this is done at build time and cannot be done at runtime:** Chrome fires an iframe's `load`
event even on an X-Frame-Options-blocked frame, so a page watching `onLoad` cannot tell "rendered"
from "refused". The probe result is the only reliable answer, and it has to be taken from outside a
browser.

## Exports

- `EMBED_CATALOG` / `EMBED_REJECTS` — the two generated lists, frozen.
- `findEmbed(id)` — one entry by its `<provider-slug>-<title-slug>` id.
- `EMBEDS_COLLECTION` (`'iframes'`) and `embedSchema()` — the record contract. `validate: false` on
  purpose: this collection held hand-added rows before it had a schema, and enforcing a guessed field
  list would 422 edits to rows nobody here wrote.
- `hydrateEmbedCatalog({ adapter, upsert })` — registers the schema if absent, then upserts the
  catalogue with `hydrateCollection`. Called once at API boot.
- `@leumas/embed-catalog/taxonomy` — `providerFor`, `categoryFor`, `tagsFor`, `PROVIDERS`, `CATEGORIES`.

There is deliberately **no facet-counting helper**: `useCollection` in `@leumas/ui` already counts a
facet's options against the rows surviving every *other* facet, and a copy here would be a worse one.

## How far to trust a field

`kind` and `provider` are facts — which list the line came from, which host serves it. `httpStatus`
and `verifiedAt` are a measurement. **`category` is a keyword guess**, and the two halves of the list
are not equally guessable: tool slugs are descriptive (`/embed/pdf-watermark`) so the pass is close to
exact, while game titles carry almost no signal — `OvO`, `Dotless`, `Paint Race`. About a third of the
games are therefore `Uncategorised`, which is a real answer. Filing them under a confident wrong genre
would make the facet wrong a third of the time with no way for a reader to tell which third.

## Ownership, and who may change a built-in

`hydrateCollection` writes through the adapter, not the CRUD router, so seeded rows carry **no
`owner`** and are marked `public: true` (without which nobody could read them — reads are
owner-or-public filtered). Since `canWrite === isOwner`:

- an **admin** may edit or delete a built-in;
- a **member** may not — one member deleting a shared row would delete it for the whole install;
- **anyone may add their own**, as a normal owner-stamped row in the same collection. It shows up in
  the same grid, and the hydration's prune cannot reach it: it carries no `seed` flag.

Studio's Duplicate action is what turns a built-in into something you own — it creates a fresh row
rather than trying to edit one you cannot.

**Run `embeds:thumbs` AFTER `embeds:generate`** — the generator rewrites `catalog.json` from scratch,
so the local `/embeds/...` paths are stamped back on by the thumbs pass, which reads the directory
rather than trusting what it captured this run. Files are skipped when they already exist, so a
re-run only shoots what is new.

Bump `SEED_VERSION` in `src/hydrate.js` when a regenerated catalogue should overwrite untouched rows
and prune the ones that dropped out of it. Rows a person edited (`userModified`) are never clobbered.
[critical] **Adding a FIELD needs that bump too** — `hydrateCollection` skips a row whose `seedVersion`
already matches, so a new column simply never lands on an install that has already seeded. That is
how the cards first came back with no pictures despite a catalogue full of them.

## Regenerating

```
pnpm embeds:generate          # probe every URL, rewrite catalog.json + rejects.json (~3 min)
pnpm embeds:thumbs            # screenshot every row the providers gave no cover for (~15 min)
pnpm embeds:verify            # re-probe, report drift, write nothing
node ops/tools/embed-catalog/generate.mjs --offline   # taxonomy only, no network
```

## Caveat — license review pending

These are third-party embeds, and `@leumas/arcade`'s README already carries the same warning for its
own registry: rights and attribution are unconfirmed. This surface is Studio-admin-only, which is why
that is not blocking today. Settle it before any of this reaches a public page.


---
Source: shared/packages/embed-catalog/README.md
Canonical: https://docs.leumas.tech/p/packages/embed-catalog
