Files
STCourses/docs/superpowers/plans/2026-10-30-v11-studio-import.md
T

9.2 KiB

STCourses v1.1 — S: Edit imported courses in the studio

For agentic workers: executed inline (superpowers:executing-plans) by the S package owner, TDD, one commit per task. Steps use checkbox (- [ ]) syntax.

Goal: an owner-only action, "Make editable in the studio", turns an imported course (<dataDir>/courses/<id>) into a studio course (<dataDir>/studio/repos/<id>.git plus <dataDir>/worktrees/<id>/published) with the same id, so authors can edit and publish it and learners keep their progress and notes. Resolves TODO(studio): convert imported course to a studio-managed repo (ruling R9).

Requirements: docs/superpowers/plans/2026-10-30-v1-1.md ("Global Constraints", package S); spec sections 5, 8, 9.1, 12, 13; CLAUDE.md project rules.

Decisions

  1. Large media stay out of git, referenced by path, size and checksum (spec 5). No Git LFS (not installed in the image, and it would need a remote). A file is "large media" when its extension is video/audio (mp4 m4v webm mov mkv avi mp3 m4a wav ogg oga opus flac) or it is over 8 MiB. Everything else (manifest, Markdown, small images, small PDFs, code) is committed.
    • The repo gets .stcourses/media.json ({ schema: 1, files: [{ path, size, sha256 }] }) and a root .gitignore with one anchored, escaped line per referenced path (an existing root .gitignore is kept above our block).
    • Every studio worktree (published and each author's draft) gets the referenced files materialised at their manifest paths as hard links to the media store (copy with --reflink=auto across filesystems). Manifest paths do not change; core's media route (Range 206/416, 404 outside the root) serves them unchanged. No symlinks: the loader's resolveInside and the studio's write rules both refuse paths that leave the course root.
  2. The import folder is moved to <dataDir>/media/<id> (a rename, same filesystem) rather than kept in place. Reasons: a manifest left in <dataDir>/courses/<id> would be discovered as a second course with the same id (and would win: imported dirs are scanned before worktrees); the importer's "add to course" and transcription jobs would keep editing a folder learners no longer see. <dataDir>/media is never scanned. Nothing writes there after the move; permissions are not changed, because the importer's link mode shares inodes with the owner's originals.
  3. Conversion runs as a job (studio.convert-import), because hashing gigabytes of video can exceed Bun's 10 s idle timeout. The route checks everything it can first and answers 202 { jobId }; the UI polls GET /api/studio/jobs/:jobId. The job is restart-safe: a conversion interrupted before the move is rolled back and redone; one interrupted after the repo exists only finishes the move.
  4. Same id, same manifest. The manifest is committed unchanged: visibility (restricted by default for imports) and source.kind stay, so the v1 owner-only rule at publish still applies (only the owner can make it public or signed-in, or change source.kind). ACL rows, progress and notes are keyed by course and lesson ids, which do not change.
  5. Referenced media are read-only in the studio. Saves, uploads, deletes and conflict resolutions that touch a referenced path or .stcourses/media.json get 409 with a message to upload under a new name. As defence in depth, any studio write to a file with more than one hard link unlinks it first, so a write can never reach a shared inode.
  6. Hostile input: symlinks and special files in the import folder are skipped (never followed), .git folders are skipped, a large file whose name contains a newline (inexpressible in .gitignore) refuses the conversion, and the published worktree must be clean after checkout or the conversion rolls back. Materialisation checks each reference with checkRelPath, requires a regular file whose real path is inside <dataDir>/media/<id>, and writes through safeWritePath (refuses symlinks in the destination path).

Global Constraints

  • Everything in CLAUDE.md "Project rules" and the v1.1 "Global Constraints" applies. No DB change is needed (no migration).
  • v1 safeguards stay: symlink-refusing writes, the remote allowlist, the owner-only visibility rule at publish, core's CSRF Origin check (applies to the new POST route automatically), repos at <dataDir>/studio/repos/<id>.git.
  • Synthetic fixtures only. bun test, bun run typecheck and the Postgres run (STC_TEST_PG_URL) stay green.

File structure

  • Create packages/feature-studio/src/server/media-refs.ts: classification, .gitignore escaping, hashing, reference file read/write.
  • Create packages/feature-studio/src/server/convert.ts: convertImported (stage, commit, worktree, materialise, move).
  • Modify packages/feature-studio/src/server/store.ts: mediaDir option, materializeMedia, draft materialisation, write guards, listFiles includes referenced media, convertImported wiring.
  • Modify packages/feature-studio/src/server/feature.ts: POST /api/studio/courses/:courseId/convert, GET /api/studio/jobs/:jobId, job handler, convertible flag; remove the TODO.
  • Modify packages/feature-studio/src/ui/*: convert panel in the read-only view, imported tag on the studio home.
  • Create tests: packages/feature-studio/test/media-refs.test.ts, convert.test.ts, convert-routes.test.ts, convert-ui.test.tsx; apps/server/test/studio-import-e2e.test.ts.
  • Modify docs/operations.md (media decision, layout).

Task 1: Media references

  • Tests: isLargeMedia by extension and size; gitignoreLine escapes \ * ? [ ] # ! and trailing spaces, and git check-ignore matches exactly the listed paths for names with spaces, brackets, #, !, non-ASCII and trailing spaces (and does not match siblings); sha256File matches Bun.CryptoHasher; parseMediaRefs rejects bad shapes.
  • Implement media-refs.ts. Run tests, typecheck. Commit.

Task 2: Conversion in the store

  • Tests (convert.test.ts, synthetic import folder built in the test: manifest, lesson Markdown, a fake .mp4 with space and non-ASCII name, a > 8 MiB PDF, a nested .gitignore that ignores a lesson file, a symlink pointing outside, a .git dir):
    • the bare repo, published worktree and media store exist; the import folder is gone; isManaged;
    • git tracks the manifest, lessons (including the nested-ignored one) and small files, never the video or big PDF; the symlink and .git contents are not tracked;
    • .stcourses/media.json lists path, size, sha256; the published worktree is clean; the video in the worktree is a hard link (same inode) to <dataDir>/media/<id>/…;
    • a draft worktree gets the media too; listFiles includes them;
    • saving, uploading or deleting a referenced path or the reference file is refused (409); a write to a hard-linked file never changes the other link;
    • refusals: not under the imports dir, source.kind: authored, manifest id mismatch, root: set, already a studio course, media store already present; each leaves the import folder untouched and no repo behind;
    • resume: a leftover partial repo is rebuilt; a managed course whose import folder still exists only finishes the move;
    • publish after an edit keeps the media in the published worktree.
  • Implement convert.ts and the store changes. Run studio tests, typecheck. Commit.

Task 3: Routes and job

  • Tests (convert-routes.test.ts, real job queue from @stc/core):
    • owner POST …/convert gives 202 and a job id; polling reaches done; the course is then studio-managed, served from the published worktree, still restricted, with ACL rows unchanged;
    • an author or learner gets 403; a foreign Origin gets 403 (CSRF); an overlay or studio course gets 409; an unknown course 404; a second request while one is queued gets 409;
    • GET /api/studio/courses/:id for an imported course says convertible: true for the owner only;
    • after conversion a non-owner author can edit and publish a lesson title, but cannot publish visibility: public (403); the owner can.
  • Implement. Run tests, typecheck. Commit.

Task 4: Studio UI

  • Test (convert-ui.test.tsx, server render): the read-only panel shows "Make editable in the studio" and the explanation only when convertible; progress and error states render.
  • Implement ConvertPanel (button, job polling, then reload into the workbench) and the imported tag on the home list. Existing tokens and classes only; no new colours, fonts or motion. Commit.

Task 5: End-to-end

  • apps/server/test/studio-import-e2e.test.ts, SQLite and Postgres: production feature set; owner signs up; a synthetic video folder is imported through /api/import/jobs; a learner is granted access by ACL, marks a lesson done and writes a note; owner converts; edits a lesson title in the studio; publishes. The learner sees the new title, the same progress and notes, media answer Range with 206 and a bad range with 416; anonymous users still get 404.
  • Commit.

Task 6: Docs, smoke run, report

  • docs/operations.md: the media decision and the new <dataDir>/media folder; backups; that media are not pushed to studio remotes.
  • Dev server on port 3492 with STC_DATA_DIR=/tmp/stc-v11-studio: import, convert, edit, publish by curl.
  • Full bun test, typecheck, Postgres run. Commit. Write the report.