oficina / docs Public

Expose native Docs metadata and sharing with current session checks

Paul Campbell pushed by codex agent, operated by paul@keymail.dev 8dd011826b720900997e71551c2a36855c41522d parent d5ff04e
10 files changed, +369 −2
  • go.mod +2 −1
  • go.sum +2 −0
  • internal/docs/app.go +26 −0
  • internal/docs/handlers.go +14 −1
  • internal/docs/native.go +80 −0
  • internal/docs/native_stream_test.go +55 −0
  • internal/docs/native_test.go +162 −0
  • internal/docs/publish.go +4 −0
  • internal/docs/share.go +20 −0
  • internal/docs/stream.go +4 −0
diff --git a/go.mod b/go.mod
index a3bec91..55dcef6 100644
--- a/go.mod
+++ b/go.mod
@@ -6,11 +6,13 @@ require (
amadan.net/oficina/estilo.git v0.0.0-20260908001419-672a447da335
amadan.net/oficina/home/handoff v0.0.0-20260908102137-481d2d57f489
amadan.net/oficina/home/roster v0.0.0-20260908123429-74dda570e0cf
+ amadan.net/rastrillo/native/server v0.0.0-20260912204600-33ffe9941734
amadan.net/rastrillo/rastrillo v0.26.1-0.20260908132728-d9c28d708556
github.com/carlosframework/rastrillo v0.23.0
github.com/go-chi/chi/v5 v5.3.2
github.com/go-json-experiment/json v0.0.0-20250725192818-e39067aee2d2
github.com/google/uuid v1.6.0
+ github.com/keymaildev/signin v0.1.1
github.com/tetratelabs/wazero v1.12.0
golang.org/x/text v0.20.0
gorm.io/gorm v1.31.2
@@ -21,7 +23,6 @@ require (
github.com/dustin/go-humanize v1.0.1 // indirect
github.com/jinzhu/inflection v1.0.0 // indirect
github.com/jinzhu/now v1.1.5 // indirect
- github.com/keymaildev/signin v0.1.1 // indirect
github.com/mattn/go-isatty v0.0.20 // indirect
github.com/ncruces/go-strftime v1.0.0 // indirect
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
diff --git a/go.sum b/go.sum
index f458953..106177a 100644
--- a/go.sum
+++ b/go.sum
@@ -4,6 +4,8 @@ amadan.net/oficina/home/handoff v0.0.0-20260908102137-481d2d57f489 h1:RPKtyry/71
amadan.net/oficina/home/handoff v0.0.0-20260908102137-481d2d57f489/go.mod h1:YISpzFJ+iMgrM0rUL032OSzsAQbP4/nozvXLCCOcn1k=
amadan.net/oficina/home/roster v0.0.0-20260908123429-74dda570e0cf h1:SRKjCUgtk42WwR7VSn+V0eLNk20mfiY8dQmzdw13Sbo=
amadan.net/oficina/home/roster v0.0.0-20260908123429-74dda570e0cf/go.mod h1:gqshuXyDtFZUCYbhuZyV3Epc51pUcsLbpqAqiA5UiXg=
+amadan.net/rastrillo/native/server v0.0.0-20260912204600-33ffe9941734 h1:hI8/x1OEHCragb3bclo1X2bI6XCO1DDQ3plWcQPbI0g=
+amadan.net/rastrillo/native/server v0.0.0-20260912204600-33ffe9941734/go.mod h1:ZaaxeE278dJltdrLwmwpxUjQ5wuKzqDAVSNRet+WupY=
amadan.net/rastrillo/rastrillo v0.26.1-0.20260908075707-29e4491631bd h1:jlFSt95M1ontOg+VePU+kIJpChtWqDQAbnmlxqCnbgc=
amadan.net/rastrillo/rastrillo v0.26.1-0.20260908075707-29e4491631bd/go.mod h1:RpyHVPD0udcSfHJroY0KZE/FUjgw6Tvfo8SJm2qeZGE=
amadan.net/rastrillo/rastrillo v0.26.1-0.20260908132728-d9c28d708556 h1:/vHMeq9T+PZmUACixGkg32YEjyM8s31+bfAqqrMha0A=
diff --git a/internal/docs/app.go b/internal/docs/app.go
index 1e2e5aa..01e27e3 100644
--- a/internal/docs/app.go
+++ b/internal/docs/app.go
@@ -21,6 +21,7 @@ import (
"time"
"amadan.net/oficina/home/handoff"
+ nativeauth "amadan.net/rastrillo/native/server"
"github.com/carlosframework/docs/internal/docs/linkstore"
"github.com/carlosframework/rastrillo/db"
"github.com/carlosframework/rastrillo/mail"
@@ -481,13 +482,38 @@ func New(cfg Config) (*App, error) {
}
}
+ // Approval and long-lived streams reuse the same current admission chain.
+ h.requireCurrent = func(next http.Handler) http.Handler {
+ if store.team != nil {
+ next = store.team.admission(next)
+ } else {
+ next = requireAdmission(list, "/signin")(next)
+ }
+ if home == nil {
+ return authPlugin.RequireSession(next)
+ }
+ return home.bridge.sessions.Require(home.bridge.resolver.middleware("/signin/local")(next))
+ }
+ nativeSessions, err := sessions.New(sessions.Config{DB: d.Writer(), Origin: cfg.Origin, SigninPath: "/signin", Logger: logger})
+ if err != nil {
+ rt.Close(context.Background())
+ d.Close()
+ return nil, err
+ }
+ native := nativeauth.New(nativeSessions, cfg.Origin, "Docs", h.requireCurrent)
r := chi.NewRouter()
+ r.Use(native.Resume)
// Mounted above every route, per rastrillo SKILL.md ยง5: refuses a
// cross-origin POST/PUT/PATCH/DELETE by Sec-Fetch-Site/Origin/
// Referer evidence; GET/HEAD/OPTIONS pass untouched, so this does
// nothing to "/" or "/d/{sqid}" and everything to "/documents",
// "/signin" and "/signout".
r.Use(homeCSRF(cfg.Origin, home))
+ r.Post("/native/request", native.ServeHTTP)
+ r.Post("/native/exchange", native.ServeHTTP)
+ r.Get("/native/start", native.ServeHTTP)
+ r.Get("/native/authorize", native.ServeHTTP)
+ r.Post("/native/authorize", native.ServeHTTP)
// Unauthenticated: the sign-in page and its four handlers, and the
// static assets that page (and the shell around it) needs to
// render at all.
diff --git a/internal/docs/handlers.go b/internal/docs/handlers.go
index 5fd579c..a0b6c73 100644
--- a/internal/docs/handlers.go
+++ b/internal/docs/handlers.go
@@ -34,7 +34,8 @@ const maxUpdateBytes = 1 << 20
// instead: this app reads and writes documents through Store, never
// through a *gorm.DB of its own.
type handlers struct {
- homeEnabled bool
+ requireCurrent func(http.Handler) http.Handler
+ homeEnabled bool
render *renderer
logger *slog.Logger
@@ -243,6 +244,10 @@ func (h *handlers) member(r *http.Request) bool {
// the page โ€” rendering it would be inventing display data, not
// showing real ownership.
func (h *handlers) list(w http.ResponseWriter, r *http.Request) {
+ if wantsNative(r) {
+ h.nativeList(w, r)
+ return
+ }
// actor, not owner: this page answers "what can I reach", which
// since sharing landed is a wider question than "what is mine".
who, err := h.actor(r)
@@ -353,6 +358,10 @@ func (h *handlers) create(w http.ResponseWriter, r *http.Request) {
http.Error(w, rastrillo.T(r, "docs.error.generic"), h.internalFailureStatus())
return
}
+ if wantsNative(r) {
+ nativeJSON(w, r, 201, map[string]any{"sqid": doc.Sqid, "title": doc.Title, "guid": doc.GUID})
+ return
+ }
http.Redirect(w, r, "/d/"+doc.Sqid, http.StatusSeeOther)
}
@@ -562,6 +571,10 @@ func (h *handlers) show(w http.ResponseWriter, r *http.Request) {
return
}
+ if wantsNative(r) {
+ nativeJSON(w, r, 200, map[string]any{"sqid": doc.Sqid, "title": doc.Title, "guid": doc.GUID, "can_share": doc.OwnerID == actor.PersonID && h.member(r), "can_write": h.store.CanWrite(r.Context(), actor, doc.ID)})
+ return
+ }
html, err := h.store.HTML(r.Context(), doc)
if err != nil {
h.logger.Error("docs: document html", "sqid", sqid, "err", err)
diff --git a/internal/docs/native.go b/internal/docs/native.go
new file mode 100644
index 0000000..025d9a0
--- /dev/null
+++ b/internal/docs/native.go
@@ -0,0 +1,80 @@
+package docs
+
+import (
+ "encoding/json"
+ "github.com/carlosframework/rastrillo"
+ "net/http"
+ "strings"
+ "time"
+)
+
+func wantsNative(r *http.Request) bool {
+ return strings.Contains(r.Header.Get("Accept"), "application/json")
+}
+
+func nativeJSON(w http.ResponseWriter, r *http.Request, status int, value any) {
+ body, err := json.Marshal(value)
+ if err != nil {
+ http.Error(w, rastrillo.T(r, "docs.error.generic"), http.StatusInternalServerError)
+ return
+ }
+ w.Header().Set("Content-Type", "application/json")
+ w.Header().Set("Cache-Control", "no-store")
+ w.Header().Add("Vary", "Accept")
+ _ = writeGuardedBody(w, r, status, body)
+}
+
+func (h *handlers) nativeList(w http.ResponseWriter, r *http.Request) {
+ actor, err := h.actor(r)
+ if err != nil {
+ http.Error(w, rastrillo.T(r, "docs.error.generic"), http.StatusUnauthorized)
+ return
+ }
+ var documents []*Document
+ if query := strings.TrimSpace(r.URL.Query().Get("q")); query != "" {
+ documents, err = h.store.SearchFor(r.Context(), actor, query)
+ } else {
+ documents, err = h.store.ListFor(r.Context(), actor)
+ }
+ if err != nil {
+ http.Error(w, rastrillo.T(r, "docs.error.generic"), h.internalFailureStatus())
+ return
+ }
+ type item struct {
+ Sqid string `json:"sqid"`
+ Title string `json:"title"`
+ UpdatedAt time.Time `json:"updated_at"`
+ Shared bool `json:"shared"`
+ Thumbnail thumbView `json:"thumbnail"`
+ }
+ items := make([]item, 0, len(documents))
+ thumbnail := h.thumbnailer(r.Context())
+ for _, doc := range documents {
+ items = append(items, item{doc.Sqid, doc.Title, doc.UpdatedAt, doc.OwnerID != actor.PersonID, thumbnail(doc)})
+ }
+ nativeJSON(w, r, 200, map[string]any{"documents": items, "can_create": h.member(r)})
+}
+
+// The authentication middleware is rerun against the cookie before every
+// stream write. A context captured when the stream opened is not current proof.
+func (h *handlers) nativeStreamAllowed(r *http.Request, sqid string) bool {
+ if h.requireCurrent == nil {
+ return true
+ } // isolated handler fixtures
+ allowed := false
+ h.requireCurrent(http.HandlerFunc(func(_ http.ResponseWriter, fresh *http.Request) {
+ actor, err := h.actor(fresh)
+ if err != nil {
+ return
+ }
+ _, err = h.store.GetFor(fresh.Context(), actor, sqid)
+ allowed = err == nil
+ })).ServeHTTP(nativeDiscard{}, r)
+ return allowed
+}
+
+type nativeDiscard struct{}
+
+func (nativeDiscard) Header() http.Header { return make(http.Header) }
+func (nativeDiscard) Write(p []byte) (int, error) { return len(p), nil }
+func (nativeDiscard) WriteHeader(int) {}
diff --git a/internal/docs/native_stream_test.go b/internal/docs/native_stream_test.go
new file mode 100644
index 0000000..f760928
--- /dev/null
+++ b/internal/docs/native_stream_test.go
@@ -0,0 +1,55 @@
+package docs
+
+import (
+ "bufio"
+ "io"
+ "net/http"
+ "net/http/httptest"
+ "testing"
+ "time"
+)
+
+func TestNativeStreamStopsAfterSessionRevocation(t *testing.T) {
+ app, err := NewForTest(t)
+ if err != nil {
+ t.Fatal(err)
+ }
+ owner := app.TestPerson(t, "stream@example.com")
+ doc, err := app.Store().Create(t.Context(), owner, "Live fixture")
+ if err != nil {
+ t.Fatal(err)
+ }
+ streamDurations(t, app, 20*time.Millisecond, time.Second)
+ server := httptest.NewServer(app.Handler())
+ defer server.Close()
+ authenticated := app.TestRequest(t, "stream@example.com", "GET", "/", nil)
+ req, _ := http.NewRequest("GET", server.URL+"/d/"+doc.Sqid+"/stream", nil)
+ for _, cookie := range authenticated.Cookies() {
+ req.AddCookie(cookie)
+ }
+ response, err := server.Client().Do(req)
+ if err != nil {
+ t.Fatal(err)
+ }
+ defer response.Body.Close()
+ reader := bufio.NewReader(response.Body)
+ if name, _ := nextEvent(t, reader, time.Second); name != "update" {
+ t.Fatalf("no initial event: %s", name)
+ }
+ signout := httptest.NewRequest("POST", "/signout", nil)
+ signout.Header.Set("Sec-Fetch-Site", "same-origin")
+ for _, cookie := range authenticated.Cookies() {
+ signout.AddCookie(cookie)
+ }
+ app.Handler().ServeHTTP(httptest.NewRecorder(), signout)
+ closed := make(chan error, 1)
+ go func() { _, err := io.Copy(io.Discard, reader); closed <- err }()
+ select {
+ case err := <-closed:
+ if err != nil {
+ t.Fatalf("stream failed instead of clean revocation: %v", err)
+ }
+ case <-time.After(300 * time.Millisecond):
+ t.Fatal("revoked session retains stream until TTL")
+ }
+}
diff --git a/internal/docs/native_test.go b/internal/docs/native_test.go
new file mode 100644
index 0000000..7402a12
--- /dev/null
+++ b/internal/docs/native_test.go
@@ -0,0 +1,162 @@
+package docs_test
+
+import (
+ "bytes"
+ "crypto/sha256"
+ "encoding/base64"
+ "encoding/json"
+ "net/http"
+ "net/http/httptest"
+ "net/url"
+ "strings"
+ "testing"
+
+ "github.com/carlosframework/docs/internal/docs"
+)
+
+func TestNativeProofAndSessionRevocation(t *testing.T) {
+ app := members(t, "alice@example.com")
+ browser := signInCookies(t, app, "alice@example.com")
+ verifier := base64.RawURLEncoding.EncodeToString(bytes.Repeat([]byte{42}, 32))
+ hash := sha256.Sum256([]byte(verifier))
+ begin := nativeRequest(app, nil, "POST", "/native/request", `{"challenge":"`+base64.RawURLEncoding.EncodeToString(hash[:])+`"}`)
+ var pending struct{ ID string }
+ if begin.Code != 200 || json.Unmarshal(begin.Body.Bytes(), &pending) != nil || pending.ID == "" {
+ t.Fatalf("native begin: %d", begin.Code)
+ }
+ exchange := `{"id":"` + pending.ID + `","verifier":"` + verifier + `"}`
+ if got := nativeRequest(app, nil, "POST", "/native/exchange", exchange); got.Code != 202 || strings.Contains(got.Body.String(), `"value"`) {
+ t.Fatalf("pending leaks credential: %d", got.Code)
+ }
+ wrong := nativeRequest(app, nil, "POST", "/native/exchange", `{"id":"`+pending.ID+`","verifier":"wrong"}`)
+ if wrong.Code == 200 {
+ t.Fatal("wrong proof accepted")
+ }
+ if got := nativeRequest(app, browser, "POST", "/native/authorize?id="+pending.ID, ""); got.Code != 200 {
+ t.Fatalf("approve: %d", got.Code)
+ }
+ done := nativeRequest(app, nil, "POST", "/native/exchange", exchange)
+ var credential struct{ Name, Value string }
+ if done.Code != 200 || json.Unmarshal(done.Body.Bytes(), &credential) != nil || credential.Value == "" {
+ t.Fatalf("exchange: %d", done.Code)
+ }
+ native := []*http.Cookie{{Name: credential.Name, Value: credential.Value}}
+ if got := nativeRequest(app, native, "GET", "/", ""); got.Code != 200 {
+ t.Fatalf("native session rejected: %d", got.Code)
+ }
+ nativeRequest(app, native, "POST", "/signout", "")
+ if got := nativeRequest(app, native, "GET", "/", ""); got.Code != 303 {
+ t.Fatalf("revoked native session accepted: %d", got.Code)
+ }
+ if got := nativeRequest(app, browser, "GET", "/", ""); got.Code != 200 {
+ t.Fatalf("native signout incorrectly revoked browser: %d", got.Code)
+ }
+ request := httptest.NewRequest("POST", "/native/request", strings.NewReader(`{"challenge":"`+base64.RawURLEncoding.EncodeToString(hash[:])+`"}`))
+ request.Header.Set("Origin", "https://foreign.invalid")
+ request.Header.Set("Sec-Fetch-Site", "cross-site")
+ recorder := httptest.NewRecorder()
+ app.Handler().ServeHTTP(recorder, request)
+ if recorder.Code != 403 {
+ t.Fatalf("cross-origin native begin: %d", recorder.Code)
+ }
+}
+
+func nativeRequest(app *docs.App, cookies []*http.Cookie, method, path, form string) *httptest.ResponseRecorder {
+ r := httptest.NewRequest(method, path, strings.NewReader(form))
+ r.Header.Set("Accept", "application/json")
+ r.Header.Set("Content-Type", "application/x-www-form-urlencoded")
+ if strings.HasPrefix(form, "{") {
+ r.Header.Set("Content-Type", "application/json")
+ }
+ r.Header.Set("Sec-Fetch-Site", "same-origin")
+ for _, cookie := range cookies {
+ r.AddCookie(cookie)
+ }
+ w := httptest.NewRecorder()
+ app.Handler().ServeHTTP(w, r)
+ return w
+}
+
+func TestNativeMetadataPreservesPermissions(t *testing.T) {
+ app := members(t, "alice@example.com,bob@example.com")
+ alice := signInCookies(t, app, "alice@example.com")
+ bob := signInCookies(t, app, "bob@example.com")
+ created := nativeRequest(app, alice, "POST", "/documents", "title=Native+minutes")
+ if created.Code != 201 {
+ t.Fatalf("create: %d %s", created.Code, created.Body.String())
+ }
+ var document struct{ Sqid, Title, GUID string }
+ if err := json.Unmarshal(created.Body.Bytes(), &document); err != nil || document.Sqid == "" || document.GUID == "" {
+ t.Fatalf("missing native identity: %s", created.Body.String())
+ }
+ path := "/d/" + document.Sqid
+ if got := nativeRequest(app, bob, "GET", path, ""); got.Code != 404 {
+ t.Fatalf("foreign document: %d", got.Code)
+ }
+ grant := nativeRequest(app, alice, "POST", path+"/share", "address=bob%40example.com&role=reader")
+ if grant.Code != 200 {
+ t.Fatalf("grant: %d", grant.Code)
+ }
+ got := nativeRequest(app, bob, "GET", path, "")
+ var detail struct {
+ CanWrite bool `json:"can_write"`
+ CanShare bool `json:"can_share"`
+ GUID string `json:"guid"`
+ }
+ if got.Code != 200 || json.Unmarshal(got.Body.Bytes(), &detail) != nil || detail.GUID == "" || detail.CanWrite || detail.CanShare {
+ t.Fatalf("reader metadata: %d %s", got.Code, got.Body.String())
+ }
+ if got.Header().Get("Cache-Control") != "no-store" {
+ t.Fatal("native content is cacheable")
+ }
+ if got := nativeRequest(app, bob, "GET", path+"/share", ""); got.Code != 404 {
+ t.Fatalf("reader sees sharing: %d", got.Code)
+ }
+ listing := nativeRequest(app, bob, "GET", "/?q=Native", "")
+ if listing.Code != 200 || !strings.Contains(listing.Body.String(), document.Sqid) {
+ t.Fatalf("shared search missing: %s", listing.Body.String())
+ }
+ revoked := nativeRequest(app, alice, "POST", path+"/share/revoke", "address=bob%40example.com")
+ if revoked.Code != 200 {
+ t.Fatalf("revoke: %d", revoked.Code)
+ }
+ if got := nativeRequest(app, bob, "GET", path, ""); got.Code != 404 {
+ t.Fatalf("revoked reader: %d", got.Code)
+ }
+ listing = nativeRequest(app, bob, "GET", "/", "")
+ if strings.Contains(listing.Body.String(), document.Sqid) {
+ t.Fatal("revoked document remains in native list")
+ }
+}
+
+func TestNativePublicLinkShownOnceAndAnonymousRefused(t *testing.T) {
+ app := members(t, "alice@example.com")
+ alice := signInCookies(t, app, "alice@example.com")
+ sqid := createDoc(t, app, alice, "Published native")
+ path := "/d/" + sqid + "/share"
+ pub := nativeRequest(app, alice, "POST", path+"/link", "on=1")
+ var result struct {
+ Published bool `json:"published"`
+ URL string `json:"public_url"`
+ }
+ if pub.Code != 200 || json.Unmarshal(pub.Body.Bytes(), &result) != nil || !result.Published || result.URL == "" {
+ t.Fatalf("publish: %d %s", pub.Code, pub.Body.String())
+ }
+ if pub.Header().Get("Cache-Control") != "no-store" {
+ t.Fatal("public token response cacheable")
+ }
+ url, err := url.Parse(result.URL)
+ if err != nil || !strings.HasPrefix(url.Path, "/p/") {
+ t.Fatal("invalid public link")
+ }
+ next := nativeRequest(app, alice, "GET", path, "")
+ if next.Code != 200 || strings.Contains(next.Body.String(), result.URL) {
+ t.Fatal("public token returned twice")
+ }
+ if got := nativeRequest(app, nil, "GET", "/", ""); got.Code != 303 {
+ t.Fatalf("anonymous native listing: %d", got.Code)
+ }
+ if got := nativeRequest(app, nil, "POST", "/documents", "title=No"); got.Code != 403 {
+ t.Fatalf("anonymous native creation: %d", got.Code)
+ }
+}
diff --git a/internal/docs/publish.go b/internal/docs/publish.go
index 3a4ea8d..361a31a 100644
--- a/internal/docs/publish.go
+++ b/internal/docs/publish.go
@@ -124,6 +124,10 @@ func (h *handlers) shareLink(w http.ResponseWriter, r *http.Request) {
}
switch {
case err == nil:
+ if wantsNative(r) {
+ nativeJSON(w, r, 200, map[string]any{"published": on, "public_url": h.oneShots.take(oneShotKey(owner, sqid))})
+ return
+ }
http.Redirect(w, r, "/d/"+sqid+"/share", http.StatusSeeOther)
case errors.Is(err, gorm.ErrRecordNotFound):
http.NotFound(w, r)
diff --git a/internal/docs/share.go b/internal/docs/share.go
index 6bd3f01..35554f0 100644
--- a/internal/docs/share.go
+++ b/internal/docs/share.go
@@ -108,6 +108,18 @@ func (h *handlers) renderShare(w http.ResponseWriter, r *http.Request, doc *Docu
// without it, exactly as a visit tomorrow would.
publicURL := h.oneShots.take(oneShotKey(doc.OwnerID, doc.Sqid))
+ if wantsNative(r) {
+ type grant struct {
+ Address string `json:"address"`
+ Role Role `json:"role"`
+ }
+ rows := make([]grant, 0, len(grants))
+ for _, g := range grants {
+ rows = append(rows, grant{g.Subject, g.Role})
+ }
+ nativeJSON(w, r, status, map[string]any{"sqid": doc.Sqid, "title": doc.Title, "grants": rows, "published": published, "public_url": publicURL, "error": errKey})
+ return
+ }
rows := make([]shareRow, 0, len(grants))
for _, g := range grants {
label := "docs.share.role.reader"
@@ -196,6 +208,10 @@ func (h *handlers) sharePost(w http.ResponseWriter, r *http.Request) {
err = h.store.Grant(r.Context(), owner, sqid, r.PostFormValue("address"), role)
switch {
case err == nil:
+ if wantsNative(r) {
+ nativeJSON(w, r, 200, map[string]bool{"ok": true})
+ return
+ }
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
@@ -239,6 +255,10 @@ func (h *handlers) shareRevoke(w http.ResponseWriter, r *http.Request) {
http.Error(w, rastrillo.T(r, "docs.error.generic"), h.internalFailureStatus())
return
}
+ if wantsNative(r) {
+ nativeJSON(w, r, 200, map[string]bool{"ok": true})
+ return
+ }
http.Redirect(w, r, "/d/"+sqid+"/share", http.StatusSeeOther)
}
diff --git a/internal/docs/stream.go b/internal/docs/stream.go
index 9469f18..abdc6c5 100644
--- a/internal/docs/stream.go
+++ b/internal/docs/stream.go
@@ -141,6 +141,10 @@ func (h *handlers) stream(w http.ResponseWriter, r *http.Request) {
// unbounded โ€” while a write error ends the handler, because it
// means the client is gone.
write := func(text string) bool {
+ // Team streams already revalidate through their original bounded lease.
+ if guard == nil && !h.nativeStreamAllowed(r, doc.Sqid) {
+ return false
+ }
limit := defaultStreamWriteDeadline
if guard != nil {
var ok bool