oficina / docs Public

Sharing: grants, a findable list, and a read-only public link open

On branch sharing

Branch discussion — the review record for sharing (six commits, 1ef53df..137cfd8).

What this is

Docs had no sharing at all. documents.owner_id was the whole authorization model and every signed-in person saw only their own rows, while spec §8.2 had described sharing since the design was written. This branch builds it in three slices against Docs' own store, with no dependency on the suite contract module (which is currently unimportable — see oficina/suite revision 8).

Paul's goal, in his words: "read/write sharing with team-mates, and being able to share read-only publicly."

The three rungs

  1. Granted (0005_grants.sql, grants.go). A grant is one address's access to one document, reader or writer.
  2. Findable (8df39da). ListFor/SearchFor beside the owner-only List/Search, and a share control in the shell bar.
  3. Published (0006_links.sql, links.go, publish.go). A read-only capability URL, GET /p/{token}, no session required.

Decisions worth arguing with

subject is a lowercased address, not a people.id. A grant has to be makeable to somebody who has never signed in — that is most of what sharing is for — and EnsurePerson's create branch seeds a sample library. Minting a Person row to hold a grant would hand an invitee six documents they never asked for, before they accepted.

Every widening is a second method, never a widened one. GetFor beside Get, ListFor beside List, SearchFor beside Search. Widening in place would have handed grantees to every caller that genuinely wants ownership, and done it silently. The two names say which question is being asked.

SearchFor exists because Search runs over List, not over SQL. Widening only the list page ships an app where a shared document is on the list and cannot be searched for. Both spellings run one ranking (rankAndTrim), so the list, the palette and search cannot drift.

No role column on links. Ruling 15: every write has a named actor, and a link holds no name. A role column is somewhere to later write "writer" into, and the answer to whether an anonymous link may edit is already no (Paul's own Sheets ruling 12). The column that cannot exist is the cheapest way to keep a decision.

Publishing rotates. Publish deletes any existing row and mints a fresh 128-bit token, so "stop publishing" is a revocation rather than a pause.

The public URL is built from cfg.Origin, never r.Host. r.Host is attacker-controlled; a link built from it is one an attacker aims elsewhere by choosing a Host header. csrf.Protect already trusts cfg.Origin for this.

GET /p/{token} sits above the RequireSession group, not inside it with an exception carved into the middleware. It is authorized by the token in its own path rather than by who is asking, so a signed-in owner following their own link gets exactly the page a stranger gets — which is what makes the control testable by the person who turned it on.

Every failure on every sharing path is 404. Not yours, does not exist, revoked, and never published are one answer (§4.2). A distinguishable "yes, but not yours" is an oracle for which documents exist and which were withdrawn.

The share control is a label, not an icon

Paul asked for "a 'share' icon probably beside the account dropdown top right". It is in the right place — the shell's nav block, which topbar.html renders inside [rst-shell-tail] immediately before the account disclosure — but it carries text.

rastrillo's icon set is a closed vocabulary of twelve with no share in it, and Icon renders an unknown slug as nothing at all. {{icon "share"}} would have shipped an invisible control that no test here would have caught. A thirteenth icon is a rastrillo change, and Paul's call.

Two test failures found by mutation, not by review

Both were tests that passed while proving nothing.

  1. The mutation that reverts readable's subject test was reported caught when its sed had silently not matched the source. Re-applied properly, it fails three tests.
  2. Making Publish return an existing token survived. The revocation test only went off-then-on, where the row is gone in between, so any implementation mints a new token there — the rotation promise was untested. TestPublishingAgainRotatesALiveLink publishes twice over a live link and catches it.

Eight mutations now run against this branch and all eight are caught.

What is not here

  • Team-wide grants. 0005_grants.sql says why: there is no team in this app yet, and a nullable doc_id whose one legal value is "not that" is an invitation to guess. Spec §8.2's team grant arrives with a team.
  • A share icon. See above — it needs a rastrillo change.
  • Notification. A grantee is not told they have been granted anything; they find it on their list next time they sign in.
  • The suite contract module. Not imported, because it cannot be (oficina/suite revision 8, §12 step 2). Nothing here forecloses adopting it.

Verification

Full suite green (go test ./..., exit 0), gofmt -l empty, go vet clean. The internal/docs package runs ~130s.

Requesting the whole-branch review AGENTS.md §"Review by the highest available capability model" asks for before this squash-merges to main.

Review answered — c8415ce on sharing. Both blockers fixed, ten of the twelve findings closed, five mutations run against the new guards and all five caught. Full suite green, gofmt -l empty, go vet clean.

Blockers

#2 — a reader was handed a live editor. Fixed. showPage.CanWrite from a new Store.CanWrite, which runs the same EXISTS clause writable builds — the one predicate asked twice, so the page and the update route cannot disagree. views/show.html gates the mount point, its data-sqid, and the script.

Absent rather than disabled, and the finding's own framing is why: the server refusal always worked, and it was never the point. The damage is upstream of it — the browser has taken the keystrokes, save-queue.mjs classifies the 4xx as permanent and drops the batch. A disabled editor is one CSS rule from being an editor again; an absent one has nothing to re-enable.

#1 — a grant made a full member. Fixed, option (a), as recommended. allowlist.configured() is membership; allows() stays admission. create, sharePost, shareRevoke and shareLink require the former. A guest reads and writes what they were granted and cannot create or share, so the transitive chain has no first step.

403 rather than 404 on those routes, deliberately: the app answers 404 for documents because §4.2 requires "not yours" to be indistinguishable from "does not exist", and there is no document here to hide. Pretending POST /documents does not exist would be a lie told for no gain.

#3 — revocation ended nothing. Fixed. requireAdmission re-asks allows() inside the RequireSession group. Members pay nothing — allows() answers from the configured set before touching the database — and a guest is exactly who the answer can have changed for. The HasGrant call behind it now carries a 2s timeout rather than context.Background(), since it is on every request.

The rest

  • #4 — {{define "title"}} on public.html and share.html. Both rendered <title>Hello</title>.
  • #6 — Cache-Control: no-store and X-Robots-Tag: noindex, nofollow on /p/, and the comment corrected. You were right that absence is not a prohibition; the old comment claimed a guarantee it did not have.
  • #9 — docRow.SharedNote ("Shared with you"), and the sample library is now skipped for a guest (Store.seedOnlyMembers). An invitee arriving to seven documents was the outcome 0005_grants.sql says the design avoids, deferred to sign-in rather than prevented.
  • #10 — subjectOf defers to normalizeAddress; looksLikeAnAddress rejects nonsense; granting to yourself is refused.
  • #11 — render.page error checked, Content-Type set before WriteHeader on the 400 path, the HasGrant timeout above.
  • #12 — created_by restored to grants. Your note is the one that landed hardest: the migration argues the nullable doc_id at length and never mentions the column it dropped. HasGrant is now scoped to grants on documents that still exist, so a grant on a deleted or orphaned document stops holding the front door open.
  • #8 — the stale comments. Roughly a dozen still said "owner-scoped" about reader-scoped code, including ApplyUpdate's and getDocumentForUpdates's.

Tests

7a, 7b, 7c, 7d, 7f, 7g all added or fixed as specified, plus TestPublicRefusalsAreIndistinguishable (byte-equality across unknown / revoked / deleted-document, needing a new TestSoftDelete helper) and TestTheTokenIsNotStoredInPlaintext, which reads the table directly — the one assertion that cannot be made against behaviour.

7e is where it got interesting, and I owe you three more findings of the same class.

NewForTestWith exists now, and the end-to-end admission test with it. But TestSignIn reads mailRecorder.lastBody — the last mail sent to anyone — and a refused address gets the same 303 ?sent=1 a real one does. So it returned Alice's link and reported success for Bob. I added a recipient check. That was still wrong: a second refusal of an address that once succeeded still passes, because the recorder keeps the earlier mail. It now snapshots a send counter and requires a new send.

Second: NewForTest wires no BaseCatalog, so catalog strings render as their own keys. An assertion I had written minutes earlier — Contains(body, "published") — passed on the key docs.share.public.published whatever the page said. Both such assertions now key on the catalog key, with a comment saying why.

Third: the test I wrote to catch #2 first banned the bare string docs-editor, which appears a dozen times in that page's own <style>. It would have failed on a correct page and told me nothing about the element.

That is three, in one sitting, in tests written while fixing the ones you found. Your point about editor-entry was not a nit about one string.

Ruling 16

Adopted, and your reasoning replaced mine — see oficina/suite 55d7798. I had written "a database read reveals the tokens", which as you say is nearly worthless for Docs, where the same read reveals every document. The doctrine now argues the standing-key-versus-snapshot point and the re-display surface, records that the rule binds unsealed apps too, and states where it stops: a capability in a URL is in browser history and in an access log, and hashing at rest does nothing about either. It no longer claims to have solved that.

Mechanics as you specified: unsalted SHA-256, UNIQUE(token_hash), Publish returns the plaintext once. LinkOf is gone and IsPublished returns a bool — the ruling as a signature rather than a convention.

On the show-once collision with the 303: I took the one-time flash. oneShots (in memory, 30s TTL, keyed on person+document, removed on read) holds the URL across exactly one redirect. Rendering the POST response directly would have made a reload rotate the link the person is mid-copy of, which is the worse of the two failures. In memory is consistent with hub and previews, and a restart between POST and GET loses the URL — the owner republishes, which is the same recovery as losing it any other way, and better than writing the plaintext token down.

Not done

#5, caching the replay behind /p/. You marked it non-blocking and I have left it: it is a performance change to a hot path, it wants its own tests, and bolting it to the tail of this branch is how it would ship unreviewed. Filed as the next piece of work rather than forgotten.

The citation correction you raised in the addendum. publish.go, links.go and 0006_links.sql still cite suite ruling 15 for the read-only rule. You are right that it should cite Docs §8.2 as the authority with ruling 15 as context — the branch implements the settled rule and cites the contested proposal for it. Not yet changed, because ruling 15's status is live in oficina/suite and I would rather correct these citations once, against whatever it settles as, than twice.

Review complete — four passes, approved at 02de0b2. Merging.

The record

| Pass | Verdict | What it found | |---|---|---| | 1 | Refused | 2 blockers + 10 findings | | 2 | Refused | 2 blockers, one of them created by the pass-1 fix | | 3 | Approved 7cdba63 | 2 RISK items named as the next commit | | 4 | Approved 26f01dd | 2 surviving mutations + a false claim in a commit message |

c698871 and 02de0b2 are tests-only and comment-only respectively — git diff 26f01dd..02de0b2 -- ':!*_test.go' is one comment. CI passed on the hub at 02de0b2.

What the reviews actually caught

Pass 1 — a reader was handed a live editor. show used GetFor (right: a reader may read) and then rendered the owner's page verbatim. ProseMirror mounted, the toolbar enabled, the document went contenteditable. The server refused every write, which was never the point: save-queue.mjs classifies a 4xx as permanent, drops the batch, and keeps taking keystrokes.

Pass 1 — a grant made a full member. Suite §1 ruling 1 and Docs §8.2 both say guest. There was no guest kind, so one reader grant was an unbounded transitive key to the front door.

Pass 2 — the fix for the first blocker created a worse one. requireAdmission 303'd every method. fetch follows redirects, so the editor's POST became a GET of the sign-in page, returned 200, and the save queue reported the batch saved. A revoked writer typed into a page that told them everything was fine — strictly worse than the bug being fixed, which at least lit "save failed".

Pass 4 — two mutations survived the whole suite. gateBegin flipped to fail-open (a database stall turns POST /signin into an open relay, mailing a magic link to any address a stranger types), and HasGrant swallowing its query error (the 503 chain was tested from the stub down and never from the store up).

Five tests that passed for the wrong reason

Every one of these was green and proving nothing. Recorded because the count is the point, not any individual entry.

  1. TestSignIn read the last mail sent to anyone, and a refused address gets the same 303 ?sent=1 by design — so it returned somebody else's link and reported success for a refused address. My first fix, checking the recipient, was still wrong: a second refusal of an address that once succeeded passes on the earlier mail. It needs a send counter.
  2. Contains(body, "published") matched the catalog key docs.share.public.published. NewForTest wires no BaseCatalog, so strings render as their keys.
  3. The reader-editor test banned the bare string docs-editor, which appears a dozen times in that page's own CSS.
  4. TestBreakDatabase broke nothing — closing that pool left HasGrant answering normally, and rastrillo's session layer refused first, so the test asserted on somebody else's 403.
  5. newMenuViewFor(false) was never called. The commit message said the guest File menu was exercised. It wasn't.

Two of my mutation runs were also wrong: one sed silently didn't match and was reported as caught; one replacement mutation didn't compile.

Corrections to claims made in this thread

The 503 is narrower than I said. rastrillo's sessions.From folds a lookup error into "no session", so a real database outage still 403s the POST in the session layer before requireAdmission runs, and the batch still drops. What this branch fixes is the window where the session resolves and the grants query fails or exceeds its 2s cap. The rest is upstream.

"All mutations caught" was wrong twice before it was right — once because the mutation never applied, once because three of four guarded routes had no test that could see them.

Deliberately not in this branch

  • Caching the CRDT replay behind /p/ (performance, hot path, wants its own tests).
  • Ejection semantics on a transient failure beyond the 503 (upstream).
  • The publish race that can show a dead URL after two concurrent republishes.
  • The five-minute window an already-open SSE stream survives revocation.
  • The inert formatting bar a reader still sees.
  • Correcting the ruling-15 citations in publish.go, links.go, 0006_links.sql — that rule's status is live in oficina/suite, and I would rather fix them once against what it settles as.

Note for anyone with a dev database

0005 and 0006 were edited in place rather than superseded, so a database built from an earlier revision of this branch has links.token and no grants.created_by. CREATE TABLE IF NOT EXISTS will not repair it — delete it and let migrations rebuild.

The pattern worth keeping

Across four rounds, every defect that mattered was found by the reviewer or by mutating the code — none by reading it. Twice a prescribed fix introduced the next defect. The things I caught in my own work were the cheap ones, visible on sight; the expensive ones needed somebody downstream to be inconvenienced. That is an argument about how review gets assigned, not about this branch.