Skip to content

Attachment object-store pipeline: serve tutorial repo attachments like images (#1931) - #1967

Merged
jung-thomas merged 20 commits into
DEVfrom
worktree-attachment-object-store
Aug 21, 2026
Merged

Attachment object-store pipeline: serve tutorial repo attachments like images (#1931)#1967
jung-thomas merged 20 commits into
DEVfrom
worktree-attachment-object-store

Conversation

@jung-thomas

Copy link
Copy Markdown
Contributor

Implements docs/superpowers/specs/2026-08-21-tutorial-attachment-object-store-design.md.

What & why

Fixes the #1931 follow-up: relative attachment links in tutorial markdown — [doc](EX2_DDLX_ZRAP100_C_TRAVELTP.txt) — rendered as bare relative anchors and 404'd (the browser resolved them against the page path). resolveImageURLs rewrites images but not plain links, so repo attachments (.txt/.zip/.pdf/.csv/.json/...) were never served. This adds an attachment pipeline that mirrors the image pipeline minus resize, ingesting repo attachments into the object store and serving them through a CAP endpoint the approuter already routes.

Scope of the bug: 44 relative attachment links across 24 tutorials.

How it works (end-to-end)

  1. Parse (scripts/parsers/attachment-links.ts, wired in compose.ts): rewrites relative allowlisted [text](file.ext) links to raw.githubusercontent.com/... (fence-aware, idempotent, skips images/absolute/anchor/mailto/root/../).
  2. Bake (hugo/layouts/_default/_markup/render-link.html): wraps raw-GitHub attachment links to /content/attachment-source?u=<enc> plus a &dl=1 download sibling; all other links pass through unchanged. No approuter change (existing ^/content/(.*)$ route carries it).
  3. Store (db/tutorial-assets.cds TutorialAssets + srv/lib/attachment-store.cjs): @cap-js/attachments composition on the existing shared S3 binding, keyed by sourceUrl.
  4. Ingest / push (attachment-ingest.cjs + attachment-ingest-handler.js, POST /content/attachment, CONTENT_API_KEY-gated) and warm on publish (content-publish-session.js, fire-and-forget).
  5. Serve (attachment-source-handler.js, GET /content/attachment-source, anonymous, self-heal single-flight): text inline, binaries download, .html neutered to text/plain + nosniff, ?dl=1 forces download.
  6. Backfill (scripts/backfill-attachments.ts, npm run backfill-attachments).

Decisions

  • New TutorialAssets entity (clean separation from images).
  • Malware scanning mocked/off — image parity (files are already public on GitHub); real scanning is a documented follow-up.
  • Serve inline-by-default with an explicit download affordance; .html never executed.

Testing

  • Unit/parser/store/ingest/serve tests green; endpoint suites 11/11 each. build:cds emits TutorialAssets tables cleanly. Full suite: only the ~2 known pre-existing selfie failures (none attachment-related). All srv-qa/route/public-endpoint guards green.
  • Render-link hook validated by a real Hugo build (v0.147.7): attachment .txt to endpoint + download sibling; external/relative/anchor links unchanged.
  • Deferred to maintainer (needs deployed backend + CONTENT_API_KEY): live e2e — npm run build:all, then CAP_BASE_URL=<srv> CONTENT_API_KEY=<key> npm run backfill-attachments, then curl -I the RAP100 EX2_DDLX_ZRAP100_C_TRAVELTP.txt via /content/attachment-source (expect 200 text/plain inline; &dl=1 gives attachment).

Follow-ups (out of scope here)

- HTML neutering unconditional: text/html always served as text/plain, even with download:true
- Filename header-injection: sanitize CR/LF in addition to quotes
- Add tests for HTML+download and filename injection cases
)

- srv/lib/attachment-source-handler.js: attachmentSourceHandler (GET
  /content/attachment-source?u=&dl=) + warmAttachmentsLive. Streams
  stored attachment; self-heals via single-flight ingestAttachment on
  miss; sets Content-Type, Content-Disposition (via dispositionFor),
  X-Content-Type-Options, Cache-Control, X-Content-Source headers.
- test/unit/attachment-source-endpoint.test.js: 4 cases (inline serve,
  dl=1 download, 400 missing u, deterministic 404 disallowed host).
  Red-by-design: route not registered until Task 11.
srv/lib/attachment-ingest-handler.js — bytes-in POST endpoint mirroring
image-ingest-handler.js. Adds ext-MIME fallback (extToMime when
content-type absent/octet-stream), filename extraction, and 25MB cap.

test/unit/attachment-ingest-endpoint.test.js — RED by design until
Task 11 registers POST /content/attachment in server.js.
Replace unanchored regex /raw\.githubusercontent\.com/.test(u) with
isRawGithubHost() which compares new URL(u).hostname exactly, preventing
token leakage to lookalike hosts like raw.githubusercontent.com.evil.com.
Export helper for unit-testability; add 4 isRawGithubHost tests.
…1931)

Add GET /content/attachment-source and POST /content/attachment to
ALLOWLIST_ONLY_ON_SRV in check-srv-qa-route-drift.ts. Both routes are
dark-launched (no approuter proxy route yet); mirrors the existing
image-source/image allowlist rationale.
@jung-thomas
jung-thomas marked this pull request as ready for review August 21, 2026 19:07
@jung-thomas
jung-thomas changed the base branch from main to DEV August 21, 2026 19:08
@jung-thomas
jung-thomas merged commit 22d30f0 into DEV Aug 21, 2026
8 of 9 checks passed
@jung-thomas
jung-thomas deleted the worktree-attachment-object-store branch August 21, 2026 19:31
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant