oficina / docs Public

Add unmounted TeamMode identity and durable authority state

Paul Campbell pushed by paul@keymail.dev 7b52265c96c6ed72f8b10f3e67359ee45a76ba0b parent 54cedb8
22 files changed, +559 −42
  • go.mod +4 −3
  • go.sum +2 −2
  • internal/docs/auth.go +4 −1
  • internal/docs/content_runtime_test.go +2 −2
  • internal/docs/grants.go +35 −11
  • internal/docs/grants_test.go +6 −6
  • internal/docs/grants_write_test.go +5 −5
  • internal/docs/handlers.go +1 −1
  • internal/docs/home_exchange_test.go +1 −1
  • internal/docs/home_router_integration_test.go +1 −1
  • internal/docs/identity.go +36 −4
  • internal/docs/identity_test.go +2 −2
  • internal/docs/principalstore/testdata/compat_main.go.txt +1 −1
  • internal/docs/principalstore/testdata/compat_new.go.txt +5 −0
  • internal/docs/principalstore/testdata/compat_old.go.txt +5 −0
  • internal/docs/store.go +2 −1
  • internal/docs/team_config.go +116 −0
  • internal/docs/team_identity_test.go +133 −0
  • internal/docs/teamstate/migrations/0001_team.sql +10 −0
  • internal/docs/teamstate/store.go +98 −0
  • internal/docs/teamstate/store_test.go +89 −0
  • internal/docs/testing.go +1 −1

CI failed — run details

diff --git a/go.mod b/go.mod
index 2b025da..7d75590 100644
--- a/go.mod
+++ b/go.mod
@@ -5,18 +5,20 @@ go 1.26.5
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
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/tetratelabs/wazero v1.12.0
golang.org/x/text v0.20.0
gorm.io/gorm v1.31.2
+ modernc.org/sqlite v1.55.0
)
require (
- amadan.net/rastrillo/rastrillo v0.26.1-0.20260908075707-29e4491631bd // indirect
github.com/dustin/go-humanize v1.0.1 // indirect
- github.com/go-json-experiment/json v0.0.0-20250725192818-e39067aee2d2 // 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
@@ -28,5 +30,4 @@ require (
modernc.org/libc v1.74.1 // indirect
modernc.org/mathutil v1.7.1 // indirect
modernc.org/memory v1.11.0 // indirect
- modernc.org/sqlite v1.55.0 // indirect
)
diff --git a/go.sum b/go.sum
index 69457d9..b43fed0 100644
--- a/go.sum
+++ b/go.sum
@@ -1,9 +1,9 @@
amadan.net/oficina/estilo.git v0.0.0-20260908001419-672a447da335 h1:YogZKYINrPNXRfPedXxOIDbGCMY4ff7yAdohYphEWMg=
amadan.net/oficina/estilo.git v0.0.0-20260908001419-672a447da335/go.mod h1:0R45/vCUAT+kpS7S6K/ZxBdE8f7YxFN4HT3HyDJ3nPY=
-amadan.net/oficina/home/handoff v0.0.0-20260908100301-967d29f96288 h1:T1GQM8pChN+4+cpx7TUF7ogCczUaCw/D98YR8nH35NA=
-amadan.net/oficina/home/handoff v0.0.0-20260908100301-967d29f96288/go.mod h1:YISpzFJ+iMgrM0rUL032OSzsAQbP4/nozvXLCCOcn1k=
amadan.net/oficina/home/handoff v0.0.0-20260908102137-481d2d57f489 h1:RPKtyry/71i06o8L+gjAaUyexbmG56kO1YPs601URAM=
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/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=
github.com/carlosframework/rastrillo v0.23.0 h1:tKAAlwPt4Pg/rkqk7o6jvV12bbZ0r34oEVpuOlKfX7w=
diff --git a/internal/docs/auth.go b/internal/docs/auth.go
index 376dc04..111afb5 100644
--- a/internal/docs/auth.go
+++ b/internal/docs/auth.go
@@ -394,7 +394,10 @@ func gateBegin(next http.HandlerFunc, list allowlist, signinPath string) http.Ha
func requireAdmission(list allowlist, signinPath string) func(http.Handler) http.Handler {
admission := requireAdmissionOf(list, signinPath, func(r *http.Request) (string, bool) {
if id, ok := currentIdentity(r); ok {
- return id.Actor.Subject, true
+ if id.Actor.VerifiedAddress == nil {
+ return "", false
+ }
+ return *id.Actor.VerifiedAddress, true
}
s, ok := sessions.Current(r)
return s.Subject, ok && s.Method != homeAssertionMethod
diff --git a/internal/docs/content_runtime_test.go b/internal/docs/content_runtime_test.go
index 17b081a..66d09c5 100644
--- a/internal/docs/content_runtime_test.go
+++ b/internal/docs/content_runtime_test.go
@@ -133,7 +133,7 @@ func TestLegacyContentWriterRefusesAfterActivation(t *testing.T) {
if err := convertContent(ctx, app.store.db, cipher); err != nil {
t.Fatal(err)
}
- if seq, err := app.store.ApplyUpdate(ctx, doc, Actor{PersonID: owner, Subject: "owner@example.com"}, validUpdate(t)); !errors.Is(err, errContentState) || seq != 0 {
+ if seq, err := app.store.ApplyUpdate(ctx, doc, localActor(owner, "owner@example.com"), validUpdate(t)); !errors.Is(err, errContentState) || seq != 0 {
t.Fatalf("stale Store.ApplyUpdate committed: seq %d error %v", seq, err)
}
update := &DocUpdate{DocID: doc.ID, Seq: 1, UpdateBin: validUpdate(t), CreatedAt: time.Now().UTC()}
@@ -287,7 +287,7 @@ func TestStaleLegacyStoreRefusesSealedUpdatesAndSnapshots(t *testing.T) {
if err != nil {
t.Fatal(err)
}
- if _, err := app.store.ApplyUpdate(ctx, doc, Actor{PersonID: owner, Subject: "owner@example.com"}, validUpdate(t)); err != nil {
+ if _, err := app.store.ApplyUpdate(ctx, doc, localActor(owner, "owner@example.com"), validUpdate(t)); err != nil {
t.Fatal(err)
}
h, seq, _, err := app.store.replay(ctx, doc)
diff --git a/internal/docs/grants.go b/internal/docs/grants.go
index 5574a77..286dae4 100644
--- a/internal/docs/grants.go
+++ b/internal/docs/grants.go
@@ -67,8 +67,17 @@ var errEmptyAddress = errors.New("docs: cannot grant to an empty address")
// keyed on the id and grants are keyed on the address — see 0005_grants.sql
// for why grants are not keyed on the id.
type Actor struct {
- PersonID int64
- Subject string
+ PersonID int64
+ VerifiedAddress *string
+}
+
+// localActor keeps a verified local address distinct from an opaque Home subject.
+func localActor(id int64, address string) Actor {
+ address = subjectOf(address)
+ if address == "" {
+ return Actor{PersonID: id}
+ }
+ return Actor{PersonID: id, VerifiedAddress: &address}
}
// writable scopes a query to the documents one person may change: the ones
@@ -79,11 +88,14 @@ func writable(g *gorm.DB, a Actor) *gorm.DB {
if a.PersonID <= 0 {
return g.Where("1 = 0")
}
+ if a.VerifiedAddress == nil {
+ return g.Where("documents.owner_id > 0 AND documents.owner_id = ?", a.PersonID)
+ }
return g.Where(
"documents.owner_id > 0 AND (documents.owner_id = ? OR EXISTS ("+
"SELECT 1 FROM grants WHERE grants.doc_id = documents.id "+
"AND grants.subject = ? AND grants.role = ?))",
- a.PersonID, subjectOf(a.Subject), RoleWriter,
+ a.PersonID, subjectOf(*a.VerifiedAddress), RoleWriter,
)
}
@@ -100,12 +112,20 @@ func subjectOf(address string) string {
// addressOf maps a person id back to the address they signed in with, so
// Grant can refuse a grant somebody makes to themselves.
-func (s *Store) addressOf(ctx context.Context, person int64) (string, error) {
+func (s *Store) addressOf(ctx context.Context, person int64) (*string, error) {
+ if s.team != nil {
+ p, err := s.team.principals.Person(ctx, person)
+ return p.VerifiedAddress, err
+ }
var p Person
if err := s.db.WithContext(ctx).First(&p, person).Error; err != nil {
- return "", err
+ return nil, err
}
- return subjectOf(p.Email), nil
+ address := subjectOf(p.Email)
+ if address == "" {
+ return nil, errIdentityRefused
+ }
+ return &address, nil
}
// looksLikeAnAddress is the cheapest check that keeps nonsense out of the
@@ -139,14 +159,18 @@ func looksLikeAnAddress(subject string) bool {
// it. 0002_owners.sql wrote owner_id 0 onto every pre-existing row, and three
// tests pin that 0 matches nothing; a grants clause must not become the way
// around them.
-func readable(g *gorm.DB, owner int64, subject string) *gorm.DB {
+func readable(g *gorm.DB, a Actor) *gorm.DB {
+ owner := a.PersonID
if owner <= 0 {
return g.Where("1 = 0")
}
+ if a.VerifiedAddress == nil {
+ return g.Where("documents.owner_id > 0 AND documents.owner_id = ?", a.PersonID)
+ }
return g.Where(
"documents.owner_id > 0 AND (documents.owner_id = ? OR EXISTS ("+
"SELECT 1 FROM grants WHERE grants.doc_id = documents.id AND grants.subject = ?))",
- owner, subjectOf(subject),
+ owner, subjectOf(*a.VerifiedAddress),
)
}
@@ -181,7 +205,7 @@ func (s *Store) Grant(ctx context.Context, owner int64, sqid, address string, ro
if err != nil {
return fmt.Errorf("docs: resolve owner address: %w", err)
}
- if ownerAddress == subject {
+ if ownerAddress != nil && *ownerAddress == subject {
return errEmptyAddress
}
@@ -283,7 +307,7 @@ func (s *Store) Revoke(ctx context.Context, owner int64, sqid, address string) e
// as one that does not exist, and the handler turns both into 404 (§4.2).
func (s *Store) GetFor(ctx context.Context, a Actor, sqid string) (*Document, error) {
var doc Document
- err := readable(s.db.WithContext(ctx), a.PersonID, a.Subject).
+ err := readable(s.db.WithContext(ctx), a).
Where("documents.sqid = ? AND documents.deleted_at IS NULL", sqid).
First(&doc).Error
if err != nil {
@@ -330,7 +354,7 @@ func (s *Store) CanWrite(ctx context.Context, a Actor, docID int64) bool {
// docRow.SharedNote, without reordering the list.
func (s *Store) ListFor(ctx context.Context, a Actor) ([]*Document, error) {
var docs []*Document
- err := readable(s.db.WithContext(ctx), a.PersonID, a.Subject).
+ err := readable(s.db.WithContext(ctx), a).
Where("documents.deleted_at IS NULL").
Order("documents.updated_at DESC, documents.id DESC").
Find(&docs).Error
diff --git a/internal/docs/grants_test.go b/internal/docs/grants_test.go
index 09e817b..c4f9b33 100644
--- a/internal/docs/grants_test.go
+++ b/internal/docs/grants_test.go
@@ -45,7 +45,7 @@ func TestReadableIncludesGrantees(t *testing.T) {
// Bob reads it because he was granted it.
var bobRows []*Document
- if err := readable(store.db.WithContext(ctx), bob, "bob@example.com").Find(&bobRows).Error; err != nil {
+ if err := readable(store.db.WithContext(ctx), localActor(bob, "bob@example.com")).Find(&bobRows).Error; err != nil {
t.Fatalf("readable(bob): %v", err)
}
if len(bobRows) != 1 || bobRows[0].ID != doc.ID {
@@ -54,7 +54,7 @@ func TestReadableIncludesGrantees(t *testing.T) {
// Alice still reads her own.
var aliceRows []*Document
- if err := readable(store.db.WithContext(ctx), alice, "alice@example.com").Find(&aliceRows).Error; err != nil {
+ if err := readable(store.db.WithContext(ctx), localActor(alice, "alice@example.com")).Find(&aliceRows).Error; err != nil {
t.Fatalf("readable(alice): %v", err)
}
if len(aliceRows) != 1 {
@@ -64,7 +64,7 @@ func TestReadableIncludesGrantees(t *testing.T) {
// Carol was granted nothing and reads nothing. Without this the whole
// thing could be a query that returns every row.
var carolRows []*Document
- if err := readable(store.db.WithContext(ctx), carol, "carol@example.com").Find(&carolRows).Error; err != nil {
+ if err := readable(store.db.WithContext(ctx), localActor(carol, "carol@example.com")).Find(&carolRows).Error; err != nil {
t.Fatalf("readable(carol): %v", err)
}
if len(carolRows) != 0 {
@@ -111,7 +111,7 @@ func TestGrantToSomebodyWhoHasNeverSignedIn(t *testing.T) {
t.Fatalf("EnsurePerson(dave): %v", err)
}
var daveRows []*Document
- if err := readable(store.db.WithContext(ctx), dave, "dave@example.com").Find(&daveRows).Error; err != nil {
+ if err := readable(store.db.WithContext(ctx), localActor(dave, "dave@example.com")).Find(&daveRows).Error; err != nil {
t.Fatalf("readable(dave): %v", err)
}
if len(daveRows) != 1 {
@@ -141,7 +141,7 @@ func TestGrantSubjectIsNormalised(t *testing.T) {
}
var rows []*Document
- if err := readable(store.db.WithContext(ctx), bob, "bob@example.com").Find(&rows).Error; err != nil {
+ if err := readable(store.db.WithContext(ctx), localActor(bob, "bob@example.com")).Find(&rows).Error; err != nil {
t.Fatalf("readable(bob): %v", err)
}
if len(rows) != 1 {
@@ -178,7 +178,7 @@ func TestReadableKeepsOrphansInvisible(t *testing.T) {
}
var rows []*Document
- if err := readable(store.db.WithContext(ctx), bob, "bob@example.com").Find(&rows).Error; err != nil {
+ if err := readable(store.db.WithContext(ctx), localActor(bob, "bob@example.com")).Find(&rows).Error; err != nil {
t.Fatalf("readable(bob): %v", err)
}
if len(rows) != 0 {
diff --git a/internal/docs/grants_write_test.go b/internal/docs/grants_write_test.go
index 579636e..fab13b3 100644
--- a/internal/docs/grants_write_test.go
+++ b/internal/docs/grants_write_test.go
@@ -40,7 +40,7 @@ func TestApplyUpdateRefusesAReader(t *testing.T) {
// Bob can read it — that is what the grant is for.
var rows []*Document
- if err := readable(store.db.WithContext(ctx), bob, "bob@example.com").Find(&rows).Error; err != nil {
+ if err := readable(store.db.WithContext(ctx), localActor(bob, "bob@example.com")).Find(&rows).Error; err != nil {
t.Fatalf("readable(bob): %v", err)
}
if len(rows) != 1 {
@@ -48,7 +48,7 @@ func TestApplyUpdateRefusesAReader(t *testing.T) {
}
// And he cannot write it.
- _, err = store.ApplyUpdate(ctx, doc, Actor{PersonID: bob, Subject: "bob@example.com"}, []byte{0})
+ _, err = store.ApplyUpdate(ctx, doc, localActor(bob, "bob@example.com"), []byte{0})
if !errors.Is(err, ErrNotWritable) {
t.Errorf("ApplyUpdate as a reader: got %v, want ErrNotWritable", err)
}
@@ -77,7 +77,7 @@ func TestApplyUpdateAllowsAWriter(t *testing.T) {
// A malformed CRDT update is fine here: it must get *past* authorization
// and fail on its own merits, which is what distinguishes "refused" from
// "not a valid update".
- _, err = store.ApplyUpdate(ctx, doc, Actor{PersonID: bob, Subject: "bob@example.com"}, []byte{0})
+ _, err = store.ApplyUpdate(ctx, doc, localActor(bob, "bob@example.com"), []byte{0})
if errors.Is(err, ErrNotWritable) {
t.Errorf("ApplyUpdate as a writer was refused authorization; the grant's role is doing nothing")
}
@@ -98,7 +98,7 @@ func TestApplyUpdateAllowsTheOwner(t *testing.T) {
t.Fatalf("Create: %v", err)
}
- _, err = store.ApplyUpdate(ctx, doc, Actor{PersonID: alice, Subject: "alice@example.com"}, []byte{0})
+ _, err = store.ApplyUpdate(ctx, doc, localActor(alice, "alice@example.com"), []byte{0})
if errors.Is(err, ErrNotWritable) {
t.Errorf("the owner was refused a write to her own document: %v", err)
}
@@ -121,7 +121,7 @@ func TestApplyUpdateRefusesAStranger(t *testing.T) {
t.Fatalf("Create: %v", err)
}
- _, err = store.ApplyUpdate(ctx, doc, Actor{PersonID: carol, Subject: "carol@example.com"}, []byte{0})
+ _, err = store.ApplyUpdate(ctx, doc, localActor(carol, "carol@example.com"), []byte{0})
if !errors.Is(err, ErrNotWritable) {
t.Errorf("ApplyUpdate as a stranger: got %v, want ErrNotWritable", err)
}
diff --git a/internal/docs/handlers.go b/internal/docs/handlers.go
index 4894145..81402d8 100644
--- a/internal/docs/handlers.go
+++ b/internal/docs/handlers.go
@@ -212,7 +212,7 @@ func (h *handlers) member(r *http.Request) bool {
if err != nil {
return false
}
- return h.isMember == nil || h.isMember(id.Subject)
+ return id.VerifiedAddress != nil && (h.isMember == nil || h.isMember(*id.VerifiedAddress))
}
// list serves GET /: the document list, spec §6.4 and Paul's four
diff --git a/internal/docs/home_exchange_test.go b/internal/docs/home_exchange_test.go
index 8b491f4..49ad50b 100644
--- a/internal/docs/home_exchange_test.go
+++ b/internal/docs/home_exchange_test.go
@@ -204,7 +204,7 @@ func TestHomeBridgeLinkThenOrdinarySignInPreservesIdentity(t *testing.T) {
t.Fatalf("wrong Home session: %+v", session)
}
identity, err := f.b.resolver.resolve(sessions.WithSession(r, session))
- if err != nil || identity.Actor.PersonID != person || identity.Actor.Subject != "one@example.com" {
+ if err != nil || identity.Actor.PersonID != person || (identity.Actor.VerifiedAddress == nil || *identity.Actor.VerifiedAddress != "one@example.com") {
t.Fatalf("identity=%+v %v", identity, err)
}
if _, err := f.app.store.Get(context.Background(), person, doc.Sqid); err != nil {
diff --git a/internal/docs/home_router_integration_test.go b/internal/docs/home_router_integration_test.go
index 59ec79f..04d5430 100644
--- a/internal/docs/home_router_integration_test.go
+++ b/internal/docs/home_router_integration_test.go
@@ -220,7 +220,7 @@ func TestHomeRouterExistingAccountRoundTripPreservesOwnedAndSharedContent(t *tes
owner = bob
address = "bob@example.com"
}
- if _, err = s.ApplyUpdate(ctx, doc, Actor{PersonID: owner, Subject: address}, ReadFixture(t, "hello")); err != nil {
+ if _, err = s.ApplyUpdate(ctx, doc, localActor(owner, address), ReadFixture(t, "hello")); err != nil {
t.Fatal(err)
}
}
diff --git a/internal/docs/identity.go b/internal/docs/identity.go
index 6fdae35..2fa28eb 100644
--- a/internal/docs/identity.go
+++ b/internal/docs/identity.go
@@ -2,7 +2,9 @@ package docs
import (
"context"
+ "database/sql"
"errors"
+ "github.com/carlosframework/docs/internal/docs/principalstore"
"net/http"
"time"
@@ -21,8 +23,9 @@ var (
// requestIdentity keeps the verified session separate from the local person
// and address that own documents and hold grants.
type requestIdentity struct {
- Actor Actor
- Session sessions.Session
+ Actor Actor
+ HomeSubject string
+ Session sessions.Session
}
type identityContextKey struct{}
@@ -32,6 +35,7 @@ func currentIdentity(r *http.Request) (requestIdentity, bool) {
}
type identityResolver struct {
+ team *teamRuntime
store *Store
issuer string
// Return zero for an unknown link. Errors must not contain identity values.
@@ -44,6 +48,16 @@ func (v identityResolver) resolve(r *http.Request) (requestIdentity, error) {
return requestIdentity{}, errIdentityRefused
}
if session.Method == homeAssertionMethod {
+ if v.team != nil {
+ p, err := v.team.principals.Resolve(r.Context(), v.team.bindings.Issuer, session.Subject)
+ if errors.Is(err, principalstore.ErrRefused) {
+ return requestIdentity{}, errIdentityRefused
+ }
+ if err != nil {
+ return requestIdentity{}, errIdentityStorage
+ }
+ return requestIdentity{Actor: Actor{PersonID: p.ID, VerifiedAddress: p.VerifiedAddress}, Session: session, HomeSubject: session.Subject}, nil
+ }
if v.issuer == "" || v.linkedPerson == nil {
return requestIdentity{}, errIdentityRefused
}
@@ -68,7 +82,10 @@ func (v identityResolver) resolve(r *http.Request) (requestIdentity, error) {
if address == "" {
return requestIdentity{}, errIdentityRefused
}
- return requestIdentity{Actor: Actor{PersonID: p.ID, Subject: address}, Session: session}, nil
+ return requestIdentity{Actor: localActor(p.ID, address), Session: session}, nil
+ }
+ if v.team != nil && session.Method != "magiclink" && session.Method != "keymail" {
+ return requestIdentity{}, errIdentityRefused
}
// The local authentication plugin verified this address. Preserve its
// existing get-or-create behavior, including opt-in sample seeding.
@@ -76,7 +93,22 @@ func (v identityResolver) resolve(r *http.Request) (requestIdentity, error) {
if err != nil {
return requestIdentity{}, errIdentityStorage
}
- return requestIdentity{Actor: Actor{PersonID: person, Subject: session.Subject}, Session: session}, nil
+ id := requestIdentity{Actor: localActor(person, session.Subject), Session: session}
+ if v.team != nil {
+ p, err := v.team.principals.Person(r.Context(), person)
+ if err != nil {
+ return requestIdentity{}, errIdentityStorage
+ }
+ if p.Kind != principalstore.Local {
+ return requestIdentity{}, errIdentityRefused
+ }
+ id.Actor = Actor{PersonID: p.ID, VerifiedAddress: p.VerifiedAddress}
+ err = v.team.db.QueryRowContext(r.Context(), "SELECT subject FROM docs_identity_links WHERE issuer=? AND person_id=?", v.team.bindings.Issuer, p.ID).Scan(&id.HomeSubject)
+ if err != nil && !errors.Is(err, sql.ErrNoRows) {
+ return requestIdentity{}, errIdentityStorage
+ }
+ }
+ return id, nil
}
// middleware belongs after RequireSession and before admission. A verified
diff --git a/internal/docs/identity_test.go b/internal/docs/identity_test.go
index 2e9bd62..dc0fd88 100644
--- a/internal/docs/identity_test.go
+++ b/internal/docs/identity_test.go
@@ -99,7 +99,7 @@ func TestLinkedHomeIdentityPreservesOwnersAndAddressGrants(t *testing.T) {
end := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
reached = true
a, err := h.actor(r)
- if err != nil || a.PersonID != alice || a.Subject != "alice@example.com" {
+ if err != nil || a.PersonID != alice || (a.VerifiedAddress == nil || *a.VerifiedAddress != "alice@example.com") {
t.Fatal("linked actor changed", a, err)
}
id, err := h.owner(r)
@@ -140,7 +140,7 @@ func TestLinkedHomeIdentityPreservesOwnersAndAddressGrants(t *testing.T) {
t.Fatal(err)
}
id, err := v.resolve(identityRequest("GET", "opaque_alice", homeAssertionMethod))
- if err != nil || id.Actor.Subject != "renamed@example.com" {
+ if err != nil || (id.Actor.VerifiedAddress == nil || *id.Actor.VerifiedAddress != "renamed@example.com") {
t.Fatal("stale local address")
}
}
diff --git a/internal/docs/principalstore/testdata/compat_main.go.txt b/internal/docs/principalstore/testdata/compat_main.go.txt
index a4d3ddd..63c67e7 100644
--- a/internal/docs/principalstore/testdata/compat_main.go.txt
+++ b/internal/docs/principalstore/testdata/compat_main.go.txt
@@ -51,7 +51,7 @@ func main() {
sqid = doc.Sqid
update, e := os.ReadFile("fixtures/vectors/fragment/hello.update.bin")
must(e)
- _, e = app.Store().ApplyUpdate(ctx, doc, docs.Actor{PersonID: id, Subject: *address}, update)
+ _, e = app.Store().ApplyUpdate(ctx, doc, fixtureActor(id, *address), update)
must(e)
must(app.Store().Grant(ctx, id, sqid, "reader@example.invalid", docs.RoleWriter))
_, e = d.Writer().Exec(`INSERT INTO doc_snapshots(doc_id,seq,state,abi_version,created_at)VALUES(?,1,?,?,CURRENT_TIMESTAMP)`, doc.ID, update, core.ABIVersion)
diff --git a/internal/docs/principalstore/testdata/compat_new.go.txt b/internal/docs/principalstore/testdata/compat_new.go.txt
index 835cbb1..b55d4a8 100644
--- a/internal/docs/principalstore/testdata/compat_new.go.txt
+++ b/internal/docs/principalstore/testdata/compat_new.go.txt
@@ -2,6 +2,7 @@ package main
import (
"context"
+ "github.com/carlosframework/docs/internal/docs"
"github.com/carlosframework/docs/internal/docs/linkstore"
p "github.com/carlosframework/docs/internal/docs/principalstore"
"github.com/carlosframework/rastrillo/db"
@@ -20,3 +21,7 @@ func principalStep(ctx context.Context, d *db.DB, phase string) int64 {
}
return v.ID
}
+
+func fixtureActor(id int64, address string) docs.Actor {
+ return docs.Actor{PersonID: id, VerifiedAddress: &address}
+}
diff --git a/internal/docs/principalstore/testdata/compat_old.go.txt b/internal/docs/principalstore/testdata/compat_old.go.txt
index cc7b454..a0143f4 100644
--- a/internal/docs/principalstore/testdata/compat_old.go.txt
+++ b/internal/docs/principalstore/testdata/compat_old.go.txt
@@ -2,7 +2,12 @@ package main
import (
"context"
+ "github.com/carlosframework/docs/internal/docs"
"github.com/carlosframework/rastrillo/db"
)
func principalStep(context.Context, *db.DB, string) int64 { return 0 }
+
+func fixtureActor(id int64, address string) docs.Actor {
+ return docs.Actor{PersonID: id, Subject: address}
+}
diff --git a/internal/docs/store.go b/internal/docs/store.go
index 5abefeb..c283b89 100644
--- a/internal/docs/store.go
+++ b/internal/docs/store.go
@@ -21,6 +21,7 @@ import (
// the update log, and the document's actual text read back through
// the real CRDT core rather than any column here — see Text.
type Store struct {
+ team *teamRuntime
db *gorm.DB
core *core.Runtime
content *contentCipher
@@ -260,7 +261,7 @@ func (s *Store) seedIfEnabled(ctx context.Context, newOwner int64, email string)
if s.seedOnlyMembers != nil && !s.seedOnlyMembers(email) {
return
}
- if err := Seed(ctx, s, Actor{PersonID: newOwner, Subject: email}); err != nil {
+ if err := Seed(ctx, s, localActor(newOwner, email)); err != nil {
s.logger.Error("docs: seed sample documents for new person", "person", newOwner, "address", email, "err", err)
}
}
diff --git a/internal/docs/team_config.go b/internal/docs/team_config.go
new file mode 100644
index 0000000..38ee412
--- /dev/null
+++ b/internal/docs/team_config.go
@@ -0,0 +1,116 @@
+package docs
+
+import (
+ "context"
+ "crypto/sha256"
+ "database/sql"
+ "encoding/base64"
+ "errors"
+
+ "amadan.net/oficina/home/handoff/seal"
+ "amadan.net/oficina/home/roster"
+ "github.com/carlosframework/docs/internal/docs/principalstore"
+ "github.com/carlosframework/docs/internal/docs/teamstate"
+ "github.com/carlosframework/rastrillo/migrate"
+ "github.com/go-json-experiment/json"
+)
+
+// TeamConfig is explicit deployment configuration, never browser request input.
+type TeamConfig struct {
+ TeamID string
+ DirectoryOrigin string
+ ProvisioningBase64 string
+ OperatorKeysBase64 string
+ RosterToken string
+}
+
+var errTeamConfig = errors.New("docs: invalid team configuration")
+var errTeamUnavailable = errors.New("docs: team authority unavailable")
+
+type teamSettings struct {
+ record roster.VerifiedRecord
+ token string
+ fingerprint string
+}
+
+var TeamBootSchema = migrate.Merge(BootSchema, principalstore.Schema, teamstate.Schema)
+
+func validatedTeamConfig(cfg *TeamConfig, home *homeSettings, origin string) (*teamSettings, error) {
+ if cfg == nil {
+ return nil, nil
+ }
+ if home == nil || !roster.ValidChallenge(cfg.RosterToken) {
+ return nil, errTeamConfig
+ }
+ raw, ok := homeConfigBase64(cfg.ProvisioningBase64, roster.BodyLimit)
+ if !ok {
+ return nil, errTeamConfig
+ }
+ encoded, ok := homeConfigBase64(cfg.OperatorKeysBase64, 4096)
+ if !ok {
+ return nil, errTeamConfig
+ }
+ var keys []roster.Key
+ if json.Unmarshal(encoded, &keys, json.RejectUnknownMembers(true)) != nil {
+ return nil, errTeamConfig
+ }
+ expected := roster.Bindings{Issuer: home.issuer, Directory: cfg.DirectoryOrigin, Consumer: home.consumerID, App: "docs", Team: cfg.TeamID, Audience: origin}
+ record, e := roster.VerifyProvisioning(raw, keys, expected)
+ if e != nil {
+ return nil, errTeamConfig
+ }
+ b, e := json.Marshal(record.Bindings())
+ if e != nil {
+ return nil, errTeamConfig
+ }
+ digest := sha256.Sum256(b)
+ return &teamSettings{record: record, token: cfg.RosterToken, fingerprint: base64.RawURLEncoding.EncodeToString(digest[:])}, nil
+}
+
+type teamRuntime struct {
+ cache *roster.Cache
+ principals *principalstore.Store
+ bindings roster.Bindings
+ db *sql.DB
+}
+
+func newTeamRuntime(ctx context.Context, settings *teamSettings, db *sql.DB, home *homeSettings) (*teamRuntime, error) {
+ if settings == nil || db == nil || home == nil {
+ return nil, errTeamConfig
+ }
+ b := settings.record.Bindings()
+ var foreign int
+ if e := db.QueryRowContext(ctx, "SELECT (SELECT COUNT(*) FROM docs_identity_links WHERE issuer!=?)+(SELECT COUNT(*) FROM docs_home_identities WHERE issuer!=?)", b.Issuer, b.Issuer).Scan(&foreign); e != nil {
+ return nil, errTeamUnavailable
+ }
+ if foreign != 0 {
+ return nil, errTeamConfig
+ }
+ if e := teamstate.Activate(ctx, db, settings.fingerprint); e != nil {
+ return nil, errTeamConfig
+ }
+ sealer, e := seal.New(home.dek, "oficina/docs")
+ 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})
+ if e != nil {
+ return nil, errTeamUnavailable
+ }
+ runtime := &teamRuntime{cache: cache, bindings: b, db: db}
+ principals, e := principalstore.New(db, principalstore.Config{Issuer: b.Issuer, Team: b.Team, RequireMembership: func(ctx context.Context, request principalstore.MembershipRequest) error {
+ d := cache.Check(ctx, request.Issuer, request.Team, request.Subject)
+ if d.State == roster.Member && d.Lease.Valid() {
+ return nil
+ }
+ if d.State == roster.NotMember {
+ return principalstore.ErrRefused
+ }
+ return principalstore.ErrUnavailable
+ }})
+ if e != nil {
+ return nil, errTeamUnavailable
+ }
+ runtime.principals = principals
+ return runtime, nil
+}
diff --git a/internal/docs/team_identity_test.go b/internal/docs/team_identity_test.go
new file mode 100644
index 0000000..02800b5
--- /dev/null
+++ b/internal/docs/team_identity_test.go
@@ -0,0 +1,133 @@
+package docs
+
+import (
+ "bytes"
+ "context"
+ "encoding/base64"
+ "errors"
+ "strings"
+ "testing"
+
+ "amadan.net/oficina/home/roster"
+ family "amadan.net/rastrillo/rastrillo/crypto"
+ "github.com/carlosframework/docs/internal/docs/principalstore"
+ "github.com/carlosframework/docs/internal/docs/teamstate"
+ "github.com/carlosframework/rastrillo/migrate"
+ "github.com/go-json-experiment/json"
+ "gorm.io/gorm"
+)
+
+func teamConfigFixture(t *testing.T) (*TeamConfig, *homeSettings) {
+ t.Helper()
+ op, e := family.Generate()
+ if e != nil {
+ t.Fatal(e)
+ }
+ key, e := family.Generate()
+ if e != nil {
+ t.Fatal(e)
+ }
+ b := roster.Bindings{Issuer: "https://id.example", Directory: "https://home.example", Consumer: "docs-A", App: "docs", Team: "TeamA", Audience: "https://docs.example"}
+ p := roster.Provisioning{Version: 1, Operation: "provision1", Sequence: 1, Bindings: b, Generation: base64.RawURLEncoding.EncodeToString(bytes.Repeat([]byte{1}, 32)), MinEpoch: 1, Keys: []roster.Key{{ID: "roster1", Public: base64.RawURLEncoding.EncodeToString(key.SignPub())}}}
+ raw, e := roster.SignProvisioning(op, "operator1", p)
+ if e != nil {
+ t.Fatal(e)
+ }
+ keys, _ := json.Marshal([]roster.Key{{ID: "operator1", Public: base64.RawURLEncoding.EncodeToString(op.SignPub())}})
+ return &TeamConfig{TeamID: b.Team, DirectoryOrigin: b.Directory, ProvisioningBase64: base64.StdEncoding.EncodeToString(raw), OperatorKeysBase64: base64.StdEncoding.EncodeToString(keys), RosterToken: base64.RawURLEncoding.EncodeToString(bytes.Repeat([]byte{7}, 32))}, &homeSettings{issuer: b.Issuer, consumerID: b.Consumer, dek: bytes.Repeat([]byte{9}, 32)}
+}
+func TestTeamConfigPinsAndColdMarker(t *testing.T) {
+ cfg, home := teamConfigFixture(t)
+ settings, e := validatedTeamConfig(cfg, home, "https://docs.example")
+ if e != nil {
+ t.Fatal(e)
+ }
+ for _, change := range []func(*TeamConfig){func(c *TeamConfig) { c.TeamID = "Other" }, func(c *TeamConfig) { c.DirectoryOrigin = "https://other.example" }, func(c *TeamConfig) { c.RosterToken += "=" }, func(c *TeamConfig) { c.ProvisioningBase64 += "\n" }, func(c *TeamConfig) { c.OperatorKeysBase64 = "e30=" }} {
+ bad := *cfg
+ change(&bad)
+ if _, e := validatedTeamConfig(&bad, home, "https://docs.example"); e != errTeamConfig {
+ t.Fatal("bad team config")
+ }
+ }
+ if _, e := validatedTeamConfig(cfg, nil, "https://docs.example"); e != errTeamConfig {
+ t.Fatal("team without Home")
+ }
+ app := newSampleDocumentsInternalTestApp(t, "0")
+ if _, e := migrate.Apply(t.Context(), app.db, TeamBootSchema); e != nil {
+ t.Fatal(e)
+ }
+ rt, e := newTeamRuntime(t.Context(), settings, app.db.Writer(), home)
+ if e != nil {
+ t.Fatal(e)
+ }
+ if rt.cache == nil || rt.principals == nil {
+ t.Fatal("runtime missing")
+ }
+ if got, yes, e := teamstate.Required(t.Context(), app.db.Writer()); e != nil || !yes || got != settings.fingerprint {
+ t.Fatal("runtime did not commit marker")
+ }
+ var count int
+ _ = app.db.Writer().QueryRow("SELECT COUNT(*) FROM docs_home_identities").Scan(&count)
+ if count != 0 {
+ t.Fatal("runtime seeded identity")
+ }
+}
+func TestTeamIdentityAddressAbsenceNeverRedeemsEmptyGrant(t *testing.T) {
+ app := newSampleDocumentsInternalTestApp(t, "0")
+ ctx := context.Background()
+ s := app.Store()
+ local, e := s.EnsurePerson(ctx, "owner@example.com")
+ if e != nil {
+ t.Fatal(e)
+ }
+ doc, e := s.Create(ctx, local, "private")
+ if e != nil {
+ t.Fatal(e)
+ }
+ if _, e = migrate.Apply(ctx, app.db, TeamBootSchema); e != nil {
+ t.Fatal(e)
+ }
+ ps, e := principalstore.New(app.db.Writer(), principalstore.Config{Issuer: "https://id.example", Team: "TeamA", RequireMembership: func(context.Context, principalstore.MembershipRequest) error { return nil }})
+ if e != nil {
+ t.Fatal(e)
+ }
+ subject := "pr_" + strings.Repeat("a", 26)
+ p, e := ps.EnsureHome(ctx, principalstore.HomeIdentity{Issuer: "https://id.example", Subject: subject})
+ if e != nil {
+ t.Fatal(e)
+ }
+ rt := &teamRuntime{principals: ps, bindings: roster.Bindings{Issuer: "https://id.example", Team: "TeamA"}, db: app.db.Writer()}
+ s.team = rt
+ resolver := identityResolver{store: s, team: rt}
+ id, e := resolver.resolve(identityRequest("GET", subject, homeAssertionMethod))
+ if e != nil || id.Actor.PersonID != p.ID || id.Actor.VerifiedAddress != nil || id.HomeSubject != subject {
+ t.Fatal("wrong Home principal", e)
+ }
+ if e = s.db.Create(&Grant{DocID: doc.ID, Subject: "", Role: RoleWriter, CreatedBy: local}).Error; e != nil {
+ t.Fatal(e)
+ }
+ if _, e = s.GetFor(ctx, id.Actor, doc.Sqid); !errors.Is(e, gorm.ErrRecordNotFound) {
+ t.Fatal("nil address redeemed malformed grant", e)
+ }
+ if s.CanWrite(ctx, id.Actor, doc.ID) {
+ t.Fatal("nil address redeemed malformed writer grant")
+ }
+ own, e := s.Create(ctx, p.ID, "Home owned")
+ if e != nil {
+ t.Fatal(e)
+ }
+ if e = s.Grant(ctx, p.ID, own.Sqid, "guest@example.com", RoleReader); e != nil {
+ t.Fatal("addressless owner could not grant", e)
+ }
+ var people int64
+ s.db.Model(&Person{}).Count(&people)
+ if people != 1 {
+ t.Fatal("Home identity created legacy person")
+ }
+ if _, e = resolver.resolve(identityRequest("GET", "pr_"+strings.Repeat("b", 26), homeAssertionMethod)); e != errIdentityRefused {
+ t.Fatal("unknown identity allocated")
+ }
+ if _, e = resolver.resolve(identityRequest("GET", "unknown@example.com", "unsupported")); e != errIdentityRefused {
+ t.Fatal("unsupported local method accepted")
+ }
+}
diff --git a/internal/docs/teamstate/migrations/0001_team.sql b/internal/docs/teamstate/migrations/0001_team.sql
new file mode 100644
index 0000000..637b599
--- /dev/null
+++ b/internal/docs/teamstate/migrations/0001_team.sql
@@ -0,0 +1,10 @@
+CREATE TABLE docs_team_required (
+ id INTEGER PRIMARY KEY CHECK(id=1),
+ bindings TEXT NOT NULL CHECK(typeof(bindings)='text' AND length(bindings)=43)
+);
+CREATE TRIGGER docs_team_required_keep_update BEFORE UPDATE ON docs_team_required BEGIN SELECT RAISE(ABORT,'docs team: refused'); END;
+CREATE TRIGGER docs_team_required_keep_delete BEFORE DELETE ON docs_team_required BEGIN SELECT RAISE(ABORT,'docs team: refused'); END;
+CREATE TABLE docs_team_roster_evidence (
+ id INTEGER PRIMARY KEY CHECK(id=1),
+ payload BLOB NOT NULL CHECK(typeof(payload)='blob' AND length(payload)<=1048704)
+);
diff --git a/internal/docs/teamstate/store.go b/internal/docs/teamstate/store.go
new file mode 100644
index 0000000..00d016a
--- /dev/null
+++ b/internal/docs/teamstate/store.go
@@ -0,0 +1,98 @@
+// Package teamstate keeps the mandatory-mode marker and opaque roster evidence.
+package teamstate
+
+import (
+ "context"
+ "database/sql"
+ "embed"
+ "encoding/base64"
+ "errors"
+
+ "amadan.net/oficina/home/roster"
+ "github.com/carlosframework/rastrillo/migrate"
+)
+
+//go:embed migrations/*.sql
+var migrations embed.FS
+var Schema = migrate.MustFromFS(migrations, "docs_team")
+var ErrRefused = errors.New("docs team: refused")
+var ErrUnavailable = errors.New("docs team: unavailable")
+
+func validBinding(s string) bool {
+ b, e := base64.RawURLEncoding.DecodeString(s)
+ return e == nil && len(b) == 32 && base64.RawURLEncoding.EncodeToString(b) == s
+}
+
+// Required distinguishes a never-activated database from an unreadable marker.
+func Required(ctx context.Context, db *sql.DB) (string, bool, error) {
+ var count int
+ if e := db.QueryRowContext(ctx, "SELECT COUNT(*) FROM sqlite_master WHERE type='table' AND name='docs_team_required'").Scan(&count); e != nil {
+ return "", false, ErrUnavailable
+ }
+ if count == 0 {
+ return "", false, nil
+ }
+ var value string
+ e := db.QueryRowContext(ctx, "SELECT bindings FROM docs_team_required WHERE id=1").Scan(&value)
+ if errors.Is(e, sql.ErrNoRows) {
+ return "", false, nil
+ }
+ if e != nil {
+ return "", false, ErrUnavailable
+ }
+ if !validBinding(value) {
+ return "", false, ErrRefused
+ }
+ return value, true, nil
+}
+
+// Activate commits before the caller may serve requests or allocate identities.
+// It records an already-verified deployment binding, never an owner or roster.
+func Activate(ctx context.Context, db *sql.DB, binding string) error {
+ if !validBinding(binding) {
+ return ErrRefused
+ }
+ tx, e := db.BeginTx(ctx, nil)
+ if e != nil {
+ return ErrUnavailable
+ }
+ defer tx.Rollback()
+ if _, e = tx.ExecContext(ctx, "INSERT INTO docs_team_required(id,bindings) VALUES(1,?) ON CONFLICT(id) DO NOTHING", binding); e != nil {
+ return ErrUnavailable
+ }
+ var got string
+ if e = tx.QueryRowContext(ctx, "SELECT bindings FROM docs_team_required WHERE id=1").Scan(&got); e != nil {
+ return ErrUnavailable
+ }
+ if got != binding {
+ return ErrRefused
+ }
+ if tx.Commit() != nil {
+ return ErrUnavailable
+ }
+ return nil
+}
+
+type Evidence struct{ DB *sql.DB }
+
+func (s Evidence) Load(ctx context.Context) ([]byte, error) {
+ var b []byte
+ e := s.DB.QueryRowContext(ctx, "SELECT payload FROM docs_team_roster_evidence WHERE id=1").Scan(&b)
+ if errors.Is(e, sql.ErrNoRows) {
+ return nil, roster.ErrNoEvidence
+ }
+ if e != nil || len(b) > roster.EvidenceLimit+128 {
+ return nil, ErrUnavailable
+ }
+ return b, nil
+}
+func (s Evidence) Save(ctx context.Context, b []byte) error {
+ if len(b) == 0 || len(b) > roster.EvidenceLimit+128 {
+ return ErrRefused
+ }
+ _, e := s.DB.ExecContext(ctx, "INSERT INTO docs_team_roster_evidence(id,payload) VALUES(1,?) ON CONFLICT(id) DO UPDATE SET payload=excluded.payload", b)
+ if e != nil {
+ return ErrUnavailable
+ }
+ return nil
+}
diff --git a/internal/docs/teamstate/store_test.go b/internal/docs/teamstate/store_test.go
new file mode 100644
index 0000000..097ec5f
--- /dev/null
+++ b/internal/docs/teamstate/store_test.go
@@ -0,0 +1,89 @@
+package teamstate
+
+import (
+ "bytes"
+ "context"
+ "encoding/base64"
+ "path/filepath"
+ "strings"
+ "testing"
+
+ "amadan.net/oficina/home/roster"
+ "github.com/carlosframework/rastrillo/db"
+ "github.com/carlosframework/rastrillo/migrate"
+)
+
+func fixture(t *testing.T) *db.DB {
+ t.Helper()
+ d, e := db.Open(filepath.Join(t.TempDir(), "team.db"), nil)
+ if e != nil {
+ t.Fatal(e)
+ }
+ t.Cleanup(func() { d.Close() })
+ return d
+}
+func TestMarkerActivationAndImmutability(t *testing.T) {
+ d := fixture(t)
+ ctx := context.Background()
+ if _, yes, e := Required(ctx, d.Writer()); e != nil || yes {
+ t.Fatal("fresh DB requires team")
+ }
+ if _, e := migrate.Apply(ctx, d, Schema); e != nil {
+ t.Fatal(e)
+ }
+ if _, yes, e := Required(ctx, d.Writer()); e != nil || yes {
+ t.Fatal("schema alone activated mode")
+ }
+ b := base64.RawURLEncoding.EncodeToString(bytes.Repeat([]byte{1}, 32))
+ if e := Activate(ctx, d.Writer(), b); e != nil {
+ t.Fatal(e)
+ }
+ if got, yes, e := Required(ctx, d.Writer()); e != nil || !yes || got != b {
+ t.Fatal("marker missing")
+ }
+ if e := Activate(ctx, d.Writer(), b); e != nil {
+ t.Fatal("idempotent activation", e)
+ }
+ other := base64.RawURLEncoding.EncodeToString(bytes.Repeat([]byte{2}, 32))
+ if Activate(ctx, d.Writer(), other) != ErrRefused {
+ t.Fatal("binding changed")
+ }
+ for _, q := range []string{"DELETE FROM docs_team_required", "UPDATE docs_team_required SET bindings=bindings"} {
+ if _, e := d.Writer().Exec(q); e == nil {
+ t.Fatal("marker mutable")
+ }
+ }
+}
+func TestMalformedMarkerAndEvidenceFailure(t *testing.T) {
+ d := fixture(t)
+ ctx := context.Background()
+ if _, e := migrate.Apply(ctx, d, Schema); e != nil {
+ t.Fatal(e)
+ }
+ if _, e := d.Writer().Exec("INSERT INTO docs_team_required VALUES(1,?)", strings.Repeat("?", 43)); e != nil {
+ t.Fatal(e)
+ }
+ if _, _, e := Required(ctx, d.Writer()); e != ErrRefused {
+ t.Fatal("malformed marker accepted")
+ }
+ evidence := Evidence{DB: d.Writer()}
+ if _, e := evidence.Load(ctx); e != roster.ErrNoEvidence {
+ t.Fatal(e)
+ }
+ first := []byte("opaque sealed test envelope")
+ if e := evidence.Save(ctx, first); e != nil {
+ t.Fatal(e)
+ }
+ cancelled, cancel := context.WithCancel(ctx)
+ cancel()
+ if evidence.Save(cancelled, []byte("replacement")) == nil {
+ t.Fatal("cancelled replacement succeeded")
+ }
+ if evidence.Save(ctx, make([]byte, roster.EvidenceLimit+129)) != ErrRefused {
+ t.Fatal("oversize accepted")
+ }
+ got, e := evidence.Load(ctx)
+ if e != nil || !bytes.Equal(got, first) {
+ t.Fatal("failed write replaced evidence")
+ }
+}
diff --git a/internal/docs/testing.go b/internal/docs/testing.go
index 29562f2..7ff32a8 100644
--- a/internal/docs/testing.go
+++ b/internal/docs/testing.go
@@ -279,7 +279,7 @@ func (a *App) TestActorForPerson(t *testing.T, person int64) Actor {
if err := a.Store().db.Where("id = ?", person).First(&p).Error; err != nil {
t.Fatalf("TestActorForPerson: resolving person %d: %v", person, err)
}
- return Actor{PersonID: p.ID, Subject: p.Email}
+ return localActor(p.ID, p.Email)
}
// TestEditorScriptPath is the served path of the editor bundle, exported so