Template image assets and reaper
## Epic requirement (verbatim)
> - A template will have an accompanying image to graphically explain what it does
> - Operator will manage the library (CRUD) - standard style: list, tabbed detail. Reuse Task Edit code
> - Users should be able to browse the templates and Start any of them, favorite
The settled inputs are the PRD (Task 11059) and the architecture (Task 11060): read arch §3 "Asset-copy seam", "Reaper, both directions" and "Customize copies once", plus §8's risks. C1 has already created the `task_template_assets` table and the `task_attachments.org_template_id` subject. **This child adds no migration.**
## Scope
- **`POST /api/templates/{id}/assets`** (multipart: `kind`, file), for editors only.
- card_image: at most 2 MiB; png, jpeg, webp or svg.
- body_image: `MAX_COMMENT_IMAGE_SIZE`.
- A file over the cap gets 413 with a message naming the cap.
- A new card_image replaces the old one, sets `image_key = asset:<id>` and reaps the old bytes.
- Org templates store their assets as `task_attachments` rows with `org_template_id`.
- **`GET /api/templates/{id}/assets/{asset_id}/content`**, for anyone who can see the template. It streams or presigns, like `routes/attachments.py:993`. SVG is served with `Content-Security-Policy: default-src 'none'; style-src 'unsafe-inline'` and `X-Content-Type-Options: nosniff`.
- **`TemplateCard.image_url` / `TemplateDetail.assets`:** filled in for `asset:` keys.
- **One reaper for both stores:** `routes/attachments.py::_reap_unreferenced_object` deletes an S3 object only when no `task_attachments` row and no `task_template_assets` row names its key. Deleting a template, deleting an asset or replacing a card image all call it.
- **The real bodies** of `copy_template_assets` and `copy_assets_between_templates`, with C1's signatures unchanged. Each copies only the assets whose filename appears as `attachment:<filename>` in the body, sharing the `s3_key` (pattern: `automation_fire.py::_copy_attachments`).
## Acceptance criteria owned here (verbatim; API half of AC-5, image half of AC-2, AC-9, AC-10)
- **AC-5 Image.** Oracle: upload a PNG, JPG, WebP or SVG on the Library tab, Save, and open the gallery. Expected: the card shows the image, sharp at its rendered size, not stretched, cropped at most to the card's aspect ratio. An image over the size cap is refused with a message naming the cap. A template without an image shows its purpose-group placeholder.
- **AC-2 (image half).** … description with rich text and pasted images … The rendered description looks the same as on a task.
- **AC-9 / AC-10 (image half):** a task started or customized from a template with body images shows those images. Each image is copied exactly once.
- **AC-20 Live on dev.** AC-1 through AC-19 pass on `dev.jaah.ai` after the deploy. Nothing global sits behind a flag that is off in the dev deployment.
The Library-tab and gallery UI halves of these ACs belong to C3 and C4. This child proves the API and storage behaviour.
Credentials & reach:
- UI ACs as the operator (AC-1–5, 17, 19, 20) | `me/jaah/debug/main/operator-login` (operator identity; switches between the operator and tenants), environment `https://app.dev.jaah.ai` | **verified**: the credential is on the roster and `credential_get` succeeded; an HTTPS GET of app.dev.jaah.ai returned 200 from this node. `dev.jaah.ai` (internal ALB) timed out from here, so oracles use `app.dev.jaah.ai`
Out of scope:
- N-1 No change to Playbooks (`apps/web/src/pages/playbooks/`, `apps/api/app/playbook_*.py`, `routes/playbooks.py`) and no template↔playbook link.
- N-2 No change to the task lifecycle, `start_task` preconditions, or persona prompts.
- N-7 No AI generation of templates or images at runtime. Images are build-time assets (D6) or operator uploads.
- Do NOT touch: the schema or migrations (C1 owns them); the signatures of C1's routes or seam functions; any `apps/web` UI (C3, C4); the starter SVGs (C2).
Done means:
- Tests:
- upload png, jpeg, webp and svg → 201; a 2 MiB + 1 byte card image → 413 with the cap in the message; an unsupported type → 415 or 422;
- the SVG response carries the CSP and nosniff headers;
- **reaper, both ways:** delete a task started from a template, and the template image still serves; delete the template while a started task shares the key, and the task image still serves; once the last reference is deleted, the object is reaped;
- replacing a card image reaps the old key when nothing else references it;
- `copy_template_assets` copies only the filenames referenced in the body, and runs once per create (AC-10: no double copy);
- duplicate copies the assets into the org template;
- as org B, the content route for an org A template asset → 404.
- AC-20: on app.dev.jaah.ai, an uploaded PNG on a template returns 200 from its content URL with the right Content-Type, and an uploaded SVG returns the CSP header (check with an authenticated request).
Activity
- Draft
- Ready