# ADR-134 — Explainer and Marketing Media Are Self-Hosted on the VPS Under `/share/<slug>/`; Public Exposure Requires an Explicit Classification Gate

## Status

Proposed, 2026-08-09.

## Status History

```yaml
status_history:
  - date: 2026-08-09
    status: Proposed
    changed_by: hkl
    reason: >
      Formalising the media-delivery surface after the Track C explainer video was published.
      Two share pages now exist with two different hosting mechanisms (Drive-redirect vs
      VPS self-host) and no ADR governed the choice; the second one published client
      artwork to an unauthenticated path with directory listing enabled.
    changed_via: adr-kit (360lm)
```

## Context

360LM produces explainer/marketing videos that must be shareable by **pasting a link into
WhatsApp** and having a **thumbnail preview card** render. Two such videos exist, built five
weeks apart, using **two incompatible hosting mechanisms** — neither governed by an ADR:

- **ImageBinding explainer (2026-08-02)** — MP4 uploaded to **Google Drive** on the YEMO account
  (`harish@yemo.in`); the VPS held **only** `index.html` (1,134 B) + `og_thumb.jpg` at
  `/var/www/360lm/share/imgbind-explainer/`. The page carries OG tags and
  `<meta http-equiv="refresh">`-bounces to `drive.google.com/file/d/<id>/view`. Verified
  2026-08-09 by sweeping every `.mp4` >1 MB under `/var/www`: **the video was never on the VPS.**
  Drive was chosen only because `drive.file` upload had been built that same day — not on merit.
- **Track C explainer (2026-08-09)** — MP4 (36 MB, 7 m 16 s, 1280×720 h264+aac) generated via
  NotebookLM and **self-hosted on the VPS** at `/var/www/360lm/share/trackc-explainer/` with a
  real inline `<video controls poster>` player.

Constraints and evidence that forced the decision:

1. **The Drive path is no longer safe to default to.** `yemo.in` Workspace sits at **95 % of its
   pooled storage limit** (weekly `workspace-noreply@google.com` warnings 2026-06-06 → 2026-08-08;
   "Pooled storage grace period expired" already fired 2026-05-16 and 2026-05-23). Exceeding it
   triggers a 14-day grace period then a **read-only** state — no new uploads, no new Docs/Sheets.
   Pushing marketing media into that pool risks a business-domain outage. `drive.file` OAuth is
   also consented on **YEMO only**; `business`/`haris`/`promo` each need a manual consent click.
2. **The VPS can serve video properly.** 21 GB free (96 G disk, 79 % used) and Apache
   (`360lm-web`, `httpd:alpine`) returns `Accept-Ranges: bytes` — a ranged GET returns **206**, so
   seeking works. A 36 MB asset is negligible against that headroom.
3. **Signed-URL delivery cannot produce a WhatsApp preview card.** ADR-049's HMAC proxy issues
   tokens with a 15-min default TTL (6 h max). A pasted link must survive far longer, and OG
   unfurl bots are unauthenticated — they cannot present a token, so no thumbnail would render.
4. **⚠ Public exposure is real and was hit in practice.** The Track C video embeds a **client's
   artwork** (Livpure Studio creative) and their **12-board size schedule** — visible as on-screen
   frames. It was published to a guessable, unauthenticated URL, and `/share/` had **Apache
   autoindex enabled**, so anyone reaching the parent path could **enumerate every slug**. This is
   the same failure class ADR-049 was written to close ("directory listing was enabled on that
   path and no authentication was required"). Directory listing was suppressed the same day by
   placing a sentinel `index.html` at `/var/www/360lm/share/`; `.htaccess` was ineffective because
   the container's `AllowOverride` is off, and editing the shared vhost was rejected as too broad
   a blast radius for every other PWA it serves.

**Affected surfaces:** any PWA or campaign publishing an explainer/marketing video or image for
external circulation — today ImageBinding and Design-Automation Track C; the pattern is general.

## Decision

Explainer and marketing media are **self-hosted on the VPS** at
`/var/www/360lm/share/<slug>/`, served publicly and unauthenticated, and **no asset is placed
there until it has passed an explicit public-distribution classification gate approved by hkl**.

Concretely:

1. **Layout.** One directory per artefact: `index.html` + `og_thumb.jpg` + the media file
   (`<slug>.mp4` / image). Files `chown www-data:www-data`, `chmod 644`; directory `755`.
2. **Page.** A real inline `<video controls preload="metadata" playsinline poster="og_thumb.jpg">`
   player — **not** a redirect to a third party. It carries `og:type=video.other`, `og:title`,
   `og:description`, `og:image` (absolute URL), `og:image:width/height`, `og:url`, and
   `twitter:card=summary_large_image`. **Do not** declare `og:video` or `twitter:card=player`;
   the proven-rendering tag set is the minimal one (`og:type` + `og:image`), and
   `twitter:card=player` is invalid without a `twitter:player` iframe URL.
3. **Thumbnail.** 1200×670 JPEG. NotebookLM output is pillarboxed, so run `cropdetect` first, then
   `crop=…,scale=1200:670:force_original_aspect_ratio=increase,crop=1200:670`.
4. **No enumeration.** `/var/www/360lm/share/` holds a sentinel `index.html` so Apache never emits
   a directory listing. Every share page carries `<meta name="robots" content="noindex, nofollow,
   noarchive">` — search-indexing client artwork is the avoidable harm; `noindex` does not
   interfere with WhatsApp/OG unfurling.
5. **Classification gate (the binding constraint).** Because this surface is unauthenticated,
   media may be published here **only** when cleared for public distribution. Media containing
   client IP, per-store/site operational data, PII, or internal financials **must not** go here —
   it belongs behind the **ADR-049 HMAC-signed proxy**. When a client-supplied asset appears in an
   explainer, hkl must confirm the intended audience (internal circulation vs client-facing)
   **before** upload; if in doubt, it goes behind ADR-049.
6. **Google Drive is no longer the default** for 360LM-owned media, and must not be used at all
   while `yemo.in` remains near its storage ceiling.

**Decision Maker:** hkl

## Alternatives Considered

- **Keep the ImageBinding v1 pattern — Drive-hosted MP4 + VPS OG redirect page.** Rejected:
  consumes `yemo.in` pooled storage which is already at 95 % with two expired grace periods,
  risking a read-only business domain; requires per-account `drive.file` OAuth consent (only YEMO
  has it); bounces the viewer to a third-party UI we do not control; and availability depends on a
  Google account remaining in good standing. It was never a considered choice — it was an artefact
  of `drive.file` upload having shipped that morning.
- **Serve all media through the ADR-049 HMAC-signed proxy.** Rejected **for public marketing media
  only**: 15-min-default (6 h max) token TTL cannot back a link pasted into WhatsApp, and
  unauthenticated OG unfurl bots cannot present a token, so no preview card would ever render —
  defeating the entire purpose. **This alternative remains mandatory for non-public media**, and
  §5 above routes such assets to it.
- **Upload to YouTube as an unlisted video.** Rejected: places 360LM and client artwork on an
  external platform under an account dependency; introduces YouTube branding, related-video
  chrome, and potential ads around client material; and "unlisted" is a weaker guarantee than a
  `noindex` page on infrastructure we control.
- **Serve from the existing VideoSmith / MoneyPrinterTurbo containers.** Rejected: those are video
  *generation* services (ADR-109), not a delivery surface; their `storage/tasks/` and
  `cache_videos/` trees are working scratch space subject to cleanup, not a durable public path.
- **Enable HTTP Basic Auth on `/share/`.** Rejected for the public case on the same grounds as
  ADR-049 rejected it — a single shared credential for all viewers, and OG unfurl bots cannot
  authenticate, so the thumbnail card breaks. It remains available as a targeted mitigation if a
  specific artefact needs to be semi-private but does not warrant the full proxy.

## Consequences

**Positive**

- Zero Google-storage consumption and zero OAuth consent friction; removes `yemo.in` from the
  critical path of publishing a video.
- Durable, self-owned link with no third-party redirect; survives Google account changes.
- Range requests work (`206` verified), so viewers can seek within the video.
- One consistent, reproducible recipe; the OG card renders from a tag set proven in production.
- The Drive-vs-VPS ambiguity that produced two incompatible patterns is closed.

**Negative**

- The surface is **genuinely public** — anyone with (or guessing) the URL can view and download.
  There is no per-user access control and no revocation short of deleting the file.
- VPS disk and egress now carry media; 21 GB headroom is finite and unmonitored.
- Every artefact requires a **human classification judgement** before publishing; this is process,
  not automation, and process gets skipped under time pressure.
- Two live share pages now use two different mechanisms until `imgbind-explainer` is migrated.

**Risks and mitigations**

| Risk | Mitigation |
|---|---|
| Client IP published to a public URL (**already occurred** — Track C / Livpure) | §5 classification gate before upload; `noindex`; hkl confirms audience; migrate to ADR-049 proxy if client-facing exposure is not intended |
| Slug enumeration via directory listing (**already occurred** on `/share/`) | Sentinel `index.html` at `/var/www/360lm/share/` (deployed 2026-08-09); re-verify after any web-container change, since `.htaccess` is inert with `AllowOverride` off |
| Search engines index client artwork | `robots` meta `noindex, nofollow, noarchive` on every share page |
| VPS disk fills as media accumulates | Media is 36 MB against 21 GB free today; revisit if `/share/` exceeds ~2 GB |
| OG card silently stops rendering after a tag edit | Treat the minimal tag set as the contract; re-verify by pasting the URL into a real WhatsApp chat after any change — HTTP 200 does **not** prove card rendering |

## Revisit If

*(Section mandated 2026-08-09 by the WHY rule in `adr-coding-rules.md` — invalidation triggers,
plus explicit non-scope.)*

**Revisit if:**
- **The VPS stops being the cheapest/safest host.** Self-hosting rests on 21 GB free disk and Apache
  already serving range requests. If `/share/` approaches ~2 GB, or the VPS moves to metered egress,
  re-weigh object storage or a CDN.
- **`yemo.in` storage stops being the blocker.** Much of the case against Drive was the 95% pooled
  storage ceiling. If that is resolved, Drive becomes viable again for genuinely public media —
  though the no-third-party-redirect and self-owned-link arguments stand on their own.
- **An unauthenticated surface stops being acceptable.** The §5 classification gate is a *human
  process*, and process degrades under time pressure. If a second client-IP exposure occurs,
  escalate from process to mechanism (per-artefact basic auth, or route everything via ADR-049).
- **OG unfurl bots gain a way to authenticate.** The entire reason public hosting is required is
  that unfurl bots cannot present a token, so ADR-049's proxy cannot render a preview card. If that
  changes, the public surface loses its justification and this can all move behind the proxy.
- **Apache `AllowOverride` is enabled on the `360lm-web` container.** The sentinel-`index.html` fix
  for directory listing is a workaround for `.htaccess` being inert; with `AllowOverride` on,
  `Options -Indexes` is the cleaner mechanism.

**Does NOT govern:**
- **Non-public media of any kind** — client IP, per-store/site operational data, PII, internal
  financials. That is ADR-049's territory, unconditionally. This ADR must never be cited to justify
  putting such media on `/share/`.
- **Proof images** (ADR-072) or **bulk file upload/export** (ADR-085) — different surfaces.
- **Video *generation*** — VideoSmith/MPT (ADR-109). This ADR covers delivery only.
- **Internal-only documents** needing no WhatsApp preview card; they have no reason to be public and
  should not adopt this pattern by default.

## Related Decisions

- **ADR-049** — Recce view files via HMAC-signed proxy. The direct complement to this ADR: ADR-049
  governs **non-public** media; this ADR governs **public** media, and §5 routes between them.
  Both exist because of the same failure (listing enabled, no auth).
- **ADR-085** — File upload patterns; general file size limits and upload endpoints.
- **ADR-072** — Proof images stored base64 in DB (≤ 100 KB). Distinct surface: operational proof
  media, never public.
- **ADR-131** — ImageBinding media schema and anchored post feed; the PWA whose explainer video
  established the v1 Drive pattern superseded here.
- **ADR-109** — VideoSmith as an isolated wrapper around the internal engine (generation, not
  delivery).
- **ADR-064** — Video tutorial production pipeline (how tutorial video is produced).
- **ADR-105** — Signed JWT for proxy and native auth.

## References

- Live: `https://dev.srv1111289.hstgr.cloud/share/trackc-explainer/` (VPS self-host, v2 pattern)
- Live: `https://dev.srv1111289.hstgr.cloud/share/imgbind-explainer/` (Drive-redirect, v1 pattern)
- `/var/www/360lm/share/` — sentinel `index.html` suppressing autoindex (added 2026-08-09)
- Storage evidence: `workspace-noreply@google.com` → "Approaching pooled storage limit — your
  account yemo.in is currently using 95% of your pooled storage limit" (weekly, to
  `haris.lal.1974@gmail.com`); "Pooled storage grace period expired" 2026-05-16, 2026-05-23
- Capacity evidence: `df -h /` → 96 G total, 76 G used, **21 G available** (79 %); ranged GET on
  the MP4 returns **206**
- Source plan: `Documents\design-automation\TRACK_C_SIZEADAPT_PLAN.md`;
  explainer source `TRACK_C_EXPLAINER.md`

## Enforcement

```yaml
rules:
  - id: no-drive-redirect-share-pages
    description: Share pages must embed a local media file, not redirect to a third-party host.
    glob: "share/**/index.html"
    forbid_regex: "http-equiv=[\"']refresh[\"'][^>]*drive\\.google\\.com"
  - id: share-pages-must-noindex
    description: Every public share page must carry a robots noindex meta tag.
    glob: "share/**/index.html"
    require_regex: "name=[\"']robots[\"'][^>]*noindex"
  - id: share-root-sentinel-index
    description: /share/ must contain an index.html so Apache never emits a directory listing.
    require_path: "share/index.html"
```
