9.2 KiB
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
- 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.gitignorewith one anchored, escaped line per referenced path (an existing root.gitignoreis 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=autoacross filesystems). Manifest paths do not change; core's media route (Range 206/416, 404 outside the root) serves them unchanged. No symlinks: the loader'sresolveInsideand the studio's write rules both refuse paths that leave the course root.
- The repo gets
- The import folder is moved to
<dataDir>/media/<id>(arename, 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>/mediais never scanned. Nothing writes there after the move; permissions are not changed, because the importer'slinkmode shares inodes with the owner's originals. - 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 answers202 { jobId }; the UI pollsGET /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. - Same id, same manifest. The manifest is committed unchanged:
visibility(restricted by default for imports) andsource.kindstay, so the v1 owner-only rule at publish still applies (only the owner can make it public or signed-in, or changesource.kind). ACL rows, progress and notes are keyed by course and lesson ids, which do not change. - Referenced media are read-only in the studio. Saves, uploads, deletes and conflict resolutions that touch a referenced path or
.stcourses/media.jsonget 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. - Hostile input: symlinks and special files in the import folder are skipped (never followed),
.gitfolders 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 withcheckRelPath, requires a regular file whose real path is inside<dataDir>/media/<id>, and writes throughsafeWritePath(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 typecheckand the Postgres run (STC_TEST_PG_URL) stay green.
File structure
- Create
packages/feature-studio/src/server/media-refs.ts: classification,.gitignoreescaping, 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:mediaDiroption,materializeMedia, draft materialisation, write guards,listFilesincludes referenced media,convertImportedwiring. - Modify
packages/feature-studio/src/server/feature.ts:POST /api/studio/courses/:courseId/convert,GET /api/studio/jobs/:jobId, job handler,convertibleflag; 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:
isLargeMediaby extension and size;gitignoreLineescapes\ * ? [ ] # !and trailing spaces, andgit check-ignorematches exactly the listed paths for names with spaces, brackets,#,!, non-ASCII and trailing spaces (and does not match siblings);sha256FilematchesBun.CryptoHasher;parseMediaRefsrejects 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.mp4with space and non-ASCII name, a > 8 MiB PDF, a nested.gitignorethat ignores a lesson file, a symlink pointing outside, a.gitdir):- 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
.gitcontents are not tracked; .stcourses/media.jsonlists 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;
listFilesincludes 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.
- the bare repo, published worktree and media store exist; the import folder is gone;
- Implement
convert.tsand 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 …/convertgives 202 and a job id; polling reachesdone; 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/:idfor an imported course saysconvertible: truefor 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.
- owner
- 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 whenconvertible; 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 answerRangewith 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>/mediafolder; 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.