oficina / docs Public

Sharing, slice 2: the share page, scriptless

GET /d/{sqid}/share lists who reaches a document, POST grants, POST
.../revoke takes it back. Alice types Bob's address, picks read or
read-and-edit, and Bob opens the document — which is the feature §8.2 has
described since this spec was written.

All three are owner-only and all three answer 404 to anybody else, including
a writer: a grant does not carry re-sharing, and a document you may not
share does not announce itself as one you may not share (§4.2).

GetFor is a second method rather than a widening of Get, and that is the
shape of the whole change. Get stays owner-scoped, because sharing, revoking
and listing grants are the owner's alone — widening it in place would have
handed all three of those paths grantees too, silently, which is the same
mistake owned() was written to avoid at the query layer. show, the stream,
the updates GET and both exports moved to GetFor; the updates POST still
authorizes separately in ApplyUpdate, where a reader is refused.

Each row's revoke is its own form rather than a link, because revoking is a
mutation and a GET that changes state is one a prefetcher can fire. The
address rides a hidden field rather than the URL for the same reason: the
row already names it in text, and a URL is copied, logged and pasted in a
way a form body is not. Reader is the checked radio, so the safer of the two
is what somebody gets by not choosing. An empty address re-renders the page
with a message beside the field rather than 500ing or storing a grant that
matches nobody.

One regression, and it was the comment rather than the markup.
TestOurTemplatesUseTheAttributeGrammar greps raw bytes, and the comment
explaining that this template does not use the class vocabulary contained
the spelling it was disclaiming. Reworded rather than loosening the guard: a
raw substring check is a defensible way to write that test, and the comment
now records the trap for whoever writes the next template.

Full suite green, gofmt and vet clean.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Paul Campbell pushed by paul@keymail.dev e81d9340962ce5c06513f3ce6667a4c1ba875e17 parent 76d9b49
7 files changed, +498 −5
  • internal/docs/app.go +7 −0
  • internal/docs/grants.go +61 −1
  • internal/docs/handlers.go +9 −4
  • internal/docs/render.go +13 −0
  • internal/docs/share.go +165 −0
  • internal/docs/share_test.go +175 −0
  • internal/docs/views/share.html +68 −0

CI cancelled — superseded by a newer push — run details

diff --git a/internal/docs/app.go b/internal/docs/app.go
index b576fc3..5835486 100644
--- a/internal/docs/app.go
+++ b/internal/docs/app.go
@@ -464,6 +464,13 @@ func New(cfg Config) (*App, error) {
// is exempted from csrf.Protect above: rastrillo SKILL.md §5's
// origin check is what stops a cross-site page from writing
// to a document through this route.
+ // Sharing. All three owner-only, and all three answering 404 to
+ // anybody else — including a writer, who may edit the document and
+ // still may not decide who else reaches it.
+ r.Get("/d/{sqid}/share", h.shareGet)
+ r.Post("/d/{sqid}/share", h.sharePost)
+ r.Post("/d/{sqid}/share/revoke", h.shareRevoke)
+
r.Get("/d/{sqid}/updates", h.updatesGet)
r.Post("/d/{sqid}/updates", h.updatesPost)
// Export (export.go). Inside this group, so a signed-out
diff --git a/internal/docs/grants.go b/internal/docs/grants.go
index ee5cd82..a8c9c3b 100644
--- a/internal/docs/grants.go
+++ b/internal/docs/grants.go
@@ -51,6 +51,12 @@ var ErrNotOwner = errors.New("docs: only a document's owner may share it")
// both the same way, for the reason §4.2 gives about reads.
var ErrNotWritable = errors.New("docs: not writable by this person")
+// errEmptyAddress is a share form submitted with nothing in the address
+// field. Its own error rather than a generic one because the share handler
+// re-renders the page with a message beside the field, and a person mistyping
+// should not get a 500.
+var errEmptyAddress = errors.New("docs: cannot grant to an empty address")
+
// Actor is who is acting on a request: this app's numeric person id, and the
// address their grants are keyed on. Both are needed because ownership is
// keyed on the id and grants are keyed on the address — see 0005_grants.sql
@@ -123,7 +129,7 @@ func (s *Store) Grant(ctx context.Context, owner int64, sqid, address string, ro
}
subject := subjectOf(address)
if subject == "" {
- return errors.New("docs: cannot grant to an empty address")
+ return errEmptyAddress
}
doc, err := s.Get(ctx, owner, sqid)
@@ -163,3 +169,57 @@ func (s *Store) HasGrant(ctx context.Context, address string) bool {
}
return count > 0
}
+
+// ListGrants returns who currently reaches a document, oldest first, for its
+// owner. Owner-scoped through Get, so a grantee asking gets the same answer a
+// stranger does — the share page is the owner's alone (§4.2).
+func (s *Store) ListGrants(ctx context.Context, owner int64, sqid string) ([]*Grant, error) {
+ doc, err := s.Get(ctx, owner, sqid)
+ if err != nil {
+ return nil, err
+ }
+ var grants []*Grant
+ err = s.db.WithContext(ctx).
+ Where("doc_id = ?", doc.ID).
+ Order("created_at ASC, id ASC").
+ Find(&grants).Error
+ return grants, err
+}
+
+// Revoke takes an address's access away.
+//
+// Revoking something that is not there succeeds. The owner asked for an
+// outcome — that this address does not reach this document — and that outcome
+// holds either way; reporting an error would only tell them about a race with
+// their own second click.
+func (s *Store) Revoke(ctx context.Context, owner int64, sqid, address string) error {
+ doc, err := s.Get(ctx, owner, sqid)
+ if err != nil {
+ return err
+ }
+ return s.db.WithContext(ctx).
+ Where("doc_id = ? AND subject = ?", doc.ID, subjectOf(address)).
+ Delete(&Grant{}).Error
+}
+
+// GetFor fetches a document the acting person may read: theirs, or one they
+// hold a grant on.
+//
+// Deliberately a second method rather than a widening of Get. Get is
+// owner-scoped and several paths want exactly that — sharing, revoking and
+// listing grants are the owner's alone — so widening Get in place would have
+// handed every one of them grantees as well, silently. The two names say
+// which question is being asked.
+//
+// A document the person may not read answers gorm.ErrRecordNotFound, the same
+// as one that does not exist, and the handler turns both into 404 (§4.2).
+func (s *Store) GetFor(ctx context.Context, a Actor, sqid string) (*Document, error) {
+ var doc Document
+ err := readable(s.db.WithContext(ctx), a.PersonID, a.Subject).
+ Where("documents.sqid = ? AND documents.deleted_at IS NULL", sqid).
+ First(&doc).Error
+ if err != nil {
+ return nil, err
+ }
+ return &doc, nil
+}
diff --git a/internal/docs/handlers.go b/internal/docs/handlers.go
index e9cfcc1..4260f0b 100644
--- a/internal/docs/handlers.go
+++ b/internal/docs/handlers.go
@@ -444,14 +444,16 @@ func newDialogView(r *http.Request) dialogView {
func (h *handlers) show(w http.ResponseWriter, r *http.Request) {
sqid := r.PathValue("sqid")
- owner, err := h.owner(r)
+ actor, err := h.actor(r)
if err != nil {
h.logger.Error("docs: resolve signed-in person", "err", err)
http.Error(w, rastrillo.T(r, "docs.error.generic"), http.StatusInternalServerError)
return
}
- doc, err := h.store.Get(r.Context(), owner, sqid)
+ // GetFor, not Get: a grantee reads this page too. Get stays owner-only
+ // and is what the share and revoke paths use.
+ doc, err := h.store.GetFor(r.Context(), actor, sqid)
if err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
http.Error(w, rastrillo.T(r, "docs.error.notfound"), http.StatusNotFound)
@@ -513,14 +515,17 @@ const maxLogBytes = 8 << 20
func (h *handlers) getDocumentForUpdates(w http.ResponseWriter, r *http.Request) (doc *Document, ok bool) {
sqid := r.PathValue("sqid")
- owner, err := h.owner(r)
+ actor, err := h.actor(r)
if err != nil {
h.logger.Error("docs: resolve signed-in person", "err", err)
http.Error(w, rastrillo.T(r, "docs.error.generic"), http.StatusInternalServerError)
return nil, false
}
- doc, err = h.store.Get(r.Context(), owner, sqid)
+ // Readable, not owned. This backs the stream, the updates GET and both
+ // exports, all of which a grantee may use. The updates POST authorizes
+ // separately in ApplyUpdate, where a reader is refused.
+ doc, err = h.store.GetFor(r.Context(), actor, sqid)
if err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
http.Error(w, rastrillo.T(r, "docs.error.notfound"), http.StatusNotFound)
diff --git a/internal/docs/render.go b/internal/docs/render.go
index e18cbe5..24bf64c 100644
--- a/internal/docs/render.go
+++ b/internal/docs/render.go
@@ -62,6 +62,18 @@ var Catalog = rastrillo.Catalog{
// labels are recency buckets (handlers.go's recencyBucket); a
// document's own title and its row's timestamp are data, not
// authored copy, so neither is a catalog key.
+ "docs.share.title": "Share",
+ "docs.share.heading": "Who can open this",
+ "docs.share.address": "Email address",
+ "docs.share.role": "They can",
+ "docs.share.role.reader": "Read it",
+ "docs.share.role.writer": "Read and edit it",
+ "docs.share.submit": "Share",
+ "docs.share.revoke": "Stop sharing",
+ "docs.share.back": "Back to the document",
+ "docs.share.empty": "Only you can open this document.",
+ "docs.share.error.address": "Type the email address of the person you want to share with.",
+
"docs.list.title": "Documents",
"docs.list.group.today": "Today",
"docs.list.group.yesterday": "Yesterday",
@@ -393,6 +405,7 @@ const headTemplate = "views/head.html"
// pageTemplates maps a page name to its file under views/.
var pageTemplates = map[string]string{
"list": "views/list.html",
+ "share": "views/share.html",
"show": "views/show.html",
"signin": "views/signin.html",
}
diff --git a/internal/docs/share.go b/internal/docs/share.go
new file mode 100644
index 0000000..8bc5f98
--- /dev/null
+++ b/internal/docs/share.go
@@ -0,0 +1,165 @@
+package docs
+
+import (
+ "errors"
+ "net/http"
+
+ "github.com/carlosframework/rastrillo"
+)
+
+// sharePage is the share screen's view data, resolved in the handler rather
+// than in the template, as every other page here does it.
+type sharePage struct {
+ Title string
+ DocTitle string
+ Sqid string
+ Heading string
+ AddressLabel string
+ RoleLabel string
+ RoleReader string
+ RoleWriter string
+ ShareButton string
+ RevokeButton string
+ BackLabel string
+ EmptyBody string
+ Error string
+ Grants []shareRow
+ SignOutLabel string
+}
+
+// shareRow is one person who reaches this document.
+type shareRow struct {
+ Address string
+ Role string
+}
+
+// shareGet serves GET /d/{sqid}/share: who reaches this document, and the
+// form that adds somebody.
+//
+// Owner-only, through Store.ListGrants, which goes through the owner-scoped
+// Get. A grantee — even a writer — gets the 404 a stranger gets: re-sharing
+// is not something a grant carries, and a document you may not share does not
+// announce itself as one you may not share (spec §4.2).
+func (h *handlers) shareGet(w http.ResponseWriter, r *http.Request) {
+ owner, err := h.owner(r)
+ if err != nil {
+ h.logger.Error("docs: resolve owner for share", "err", err)
+ http.Error(w, rastrillo.T(r, "docs.error.generic"), http.StatusInternalServerError)
+ return
+ }
+ sqid := r.PathValue("sqid")
+
+ doc, err := h.store.Get(r.Context(), owner, sqid)
+ if err != nil {
+ http.NotFound(w, r)
+ return
+ }
+ grants, err := h.store.ListGrants(r.Context(), owner, sqid)
+ if err != nil {
+ h.logger.Error("docs: list grants", "sqid", sqid, "err", err)
+ http.Error(w, rastrillo.T(r, "docs.error.generic"), http.StatusInternalServerError)
+ return
+ }
+
+ h.renderShare(w, r, doc, grants, "")
+}
+
+func (h *handlers) renderShare(w http.ResponseWriter, r *http.Request, doc *Document, grants []*Grant, errKey string) {
+ rows := make([]shareRow, 0, len(grants))
+ for _, g := range grants {
+ label := "docs.share.role.reader"
+ if g.Role == RoleWriter {
+ label = "docs.share.role.writer"
+ }
+ rows = append(rows, shareRow{Address: g.Subject, Role: rastrillo.T(r, label)})
+ }
+
+ page := sharePage{
+ Title: rastrillo.T(r, "docs.share.title"),
+ DocTitle: doc.Title,
+ Sqid: doc.Sqid,
+ Heading: rastrillo.T(r, "docs.share.heading"),
+ AddressLabel: rastrillo.T(r, "docs.share.address"),
+ RoleLabel: rastrillo.T(r, "docs.share.role"),
+ RoleReader: rastrillo.T(r, "docs.share.role.reader"),
+ RoleWriter: rastrillo.T(r, "docs.share.role.writer"),
+ ShareButton: rastrillo.T(r, "docs.share.submit"),
+ RevokeButton: rastrillo.T(r, "docs.share.revoke"),
+ BackLabel: rastrillo.T(r, "docs.share.back"),
+ EmptyBody: rastrillo.T(r, "docs.share.empty"),
+ Grants: rows,
+ SignOutLabel: rastrillo.T(r, "docs.signout.label"),
+ }
+ if errKey != "" {
+ page.Error = rastrillo.T(r, errKey)
+ }
+
+ h.render.page(w, r, "share", page)
+}
+
+// sharePost serves POST /d/{sqid}/share: grant an address access.
+//
+// Scriptless: a plain form with a named address field and a role radio, and a
+// 303 back to this page afterwards so a reload does not re-post.
+func (h *handlers) sharePost(w http.ResponseWriter, r *http.Request) {
+ owner, err := h.owner(r)
+ if err != nil {
+ h.logger.Error("docs: resolve owner for share", "err", err)
+ http.Error(w, rastrillo.T(r, "docs.error.generic"), http.StatusInternalServerError)
+ return
+ }
+ sqid := r.PathValue("sqid")
+
+ doc, err := h.store.Get(r.Context(), owner, sqid)
+ if err != nil {
+ http.NotFound(w, r)
+ return
+ }
+
+ role := RoleReader
+ if r.PostFormValue("role") == string(RoleWriter) {
+ role = RoleWriter
+ }
+
+ err = h.store.Grant(r.Context(), owner, sqid, r.PostFormValue("address"), role)
+ switch {
+ case err == nil:
+ http.Redirect(w, r, "/d/"+sqid+"/share", http.StatusSeeOther)
+ case errors.Is(err, errEmptyAddress):
+ // Re-render with the message rather than redirecting, so the person
+ // sees what was wrong beside the field they typed it into.
+ grants, listErr := h.store.ListGrants(r.Context(), owner, sqid)
+ if listErr != nil {
+ h.logger.Error("docs: list grants after refusal", "sqid", sqid, "err", listErr)
+ http.Error(w, rastrillo.T(r, "docs.error.generic"), http.StatusInternalServerError)
+ return
+ }
+ w.WriteHeader(http.StatusBadRequest)
+ h.renderShare(w, r, doc, grants, "docs.share.error.address")
+ default:
+ h.logger.Error("docs: grant", "sqid", sqid, "err", err)
+ http.Error(w, rastrillo.T(r, "docs.error.generic"), http.StatusInternalServerError)
+ }
+}
+
+// shareRevoke serves POST /d/{sqid}/share/revoke.
+func (h *handlers) shareRevoke(w http.ResponseWriter, r *http.Request) {
+ owner, err := h.owner(r)
+ if err != nil {
+ h.logger.Error("docs: resolve owner for revoke", "err", err)
+ http.Error(w, rastrillo.T(r, "docs.error.generic"), http.StatusInternalServerError)
+ return
+ }
+ sqid := r.PathValue("sqid")
+
+ if _, err := h.store.Get(r.Context(), owner, sqid); err != nil {
+ http.NotFound(w, r)
+ return
+ }
+ if err := h.store.Revoke(r.Context(), owner, sqid, r.PostFormValue("address")); err != nil {
+ h.logger.Error("docs: revoke", "sqid", sqid, "err", err)
+ http.Error(w, rastrillo.T(r, "docs.error.generic"), http.StatusInternalServerError)
+ return
+ }
+ http.Redirect(w, r, "/d/"+sqid+"/share", http.StatusSeeOther)
+}
diff --git a/internal/docs/share_test.go b/internal/docs/share_test.go
new file mode 100644
index 0000000..b861990
--- /dev/null
+++ b/internal/docs/share_test.go
@@ -0,0 +1,175 @@
+package docs_test
+
+import (
+ "net/http"
+ "net/http/httptest"
+ "strings"
+ "testing"
+
+ "github.com/carlosframework/docs/internal/docs"
+)
+
+// post sends a scriptless form the way a browser would: urlencoded body, a
+// real Origin, and the signed-in person's cookies. There is no CSRF token
+// field anywhere in this app — csrf.Protect reads Sec-Fetch-Site first and
+// falls back to Origin — so a test that forgets the header is testing the
+// CSRF layer rather than the handler.
+func post(t *testing.T, app *docs.App, cookies []*http.Cookie, path, body string) *httptest.ResponseRecorder {
+ t.Helper()
+ req := httptest.NewRequest(http.MethodPost, path, strings.NewReader(body))
+ req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
+ req.Header.Set("Origin", docs.DefaultOrigin)
+ for _, c := range cookies {
+ req.AddCookie(c)
+ }
+ rec := httptest.NewRecorder()
+ app.Handler().ServeHTTP(rec, req)
+ return rec
+}
+
+func get(t *testing.T, app *docs.App, cookies []*http.Cookie, path string) *httptest.ResponseRecorder {
+ t.Helper()
+ req := httptest.NewRequest(http.MethodGet, path, nil)
+ for _, c := range cookies {
+ req.AddCookie(c)
+ }
+ rec := httptest.NewRecorder()
+ app.Handler().ServeHTTP(rec, req)
+ return rec
+}
+
+// createDoc makes a document as the signed-in person and returns its sqid.
+func createDoc(t *testing.T, app *docs.App, cookies []*http.Cookie, title string) string {
+ t.Helper()
+ rec := post(t, app, cookies, "/documents", "title="+title)
+ if rec.Code != http.StatusSeeOther {
+ t.Fatalf("POST /documents: status %d, want 303", rec.Code)
+ }
+ return strings.TrimPrefix(rec.Header().Get("Location"), "/d/")
+}
+
+// The whole point of the feature, end to end and scriptless: Alice shares a
+// document with Bob by typing his address into a form, and Bob can read it.
+func TestShareThenTheGranteeCanRead(t *testing.T) {
+ app, _ := docs.NewForTest(t)
+ alice := signInCookies(t, app, "alice@example.com")
+ sqid := createDoc(t, app, alice, "Minutes")
+
+ // Bob cannot see it yet.
+ bob := signInCookies(t, app, "bob@example.com")
+ if rec := get(t, app, bob, "/d/"+sqid); rec.Code != http.StatusNotFound {
+ t.Fatalf("bob reading before the share: status %d, want 404", rec.Code)
+ }
+
+ rec := post(t, app, alice, "/d/"+sqid+"/share", "address=bob@example.com&role=reader")
+ if rec.Code != http.StatusSeeOther {
+ t.Fatalf("POST share: status %d, want 303", rec.Code)
+ }
+
+ if rec := get(t, app, bob, "/d/"+sqid); rec.Code != http.StatusOK {
+ t.Errorf("bob reading after the share: status %d, want 200", rec.Code)
+ }
+}
+
+// The share page lists who currently holds a grant, or the owner cannot tell
+// what they have shared and with whom — which is the question the page exists
+// to answer.
+func TestSharePageListsGrantees(t *testing.T) {
+ app, _ := docs.NewForTest(t)
+ alice := signInCookies(t, app, "alice@example.com")
+ sqid := createDoc(t, app, alice, "Minutes")
+
+ if rec := post(t, app, alice, "/d/"+sqid+"/share", "address=bob@example.com&role=writer"); rec.Code != http.StatusSeeOther {
+ t.Fatalf("POST share: status %d", rec.Code)
+ }
+
+ rec := get(t, app, alice, "/d/"+sqid+"/share")
+ if rec.Code != http.StatusOK {
+ t.Fatalf("GET share: status %d, want 200", rec.Code)
+ }
+ if !strings.Contains(rec.Body.String(), "bob@example.com") {
+ t.Error("the share page does not name the person the document is shared with")
+ }
+}
+
+// Only the owner shares. A grantee — even one who can write — reaches the
+// share page exactly as a stranger does, with a 404 rather than a 403: a
+// document you may not share does not announce itself as one you may not
+// share (spec §4.2).
+func TestOnlyTheOwnerReachesTheSharePage(t *testing.T) {
+ app, _ := docs.NewForTest(t)
+ alice := signInCookies(t, app, "alice@example.com")
+ sqid := createDoc(t, app, alice, "Minutes")
+
+ if rec := post(t, app, alice, "/d/"+sqid+"/share", "address=bob@example.com&role=writer"); rec.Code != http.StatusSeeOther {
+ t.Fatalf("POST share: status %d", rec.Code)
+ }
+
+ bob := signInCookies(t, app, "bob@example.com")
+ if rec := get(t, app, bob, "/d/"+sqid+"/share"); rec.Code != http.StatusNotFound {
+ t.Errorf("a writer reaching the share page: status %d, want 404", rec.Code)
+ }
+ if rec := post(t, app, bob, "/d/"+sqid+"/share", "address=carol@example.com&role=reader"); rec.Code != http.StatusNotFound {
+ t.Errorf("a writer re-sharing: status %d, want 404", rec.Code)
+ }
+
+ carol := signInCookies(t, app, "carol@example.com")
+ if rec := get(t, app, carol, "/d/"+sqid); rec.Code != http.StatusNotFound {
+ t.Error("bob managed to re-share the document with carol")
+ }
+}
+
+// Revoking takes the access away again. Without this the share page is a
+// one-way door and a mistyped address is permanent.
+func TestRevokeRemovesAccess(t *testing.T) {
+ app, _ := docs.NewForTest(t)
+ alice := signInCookies(t, app, "alice@example.com")
+ sqid := createDoc(t, app, alice, "Minutes")
+ bob := signInCookies(t, app, "bob@example.com")
+
+ if rec := post(t, app, alice, "/d/"+sqid+"/share", "address=bob@example.com&role=reader"); rec.Code != http.StatusSeeOther {
+ t.Fatalf("POST share: status %d", rec.Code)
+ }
+ if rec := get(t, app, bob, "/d/"+sqid); rec.Code != http.StatusOK {
+ t.Fatalf("bob cannot read after the share; the revoke below would prove nothing")
+ }
+
+ if rec := post(t, app, alice, "/d/"+sqid+"/share/revoke", "address=bob@example.com"); rec.Code != http.StatusSeeOther {
+ t.Fatalf("POST revoke: status %d, want 303", rec.Code)
+ }
+
+ if rec := get(t, app, bob, "/d/"+sqid); rec.Code != http.StatusNotFound {
+ t.Errorf("bob reading after revoke: status %d, want 404", rec.Code)
+ }
+}
+
+// A reader granted read access cannot write, through the real HTTP path
+// rather than only at the store. The 404 is the same one a stranger gets.
+func TestAReaderCannotPostUpdates(t *testing.T) {
+ app, _ := docs.NewForTest(t)
+ alice := signInCookies(t, app, "alice@example.com")
+ sqid := createDoc(t, app, alice, "Minutes")
+
+ if rec := post(t, app, alice, "/d/"+sqid+"/share", "address=bob@example.com&role=reader"); rec.Code != http.StatusSeeOther {
+ t.Fatalf("POST share: status %d", rec.Code)
+ }
+
+ bob := signInCookies(t, app, "bob@example.com")
+ rec := post(t, app, bob, "/d/"+sqid+"/updates", "")
+ if rec.Code != http.StatusNotFound {
+ t.Errorf("a reader posting an update: status %d, want 404", rec.Code)
+ }
+}
+
+// An empty address is refused rather than stored, so the page cannot mint a
+// grant that matches nobody and lists as a blank row forever.
+func TestShareRefusesAnEmptyAddress(t *testing.T) {
+ app, _ := docs.NewForTest(t)
+ alice := signInCookies(t, app, "alice@example.com")
+ sqid := createDoc(t, app, alice, "Minutes")
+
+ rec := post(t, app, alice, "/d/"+sqid+"/share", "address=&role=reader")
+ if rec.Code == http.StatusSeeOther {
+ t.Error("an empty address was accepted as a share")
+ }
+}
diff --git a/internal/docs/views/share.html b/internal/docs/views/share.html
new file mode 100644
index 0000000..d8d9838
--- /dev/null
+++ b/internal/docs/views/share.html
@@ -0,0 +1,68 @@
+{{define "account"}}<form method="post" action="/signout"><button type="submit">{{.SignOutLabel}}</button></form>{{end}}
+
+{{/* The share screen. Scriptless throughout, per spec §6.2, which lists
+ sharing among the working form posts: two plain <form>s, no token
+ field (csrf.Protect reads Sec-Fetch-Site and falls back to Origin),
+ and a 303 back here afterwards so a reload never re-posts.
+
+ Bare attributes rather than the class vocabulary, per
+ TestOurTemplatesUseTheAttributeGrammar — which greps raw bytes, so
+ even naming that spelling in a comment fails it. This comment used
+ to, and did.
+
+ Each row's revoke is its own <form>, not a link: revoking is a
+ mutation and a GET that changes state is one a prefetcher can fire.
+ That is also why the address rides a hidden field rather than the
+ URL — the row already names it in text, and a URL is copied,
+ logged and pasted in a way a form body is not. */}}
+{{define "content"}}
+<article rst-stack>
+ <header rst-page-header>
+ <div rst-titles>
+ <h1>{{.Heading}}</h1>
+ <p rst-muted>{{.DocTitle}}</p>
+ </div>
+ <a href="/d/{{.Sqid}}">{{.BackLabel}}</a>
+ </header>
+
+ {{if .Error}}<p rst-notice role="alert">{{.Error}}</p>{{end}}
+
+ {{/* Who reaches it today. An empty list says so in words rather than
+ rendering an empty table, because a table with no rows reads as a
+ thing that failed to load. */}}
+ {{if .Grants}}
+ <ul rst-list docs-share-list>
+ {{range .Grants}}
+ <li rst-list-item>
+ <span docs-share-who>{{.Address}}</span>
+ <span rst-muted docs-share-role>{{.Role}}</span>
+ <form method="post" action="/d/{{$.Sqid}}/share/revoke">
+ <input type="hidden" name="address" value="{{.Address}}">
+ <button type="submit">{{$.RevokeButton}}</button>
+ </form>
+ </li>
+ {{end}}
+ </ul>
+ {{else}}
+ <p rst-muted>{{.EmptyBody}}</p>
+ {{end}}
+
+ {{/* Adding somebody. The role is a radio group rather than a select so
+ both options are visible without opening anything, and so the
+ keyboard contract is the one radios already have. Reader is
+ checked by default: the safer of the two is the one a person gets
+ by not choosing. */}}
+ <form method="post" action="/d/{{.Sqid}}/share" docs-share-form>
+ <label for="share-address">{{.AddressLabel}}</label>
+ <input id="share-address" name="address" type="email" autocomplete="email" required>
+
+ <fieldset>
+ <legend>{{.RoleLabel}}</legend>
+ <label><input type="radio" name="role" value="reader" checked> {{.RoleReader}}</label>
+ <label><input type="radio" name="role" value="writer"> {{.RoleWriter}}</label>
+ </fieldset>
+
+ <button type="submit">{{.ShareButton}}</button>
+ </form>
+</article>
+{{end}}