-
Notifications
You must be signed in to change notification settings - Fork 0
More than one version of a lesson, and proposals you can read #42
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
10c2385
1b585aa
4357f3f
d1a44ab
976a23f
8ad34d5
d89accd
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change | ||||||||||||||||||||||||||||||||||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
|
|
@@ -6,8 +6,9 @@ | |||||||||||||||||||||||||||||||||||||||||||||
| // it here the way git itself moves history: as a packfile holding every object | ||||||||||||||||||||||||||||||||||||||||||||||
| // reachable from the lesson's branch, plus the commit its branch points at. | ||||||||||||||||||||||||||||||||||||||||||||||
| // | ||||||||||||||||||||||||||||||||||||||||||||||
| // GET /git/:lessonId/refs public* -> { head, size, updatedAt } | 404 | ||||||||||||||||||||||||||||||||||||||||||||||
| // GET /git/:lessonId/pack public* -> the packfile (X-Git-Head names its tip) | ||||||||||||||||||||||||||||||||||||||||||||||
| // GET /git/:lessonId/refs public* -> { head, refs, size, updatedAt } | 404 | ||||||||||||||||||||||||||||||||||||||||||||||
| // GET /git/:lessonId/pack public* -> the packfile (X-Git-Head names its tip, | ||||||||||||||||||||||||||||||||||||||||||||||
| // X-Git-Refs every branch it holds) | ||||||||||||||||||||||||||||||||||||||||||||||
| // PUT /git/:lessonId/pack Bearer -> store it (the author, or a trusted collaborator) | ||||||||||||||||||||||||||||||||||||||||||||||
| // | ||||||||||||||||||||||||||||||||||||||||||||||
| // A pull request's proposed history is a packfile too, but it belongs to the | ||||||||||||||||||||||||||||||||||||||||||||||
|
|
@@ -24,11 +25,24 @@ | |||||||||||||||||||||||||||||||||||||||||||||
| // | ||||||||||||||||||||||||||||||||||||||||||||||
| // Two R2 objects per lesson, mirroring the /images routes' use of the bucket: | ||||||||||||||||||||||||||||||||||||||||||||||
| // git/<lessonId>/pack the packfile bytes | ||||||||||||||||||||||||||||||||||||||||||||||
| // git/<lessonId>/refs.json { head, size, updatedAt } | ||||||||||||||||||||||||||||||||||||||||||||||
| // git/<lessonId>/refs.json { head, refs, size, updatedAt } | ||||||||||||||||||||||||||||||||||||||||||||||
| // | ||||||||||||||||||||||||||||||||||||||||||||||
| // The pack carries its own tip in customMetadata (and in the X-Git-Head response | ||||||||||||||||||||||||||||||||||||||||||||||
| // header). A clone therefore reads the head from the *same object* as the bytes, | ||||||||||||||||||||||||||||||||||||||||||||||
| // so it can never pair a new refs.json with a stale pack. | ||||||||||||||||||||||||||||||||||||||||||||||
| // The pack carries its own tip *and* its branch map in customMetadata (echoed in | ||||||||||||||||||||||||||||||||||||||||||||||
| // the X-Git-Head and X-Git-Refs response headers). A clone therefore reads both | ||||||||||||||||||||||||||||||||||||||||||||||
| // from the *same object* as the bytes, so it can never pair a new refs.json with | ||||||||||||||||||||||||||||||||||||||||||||||
| // a stale pack. | ||||||||||||||||||||||||||||||||||||||||||||||
| // | ||||||||||||||||||||||||||||||||||||||||||||||
| // ---- More than one branch --------------------------------------------------- | ||||||||||||||||||||||||||||||||||||||||||||||
| // | ||||||||||||||||||||||||||||||||||||||||||||||
| // `head` is the lesson: the default branch, and the only one a reader, a forker | ||||||||||||||||||||||||||||||||||||||||||||||
| // or the lesson's own page ever asks for. `refs` maps every branch the repository | ||||||||||||||||||||||||||||||||||||||||||||||
| // holds to its tip — one per *variation* its author is trying out, kept here so a | ||||||||||||||||||||||||||||||||||||||||||||||
| // variation follows the lesson between the author's devices rather than living | ||||||||||||||||||||||||||||||||||||||||||||||
| // only in the browser it was started in. | ||||||||||||||||||||||||||||||||||||||||||||||
| // | ||||||||||||||||||||||||||||||||||||||||||||||
| // A variation is exactly as public as the lesson it belongs to: it is in the same | ||||||||||||||||||||||||||||||||||||||||||||||
| // pack, and the pack of a published lesson is public so that forking is. See | ||||||||||||||||||||||||||||||||||||||||||||||
| // /web-app/lesson-variations, which says so where an author can read it. | ||||||||||||||||||||||||||||||||||||||||||||||
| // | ||||||||||||||||||||||||||||||||||||||||||||||
| // ---- Who may push, and why it can't lose work ------------------------------- | ||||||||||||||||||||||||||||||||||||||||||||||
| // | ||||||||||||||||||||||||||||||||||||||||||||||
|
|
@@ -48,20 +62,113 @@ | |||||||||||||||||||||||||||||||||||||||||||||
| // with 409 and the client must fetch, merge, and retry. Since the client only | ||||||||||||||||||||||||||||||||||||||||||||||
| // pushes a history that already *contains* the head it merged, an accepted push | ||||||||||||||||||||||||||||||||||||||||||||||
| // can only ever move the lesson forward. | ||||||||||||||||||||||||||||||||||||||||||||||
| // | ||||||||||||||||||||||||||||||||||||||||||||||
| // The same rule runs one level down for the variations, per branch: | ||||||||||||||||||||||||||||||||||||||||||||||
| // | ||||||||||||||||||||||||||||||||||||||||||||||
| // X-Git-Refs the branches to set, `{ "<name>": "<oid>" }` | ||||||||||||||||||||||||||||||||||||||||||||||
| // X-Git-Expected what the client believes we hold for each name it touches, | ||||||||||||||||||||||||||||||||||||||||||||||
| // with "" meaning "I believe this one does not exist yet" | ||||||||||||||||||||||||||||||||||||||||||||||
| // X-Git-Deletes the branches to remove, comma-separated | ||||||||||||||||||||||||||||||||||||||||||||||
| // | ||||||||||||||||||||||||||||||||||||||||||||||
| // It is all-or-nothing, which costs nothing to arrange: refs.json is a single | ||||||||||||||||||||||||||||||||||||||||||||||
| // object and already the commit point, so every branch advances or none does. A | ||||||||||||||||||||||||||||||||||||||||||||||
| // branch the client doesn't mention is left exactly as it is — that is what stops | ||||||||||||||||||||||||||||||||||||||||||||||
| // a device which has never heard of a new variation from deleting it by omission, | ||||||||||||||||||||||||||||||||||||||||||||||
| // and it is why a delete has to be asked for by name rather than inferred. | ||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||
| import { bearerToken } from '../lib/auth.js'; | ||||||||||||||||||||||||||||||||||||||||||||||
| import { bannedResponse } from '../lib/bans.js'; | ||||||||||||||||||||||||||||||||||||||||||||||
| import { canReadLesson, fetchLessonRow, isTrustedCollaborator } from '../lib/lesson.js'; | ||||||||||||||||||||||||||||||||||||||||||||||
| import { LESSON_ID_RE, MAX_PACK_BYTES, OID_RE, isPackfile, packKey, refsKey } from '../lib/lessonGit.js'; | ||||||||||||||||||||||||||||||||||||||||||||||
| import { | ||||||||||||||||||||||||||||||||||||||||||||||
| DEFAULT_BRANCH, | ||||||||||||||||||||||||||||||||||||||||||||||
| LESSON_ID_RE, | ||||||||||||||||||||||||||||||||||||||||||||||
| MAX_BRANCHES, | ||||||||||||||||||||||||||||||||||||||||||||||
| MAX_PACK_BYTES, | ||||||||||||||||||||||||||||||||||||||||||||||
| OID_RE, | ||||||||||||||||||||||||||||||||||||||||||||||
| isBranchName, | ||||||||||||||||||||||||||||||||||||||||||||||
| isPackfile, | ||||||||||||||||||||||||||||||||||||||||||||||
| packKey, | ||||||||||||||||||||||||||||||||||||||||||||||
| parseRefMap, | ||||||||||||||||||||||||||||||||||||||||||||||
| refsKey, | ||||||||||||||||||||||||||||||||||||||||||||||
| serializeRefMap, | ||||||||||||||||||||||||||||||||||||||||||||||
| } from '../lib/lessonGit.js'; | ||||||||||||||||||||||||||||||||||||||||||||||
| import { supabaseBase, supabaseConfigured, verifySupabaseUser } from '../lib/supabase.js'; | ||||||||||||||||||||||||||||||||||||||||||||||
| import { textResponse, jsonResponse } from '../lib/http.js'; | ||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||
| /** The head we currently hold for a lesson, or null if it has no history yet. */ | ||||||||||||||||||||||||||||||||||||||||||||||
| async function storedHead(env, lessonId) { | ||||||||||||||||||||||||||||||||||||||||||||||
| /** What we currently hold for a lesson: `{ head, refs }`, or null with no history. */ | ||||||||||||||||||||||||||||||||||||||||||||||
| async function stored(env, lessonId) { | ||||||||||||||||||||||||||||||||||||||||||||||
| const object = await env.LESSON_GIT.get(refsKey(lessonId)); | ||||||||||||||||||||||||||||||||||||||||||||||
| if (!object) return null; | ||||||||||||||||||||||||||||||||||||||||||||||
| const refs = await object.json().catch(() => null); | ||||||||||||||||||||||||||||||||||||||||||||||
| return refs && refs.head ? refs.head : null; | ||||||||||||||||||||||||||||||||||||||||||||||
| const value = await object.json().catch(() => null); | ||||||||||||||||||||||||||||||||||||||||||||||
| if (!value || !value.head) return null; | ||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||
| // A lesson stored before variations existed has no map; the one branch it has | ||||||||||||||||||||||||||||||||||||||||||||||
| // is its head, and saying so here means the rest of this file has one shape to | ||||||||||||||||||||||||||||||||||||||||||||||
| // reason about rather than two. | ||||||||||||||||||||||||||||||||||||||||||||||
| // | ||||||||||||||||||||||||||||||||||||||||||||||
| // A map that is *present but unreadable* is a different thing entirely, and must | ||||||||||||||||||||||||||||||||||||||||||||||
| // not be flattened into the same answer. Both of parseRefMap's limits — the | ||||||||||||||||||||||||||||||||||||||||||||||
| // branch count and the name rules — can be tightened later, and if either is, | ||||||||||||||||||||||||||||||||||||||||||||||
| // every lesson past the new limit would read as single-branch here: the push | ||||||||||||||||||||||||||||||||||||||||||||||
| // path would skip its old-client guard and write the variations away. So say we | ||||||||||||||||||||||||||||||||||||||||||||||
| // don't know, and let the caller refuse. | ||||||||||||||||||||||||||||||||||||||||||||||
| const refs = value.refs === undefined ? { [DEFAULT_BRANCH]: value.head } : parseRefMap(value.refs); | ||||||||||||||||||||||||||||||||||||||||||||||
| if (!refs) return { head: value.head, refs: null, unreadable: true }; | ||||||||||||||||||||||||||||||||||||||||||||||
| return { head: value.head, refs }; | ||||||||||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||
| /** | ||||||||||||||||||||||||||||||||||||||||||||||
| * Apply a push's ref instructions to what we hold, or explain why we won't. | ||||||||||||||||||||||||||||||||||||||||||||||
| * | ||||||||||||||||||||||||||||||||||||||||||||||
| * The rule is the one that has always guarded this endpoint, applied per branch | ||||||||||||||||||||||||||||||||||||||||||||||
| * rather than to the lesson as a whole: a client may only move a branch it has | ||||||||||||||||||||||||||||||||||||||||||||||
| * already seen the current state of. `expected` is what it believes, and a branch | ||||||||||||||||||||||||||||||||||||||||||||||
| * it doesn't mention at all is left untouched — which is what stops a device that | ||||||||||||||||||||||||||||||||||||||||||||||
| * has never heard of somebody's new variation from deleting it by omission. | ||||||||||||||||||||||||||||||||||||||||||||||
| * | ||||||||||||||||||||||||||||||||||||||||||||||
| * @returns {{ refs: object } | { error: string, status: number }} | ||||||||||||||||||||||||||||||||||||||||||||||
| */ | ||||||||||||||||||||||||||||||||||||||||||||||
| export function applyRefs(current, { refs, deletes, expected }) { | ||||||||||||||||||||||||||||||||||||||||||||||
| const next = { ...current }; | ||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||
| const believes = (name) => (Object.hasOwn(expected, name) ? expected[name] : null); | ||||||||||||||||||||||||||||||||||||||||||||||
| const mismatch = (name) => { | ||||||||||||||||||||||||||||||||||||||||||||||
| const believed = believes(name); | ||||||||||||||||||||||||||||||||||||||||||||||
| // Nothing claimed about this branch: only safe when it is new to us. Moving | ||||||||||||||||||||||||||||||||||||||||||||||
| // one we already hold without saying what we hold is exactly the overwrite | ||||||||||||||||||||||||||||||||||||||||||||||
| // the compare-and-swap exists to refuse. | ||||||||||||||||||||||||||||||||||||||||||||||
| if (believed === null) return Boolean(current[name]); | ||||||||||||||||||||||||||||||||||||||||||||||
| return (current[name] || '') !== believed; | ||||||||||||||||||||||||||||||||||||||||||||||
| }; | ||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||
| // A name in both halves is a request that contradicts itself, and we cannot know | ||||||||||||||||||||||||||||||||||||||||||||||
| // which half was meant. Refusing is the only honest answer — applying them in | ||||||||||||||||||||||||||||||||||||||||||||||
| // order would silently let the delete win, which is how a branch that is alive | ||||||||||||||||||||||||||||||||||||||||||||||
| // on the client disappears from the hub. | ||||||||||||||||||||||||||||||||||||||||||||||
| for (const name of deletes) { | ||||||||||||||||||||||||||||||||||||||||||||||
| if (Object.hasOwn(refs, name)) { | ||||||||||||||||||||||||||||||||||||||||||||||
| return { error: 'That push asks to both keep and remove the same version.', status: 400 }; | ||||||||||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||
| for (const name of Object.keys(refs)) { | ||||||||||||||||||||||||||||||||||||||||||||||
| if (mismatch(name)) return { error: 'moved', status: 409 }; | ||||||||||||||||||||||||||||||||||||||||||||||
| next[name] = refs[name]; | ||||||||||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||||||||||
| for (const name of deletes) { | ||||||||||||||||||||||||||||||||||||||||||||||
| if (!isBranchName(name)) return { error: 'That is not a branch name.', status: 400 }; | ||||||||||||||||||||||||||||||||||||||||||||||
| if (mismatch(name)) return { error: 'moved', status: 409 }; | ||||||||||||||||||||||||||||||||||||||||||||||
| delete next[name]; | ||||||||||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||||||||||
|
qodo-code-review[bot] marked this conversation as resolved.
Comment on lines
+154
to
+162
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🗄️ Data Integrity & Integration | 🟠 Major | ⚡ Quick win Refuse a push that both sets and deletes one branch. The set loop runs first and the delete loop runs second, so a name that appears in This state is reachable today. The request contradicts itself, and the Worker cannot know which half was meant. Reject it. 🐛 Proposed fix for (const name of deletes) {
if (!isBranchName(name)) return { error: 'That is not a branch name.', status: 400 };
+ // Asked to set and to remove the same branch. Neither answer is safe to guess.
+ if (Object.hasOwn(refs, name)) {
+ return { error: 'A version cannot be saved and removed in the same request.', status: 400 };
+ }
if (mismatch(name)) return { error: 'moved', status: 409 };
delete next[name];
}Fix the client side as well; see the comment on 📝 Committable suggestion
Suggested change
🤖 Prompt for AI Agents |
||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||
| // The lesson has to still be there afterwards. Deleting the branch that *is* | ||||||||||||||||||||||||||||||||||||||||||||||
| // the lesson would leave a row whose history advertises a tip nothing points | ||||||||||||||||||||||||||||||||||||||||||||||
| // at, and no client asks for that. | ||||||||||||||||||||||||||||||||||||||||||||||
| if (!next[DEFAULT_BRANCH]) return { error: 'The lesson’s own history cannot be removed.', status: 400 }; | ||||||||||||||||||||||||||||||||||||||||||||||
| if (Object.keys(next).length > MAX_BRANCHES) { | ||||||||||||||||||||||||||||||||||||||||||||||
| return { error: `A lesson can have at most ${MAX_BRANCHES} versions.`, status: 400 }; | ||||||||||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||||||||||
| return { refs: next }; | ||||||||||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||
| /** | ||||||||||||||||||||||||||||||||||||||||||||||
|
|
@@ -128,8 +235,13 @@ export async function handleGit(request, env, lessonId, rest, cors) { | |||||||||||||||||||||||||||||||||||||||||||||
| headers.set('Cache-Control', 'no-store'); | ||||||||||||||||||||||||||||||||||||||||||||||
| const head = object.customMetadata?.head || ''; | ||||||||||||||||||||||||||||||||||||||||||||||
| if (head) headers.set('X-Git-Head', head); | ||||||||||||||||||||||||||||||||||||||||||||||
| // The SPA reads X-Git-Head cross-origin, which needs it explicitly exposed. | ||||||||||||||||||||||||||||||||||||||||||||||
| headers.set('Access-Control-Expose-Headers', 'X-Git-Head'); | ||||||||||||||||||||||||||||||||||||||||||||||
| // The branch map comes off the *same object* as the bytes, for the reason the | ||||||||||||||||||||||||||||||||||||||||||||||
| // head does: a map read from refs.json a moment later could name a tip this | ||||||||||||||||||||||||||||||||||||||||||||||
| // pack doesn't contain. | ||||||||||||||||||||||||||||||||||||||||||||||
| const refs = object.customMetadata?.refs || ''; | ||||||||||||||||||||||||||||||||||||||||||||||
| if (refs) headers.set('X-Git-Refs', refs); | ||||||||||||||||||||||||||||||||||||||||||||||
| // The SPA reads these cross-origin, which needs them explicitly exposed. | ||||||||||||||||||||||||||||||||||||||||||||||
| headers.set('Access-Control-Expose-Headers', 'X-Git-Head, X-Git-Refs'); | ||||||||||||||||||||||||||||||||||||||||||||||
| if (object.httpEtag) headers.set('ETag', object.httpEtag); | ||||||||||||||||||||||||||||||||||||||||||||||
| return new Response(object.body, { status: 200, headers }); | ||||||||||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||||||||||
|
|
@@ -162,24 +274,91 @@ export async function handleGit(request, env, lessonId, rest, cors) { | |||||||||||||||||||||||||||||||||||||||||||||
| return textResponse('Missing or invalid X-Git-Head.', 400, cors); | ||||||||||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||
| // What the client wants to happen to the lesson's branches. A client that | ||||||||||||||||||||||||||||||||||||||||||||||
| // sends none of this is one written before a lesson could have more than one | ||||||||||||||||||||||||||||||||||||||||||||||
| // branch: it means "move the lesson, leave everything else alone", and the | ||||||||||||||||||||||||||||||||||||||||||||||
| // two X-Git-Head/X-Git-Parent headers already say that. | ||||||||||||||||||||||||||||||||||||||||||||||
| const requested = parseRefMap(request.headers.get('X-Git-Refs')); | ||||||||||||||||||||||||||||||||||||||||||||||
| const believed = parseRefMap(request.headers.get('X-Git-Expected')); | ||||||||||||||||||||||||||||||||||||||||||||||
| if (request.headers.get('X-Git-Refs') && !requested) { | ||||||||||||||||||||||||||||||||||||||||||||||
| return textResponse('Invalid X-Git-Refs.', 400, cors); | ||||||||||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||||||||||
| if (request.headers.get('X-Git-Expected') && !believed) { | ||||||||||||||||||||||||||||||||||||||||||||||
| return textResponse('Invalid X-Git-Expected.', 400, cors); | ||||||||||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||||||||||
| // A ref being *set* must name a real commit; only X-Git-Expected may say "" | ||||||||||||||||||||||||||||||||||||||||||||||
| // (meaning "I believe this branch does not exist yet"). | ||||||||||||||||||||||||||||||||||||||||||||||
| if (requested && Object.values(requested).some((oid) => !OID_RE.test(oid))) { | ||||||||||||||||||||||||||||||||||||||||||||||
| return textResponse('Invalid X-Git-Refs.', 400, cors); | ||||||||||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||
| const deletes = (request.headers.get('X-Git-Deletes') || '') | ||||||||||||||||||||||||||||||||||||||||||||||
| .split(',') | ||||||||||||||||||||||||||||||||||||||||||||||
| .map((name) => name.trim()) | ||||||||||||||||||||||||||||||||||||||||||||||
| .filter(Boolean); | ||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||
| // Compare-and-swap. `parent` is the head the client merged before building | ||||||||||||||||||||||||||||||||||||||||||||||
| // this pack; if the lesson has moved on since (someone else pushed), we | ||||||||||||||||||||||||||||||||||||||||||||||
| // refuse — accepting would drop their commits. 409 tells the client to | ||||||||||||||||||||||||||||||||||||||||||||||
| // fetch, merge and try again. | ||||||||||||||||||||||||||||||||||||||||||||||
| const parent = (request.headers.get('X-Git-Parent') || '').trim(); | ||||||||||||||||||||||||||||||||||||||||||||||
| const current = await storedHead(env, lessonId); | ||||||||||||||||||||||||||||||||||||||||||||||
| const held = await stored(env, lessonId); | ||||||||||||||||||||||||||||||||||||||||||||||
| const current = held?.head || null; | ||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||
| // We hold a branch map we can't read. Every path below decides what to keep | ||||||||||||||||||||||||||||||||||||||||||||||
| // and what to drop by comparing against it, so none of them can run safely. | ||||||||||||||||||||||||||||||||||||||||||||||
| if (held?.unreadable) { | ||||||||||||||||||||||||||||||||||||||||||||||
| return textResponse('This lesson’s stored history could not be read. Please report this rather than saving over it.', 409, cors); | ||||||||||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||
| if (current && parent !== current) { | ||||||||||||||||||||||||||||||||||||||||||||||
| return textResponse( | ||||||||||||||||||||||||||||||||||||||||||||||
| 'This lesson’s history has moved on since you last synced. Merge the latest changes, then save again.', | ||||||||||||||||||||||||||||||||||||||||||||||
| 409, | ||||||||||||||||||||||||||||||||||||||||||||||
| cors, | ||||||||||||||||||||||||||||||||||||||||||||||
| ); | ||||||||||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||||||||||
| // Already there — the client is re-pushing a history we hold. Nothing to do. | ||||||||||||||||||||||||||||||||||||||||||||||
| if (current && current === head) { | ||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||
| // A pack holds every object every branch it advertises needs. An old client | ||||||||||||||||||||||||||||||||||||||||||||||
| // packs only the lesson's own branch, so accepting its pack while keeping a | ||||||||||||||||||||||||||||||||||||||||||||||
| // variation's tip in the map would leave that tip pointing at objects the | ||||||||||||||||||||||||||||||||||||||||||||||
| // stored pack no longer contains — a history that can't be cloned. Rather | ||||||||||||||||||||||||||||||||||||||||||||||
| // than silently drop the variation, refuse and say why. | ||||||||||||||||||||||||||||||||||||||||||||||
| const heldBranches = Object.keys(held?.refs || {}); | ||||||||||||||||||||||||||||||||||||||||||||||
| if (!requested && heldBranches.length > 1) { | ||||||||||||||||||||||||||||||||||||||||||||||
| return textResponse( | ||||||||||||||||||||||||||||||||||||||||||||||
| 'This lesson has more than one version, which this client cannot save without dropping them. Please update it, or save from the web editor.', | ||||||||||||||||||||||||||||||||||||||||||||||
| 409, | ||||||||||||||||||||||||||||||||||||||||||||||
| cors, | ||||||||||||||||||||||||||||||||||||||||||||||
| ); | ||||||||||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||
| const applied = applyRefs(held?.refs || {}, { | ||||||||||||||||||||||||||||||||||||||||||||||
| refs: requested || { [DEFAULT_BRANCH]: head }, | ||||||||||||||||||||||||||||||||||||||||||||||
| deletes, | ||||||||||||||||||||||||||||||||||||||||||||||
| expected: believed || (current ? { [DEFAULT_BRANCH]: current } : {}), | ||||||||||||||||||||||||||||||||||||||||||||||
| }); | ||||||||||||||||||||||||||||||||||||||||||||||
| if (applied.error) { | ||||||||||||||||||||||||||||||||||||||||||||||
| return textResponse( | ||||||||||||||||||||||||||||||||||||||||||||||
| applied.status === 409 | ||||||||||||||||||||||||||||||||||||||||||||||
| ? 'One of this lesson’s versions has moved on since you last synced. Merge the latest changes, then save again.' | ||||||||||||||||||||||||||||||||||||||||||||||
| : applied.error, | ||||||||||||||||||||||||||||||||||||||||||||||
| applied.status, | ||||||||||||||||||||||||||||||||||||||||||||||
| cors, | ||||||||||||||||||||||||||||||||||||||||||||||
| ); | ||||||||||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||||||||||
| // The lesson's own tip and the map have to agree — they are two readings of | ||||||||||||||||||||||||||||||||||||||||||||||
| // one fact, and a client that disagrees with itself is one we can't apply. | ||||||||||||||||||||||||||||||||||||||||||||||
| if (applied.refs[DEFAULT_BRANCH] !== head) { | ||||||||||||||||||||||||||||||||||||||||||||||
| return textResponse('X-Git-Head and X-Git-Refs disagree about this lesson.', 400, cors); | ||||||||||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||
| // Already there — the client is re-pushing exactly what we hold, branches and | ||||||||||||||||||||||||||||||||||||||||||||||
| // all. Nothing to write. | ||||||||||||||||||||||||||||||||||||||||||||||
| if (current && current === head && serializeRefMap(applied.refs) === serializeRefMap(held.refs)) { | ||||||||||||||||||||||||||||||||||||||||||||||
| const object = await env.LESSON_GIT.get(refsKey(lessonId)); | ||||||||||||||||||||||||||||||||||||||||||||||
| const refs = object ? await object.json().catch(() => null) : null; | ||||||||||||||||||||||||||||||||||||||||||||||
| if (refs) return jsonResponse(refs, 200, cors); | ||||||||||||||||||||||||||||||||||||||||||||||
| const existing = object ? await object.json().catch(() => null) : null; | ||||||||||||||||||||||||||||||||||||||||||||||
| if (existing) return jsonResponse(existing, 200, cors); | ||||||||||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||
| const declared = Number(request.headers.get('Content-Length') || 0); | ||||||||||||||||||||||||||||||||||||||||||||||
|
|
@@ -201,11 +380,12 @@ export async function handleGit(request, env, lessonId, rest, cors) { | |||||||||||||||||||||||||||||||||||||||||||||
| // Write the pack before the refs. The refs object is the commit point: if the | ||||||||||||||||||||||||||||||||||||||||||||||
| // pack write fails, refs still names the previous (complete) pack, and a | ||||||||||||||||||||||||||||||||||||||||||||||
| // clone of the old history beats a clone of a half-written one. | ||||||||||||||||||||||||||||||||||||||||||||||
| const map = serializeRefMap(applied.refs); | ||||||||||||||||||||||||||||||||||||||||||||||
| await env.LESSON_GIT.put(packKey(lessonId), bytes, { | ||||||||||||||||||||||||||||||||||||||||||||||
| httpMetadata: { contentType: 'application/x-git-packfile' }, | ||||||||||||||||||||||||||||||||||||||||||||||
| customMetadata: { head }, | ||||||||||||||||||||||||||||||||||||||||||||||
| customMetadata: { head, refs: map }, | ||||||||||||||||||||||||||||||||||||||||||||||
| }); | ||||||||||||||||||||||||||||||||||||||||||||||
| const refs = { head, size: bytes.byteLength, updatedAt: new Date().toISOString() }; | ||||||||||||||||||||||||||||||||||||||||||||||
| const refs = { head, refs: applied.refs, size: bytes.byteLength, updatedAt: new Date().toISOString() }; | ||||||||||||||||||||||||||||||||||||||||||||||
| await env.LESSON_GIT.put(refsKey(lessonId), JSON.stringify(refs), { | ||||||||||||||||||||||||||||||||||||||||||||||
| httpMetadata: { contentType: 'application/json' }, | ||||||||||||||||||||||||||||||||||||||||||||||
| }); | ||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
🗄️ Data Integrity & Integration | 🟠 Major | ⚡ Quick win
Distinguish a missing branch map from an unreadable one.
Line 107 treats every
parseRefMapfailure as "this lesson predates variations" and substitutes{ [DEFAULT_BRANCH]: value.head }. A stored map that fails validation is not the same thing as an absent map.parseRefMaprejects the whole map if it holds more thanMAX_BRANCHESentries or if any name failsisBranchName. Both limits are introduced in this PR, so both can be tightened later. If either is tightened, every affected lesson's stored variations disappear fromheld.refson the next read. The push path then seesheldBranches.length === 1, skips the old-client guard at line 303, and writes a map with the variations gone.Fail the request when a map is present but unreadable.
🐛 Proposed fix
async function stored(env, lessonId) { const object = await env.LESSON_GIT.get(refsKey(lessonId)); if (!object) return null; const value = await object.json().catch(() => null); if (!value || !value.head) return null; // A lesson stored before variations existed has no map; the one branch it has // is its head, and saying so here means the rest of this file has one shape to // reason about rather than two. - const refs = parseRefMap(value.refs) || { [DEFAULT_BRANCH]: value.head }; - return { head: value.head, refs }; + if (value.refs === undefined || value.refs === null) { + return { head: value.head, refs: { [DEFAULT_BRANCH]: value.head } }; + } + // A map we cannot read is not a lesson without variations. Say so, rather than + // handing back a shape that would drop them on the next write. + const refs = parseRefMap(value.refs); + if (!refs) return { head: value.head, refs: null }; + return { head: value.head, refs }; }The PUT handler then rejects
held.refs === nullwith a 500 before line 302.🤖 Prompt for AI Agents