diff --git a/docs/superpowers/plans/2026-10-30-v11-studio-import.md b/docs/superpowers/plans/2026-10-30-v11-studio-import.md new file mode 100644 index 0000000..37b4123 --- /dev/null +++ b/docs/superpowers/plans/2026-10-30-v11-studio-import.md @@ -0,0 +1,69 @@ +# 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 (`/courses/`) into a studio course (`/studio/repos/.git` plus `/worktrees//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 `/media/`** (a `rename`, same filesystem) rather than kept in place. Reasons: a manifest left in `/courses/` 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. `/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 `/media/`, 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 `/studio/repos/.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 `/media//…`; + - 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 `/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.