Mount TeamMode identity and roster standing with guarded session reads
20 files changed,
+616
−28
cmd/docs/main.go+9 −0fixtures/jump-to.mjs+2 −1go.mod+1 −1go.sum+2 −0internal/docs/app.go+51 −4internal/docs/auth.go+1 −1internal/docs/handlers.go+3 −1internal/docs/home_router_integration_test.go+5 −1internal/docs/render.go+1 −0internal/docs/search.go+5 −0internal/docs/stream.go+24 −7internal/docs/team_access.go+27 −0internal/docs/team_config.go+6 −2internal/docs/team_emission.go+10 −4internal/docs/team_router_test.go+327 −0internal/docs/team_session_reader.go+27 −0internal/docs/team_session_reader_test.go+104 −0internal/docs/team_stream.go+9 −5internal/docs/views/list.html+1 −0internal/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> |