oficina / docs Public

Mount TeamMode identity and roster standing with guarded session reads

Paul Campbell pushed by paul@keymail.dev cd4921fdae334fe2576b41f5100679254e02d02a parent 07df446
20 files changed, +616 −28
  • cmd/docs/main.go +9 −0
  • fixtures/jump-to.mjs +2 −1
  • go.mod +1 −1
  • go.sum +2 −0
  • internal/docs/app.go +51 −4
  • internal/docs/auth.go +1 −1
  • internal/docs/handlers.go +3 −1
  • internal/docs/home_router_integration_test.go +5 −1
  • internal/docs/render.go +1 −0
  • internal/docs/search.go +5 −0
  • internal/docs/stream.go +24 −7
  • internal/docs/team_access.go +27 −0
  • internal/docs/team_config.go +6 −2
  • internal/docs/team_emission.go +10 −4
  • internal/docs/team_router_test.go +327 −0
  • internal/docs/team_session_reader.go +27 −0
  • internal/docs/team_session_reader_test.go +104 −0
  • internal/docs/team_stream.go +9 −5
  • internal/docs/views/list.html +1 −0
  • internal/docs/views/partials/jumpto.html +1 −1
diff --git a/cmd/docs/main.go b/cmd/docs/main.go
index 9d27011..e640273 100644
--- a/cmd/docs/main.go
+++ b/cmd/docs/main.go
@@ -284,6 +284,15 @@ func main() {
if home.Issuer != "" || home.ConsumerID != "" || home.KeysBase64 != "" || home.DEKBase64 != "" {
cfg.Home = &home
}
+
+ team := docs.TeamConfig{TeamID: os.Getenv("DOCS_TEAM_ID"), DirectoryOrigin: os.Getenv("DOCS_TEAM_DIRECTORY_ORIGIN"), ProvisioningBase64: os.Getenv("DOCS_TEAM_PROVISIONING_BASE64"), OperatorKeysBase64: os.Getenv("DOCS_TEAM_OPERATOR_KEYS_BASE64"), RosterToken: os.Getenv("DOCS_TEAM_ROSTER_TOKEN")}
+ if team.TeamID != "" || team.DirectoryOrigin != "" || team.ProvisioningBase64 != "" || team.OperatorKeysBase64 != "" || team.RosterToken != "" {
+ cfg.Team = &team
+ if seedFlag {
+ logger.Error("docs: seed unavailable in TeamMode")
+ os.Exit(1)
+ }
+ }
app, err := docs.New(cfg)
if err != nil {
logger.Error("docs: new", "err", err)
diff --git a/fixtures/jump-to.mjs b/fixtures/jump-to.mjs
index ed90bde..414c095 100644
--- a/fixtures/jump-to.mjs
+++ b/fixtures/jump-to.mjs
@@ -56,6 +56,7 @@ export function createJumpTo(root) {
const copy = {
empty: status.getAttribute('data-empty') || '',
failed: status.getAttribute('data-failed') || '',
+ partial: status.getAttribute('data-partial') || '',
}
let open = false
@@ -178,7 +179,7 @@ export function createJumpTo(root) {
// palette closed, must not overwrite what is on screen.
if (mine !== sequence || !open) return
render(rows)
- setStatus(rows.length === 0 ? copy.empty : '')
+ setStatus(res.headers?.get('Docs-Catalog-Partial') === '1' ? copy.partial : (rows.length === 0 ? copy.empty : ''))
} catch (err) {
if (mine !== sequence || !open) return
// Said out loud rather than left as an empty list: "no results"
diff --git a/go.mod b/go.mod
index 7d75590..a3bec91 100644
--- a/go.mod
+++ b/go.mod
@@ -6,7 +6,7 @@ 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/rastrillo v0.26.1-0.20260908075707-29e4491631bd
+ 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
diff --git a/go.sum b/go.sum
index b43fed0..f458953 100644
--- a/go.sum
+++ b/go.sum
@@ -6,6 +6,8 @@ amadan.net/oficina/home/roster v0.0.0-20260908123429-74dda570e0cf h1:SRKjCUgtk42
amadan.net/oficina/home/roster v0.0.0-20260908123429-74dda570e0cf/go.mod h1:gqshuXyDtFZUCYbhuZyV3Epc51pUcsLbpqAqiA5UiXg=
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=
+amadan.net/rastrillo/rastrillo v0.26.1-0.20260908132728-d9c28d708556/go.mod h1:RpyHVPD0udcSfHJroY0KZE/FUjgw6Tvfo8SJm2qeZGE=
github.com/carlosframework/rastrillo v0.23.0 h1:tKAAlwPt4Pg/rkqk7o6jvV12bbZ0r34oEVpuOlKfX7w=
github.com/carlosframework/rastrillo v0.23.0/go.mod h1:pZlrE5F5OhspvZFzef0DrfjHAjA8FXycb9J5/8vc8Tw=
github.com/chromedp/cdproto v0.0.0-20250724212937-08a3db8b4327 h1:UQ4AU+BGti3Sy/aLU8KVseYKNALcX9UXY6DfpwQ6J8E=
diff --git a/internal/docs/app.go b/internal/docs/app.go
index b96c866..26378fe 100644
--- a/internal/docs/app.go
+++ b/internal/docs/app.go
@@ -29,11 +29,13 @@ import (
"github.com/go-chi/chi/v5"
"github.com/carlosframework/docs/internal/core"
+ "github.com/carlosframework/docs/internal/docs/teamstate"
)
// Config configures New.
type Config struct {
Home *HomeConfig
+ Team *TeamConfig
// DBPath is the SQLite file New opens via rastrillo's db package.
DBPath string
@@ -351,6 +353,10 @@ func New(cfg Config) (*App, error) {
if err != nil {
return nil, err
}
+ teamSettings, err := validatedTeamConfig(cfg.Team, homeSettings, cfg.Origin)
+ if err != nil {
+ return nil, err
+ }
instanceKey, err := resolveInstanceKey(cfg)
if err != nil {
return nil, err
@@ -379,7 +385,16 @@ func New(cfg Config) (*App, error) {
return nil, fmt.Errorf("docs: open database: %w", err)
}
- if _, err := migrate.Apply(context.Background(), d, BootSchema); err != nil {
+ required, activated, err := teamstate.Required(context.Background(), d.Writer())
+ if err != nil || activated && (teamSettings == nil || required != teamSettings.fingerprint) {
+ d.Close()
+ return nil, errTeamConfig
+ }
+ schema := BootSchema
+ if teamSettings != nil {
+ schema = TeamBootSchema
+ }
+ if _, err := migrate.Apply(context.Background(), d, schema); err != nil {
d.Close()
return nil, fmt.Errorf("docs: apply migrations: %w", err)
}
@@ -417,6 +432,14 @@ func New(cfg Config) (*App, error) {
// The sample library is for members. A guest admitted by a grant is
// visiting, not moving in.
store.seedOnlyMembers = list.configured
+ if teamSettings != nil {
+ store.team, err = newTeamRuntime(context.Background(), teamSettings, d.Writer(), homeSettings)
+ if err != nil {
+ rt.Close(context.Background())
+ d.Close()
+ return nil, err
+ }
+ }
list.granted = func(address string) (bool, error) {
// A short deadline rather than context.Background(): this runs on
@@ -464,6 +487,17 @@ func New(cfg Config) (*App, error) {
return nil, err
}
h.homeEnabled = true
+ if store.team != nil {
+ store.team.bridge = home.bridge
+ home.bridge.resolver.team = store.team
+ store.team.readSession, err = newTeamSessionReader(d.Writer(), cfg.Origin, home.bridge.sessionCookie, logger)
+ if err != nil {
+ rt.Close(context.Background())
+ d.Close()
+ return nil, err
+ }
+ }
+
}
r := chi.NewRouter()
// Mounted above every route, per rastrillo SKILL.md §5: refuses a
@@ -515,6 +549,15 @@ func New(cfg Config) (*App, error) {
// SKILL.md §5/§1's "signed-in routes... in a Group with
// sess.Require", here auth's own RequireSession. A signed-out
// GET/HEAD redirects to /signin; anything else 403s.
+
+ if store.team != nil {
+ r.Group(func(r chi.Router) {
+ r.Use(home.bridge.sessions.Require)
+ r.Use(home.bridge.resolver.middleware("/signin/local"))
+ r.Use(requireTeamAccountAdmission(list))
+ home.mountAccount(r)
+ })
+ }
r.Group(func(r chi.Router) {
if home == nil {
r.Use(authPlugin.RequireSession)
@@ -525,9 +568,13 @@ func New(cfg Config) (*App, error) {
// Admission is re-asked per request, not only at sign-in, so
// revoking a guest's last grant ends their access now rather
// than whenever their 30-day session happens to expire.
- r.Use(requireAdmission(list, "/signin"))
- if home != nil {
- home.mountAccount(r)
+ if store.team != nil {
+ r.Use(store.team.admission)
+ } else {
+ r.Use(requireAdmission(list, "/signin"))
+ if home != nil {
+ home.mountAccount(r)
+ }
}
r.Get("/", h.list)
r.Post("/documents", h.create)
diff --git a/internal/docs/auth.go b/internal/docs/auth.go
index 111afb5..a9bf757 100644
--- a/internal/docs/auth.go
+++ b/internal/docs/auth.go
@@ -281,7 +281,7 @@ func resolveAllowlist(cfg Config) (allowlist, error) {
}
addrs[addr] = struct{}{}
}
- if len(addrs) > 0 {
+ if len(addrs) > 0 || cfg.Team != nil {
return allowlist{addrs: addrs}, nil
}
if cfg.Env == "development" {
diff --git a/internal/docs/handlers.go b/internal/docs/handlers.go
index 24d9e92..5fd579c 100644
--- a/internal/docs/handlers.go
+++ b/internal/docs/handlers.go
@@ -112,7 +112,8 @@ type handlers struct {
// (rastrillo/auth.DefaultSessionTTL); a page with no way to end one
// on purpose is a real gap on something public, not a cosmetic one.
type listPage struct {
- HomeLinkLabel string
+ PartialCatalog string
+ HomeLinkLabel string
// CanCreate gates the create form in the list header. A guest is
// answered 403 by POST /documents, and a form whose only outcome is a
@@ -259,6 +260,7 @@ func (h *handlers) list(w http.ResponseWriter, r *http.Request) {
query := strings.TrimSpace(r.URL.Query().Get("q"))
data := listPage{
+ PartialCatalog: h.partialCatalog(r),
HomeLinkLabel: h.homeLinkLabel(r),
CanCreate: h.member(r),
Title: rastrillo.T(r, "docs.list.title"),
diff --git a/internal/docs/home_router_integration_test.go b/internal/docs/home_router_integration_test.go
index 04d5430..a7bd9be 100644
--- a/internal/docs/home_router_integration_test.go
+++ b/internal/docs/home_router_integration_test.go
@@ -70,7 +70,11 @@ func (b *routerBrowser) request(method, path, body string) *http.Request {
}
func (b *routerBrowser) serve(r *http.Request) *httptest.ResponseRecorder {
rec := httptest.NewRecorder()
- b.f.app.Handler().ServeHTTP(rec, r)
+ var w http.ResponseWriter = rec
+ if b.f.app.store.team != nil {
+ w = &teamDeadlineRecorder{ResponseRecorder: rec}
+ }
+ b.f.app.Handler().ServeHTTP(w, r)
for _, c := range rec.Result().Cookies() {
if c.MaxAge < 0 {
delete(b.cookies, c.Name)
diff --git a/internal/docs/render.go b/internal/docs/render.go
index 59643dd..8122c49 100644
--- a/internal/docs/render.go
+++ b/internal/docs/render.go
@@ -187,6 +187,7 @@ var Catalog = rastrillo.Catalog{
"docs.jumpto.placeholder": "Search documents",
"docs.jumpto.hint": "Type to search. Up and down to choose, Enter to open, Escape to close.",
"docs.jumpto.empty": "No documents match",
+ "docs.team.partial": "We couldn’t check your team access. Only documents shared with you are shown.",
"docs.jumpto.failed": "Search is unavailable right now.",
"docs.jumpto.close": "Close",
diff --git a/internal/docs/search.go b/internal/docs/search.go
index 5046f96..f0203ac 100644
--- a/internal/docs/search.go
+++ b/internal/docs/search.go
@@ -243,6 +243,9 @@ func (h *handlers) searchJSON(w http.ResponseWriter, r *http.Request) {
return
}
+ if h.partialCatalog(r) != "" {
+ w.Header().Set("Docs-Catalog-Partial", "1")
+ }
w.Header().Set("Content-Type", "application/json; charset=utf-8")
// A person's own document titles, and they change as they type.
w.Header().Set("Cache-Control", "no-store")
@@ -255,6 +258,7 @@ func (h *handlers) searchJSON(w http.ResponseWriter, r *http.Request) {
// request. Both the list and a document carry it, so it lives here
// with the rest of search rather than on either page's own view data.
type jumpToView struct {
+ Partial string
Label string
Placeholder string
Hint string
@@ -265,6 +269,7 @@ type jumpToView struct {
func newJumpToView(r *http.Request) jumpToView {
return jumpToView{
+ Partial: rastrillo.T(r, "docs.team.partial"),
Label: rastrillo.T(r, "docs.jumpto.label"),
Placeholder: rastrillo.T(r, "docs.jumpto.placeholder"),
Hint: rastrillo.T(r, "docs.jumpto.hint"),
diff --git a/internal/docs/stream.go b/internal/docs/stream.go
index bb94a60..9469f18 100644
--- a/internal/docs/stream.go
+++ b/internal/docs/stream.go
@@ -1,6 +1,7 @@
package docs
import (
+ "context"
"io"
"net/http"
"strconv"
@@ -80,13 +81,6 @@ func (h *handlers) stream(w http.ResponseWriter, r *http.Request) {
ch, stop := h.hub.subscribe(doc.ID)
defer stop()
- current, err := h.store.currentSeq(r.Context(), doc.ID)
- if err != nil {
- h.logger.Error("docs: stream: read current seq", "sqid", doc.Sqid, "err", err)
- http.Error(w, rastrillo.T(r, "docs.error.generic"), h.internalFailureStatus())
- return
- }
-
var guard *teamStreamGuard
var leaseTimer *time.Timer
var leaseDeadline <-chan time.Time
@@ -97,6 +91,9 @@ func (h *handlers) stream(w http.ResponseWriter, r *http.Request) {
return
}
guard = &teamStreamGuard{store: h.store, access: a, request: r, docID: doc.ID}
+ if a.decision.State == roster.Member {
+ guard.deadline = time.Now().Add(a.decision.Lease.Remaining())
+ }
if _, ok := guard.check(); !ok {
http.Error(w, rastrillo.T(r, "docs.error.generic"), http.StatusServiceUnavailable)
return
@@ -112,7 +109,27 @@ func (h *handlers) stream(w http.ResponseWriter, r *http.Request) {
leaseDeadline = leaseTimer.C
}
}
+ seqContext := r.Context()
+ if guard != nil {
+ var cancel context.CancelFunc
+ seqContext, cancel = context.WithTimeout(seqContext, min(guard.remaining(), defaultStreamWriteDeadline))
+ defer cancel()
+ }
+ current, err := h.store.currentSeq(seqContext, doc.ID)
+ if err != nil {
+ h.logger.Error("docs: stream: read current seq", "sqid", doc.Sqid, "err", err)
+ http.Error(w, rastrillo.T(r, "docs.error.generic"), h.internalFailureStatus())
+ return
+ }
+
rc := http.NewResponseController(w)
+ if guard != nil {
+ limit, ok := guard.check()
+ if !ok || rc.SetWriteDeadline(time.Now().Add(limit)) != nil {
+ http.Error(w, rastrillo.T(r, "docs.error.generic"), http.StatusServiceUnavailable)
+ return
+ }
+ }
w.Header().Set("Content-Type", "text/event-stream")
w.Header().Set("Cache-Control", "no-store")
w.WriteHeader(http.StatusOK)
diff --git a/internal/docs/team_access.go b/internal/docs/team_access.go
index e01ed79..cb427fe 100644
--- a/internal/docs/team_access.go
+++ b/internal/docs/team_access.go
@@ -219,3 +219,30 @@ func (h *handlers) requireMemberRequest(w http.ResponseWriter, r *http.Request,
http.Error(w, rastrillo.T(r, key), code)
return false
}
+
+func (h *handlers) partialCatalog(r *http.Request) string {
+ if h.store.team == nil {
+ return ""
+ }
+ a, ok := r.Context().Value(accessContextKey{}).(*requestAccess)
+ if !ok || a == nil || a.runtime != h.store.team || a.state() == roster.Unknown {
+ return rastrillo.T(r, "docs.team.partial")
+ }
+ return ""
+}
+
+// The account group contains only local reauthentication and explicit linking.
+// A known addressless Home account must be able to start that local gesture.
+func requireTeamAccountAdmission(list allowlist) func(http.Handler) http.Handler {
+ return func(next http.Handler) http.Handler {
+ local := requireAdmission(list, "/signin/local")(next)
+ return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ id, ok := currentIdentity(r)
+ if ok && id.Actor.VerifiedAddress == nil && id.Session.Method == homeAssertionMethod {
+ next.ServeHTTP(w, r)
+ return
+ }
+ local.ServeHTTP(w, r)
+ })
+ }
+}
diff --git a/internal/docs/team_config.go b/internal/docs/team_config.go
index 3e3bda6..4df74a3 100644
--- a/internal/docs/team_config.go
+++ b/internal/docs/team_config.go
@@ -19,6 +19,8 @@ import (
// TeamConfig is explicit deployment configuration, never browser request input.
type TeamConfig struct {
+ transport http.RoundTripper
+ clock roster.Clock
TeamID string
DirectoryOrigin string
ProvisioningBase64 string
@@ -30,6 +32,8 @@ var errTeamConfig = errors.New("docs: invalid team configuration")
var errTeamUnavailable = errors.New("docs: team authority unavailable")
type teamSettings struct {
+ transport http.RoundTripper
+ clock roster.Clock
record roster.VerifiedRecord
token string
fingerprint string
@@ -66,7 +70,7 @@ func validatedTeamConfig(cfg *TeamConfig, home *homeSettings, origin string) (*t
return nil, errTeamConfig
}
digest := sha256.Sum256(b)
- return &teamSettings{record: record, token: cfg.RosterToken, fingerprint: base64.RawURLEncoding.EncodeToString(digest[:])}, nil
+ return &teamSettings{transport: cfg.transport, clock: cfg.clock, record: record, token: cfg.RosterToken, fingerprint: base64.RawURLEncoding.EncodeToString(digest[:])}, nil
}
type teamRuntime struct {
@@ -97,7 +101,7 @@ func newTeamRuntime(ctx context.Context, settings *teamSettings, db *sql.DB, hom
if e != nil {
return nil, errTeamConfig
}
- cache, e := roster.New(ctx, roster.Config{Record: settings.record, Token: settings.token, Durable: teamstate.Evidence{DB: db}, Sealer: sealer})
+ cache, e := roster.New(ctx, roster.Config{Record: settings.record, Token: settings.token, Transport: settings.transport, Clock: settings.clock, Durable: teamstate.Evidence{DB: db}, Sealer: sealer})
if e != nil {
return nil, errTeamUnavailable
}
diff --git a/internal/docs/team_emission.go b/internal/docs/team_emission.go
index 36c1ae2..e7f209d 100644
--- a/internal/docs/team_emission.go
+++ b/internal/docs/team_emission.go
@@ -75,9 +75,6 @@ func writeGuardedBody(w http.ResponseWriter, r *http.Request, status int, parts
}
controller := http.NewResponseController(w)
prepare := func() error {
- if e := a.checkResponse(r.Context()); e != nil {
- return e
- }
remaining := defaultStreamWriteDeadline
if a.state() == roster.Member {
lease := a.decision.Lease.Remaining()
@@ -88,7 +85,16 @@ func writeGuardedBody(w http.ResponseWriter, r *http.Request, status int, parts
remaining = lease
}
}
- if e := controller.SetWriteDeadline(time.Now().Add(remaining)); e != nil {
+ deadline := time.Now().Add(remaining)
+ ctx, cancel := context.WithDeadline(r.Context(), deadline)
+ defer cancel()
+ if e := a.checkResponse(ctx); e != nil {
+ return e
+ }
+ if ctx.Err() != nil {
+ return errTeamUnavailable
+ }
+ if e := controller.SetWriteDeadline(deadline); e != nil {
return errTeamUnavailable
}
return nil
diff --git a/internal/docs/team_router_test.go b/internal/docs/team_router_test.go
new file mode 100644
index 0000000..154eafc
--- /dev/null
+++ b/internal/docs/team_router_test.go
@@ -0,0 +1,327 @@
+package docs
+
+import (
+ "bytes"
+ "context"
+ "crypto/sha256"
+ "encoding/base64"
+ "encoding/hex"
+ "encoding/json"
+ "errors"
+ "io"
+ "log/slog"
+ "net/http"
+ "net/url"
+ "path/filepath"
+ "strings"
+ "sync"
+ "testing"
+ "time"
+
+ "amadan.net/oficina/home/roster"
+ family "amadan.net/rastrillo/rastrillo/crypto"
+ "github.com/carlosframework/docs/internal/docs/principalstore"
+ "github.com/carlosframework/rastrillo/sessions"
+)
+
+type teamRouterDirectory struct {
+ mu sync.Mutex
+ key *family.Keypair
+ bindings roster.Bindings
+ generation string
+ epoch int64
+ members []roster.MemberEntry
+ fail bool
+}
+
+func (d *teamRouterDirectory) RoundTrip(r *http.Request) (*http.Response, error) {
+ d.mu.Lock()
+ defer d.mu.Unlock()
+ if d.fail {
+ return nil, errors.New("directory unavailable")
+ }
+ q := r.URL.Query()
+ if r.URL.Scheme+"://"+r.URL.Host != d.bindings.Directory || r.URL.Path != "/roster" || q.Get("team") != d.bindings.Team || !roster.ValidChallenge(q.Get("fetch")) || q.Get("cursor") != "" || r.Header.Get("Authorization") != "Bearer "+base64.RawURLEncoding.EncodeToString(bytes.Repeat([]byte{7}, 32)) {
+ return nil, errors.New("wrong roster request scope")
+ }
+ index, complete := 0, true
+ members := append([]roster.MemberEntry{}, d.members...)
+ p := roster.Page{Version: 1, Type: "page", Directory: d.bindings.Directory, Consumer: d.bindings.Consumer, Team: d.bindings.Team, Audience: d.bindings.Audience, Fetch: q.Get("fetch"), RequestCursorHash: roster.CursorHash(""), Generation: d.generation, Epoch: d.epoch, Index: &index, Members: &members, Complete: &complete}
+ raw, e := roster.SignPage(d.key, "roster1", p)
+ if e != nil {
+ return nil, e
+ }
+ return &http.Response{StatusCode: 200, Header: http.Header{"Content-Type": {"application/json"}}, Body: io.NopCloser(bytes.NewReader(raw))}, nil
+}
+func (d *teamRouterDirectory) change(members []roster.MemberEntry, fail bool) {
+ d.mu.Lock()
+ defer d.mu.Unlock()
+ d.members = members
+ d.epoch++
+ d.fail = fail
+}
+
+type teamRouterFixture struct {
+ *routerHomeFixture
+ directory *teamRouterDirectory
+ clock *teamTestClock
+ cfg Config
+ alice, bob int64
+ own, shared, hidden *Document
+}
+
+func newTeamRouterFixture(t *testing.T) *teamRouterFixture {
+ t.Helper()
+ ctx := t.Context()
+ credential, e := family.Generate()
+ if e != nil {
+ t.Fatal(e)
+ }
+ digest := sha256.Sum256(credential.SignPub())
+ kid := hex.EncodeToString(digest[:])
+ manifest, _ := json.Marshal(map[string]string{kid: base64.StdEncoding.EncodeToString(credential.SignPub())})
+ home := HomeConfig{Issuer: bridgeIssuer, ConsumerID: "router-docs", KeysBase64: base64.StdEncoding.EncodeToString(manifest), DEKBase64: base64.StdEncoding.EncodeToString(bytes.Repeat([]byte{83}, 32))}
+ cfg := Config{DBPath: filepath.Join(t.TempDir(), "team.db"), Env: "development", Origin: bridgeOrigin, Home: &home, AllowedAddresses: "alice@example.com,bob@example.com", SampleDocuments: "0", Mailer: navigationMail{}, Logger: slog.New(slog.NewTextHandler(io.Discard, nil))}
+ app, e := New(cfg)
+ if e != nil {
+ t.Fatal(e)
+ }
+ f := &teamRouterFixture{cfg: cfg, clock: &teamTestClock{}}
+ f.alice, e = app.store.EnsurePerson(ctx, "alice@example.com")
+ if e != nil {
+ t.Fatal(e)
+ }
+ f.bob, e = app.store.EnsurePerson(ctx, "bob@example.com")
+ if e != nil {
+ t.Fatal(e)
+ }
+ f.own, e = app.store.Create(ctx, f.alice, "Alice original")
+ if e != nil {
+ t.Fatal(e)
+ }
+ f.shared, e = app.store.Create(ctx, f.bob, "Bob shared")
+ if e != nil {
+ t.Fatal(e)
+ }
+ f.hidden, e = app.store.Create(ctx, f.bob, "Bob private")
+ if e != nil {
+ t.Fatal(e)
+ }
+ if e = app.store.Grant(ctx, f.bob, f.shared.Sqid, "alice@example.com", RoleWriter); e != nil {
+ t.Fatal(e)
+ }
+ for _, d := range []*Document{f.own, f.shared, f.hidden} {
+ address := "alice@example.com"
+ if d.OwnerID == f.bob {
+ address = "bob@example.com"
+ }
+ if _, e = app.store.ApplyUpdate(ctx, d, localActor(d.OwnerID, address), validUpdate(t)); e != nil {
+ t.Fatal(e)
+ }
+ }
+ if e = app.Close(); e != nil {
+ t.Fatal(e)
+ }
+ operator, e := family.Generate()
+ if e != nil {
+ t.Fatal(e)
+ }
+ signer, e := family.Generate()
+ if e != nil {
+ t.Fatal(e)
+ }
+ fixed := func(v byte) string { return base64.RawURLEncoding.EncodeToString(bytes.Repeat([]byte{v}, 32)) }
+ binding := roster.Bindings{Issuer: bridgeIssuer, Directory: "https://home.example", Consumer: "router-docs", App: "docs", Team: "TeamA", Audience: bridgeOrigin}
+ directory := &teamRouterDirectory{key: signer, bindings: binding, generation: fixed(1), epoch: 1, members: []roster.MemberEntry{{Subject: teamSubject('a'), Role: "member"}, {Subject: teamSubject('b'), Role: "member"}, {Subject: teamSubject('c'), Role: "member"}}}
+ record := roster.Provisioning{Version: 1, Operation: "team-provision", Sequence: 1, Bindings: binding, Generation: directory.generation, MinEpoch: 1, Keys: []roster.Key{{ID: "roster1", Public: base64.RawURLEncoding.EncodeToString(signer.SignPub())}}}
+ wire, e := roster.SignProvisioning(operator, "operator1", record)
+ if e != nil {
+ t.Fatal(e)
+ }
+ keys, _ := json.Marshal([]roster.Key{{ID: "operator1", Public: base64.RawURLEncoding.EncodeToString(operator.SignPub())}})
+ cfg.Team = &TeamConfig{TeamID: binding.Team, DirectoryOrigin: binding.Directory, ProvisioningBase64: base64.StdEncoding.EncodeToString(wire), OperatorKeysBase64: base64.StdEncoding.EncodeToString(keys), RosterToken: fixed(7), clock: f.clock, transport: directory}
+ app, e = New(cfg)
+ if e != nil {
+ t.Fatal(e)
+ }
+ t.Cleanup(func() {
+ if e := app.Close(); e != nil {
+ t.Error(e)
+ }
+ })
+ sessionStore, e := sessions.New(sessions.Config{DB: app.db.Writer(), Origin: bridgeOrigin})
+ if e != nil {
+ t.Fatal(e)
+ }
+ f.routerHomeFixture = &routerHomeFixture{app: app, key: credential, keyID: kid, sessions: sessionStore}
+ f.directory = directory
+ f.cfg = cfg
+ return f
+}
+func teamSubject(c byte) string { return "pr_" + strings.Repeat(string(c), 26) }
+func (b *routerBrowser) update(doc *Document, body []byte) int {
+ r := b.request("POST", "/d/"+doc.Sqid+"/updates", string(body))
+ r.Header.Set("Content-Type", "application/octet-stream")
+ return b.serve(r).Code
+}
+func TestTeamRouterLocalLinkMembershipRemovalAndIndependentGrant(t *testing.T) {
+ f := newTeamRouterFixture(t)
+ b := f.browser()
+ b.localSignin(t, "alice@example.com")
+ if r := b.do("GET", "/d/"+f.own.Sqid, ""); r.Code != 404 {
+ t.Fatal("email admission became team standing", r.Code)
+ }
+ if r := b.do("GET", "/d/"+f.shared.Sqid, ""); r.Code != 200 {
+ t.Fatal("unlinked guest lost exact grant", r.Code)
+ }
+ cookie := b.cookies[f.sessions.CookieName()].Value
+ b.linkProof(t, teamSubject('a'), "alice@example.com")
+ routerRedirect(t, b.do("POST", "/account/home/confirm", ""), "/")
+ if b.cookies[f.sessions.CookieName()].Value != cookie {
+ t.Fatal("link rotated local session")
+ }
+ for _, d := range []*Document{f.own, f.shared} {
+ if r := b.do("GET", "/d/"+d.Sqid, ""); r.Code != 200 {
+ t.Fatal("linked member lost access", r.Code)
+ }
+ }
+ if r := b.do("GET", "/d/"+f.hidden.Sqid, ""); r.Code != 404 {
+ t.Fatal("membership opened another owner private document", r.Code)
+ }
+ if r := b.do("GET", "/d/"+f.shared.Sqid+"/share", ""); r.Code != 404 {
+ t.Fatal("writer grant became owner administration", r.Code)
+ }
+ if r := b.do("GET", "/d/"+f.own.Sqid+"/share", ""); r.Code != 200 {
+ t.Fatal("member owner cannot administer", r.Code)
+ }
+ f.clock.step(roster.RetryAfter)
+ f.directory.change([]roster.MemberEntry{{Subject: teamSubject('b'), Role: "member"}, {Subject: teamSubject('c'), Role: "member"}}, false)
+ if e := f.app.store.team.cache.Refresh(t.Context()); e != nil {
+ t.Fatal(e)
+ }
+ for _, path := range []string{"/d/" + f.own.Sqid, "/d/nonexistent", "/d/" + f.own.Sqid + "/share"} {
+ if r := b.do("GET", path, ""); r.Code != 404 {
+ t.Fatal("removed owner retained item access", path, r.Code)
+ }
+ }
+ if r := b.do("POST", "/documents", url.Values{"title": {"refused"}}.Encode()); r.Code != 403 {
+ t.Fatal("removed member created document", r.Code)
+ }
+ if code := b.update(f.own, validUpdate(t)); code != 404 {
+ t.Fatal("removed owner update not definitely refused", code)
+ }
+ if code := b.update(f.shared, validUpdate(t)); code != 204 {
+ t.Fatal("independent writer grant lost after membership removal", code)
+ }
+ f.directory.change(nil, true)
+ f.clock.step(60 * time.Second)
+ if e := f.app.store.team.cache.Refresh(t.Context()); e == nil {
+ t.Fatal("directory failure accepted")
+ }
+ missing := b.do("GET", "/d/nonexistent", "")
+ private := b.do("GET", "/d/"+f.own.Sqid, "")
+ if missing.Code != 503 || private.Code != 503 || missing.Body.String() != private.Body.String() {
+ t.Fatal("unknown standing leaked existence or became permanent refusal")
+ }
+ if r := b.do("GET", "/d/"+f.shared.Sqid, ""); r.Code != 200 {
+ t.Fatal("grant unavailable during roster failure", r.Code)
+ }
+ if r := b.do("GET", "/", ""); r.Code != 200 || !strings.Contains(r.Body.String(), `rst-notice role="status">docs.team.partial`) || strings.Contains(r.Body.String(), "Alice original") {
+ t.Fatal("partial list misrepresented unavailable owned documents", r.Code)
+ }
+ if r := b.do("GET", "/documents/search?q=shared", ""); r.Code != 200 || r.Header().Get("Docs-Catalog-Partial") != "1" {
+ t.Fatal("partial search lost status", r.Code)
+ }
+ if code := b.update(f.own, validUpdate(t)); code != 503 {
+ t.Fatal("uncertain update became permanent failure", code)
+ }
+ if code := b.update(f.shared, validUpdate(t)); code != 204 {
+ t.Fatal("writer grant depended on roster availability", code)
+ }
+ routerRedirect(t, b.do("POST", "/signout", ""), "/signed-out")
+ nonce := b.start(t, "/auth/start?return_to=%2F", "")
+ authTime := time.Now().Add(-2 * time.Hour).Truncate(time.Second)
+ routerRedirect(t, b.exchange(t, nonce, teamSubject('a'), authTime), "/")
+ session, ok := f.sessions.From(b.request("GET", "/", ""))
+ if !ok || session.Method != homeAssertionMethod || !session.AuthTime.Equal(authTime) {
+ t.Fatal("ordinary Home sign-in changed authentication time")
+ }
+ if r := b.do("GET", "/d/"+f.shared.Sqid, ""); r.Code != 200 {
+ t.Fatal("linked Home identity lost current verified grant address", r.Code)
+ }
+ p, e := f.app.store.team.principals.Resolve(context.Background(), bridgeIssuer, teamSubject('a'))
+ if e != nil || p.ID != f.alice || p.Kind != principalstore.Local {
+ t.Fatal("existing local ownership identity changed", e)
+ }
+}
+
+func TestTeamRouterHomeOnlyFirstEntryAndConflict(t *testing.T) {
+ f := newTeamRouterFixture(t)
+ b := f.browser()
+ nonce := b.start(t, "/auth/start?return_to=%2F", "")
+ routerRedirect(t, b.exchange(t, nonce, teamSubject('b'), time.Now().Add(-time.Hour)), "/")
+ p, e := f.app.store.team.principals.Resolve(t.Context(), bridgeIssuer, teamSubject('b'))
+ if e != nil || p.Kind != principalstore.Home || p.VerifiedAddress != nil {
+ t.Fatal("first Home entry did not create addressless principal", e)
+ }
+ var count int64
+ if e = f.app.store.db.Model(&Person{}).Count(&count).Error; e != nil || count != 2 {
+ t.Fatal("Home entry created legacy email person", count, e)
+ }
+ if r := b.do("GET", "/", ""); r.Code != 200 || strings.Contains(r.Body.String(), "Alice original") || strings.Contains(r.Body.String(), "Bob private") {
+ t.Fatal("new Home principal inherited local ownership", r.Code)
+ }
+ r := b.do("POST", "/documents", url.Values{"title": {"Home document"}}.Encode())
+ target := routerRedirect(t, r, "")
+ if !strings.HasPrefix(target, "/d/") {
+ t.Fatal("member could not create document")
+ }
+ var d Document
+ if e = f.app.store.db.Where("sqid = ?", strings.TrimPrefix(target, "/d/")).First(&d).Error; e != nil || d.OwnerID != p.ID {
+ t.Fatal("Home-created document has wrong owner", e)
+ }
+ nonce = b.start(t, "/auth/start?return_to=%2F", "")
+ if r = b.exchange(t, nonce, teamSubject('c'), time.Now()); r.Code != 400 {
+ t.Fatal("existing browser switched principal", r.Code)
+ }
+ if _, e = f.app.store.team.principals.Resolve(t.Context(), bridgeIssuer, teamSubject('c')); !errors.Is(e, principalstore.ErrRefused) {
+ t.Fatal("conflict allocated unknown principal", e)
+ }
+ if r = b.do("GET", target, ""); r.Code != 200 {
+ t.Fatal("conflict replaced current session", r.Code)
+ }
+ outsider := f.browser()
+ nonce = outsider.start(t, "/auth/start?return_to=%2F", "")
+ if r = outsider.exchange(t, nonce, teamSubject('z'), time.Now()); r.Code != 403 {
+ t.Fatal("nonmember first entry accepted", r.Code)
+ }
+ if _, e = f.app.store.team.principals.Resolve(t.Context(), bridgeIssuer, teamSubject('z')); !errors.Is(e, principalstore.ErrRefused) {
+ t.Fatal("nonmember allocated principal", e)
+ }
+ if r = b.do("GET", "/account/home", ""); r.Code != 303 || r.Header().Get("Location") != "/signin/local" {
+ t.Fatal("addressless account cannot begin explicit local reauthentication", r.Code, r.Header().Get("Location"))
+ }
+}
+
+func TestTeamRouterActivatedDatabaseRefusesMissingAndMismatchedConfiguration(t *testing.T) {
+ f := newTeamRouterFixture(t)
+ cfg := f.cfg
+ cfg.Team = nil
+ if app, e := New(cfg); e == nil {
+ app.Close()
+ t.Fatal("activated database reopened without TeamMode")
+ }
+ mismatch := *f.cfg.Team
+ mismatch.TeamID = "TeamB"
+ cfg.Team = &mismatch
+ if app, e := New(cfg); e == nil {
+ app.Close()
+ t.Fatal("activated database accepted foreign team configuration")
+ }
+ cfg = f.cfg
+ cfg.Home = nil
+ if app, e := New(cfg); e == nil {
+ app.Close()
+ t.Fatal("activated database accepted missing Home identity configuration")
+ }
+}
diff --git a/internal/docs/team_session_reader.go b/internal/docs/team_session_reader.go
new file mode 100644
index 0000000..ebd712c
--- /dev/null
+++ b/internal/docs/team_session_reader.go
@@ -0,0 +1,27 @@
+package docs
+
+import (
+ "context"
+ "database/sql"
+ "log/slog"
+ "net/http"
+
+ canonicalSessions "amadan.net/rastrillo/rastrillo/sessions"
+ "github.com/carlosframework/rastrillo/sessions"
+)
+
+// Keep validation in the shared reader. Structural conversion fails to compile
+// if either framework version changes the fields carried by a session.
+func newTeamSessionReader(db *sql.DB, origin, cookieName string, logger *slog.Logger) (func(context.Context, *http.Request) (sessions.Session, bool), error) {
+ reader, e := canonicalSessions.New(canonicalSessions.Config{DB: db, Origin: origin, Logger: logger})
+ if e != nil || reader.CookieName() != cookieName {
+ return nil, errTeamConfig
+ }
+ return func(ctx context.Context, r *http.Request) (sessions.Session, bool) {
+ if _, present, ok := homeCookie(r, cookieName); !ok || !present {
+ return sessions.Session{}, false
+ }
+ got, ok := reader.FromContext(ctx, r)
+ return sessions.Session(got), ok
+ }, nil
+}
diff --git a/internal/docs/team_session_reader_test.go b/internal/docs/team_session_reader_test.go
new file mode 100644
index 0000000..63daad9
--- /dev/null
+++ b/internal/docs/team_session_reader_test.go
@@ -0,0 +1,104 @@
+package docs
+
+import (
+ "context"
+ "net/http"
+ "net/http/httptest"
+ "testing"
+ "time"
+
+ "github.com/carlosframework/rastrillo/sessions"
+)
+
+func TestTeamSessionReaderMatchesActualLegacySession(t *testing.T) {
+ f := newBridgeFixture(t)
+ reader, e := newTeamSessionReader(f.app.db.Writer(), f.b.origin, f.b.sessionCookie, f.app.logger)
+ if e != nil {
+ t.Fatal(e)
+ }
+ if _, e = newTeamSessionReader(f.app.db.Writer(), f.b.origin, "wrong-cookie", f.app.logger); e == nil {
+ t.Fatal("different cookie configuration accepted")
+ }
+ for _, method := range []string{"magiclink", "keymail", homeAssertionMethod} {
+ t.Run(method, func(t *testing.T) {
+ r := bridgeRequest("GET", "/", nil)
+ w := httptest.NewRecorder()
+ at := time.Now().Add(-time.Hour).Truncate(time.Second)
+ if e = f.b.sessions.SignIn(w, r, sessions.Session{Subject: "opaque-credential", Method: method, AuthTime: at}); e != nil {
+ t.Fatal(e)
+ }
+ cookie := bridgeCookie(t, w, f.b.sessionCookie)
+ r.AddCookie(cookie)
+ old, ok := f.b.sessions.From(r)
+ if !ok {
+ t.Fatal("actual legacy session missing")
+ }
+ got, ok := reader(t.Context(), r)
+ if !ok || got != old || !got.AuthTime.Equal(at) || got.Subject != "opaque-credential" || got.Method != method {
+ t.Fatal("reader conversion changed session authority")
+ }
+ ctx, cancel := context.WithCancel(t.Context())
+ cancel()
+ if _, ok = reader(ctx, r); ok {
+ t.Fatal("canceled session lookup accepted")
+ }
+ if got, ok = reader(t.Context(), r); !ok || got != old {
+ t.Fatal("cancellation rotated or refreshed actual session")
+ }
+ duplicate := bridgeRequest("GET", "/", []*http.Cookie{cookie, cookie})
+ if _, ok = reader(t.Context(), duplicate); ok {
+ t.Fatal("duplicate actual cookies accepted")
+ }
+ if _, e = f.app.db.Writer().Exec("UPDATE sessions SET expires_at=? WHERE token_hash=?", time.Now().Add(-time.Minute).UTC().Format(time.RFC3339), sessions.HashToken(cookie.Value)); e != nil {
+ t.Fatal(e)
+ }
+ if _, ok = reader(t.Context(), r); ok {
+ t.Fatal("expired session accepted")
+ }
+ if _, ok = f.b.sessions.From(r); ok {
+ t.Fatal("legacy reader differed on expiry")
+ }
+ })
+ }
+ r := bridgeRequest("GET", "/", nil)
+ if _, ok := reader(t.Context(), r); ok {
+ t.Fatal("missing session accepted")
+ }
+ r.AddCookie(&http.Cookie{Name: f.b.sessionCookie, Value: "malformed"})
+ if _, ok := reader(t.Context(), r); ok {
+ t.Fatal("malformed session accepted")
+ }
+}
+
+func TestTeamSessionReaderRevocationAndDatabaseErrorParity(t *testing.T) {
+ f := newBridgeFixture(t)
+ reader, e := newTeamSessionReader(f.app.db.Writer(), f.b.origin, f.b.sessionCookie, f.app.logger)
+ if e != nil {
+ t.Fatal(e)
+ }
+ r := bridgeRequest("GET", "/", nil)
+ w := httptest.NewRecorder()
+ if e := f.b.sessions.SignIn(w, r, sessions.Session{Subject: "alice@example.com", Method: "magiclink", AuthTime: time.Now()}); e != nil {
+ t.Fatal(e)
+ }
+ r.AddCookie(bridgeCookie(t, w, f.b.sessionCookie))
+ if _, ok := reader(t.Context(), r); !ok {
+ t.Fatal("live session refused")
+ }
+ f.b.sessions.SignOut(httptest.NewRecorder(), r)
+ if _, ok := reader(t.Context(), r); ok {
+ t.Fatal("revoked session accepted")
+ }
+ if _, ok := f.b.sessions.From(r); ok {
+ t.Fatal("legacy revocation differs")
+ }
+ if e := f.app.db.Writer().Close(); e != nil {
+ t.Fatal(e)
+ }
+ if _, ok := reader(t.Context(), r); ok {
+ t.Fatal("database failure accepted")
+ }
+ if _, ok := f.b.sessions.From(r); ok {
+ t.Fatal("legacy database failure differs")
+ }
+}
diff --git a/internal/docs/team_stream.go b/internal/docs/team_stream.go
index 5098f9e..633d2e6 100644
--- a/internal/docs/team_stream.go
+++ b/internal/docs/team_stream.go
@@ -10,15 +10,19 @@ import (
)
type teamStreamGuard struct {
- store *Store
- access *requestAccess
- request *http.Request
- docID int64
+ store *Store
+ access *requestAccess
+ request *http.Request
+ docID int64
+ deadline time.Time
}
func (g *teamStreamGuard) remaining() time.Duration {
if g.access.decision.State == roster.Member {
- return g.access.decision.Lease.Remaining()
+ if g.deadline.IsZero() {
+ return 0
+ }
+ return min(g.access.decision.Lease.Remaining(), time.Until(g.deadline))
}
return defaultStreamWriteDeadline
}
diff --git a/internal/docs/views/list.html b/internal/docs/views/list.html
index 406bb63..cfe9fda 100644
--- a/internal/docs/views/list.html
+++ b/internal/docs/views/list.html
@@ -317,6 +317,7 @@
</form>{{end}}
</div>
</header>
+{{if .PartialCatalog}}<p rst-notice role="status">{{.PartialCatalog}}</p>{{end}}
{{/* Searching renders a ranked, UNGROUPED result list. Recency
grouping is what an unfiltered list is for; grouping ranked
results under "Today" and "Earlier" would order the same rows
diff --git a/internal/docs/views/partials/jumpto.html b/internal/docs/views/partials/jumpto.html
index a1f9f6e..a6c6c5e 100644
--- a/internal/docs/views/partials/jumpto.html
+++ b/internal/docs/views/partials/jumpto.html
@@ -111,7 +111,7 @@
{{/* role="status" so a result count or a failure is announced
without stealing focus from the field being typed into. */}}
<p docs-jumpto-status role="status" aria-live="polite" hidden
- data-empty="{{.JumpTo.Empty}}" data-failed="{{.JumpTo.Failed}}"></p>
+ data-partial="{{.JumpTo.Partial}}" data-empty="{{.JumpTo.Empty}}" data-failed="{{.JumpTo.Failed}}"></p>
<p docs-jumpto-hint>{{.JumpTo.Hint}}</p>
</div>
</div>