Files
STCourses/docs/contract-requests/studio.md
T
Lucas WintherandClaude Opus 5.5 2d162a4282 fix(studio): review round 1 (symlink-safe writes, remote allowlist, upload cap/types/no-overwrite, owner rule at publish/sync/resolve, studio/repos layout and read-only imports, side-effect-free list)
C1: writes lstat every path component and refuse symlinks (dangling included),
check the parent's real path after mkdir, and open with O_NOFOLLOW.
I1: only https://, ssh:// and git@host: remotes; protocol.file/ext/fd=never on
every fetch and push; global/system git config ignored.
I2: STC_UPLOAD_MAX_MB cap (default 100) by Content-Length and while streaming;
extension allowlist (415); stcourse.yaml refused; 409 unless overwrite=true;
sandboxing CSP on draft raw files.
I3: owner-only visibility/source.kind rule checked on the merged result at
publish and sync, and at resolve; publish also validates the merged tree.
I4: bare repos in <dataDir>/studio/repos/<id>.git; imported courses read-only.
I5: list status comes from the bare repo; drafts are created lazily.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-30 04:43:53 +01:00

90 lines
6.1 KiB
Markdown

# Contract requests from WP-C (studio)
Each item says what is missing, what the studio does locally for now, and what Phase 2 should change.
## 1. Features that need the ServerContext (apps/server)
`startServer(config, features: Feature[])` builds the `ServerContext` internally, so a feature factory such as `studioFeature(ctx)` cannot be put into the feature list. The studio therefore does **not** touch `apps/server/src/main.ts`. Locally it runs through `packages/feature-studio/dev/serve.ts`, which mirrors `startServer`.
Proposal:
- `startServer(config, features: (Feature | ((ctx: ServerContext) => Feature))[])`, and main.ts adds `studioFeature`.
- An optional `stop?(): void | Promise<void>` on `Feature`, called from `stop()`. The studio uses it to stop its published-worktree watcher (an unref'd interval, so it never blocks exit).
## 2. Learners must read studio courses from the published worktree (config / main.ts)
Layout after the review (controller ruling on I4):
- `<dataDir>/studio/repos/<id>.git`: studio bare repos. They contain no `stcourse.yaml`, so discovery never loads them.
- `<dataDir>/worktrees/<id>/published`: the published branch, which is what learners read.
- `<dataDir>/worktrees/<id>/drafts/<author>`: drafts. They sit at depth 3, so the depth-2 discovery never loads them.
- `<dataDir>/courses/<id>`: the importer's plain folders (WP-B), untouched by the studio.
`createCourseRepository(config.courseDirs)` scans neither `<dataDir>/worktrees` nor `<dataDir>/courses`. The package exports `studioCourseDirs(config)`, which returns the configured dirs plus `<dataDir>/courses` and `<dataDir>/worktrees`. Tests and the dev entry use it.
Proposal: main.ts calls `createCourseRepository(studioCourseDirs(config))`, or `loadConfig` appends both dirs.
Imported courses (`source.kind` other than `authored`, with their root under `<dataDir>/courses`) are read-only in the studio for v1, with the message "Imported course: read-only in the studio for now". There is a `TODO(studio)` in `feature.ts` (`readOnlyMessage`) for converting one into a studio-managed repo. The studio refuses to create a course whose id already exists as `<dataDir>/courses/<id>`. WP-B should likewise refuse an import id that `<dataDir>/studio/repos/<id>.git` already uses.
## 3. Live preview: swap in the learner `LessonView`
The studio preview sits behind an adapter in `@stc/feature-studio/web`:
```ts
export interface LessonPreviewProps {
courseId: string;
markdown: string;
lesson?: { id: string; title: string; type: string; anchor?: string } | null;
resolveUrl: (rel: string) => string; // course-relative path -> URL of the draft file
}
export function setLessonPreview(c: React.ComponentType<LessonPreviewProps>): void;
```
v1 uses a small, safe built-in Markdown renderer. Proposal: `@stc/feature-learner` exports a `LessonView` that can render from a Markdown string plus a URL resolver, without fetching the published course. Phase 2 then calls `setLessonPreview(adapter)` in `packages/feature-studio/src/web.ts`, so drafts are previewed with the real renderer.
## 4. Web entry points (tsconfig paths)
Phase 1 packages have server code (`Bun.$`, `node:fs`) that must not enter the browser bundle, so the studio has a separate browser entry, `src/web.ts`. The `@stc/*` → `packages/*/src` path maps `@stc/feature-studio/web` to a directory that does not exist, and the workspace has no `node_modules/@stc` links. For now, `apps/web/src/features.ts` imports it with a relative path.
Proposal: add `"@stc/*/web": ["packages/*/src/web.ts"]` to the root tsconfig `paths`, and switch the line to `import { studioWeb } from "@stc/feature-studio/web"`.
## 5. Role-aware navigation (contracts/web.ts)
`WebRoute.nav` cannot be limited by role, so the header "Studio" link shows for everyone. The page itself tells learners and anonymous users that the studio is for authors, and the API returns 401/403.
Proposal: add `nav.roles?: Role[]`, and have the shell show the link only to matching users. This needs the learner package's session in the web shell.
## 6. Draft API for WP-D (AI)
Exported from `@stc/feature-studio`:
```ts
export interface DraftWriter {
writeDraftFiles(courseId: string, authorId: string, files: { path: string; content: string }[], message: string): Promise<{ branch: string; commit: string }>;
}
export function createDraftWriter(ctx: { config: Pick<AppConfig, "dataDir"> }, opts?: {
identity?: (authorId: string) => Promise<{ name: string; email: string } | null> | { name: string; email: string } | null;
}): DraftWriter;
```
- It writes and commits on `draft/<authorId>` and never publishes.
- Without `identity`, commits are authored as `<authorId> <[email protected]>`. WP-D should pass the signed-in user's name and email.
- It throws for courses not managed by the studio, and for paths that are absolute, contain `..` or touch `.git`.
- `studioFeature(ctx).draftWriter` is the same API, already bound to `ctx`.
## 7. Authors and roles (decision to confirm)
Studio access goes to:
- `owner` and `author` roles: all studio courses, and they can create courses;
- users with a `course_authors` row: that course only.
Creating a course inserts the creator into `course_authors`. No new tables are added, so `migrationsDirs` is `[]`.
## 8. External pushes straight into the bare repo (no action needed)
The platform reloads when the published worktree's HEAD changes: an external commit in that worktree, a studio publish, or a Sync. A direct `git push` into `<dataDir>/courses/<id>` is refused by git itself ("branch is currently checked out", checked with git 2.47), so the published files cannot silently fall behind `main`. Outside tools should commit in the published worktree, or push to a configured remote that the author then Syncs.
## 9. Core media route serves SVG/HTML inline (core, Phase 2)
The studio's draft `raw` route now sends `Content-Security-Policy: …; sandbox` and `X-Content-Type-Options: nosniff`. The learner-facing `/api/media/:courseId/*` in `packages/core` serves author-supplied SVG inline from the app's origin and sets neither header.
Proposal: add the same two headers in `serveMedia`.