Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
51 changes: 36 additions & 15 deletions apps/api/src/routes/pulls.js
Original file line number Diff line number Diff line change
Expand Up @@ -300,7 +300,31 @@ async function openPull(request, env, base, lessonId, cors) {
if (!(await canReadLesson(env, base, request, lesson))) {
return textResponse('Lesson not found.', 404, cors);
}
if (lesson.author_id === user.id) {
// The fork this came from, when it is itself a saved lesson. Only recorded if
// it really is the caller's: it becomes a link shown next to their name, and
// nobody should be able to point that at a lesson they don't own. A bad value
// is dropped rather than rejected — the pack is what carries the changes.
//
// Resolved before the own-lesson check below, which turns on it.
let sourceLessonId = null;
const claimed = typeof body.sourceLessonId === 'string' ? body.sourceLessonId.trim() : '';
if (LESSON_ID_RE.test(claimed) && claimed !== lessonId) {
const source = await fetchLessonRow(env, base, claimed);
if (source && source.author_id === user.id) sourceLessonId = claimed;
}

// Proposing to your own lesson is refused when there is nothing behind it: you
// can simply save, and a request to yourself out of nowhere is a mistake.
//
// It is allowed when it carries a fork you own, because then it means something
// specific and useful — "here is a copy with changes in it, let me read the diff
// before it lands". That is the shape of an AI assistant's work: over MCP the
// assistant acts as the account it is signed in with, so changes it proposes to
// the user's own lesson arrive from the user's own id (see apps/mcp/src/git.js).
// Holding them in the review queue is the whole point — the lesson is untouched
// until a person reads the diff and merges it. A human gets the same route via
// "fork into a new lesson" in the editor.
if (lesson.author_id === user.id && !sourceLessonId) {
Comment thread
qodo-code-review[bot] marked this conversation as resolved.
Outdated
return textResponse('This is your own lesson — save your changes to it directly instead.', 400, cors);
}

Expand All @@ -326,17 +350,6 @@ async function openPull(request, env, base, lessonId, cors) {
);
}

// The fork this came from, when it is itself a saved lesson. Only recorded if
// it really is the caller's: it becomes a link shown next to their name, and
// nobody should be able to point that at a lesson they don't own. A bad value
// is dropped rather than rejected — the pack is what carries the changes.
let sourceLessonId = null;
const claimed = typeof body.sourceLessonId === 'string' ? body.sourceLessonId.trim() : '';
if (LESSON_ID_RE.test(claimed) && claimed !== lessonId) {
const source = await fetchLessonRow(env, base, claimed);
if (source && source.author_id === user.id) sourceLessonId = claimed;
}

const insert = {
lesson_id: lessonId,
source_lesson_id: sourceLessonId,
Expand Down Expand Up @@ -431,14 +444,22 @@ async function putPullPack(request, env, base, lessonId, pullId, cors) {

// Now there is something to look at, tell the lesson's author. Best-effort —
// never fail a proposal that has landed over a notification that hasn't.
//
// A proposal from the author's own account is notified too, and is the case
// that needs it most: it is how an AI assistant working over MCP offers changes
// (see openPull above), and the notification is the only thing that tells the
// author there is something waiting. The wording doesn't claim someone else
// wrote it, because the account says otherwise; the proposal's own body records
// what opened it.
const lesson = await fetchLessonRow(env, base, lessonId);
if (lesson && lesson.author_id !== user.id) {
if (lesson) {
const own = lesson.author_id === user.id;
await createNotification(env, base, {
userId: lesson.author_id,
type: 'pull_request',
title: `${authorFromUser(user)} proposed changes to your lesson`,
title: own ? 'Changes are waiting for your review' : `${authorFromUser(user)} proposed changes to your lesson`,
body: pull.title,
link: `/hub/${lessonId}`,
link: `/hub/${lessonId}/proposals/${pullId}`,
}).catch(() => {});
}

Expand Down
6 changes: 6 additions & 0 deletions apps/docs/docs/mcp-server/overview.md
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,12 @@ app uses** (`/lessons`), authenticating as you with a Supabase token — so ever
lesson goes through the existing validation, ban checks, and author attribution.
Nothing here bypasses the normal API.

It can also **fork** a lesson and open a **proposal** against it, rather than
writing to it — the assistant edits a copy, and you read the diff and decide.
That's the only route into a lesson somebody else wrote, and the one to use when
you'd rather check the assistant's work before it goes live. See
[Proposing changes instead of making them](./tools.md#proposing-changes-instead-of-making-them).

Two ways to connect:

- **[Remote (hosted) mode](./remote-mode.md)** (recommended) — point your
Expand Down
76 changes: 63 additions & 13 deletions apps/docs/docs/mcp-server/tools.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,19 +4,69 @@ title: Tools

# Tools

| Tool | What it does |
| ---------------------- | ----------------------------------------------------------------------------- |
| `whoami` | Confirm the session is valid and show the publishing display name. |
| `create_lesson` | Build and save a new lesson (draft by default; `published: true` to share). |
| `patch_lesson` | Edit a lesson with a small diff (id-addressed ops) instead of a full replace. |
| `update_lesson` | Replace a lesson's whole title/content (author only). |
| `get_lesson` | Fetch one lesson with its full content (read before editing / as a template). |
| `list_my_lessons` | List your own lessons (drafts + published). |
| `list_hub_lessons` | Browse published lessons for inspiration / de-duplication. |
| `set_lesson_published` | Toggle a lesson between public and private draft. |
| `delete_lesson` | Permanently delete one of your lessons. |
| `search_images` | Search Wikimedia Commons for freely-licensed images to illustrate a lesson. |
| `add_image` | Download a searched image and insert it as an image block in a lesson. |
| Tool | What it does |
| ----------------------- | ----------------------------------------------------------------------------- |
| `whoami` | Confirm the session is valid and show the publishing display name. |
| `create_lesson` | Build and save a new lesson (draft by default; `published: true` to share). |
| `patch_lesson` | Edit a lesson with a small diff (id-addressed ops) instead of a full replace. |
| `update_lesson` | Replace a lesson's whole title/content (author only). |
| `fork_lesson` | Copy a lesson into a private draft of your own, keeping its version history. |
| `propose_changes` | Offer a fork's changes back to the original, for a human to review and merge. |
| `list_lesson_proposals` | List the proposals against a lesson, and whether yours have been resolved. |
| `get_lesson` | Fetch one lesson with its full content (read before editing / as a template). |
| `list_my_lessons` | List your own lessons (drafts + published). |
| `list_hub_lessons` | Browse published lessons for inspiration / de-duplication. |
| `set_lesson_published` | Toggle a lesson between public and private draft. |
| `delete_lesson` | Permanently delete one of your lessons. |
| `search_images` | Search Wikimedia Commons for freely-licensed images to illustrate a lesson. |
| `add_image` | Download a searched image and insert it as an image block in a lesson. |
Comment thread
coderabbitai[bot] marked this conversation as resolved.

## Proposing changes instead of making them

An assistant can change a lesson two ways, and which one it should use is a question
about **who decides**, not about the size of the edit.

`patch_lesson` writes straight to the lesson. It's right for a correction the user has
asked for outright — a typo, a wrong answer — where a review step is only friction.

**`fork_lesson` + `propose_changes`** leaves the lesson untouched and puts the changes in
its [Proposals](/web-app/pull-requests) tab instead, where a person reads the diff and
merges or declines it:

```text
fork_lesson({ lessonId }) -> a private draft fork you own
patch_lesson({ id: fork.id, … }) -> edit THE FORK
propose_changes({ forkLessonId }) -> a proposal, with a URL to review it
```

That's the only available route for a lesson somebody else wrote — nobody can save over
another person's lesson — and it's the better route whenever the user wants to look over
the assistant's work before it goes live. `propose_changes` returns the proposal's `url`;
the assistant is expected to hand that over and stop, rather than report the change as
done.

Some mechanics worth knowing:

- **A fork is a real clone.** It carries the original's git history, so the reviewer's
merge is a true three-way merge against the commit the two diverged from, block by
block. A lesson with no stored history can still be forked, but the fork shares no
ancestor with it, so the whole document reads as the change. `fork_lesson` says which
happened.
- **A proposal is one commit**, made when it's opened, holding the fork as it then
stands. Intermediate `patch_lesson` calls aren't separate commits, so make all the
edits first. Proposing again after further edits opens a second, separate proposal
(at most 5 open against one lesson).
- **Images aren't copied.** Blocks reference them by content hash and the bytes are
already stored, so forking is cheap.
- **Forks are private drafts** and count against the draft cap, so `delete_lesson` the
fork once its proposal has been resolved.
- **Merging is not an MCP tool.** It happens in the web app, under the reviewer's own
credentials, because it is theirs to decide. `list_lesson_proposals` is how the
assistant finds out what they decided.

Because the assistant acts as the account it's signed in with, a proposal against your
_own_ lesson is opened by _you_ — so its body carries a note saying an assistant wrote
it, and the notification you get reads "Changes are waiting for your review".

## Editing a lesson: patch vs. replace

Expand Down
10 changes: 9 additions & 1 deletion apps/docs/docs/monorepo/version-history.md
Original file line number Diff line number Diff line change
Expand Up @@ -296,6 +296,7 @@ in the browser, in Node and inside the Worker:
| `repo` | Commit, history, diff two commits, restore. |
| `pack` | Pack for upload; clone/fetch from a pack; merge base; ancestry. |
| `remote` | The `/git/:lessonId` Worker calls (incl. the 409 on a stale push). |
| `memfs` | An in-memory filesystem, for the hosts with no other. |

`remote` reads the API's base URL through `@spelling-creator/core/config` rather
than the bundler's env, which is what lets it sit on this side of the line.
Expand All @@ -308,6 +309,13 @@ needs a real browser:
| `fs` | LightningFS — the IndexedDB filesystem the repos live on. |
| `sync` | Fork (clone), merge, push, and both sides of a pull request. |

Server-side (`apps/mcp/src/git.js`) — the fork-and-propose flow for an AI
assistant, which is `browser/git/sync`'s two outbound steps built on `memfs`
instead of LightningFS. It keeps no repository between calls: a fork is a real
hub lesson with its own stored pack, so each call clones that pack, does one
thing to it and uploads the result. See
[Pull requests](/web-app/pull-requests) and [MCP tools](/mcp-server/tools).

App-bound (`apps/web/src/lib/git/`) — what cannot leave the bundle:

| File | Purpose |
Expand All @@ -317,7 +325,7 @@ App-bound (`apps/web/src/lib/git/`) — what cannot leave the bundle:

`repo` and friends take their filesystem through `repoCtx` rather than opening
one, which is exactly what lets the same commit/merge/restore logic run against
LightningFS in the browser and `node:fs` in tests.
LightningFS in the browser, and `memfs` in Node, in the Worker and in tests.

A repo tracks remotes in git's own vocabulary: `origin` (this lesson's own
published history, which a trusted collaborator may have moved on without us),
Expand Down
5 changes: 4 additions & 1 deletion apps/docs/docs/web-app/notifications.md
Original file line number Diff line number Diff line change
Expand Up @@ -28,9 +28,12 @@ queries the table directly — everything goes through the Worker's
profile. See [Following](./profiles-and-display-names.md#following).
- **`pull_request`** — someone proposed changes to a lesson you published, or the
proposal _you_ made was merged or closed. The three are one type because they're
one conversation; the title says which happened and the link opens the lesson.
one conversation; the title says which happened and the link opens the proposal.
Nothing is sent until a proposal actually has changes in it (an upload that
never finished notifies nobody), and withdrawing your own never notifies you.
A proposal opened from your own account — which is how an AI assistant working
over MCP offers changes — reads _"Changes are waiting for your review"_, because
it's the only thing telling you there's something in the queue.
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Outdated
See [Pull requests](./pull-requests.md).
- **`lesson_update`** — a trusted collaborator saved a lesson you published. It
changed under you and you didn't do it, so you're told. (Merging a proposal into
Expand Down
29 changes: 25 additions & 4 deletions apps/docs/docs/web-app/pull-requests.md
Original file line number Diff line number Diff line change
Expand Up @@ -79,12 +79,32 @@ author's queue.
| -------------------------- | ---- | ----- | -------------------------- |
| Anyone signed in | ✅ | ❌ | ❌ |
| The person who opened it | ✅ | ❌ | ✅ (withdraw) |
| The lesson's author | \* | ✅ | ✅ (decline) |
| The lesson's author | \* | ✅ | ✅ (decline) |
| A **trusted collaborator** | ✅ | ✅ | ✅ |
| A moderator/admin | ✅ | ❌ | ✅ (as with any user text) |

\* The author has nothing to propose to themselves — they can just save. The
Worker refuses that case rather than creating a request nobody needs.
\* Only from a fork they own — see below.

### Proposing to your own lesson

Out of nowhere, this is a mistake: you can just save, so the Worker refuses a
proposal against your own lesson rather than creating a request nobody needs.

It's allowed when it **carries a fork you own** (`sourceLessonId` resolves to
another of your lessons), because then it means something specific: _here is a
copy with changes in it, let me read the diff before it lands._ Two things use
that:

- An **AI assistant over MCP** acts as the account it's signed in with, so
changes it proposes to your lesson arrive from your own id. Holding them in the
review queue is the entire point — the lesson is untouched until you read the
diff and merge it. See [MCP tools](/mcp-server/tools).
- **"Fork into a new lesson"** in the editor gives a human the same route for
work they want to look over before committing to it.

You can then merge it yourself, since you're the author. The notification you get
reads "Changes are waiting for your review" rather than naming a proposer, because
the account is yours; the proposal's body says what opened it.

"Trusted collaborator" is not a new concept: it's the email list the author
already manages in the collaboration dialog (`doc.trustedCollaborators`, the same
Expand Down Expand Up @@ -166,7 +186,7 @@ which is why the submission dialog says so plainly before you send it.
| Method & path | Auth | What it does |
| ------------------------------------- | ----------------------- | ---------------------------------------------------------------------------------------------- |
| `GET /lessons/:id/pulls` | none, unless a draft\* | `{ "pulls": [...], "canReview": bool }` — newest first; unready rows only for their own author |
| `POST /lessons/:id/pulls` | `Bearer <Supabase JWT>` | Opens a proposal (`{ title, body, head, base, sourceLessonId }`); anyone but the author |
| `POST /lessons/:id/pulls` | `Bearer <Supabase JWT>` | Opens a proposal (`{ title, body, head, base, sourceLessonId }`); the author only from a fork |
| `PUT /lessons/:id/pulls/:prId/pack` | `Bearer <Supabase JWT>` | Uploads its packfile (`X-Git-Head` must match). The proposer's, once |
| `GET /lessons/:id/pulls/:prId/pack` | none, unless a draft\* | The packfile; `X-Git-Head` names its tip |
| `POST /lessons/:id/pulls/:prId/merge` | `Bearer <Supabase JWT>` | Records the merge (`{ mergeCommit }`); author or trusted collaborator only |
Expand All @@ -188,6 +208,7 @@ frontend can surface `res.text()` directly.
| `apps/api/src/lib/lessonGit.js` | R2 key layout (`git/pulls/<id>/pack`) and the sweeps that delete it |
| `@spelling-creator/core/pulls` | The browser client, and the shared length limits |
| `@spelling-creator/core/browser/git/sync` | `submitPullRequest` (propose) and `preparePullMerge` (review) |
| `apps/mcp/src/git.js` | The same two steps for an AI assistant — fork, then propose |
| `apps/web/src/components/ProposeChangesDialog.jsx` | The submission form |
| `apps/web/src/pages/lesson/LessonProposals.jsx` | The Proposals tab |
| `apps/web/src/pages/lesson/LessonProposal.jsx` | One proposal, read-only, with the hand-off into the editor |
Expand Down
14 changes: 13 additions & 1 deletion apps/mcp/manifest.json
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@
"manifest_version": "0.3",
"name": "spelling-creator-hub",
"display_name": "Spelling Creator Hub",
"version": "0.2.0",
"version": "0.3.0",
"description": "Author and publish spelling lessons to the Spelling Creator hub from your AI assistant.",
"long_description": "Connects your AI assistant to the Spelling Creator hub so it can compose and publish spelling lessons for you. You describe the lesson; the assistant builds it (reading passages with ALL-CAPS spelling words, explicit word lists, and quiz questions) and saves it to the hub through the same API the web app uses — with all the usual validation and author attribution. Lessons default to private drafts; publish them when you're ready. Requires a Supabase refresh token (run the bundled `login` helper, or copy one from the web app) and a display name set on your account.",
"author": {
Expand Down Expand Up @@ -72,6 +72,18 @@
"name": "patch_lesson",
"description": "Edit a lesson with a small list of id-addressed operations."
},
{
"name": "fork_lesson",
"description": "Copy a lesson into a private draft of your own, keeping its version history."
},
{
"name": "propose_changes",
"description": "Offer a fork's changes back to the original lesson, for a human to review and merge."
},
{
"name": "list_lesson_proposals",
"description": "List the proposals against a lesson, and whether yours have been merged."
},
{
"name": "get_lesson",
"description": "Fetch one lesson including its full content document."
Expand Down
2 changes: 1 addition & 1 deletion apps/mcp/package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "@spelling-creator/mcp",
"version": "0.2.0",
"version": "0.3.0",
"private": true,
"description": "MCP server for the Spelling Creator hub — lets any MCP-capable AI assistant author and publish spelling lessons.",
"license": "AGPL-3.0-only",
Expand Down
Loading
Loading