oficina / sheets Public

fix: three findings from an independent review pass

Codex reviewed the branch (AGENTS.md §Review — a model that did not
write the code). It returned REQUEST CHANGES with three findings. All
three were traced before anything was changed; two were real as stated,
one was right about the line and wrong about the reason.

**The rename route parsed an unbounded body.** Reported as a revocation
race — check mayWrite, block in FormValue while the client dribbles the
body, write after the grant is gone. That ordering is the app's uniform
pattern (openTab before decode in handlers_format, handlers_size,
handlers_grid), so singling out rename would have made it the odd one
out for a window every write route has. What IS unique to rename is
what sits in that gap: it was the only write route reading a form with
nothing bounding it, so FormValue buffered the entire request to find
one field — for a title truncated to maxTitleRunes immediately after.
handlers_format's comment describes this exact trap on this exact shape
of post and settled on 8 KiB, so that is the number here too. The wider
check-then-write question is a real one about every route and belongs
in the discussion, not smuggled into one handler.

TestRenameCapsTheBodyItWillParse asserts the sheet is unchanged rather
than the status code, because MaxBytesReader makes the parse fail,
FormValue answers "", and a blank title already means "keep the name".
Mutation-tested: with the cap removed a 64 KiB body renames the sheet
and the test fails, which is the whole point of writing it.

**The drop overlay's recovery controls were unreachable by keyboard.**
Both carried tabindex="-1" "to keep them out of the tab order while the
overlay is up" — exactly backwards. The overlay is deliberately LEFT up
after a refused drop so those two controls can be used to recover, and
a negative tabindex sent Tab straight past them into the list behind.
The other two states never needed it: `hidden` on the form removes the
subtree at rest, and [data-drop-page-recover] is display:none under
[data-drop-over] mid-drag. Redundant where it was harmless, harmful
where it was not.

drop.mjs now presses Tab rather than reading a style, because this
looked perfect in a screenshot — the same lesson the menu bar taught
when it worked from the keyboard and not from a mouse. Mutation-tested:
putting one tabindex back fails both new checks.

**Three browser drives still filled the deleted #title field.**
clipboard, menus and tableformat would have timed out on sign-in — they
were missed when chrome.mjs and drive.mjs were fixed, because nothing
runs them in CI. All three now click the top bar's Create and rename,
which is the only way a sheet is named. Verified by running all five
drives against this binary; every one passes.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HYALupDmJYgztTGao9WFJV
Paul Campbell pushed by paul@keymail.dev 51db7bc60e4dd72fb1c61789403a7d415c372693 parent 5f44bae
7 files changed, +108 −7
  • internal/sheets/handlers_rename.go +9 −0
  • internal/sheets/handlers_rename_test.go +34 −0
  • internal/sheets/templates/sheets.html +13 −4
  • tools/drive/clipboard.mjs +5 −1
  • tools/drive/drop.mjs +35 −0
  • tools/drive/menus.mjs +7 −1
  • tools/drive/tableformat.mjs +5 −1

Intent

Human

merge and deploy?

diff --git a/internal/sheets/handlers_rename.go b/internal/sheets/handlers_rename.go
index 38f19dc..f91f78f 100644
--- a/internal/sheets/handlers_rename.go
+++ b/internal/sheets/handlers_rename.go
@@ -51,6 +51,15 @@ func (a *App) handleSheetRename(w http.ResponseWriter, r *http.Request) {
return
}
+ // The body is bounded BEFORE it is parsed. Without this, rename was
+ // the one write route in the app reading a form with nothing
+ // capping it: FormValue buffers the whole request to find a single
+ // field, so an unbounded body is memory held for a title that gets
+ // truncated to maxTitleRunes a moment later anyway. handlers_format
+ // describes the identical trap on the identical shape of form post
+ // and settled on 8 KiB; there is no reason for a second number.
+ r.Body = http.MaxBytesReader(w, r.Body, 8<<10)
+
// Read field by field into a local, never bound onto a GORM model
// (AGENTS.md / Mechanical rules): a form that could reach the model
// could set CreatedBy, or DeletedAt, or an id.
diff --git a/internal/sheets/handlers_rename_test.go b/internal/sheets/handlers_rename_test.go
index 90a3e6f..aa3a605 100644
--- a/internal/sheets/handlers_rename_test.go
+++ b/internal/sheets/handlers_rename_test.go
@@ -111,6 +111,40 @@ func TestRenameTruncatesToTheSameCapAsCreate(t *testing.T) {
}
}
+// The body is capped before it is parsed.
+//
+// An independent review pass found rename reading its form with nothing
+// bounding the request — the only write route in the app doing so.
+// FormValue buffers the WHOLE body to pull one field out of it, so
+// without the cap a megabyte of "title=" is a megabyte held in memory
+// on the way to a value that gets truncated to maxTitleRunes anyway.
+//
+// The assertion is deliberately about the sheet rather than the status
+// code: MaxBytesReader makes the oversized parse fail, FormValue then
+// answers "", and a blank title is already refused by keeping the name
+// the sheet had. So the observable, load-bearing fact is that a body
+// far past the cap changes nothing — which is also what fails if
+// somebody later removes the MaxBytesReader line, since the rename
+// would then succeed.
+func TestRenameCapsTheBodyItWillParse(t *testing.T) {
+ app, cookies := signIn(t)
+ s, _ := newSheet(t, app, cookies, "Budget")
+
+ // Well past the 8 KiB cap, as one enormous field value.
+ huge := url.Values{"title": {strings.Repeat("x", 64<<10)}}
+ rec := do(t, app, as(postForm(renamePath(s.ID), huge), cookies))
+ if rec.Code != http.StatusSeeOther {
+ t.Fatalf("oversized rename = %d, want 303: %s", rec.Code, rec.Body.String())
+ }
+ got, err := app.sheetSummary(s.ID, signedInMember(t, app))
+ if err != nil {
+ t.Fatalf("sheetSummary: %v", err)
+ }
+ if got.Title != "Budget" {
+ t.Errorf("an oversized body renamed the sheet to %q; the cap is not there", got.Title)
+ }
+}
+
// A viewer may not rename, and is told 404 rather than 403 — AGENTS.md
// requires it, because a 403 confirms the sheet exists to somebody who
// was never shown it.
diff --git a/internal/sheets/templates/sheets.html b/internal/sheets/templates/sheets.html
index 8269e9e..4844b61 100644
--- a/internal/sheets/templates/sheets.html
+++ b/internal/sheets/templates/sheets.html
@@ -138,12 +138,21 @@ first worksheet is imported.</p>
a file picker to somebody mid-drag is a second way to do the
thing they are already doing.
- tabindex="-1" keeps them out of the tab order while the overlay
- is up; `hidden` on the form handles it the rest of the time. */}}
+ They carry no tabindex, and its absence is a fix rather than an
+ omission. Both used to carry tabindex="-1", to "keep them out of
+ the tab order while the overlay is up" — which is exactly backwards.
+ The overlay stays up after a REFUSED drop precisely so these two
+ controls can be used, and a negative tabindex meant a keyboard user
+ tabbed straight past the recovery form into the list behind it.
+ The other two states never needed the help: `hidden` on the form
+ takes the whole subtree out of the tab order at rest, and
+ [data-drop-page-recover] is display:none under [data-drop-over],
+ which does the same mid-drag. So it was redundant where it was
+ harmless and harmful where it was not. */}}
<div data-drop-page-recover>
-<input rst-input name="file" type="file" accept=".xlsx,.xlsm" tabindex="-1"
+<input rst-input name="file" type="file" accept=".xlsx,.xlsm"
aria-label="Spreadsheet to import">
-<button rst-btn="primary" type="submit" tabindex="-1">Import</button>
+<button rst-btn="primary" type="submit">Import</button>
</div>
</div>
</div>
diff --git a/tools/drive/clipboard.mjs b/tools/drive/clipboard.mjs
index 61d8f16..97d58ec 100644
--- a/tools/drive/clipboard.mjs
+++ b/tools/drive/clipboard.mjs
@@ -33,7 +33,11 @@ await page.goto(`${base}/signin`); await page.fill('input[type=email]','paul@exa
await Promise.all([page.waitForLoadState('networkidle'),page.click('button[type=submit]')]);
const mm=[...fs.readFileSync(process.env.SHEETS_LOG,'utf8').matchAll(/http:\/\/127\.0\.0\.1:8799\/auth\/verify\?[^\s\\"]+/g)].pop();
await page.goto(mm[0]); await page.waitForURL(/\/sheets/);
-await page.fill('#title','Clip2'); await Promise.all([page.waitForURL(/\/sheets\/\d+$/),page.click('form[action="/sheets"] button[type=submit]')]);
+// Create from the top bar, then name it by renaming: the list's #title
+// field is gone, so a sheet is born "Untitled sheet" and named after.
+await Promise.all([page.waitForURL(/\/sheets\/\d+$/),page.click('[rst-shell-tail] form[action="/sheets"] button[type=submit]')]);
+await page.fill('[data-doc-title-input]','Clip2');
+await Promise.all([page.waitForURL(/\/sheets\/\d+$/),page.press('[data-doc-title-input]','Enter')]);
await page.waitForFunction(()=>window.SheetsGrid&&window.SheetsGrid.worksheet());
const w=(r,c,v)=>page.evaluate(([r,c,v])=>window.SheetsGrid.write(r,c,v),[r,c,v]);
const sel=(x1,y1,x2,y2)=>page.evaluate(([a,b,c,d])=>window.SheetsGrid.worksheet().updateSelectionFromCoords(a,b,c,d),[x1,y1,x2,y2]);
diff --git a/tools/drive/drop.mjs b/tools/drive/drop.mjs
index 542035e..c96deba 100644
--- a/tools/drive/drop.mjs
+++ b/tools/drive/drop.mjs
@@ -176,6 +176,41 @@ check('two files are refused in words', refused.hint === 'Drop one file at a tim
check('...with the overlay left up to say so', refused.hidden === false, refused);
check('...and the chooser back, to recover with', refused.recover !== 'none', refused.recover);
check('...having submitted nothing', refused.path === '/sheets', refused.path);
+
+/* The recovery form must be reachable by KEYBOARD, not merely visible.
+ *
+ * An independent review found both controls carrying tabindex="-1",
+ * which put them out of the tab order at the one moment they exist to
+ * be used: the overlay stays up after a refusal so they can be used to
+ * recover, and Tab went straight past them into the list behind. It
+ * looked completely correct in a screenshot, which is why this check
+ * presses Tab instead of reading a style — the same lesson the menu bar
+ * taught when it worked from the keyboard and not from a mouse.
+ *
+ * Tabbing from the panel must land ON the panel's own controls. */
+const reachable = await page.evaluate(() => {
+ const rec = document.querySelector('[data-drop-page-recover]');
+ const controls = [...rec.querySelectorAll('input,button')];
+ return {
+ negative: controls.filter((c) => c.tabIndex < 0).length,
+ count: controls.length,
+ };
+});
+check('the recovery controls are in the tab order', reachable.negative === 0
+ && reachable.count === 2, JSON.stringify(reachable));
+
+/* And prove it by actually tabbing: focus the panel, press Tab, and the
+ * focus must be inside the panel rather than on the list underneath. */
+await page.evaluate(() => document.querySelector('[data-drop-page-recover] input').focus());
+const focusedInPanel = await page.evaluate(() =>
+ !!document.activeElement.closest('[data-drop-page-recover]'));
+check('...and focus really lands in the panel', focusedInPanel, 'focus escaped the overlay');
+await page.keyboard.press('Tab');
+const nextInPanel = await page.evaluate(() =>
+ !!document.activeElement.closest('[data-drop-page-recover]'));
+check('...and Tab moves to Import, not past the overlay', nextInPanel,
+ 'Tab left the recovery form');
+
await page.screenshot({ path: OUT + '/list-refused.png' });
await page.keyboard.press('Escape');
diff --git a/tools/drive/menus.mjs b/tools/drive/menus.mjs
index 01c11ab..d508b85 100644
--- a/tools/drive/menus.mjs
+++ b/tools/drive/menus.mjs
@@ -48,7 +48,13 @@ await page.goto(`${base}/signin`); await page.fill('input[type=email]','paul@exa
await Promise.all([page.waitForLoadState('networkidle'),page.click('button[type=submit]')]);
const mm=[...fs.readFileSync(LOG,'utf8').matchAll(/http:\/\/127\.0\.0\.1:8799\/auth\/verify\?[^\s\\"]+/g)].pop();
await page.goto(mm[0]); await page.waitForURL(/\/sheets/);
-if(!(await page.$('[data-sheet-list]'))){await page.fill('#title','Audit');await Promise.all([page.waitForURL(/\/sheets\/\d+$/),page.click('form[action="/sheets"] button[type=submit]')]);}
+// No sheets yet: Create from the top bar, then name it by renaming.
+// The list's #title field is gone — a sheet is born "Untitled sheet".
+if(!(await page.$('[data-sheet-list]'))){
+ await Promise.all([page.waitForURL(/\/sheets\/\d+$/),page.click('[rst-shell-tail] form[action="/sheets"] button[type=submit]')]);
+ await page.fill('[data-doc-title-input]','Audit');
+ await Promise.all([page.waitForURL(/\/sheets\/\d+$/),page.press('[data-doc-title-input]','Enter')]);
+}
else {await page.click('[data-sheet-list] [rst-row-main] a'); await page.waitForURL(/\/sheets\/\d+$/);}
const url=page.url();
await page.waitForFunction(()=>window.SheetsGrid&&window.SheetsGrid.worksheet());
diff --git a/tools/drive/tableformat.mjs b/tools/drive/tableformat.mjs
index 31eb994..040da0f 100644
--- a/tools/drive/tableformat.mjs
+++ b/tools/drive/tableformat.mjs
@@ -26,7 +26,11 @@ await page.goto(`${base}/signin`); await page.fill('input[type=email]','paul@exa
await Promise.all([page.waitForLoadState('networkidle'),page.click('button[type=submit]')]);
const mm=[...fs.readFileSync(process.env.SHEETS_LOG,'utf8').matchAll(/http:\/\/127\.0\.0\.1:8799\/auth\/verify\?[^\s\\"]+/g)].pop();
await page.goto(mm[0]); await page.waitForURL(/\/sheets/);
-await page.fill('#title','Table format'); await Promise.all([page.waitForURL(/\/sheets\/\d+$/),page.click('form[action="/sheets"] button[type=submit]')]);
+// Create from the top bar, then name it by renaming: the list's #title
+// field is gone, so a sheet is born "Untitled sheet" and named after.
+await Promise.all([page.waitForURL(/\/sheets\/\d+$/),page.click('[rst-shell-tail] form[action="/sheets"] button[type=submit]')]);
+await page.fill('[data-doc-title-input]','Table format');
+await Promise.all([page.waitForURL(/\/sheets\/\d+$/),page.press('[data-doc-title-input]','Enter')]);
const url=page.url();
await page.waitForFunction(()=>window.SheetsGrid&&window.SheetsGrid.worksheet());
for(const [r,c,v] of [[0,0,'Region'],[0,1,'Revenue'],[1,0,'North'],[1,1,'1200'],[2,0,'South'],[2,1,'900']])