oficina / docs Public

Wire phased content sealing and explicit offline activation

Paul Campbell pushed by paul@keymail.dev fccd1783e617a0c75656785c973c5e5d9d4c3ac6 parent 61de075
11 files changed, +498 −18
  • cmd/docs/content_maintenance.go +26 −0
  • cmd/docs/content_maintenance_test.go +53 −0
  • cmd/docs/main.go +8 −0
  • internal/docs/app.go +22 −1
  • internal/docs/content_migration.go +14 −5
  • internal/docs/content_runtime.go +67 −0
  • internal/docs/content_runtime_test.go +280 −0
  • internal/docs/handlers.go +8 −0
  • internal/docs/models.go +0 −10
  • internal/docs/snapshots.go +10 −0
  • internal/docs/store.go +10 −2
diff --git a/cmd/docs/content_maintenance.go b/cmd/docs/content_maintenance.go
new file mode 100644
index 0000000..f9a2618
--- /dev/null
+++ b/cmd/docs/content_maintenance.go
@@ -0,0 +1,26 @@
+package main
+
+import (
+ "context"
+ "flag"
+ "fmt"
+ "io"
+ "log/slog"
+
+ "github.com/carlosframework/docs/internal/docs"
+)
+
+func runContentMaintenance(ctx context.Context, args []string, getenv func(string) string, logger *slog.Logger) error {
+ flags := flag.NewFlagSet("docs-seal", flag.ContinueOnError)
+ flags.SetOutput(io.Discard)
+ path := flags.String("db", "", "")
+ fingerprint := flags.String("expect-fingerprint", "", "")
+ scrub := flags.Bool("scrub", false, "")
+ if err := flags.Parse(args); err != nil {
+ return err
+ }
+ if flags.NArg() != 0 || *path == "" || *fingerprint == "" {
+ return fmt.Errorf("docs-seal: -db, -expect-fingerprint")
+ }
+ return docs.MaintainContent(ctx, docs.ContentMaintenanceConfig{DBPath: *path, ContentKey: getenv("DOCS_CONTENT_KEY"), ExpectedFingerprint: *fingerprint, Scrub: *scrub, Logger: logger})
+}
diff --git a/cmd/docs/content_maintenance_test.go b/cmd/docs/content_maintenance_test.go
new file mode 100644
index 0000000..3cfce77
--- /dev/null
+++ b/cmd/docs/content_maintenance_test.go
@@ -0,0 +1,53 @@
+package main
+
+import (
+ "bytes"
+ "context"
+ "crypto/sha256"
+ "encoding/base64"
+ "encoding/hex"
+ "log/slog"
+ "path/filepath"
+ "testing"
+
+ "github.com/carlosframework/docs/internal/docs"
+)
+
+func TestContentMaintenanceCommandActivatesExistingDatabase(t *testing.T) {
+ path := filepath.Join(t.TempDir(), "docs.db")
+ key := bytes.Repeat([]byte{91}, 32)
+ cfg := docs.Config{DBPath: path, Env: "development", ContentKey: base64.StdEncoding.EncodeToString(key)}
+ app, err := docs.New(cfg)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if err := app.Close(); err != nil {
+ t.Fatal(err)
+ }
+ fingerprint := sha256.Sum256(key)
+ env := func(name string) string {
+ if name != "DOCS_CONTENT_KEY" {
+ t.Fatalf("unexpected env: %s", name)
+ }
+ return cfg.ContentKey
+ }
+ if err := runContentMaintenance(context.Background(), []string{"-db", path, "-expect-fingerprint", hex.EncodeToString(fingerprint[:]), "-scrub"}, env, slog.Default()); err != nil {
+ t.Fatal(err)
+ }
+ cfg.ContentKey = base64.StdEncoding.EncodeToString(bytes.Repeat([]byte{92}, 32))
+ app, err = docs.New(cfg)
+ if app != nil {
+ app.Close()
+ }
+ if err == nil {
+ t.Fatal("maintenance did not bind database to key")
+ }
+}
+
+func TestContentMaintenanceCommandRequiresExplicitInputs(t *testing.T) {
+ for _, args := range [][]string{nil, {"-db", "somewhere"}, {"-expect-fingerprint", "something"}, {"-deactivate"}, {"-db", "somewhere", "-expect-fingerprint", "something", "extra"}} {
+ if err := runContentMaintenance(context.Background(), args, func(string) string { t.Fatal("invalid CLI reached key loading"); return "" }, slog.Default()); err == nil {
+ t.Fatalf("accepted args: %v", args)
+ }
+ }
+}
diff --git a/cmd/docs/main.go b/cmd/docs/main.go
index 7f68336..69cf7d6 100644
--- a/cmd/docs/main.go
+++ b/cmd/docs/main.go
@@ -135,6 +135,13 @@ func checkSeedGate(seed bool, env string) error {
func main() {
logger := slog.Default()
+ if len(os.Args) > 1 && os.Args[1] == "docs-seal" {
+ if err := runContentMaintenance(context.Background(), os.Args[2:], os.Getenv, logger); err != nil {
+ logger.Error("docs: new", "err", err)
+ os.Exit(1)
+ }
+ return
+ }
// -env (chooseEnv above; $DOCS_ENV is consulted too, but never for
// "development" — see chooseEnv's own doc comment, fix round 2)
@@ -264,6 +271,7 @@ func main() {
// already owns.
cfg := docs.Config{
DBPath: opts.DBPath,
+ ContentKey: os.Getenv("DOCS_CONTENT_KEY"),
Env: env,
Origin: origin,
InstanceKey: instanceKey,
diff --git a/internal/docs/app.go b/internal/docs/app.go
index 5308573..a5ddb45 100644
--- a/internal/docs/app.go
+++ b/internal/docs/app.go
@@ -33,7 +33,8 @@ import (
// Config configures New.
type Config struct {
// DBPath is the SQLite file New opens via rastrillo's db package.
- DBPath string
+ DBPath string
+ ContentKey string
// Env is "development" or "production" ("" defaults to
// "production"). It gates two fail-closed defaults below — Origin
@@ -361,6 +362,11 @@ func New(cfg Config) (*App, error) {
return nil, err
}
+ cipher, err := newContentCipher(cfg.ContentKey, cfg.Env)
+ if err != nil {
+ return nil, err
+ }
+
d, err := db.Open(cfg.DBPath, logger)
if err != nil {
return nil, fmt.Errorf("docs: open database: %w", err)
@@ -371,6 +377,18 @@ func New(cfg Config) (*App, error) {
return nil, fmt.Errorf("docs: apply migrations: %w", err)
}
+ state, err := readContentSealing(context.Background(), d.G, cipher)
+ if err == nil && state != nil {
+ err = requireSealedContent(context.Background(), d.G)
+ }
+ if err != nil {
+ d.Close()
+ return nil, err
+ }
+ if state == nil {
+ logger.Error("docs: content sealing not activated", "content_encoding", 0, "key_fingerprint", cipher.fingerprint)
+ }
+
// One Runtime for the process (core.New compiles the wasm module
// once — expensive; instantiating a per-document Handle from it is
// not), shared by every document's Store.replay.
@@ -380,6 +398,9 @@ func New(cfg Config) (*App, error) {
return nil, fmt.Errorf("docs: start core runtime: %w", err)
}
store := NewStore(d.G, rt, parseSampleDocuments(cfg.SampleDocuments), logger)
+ if state != nil {
+ store.content = cipher
+ }
// A grant admits its holder. Wired here rather than inside
// resolveAllowlist because the allowlist is resolved before the store
diff --git a/internal/docs/content_migration.go b/internal/docs/content_migration.go
index 9115fb1..7b82759 100644
--- a/internal/docs/content_migration.go
+++ b/internal/docs/content_migration.go
@@ -39,7 +39,10 @@ func readContentSealing(ctx context.Context, g *gorm.DB, c *contentCipher) (*con
if err != nil {
return nil, err
}
- if state.Encoding != contentEncoding || c == nil || state.KeyFingerprint != c.fingerprint {
+ if c == nil || state.KeyFingerprint != c.fingerprint {
+ return nil, errContentKeyMismatch
+ }
+ if state.Encoding != contentEncoding {
return nil, errContentState
}
plain, err := c.open(contentKeyCheckKind, uuid.Nil.String(), 0, uint32(state.Encoding), state.KeyCheck)
@@ -61,12 +64,21 @@ func convertContent(ctx context.Context, g *gorm.DB, c *contentCipher) error {
if err != nil {
return err
}
+ var hadLegacy bool
+ if state != nil {
+ if err := tx.Raw("SELECT EXISTS(SELECT 1 FROM doc_updates WHERE content_encoding = 0 LIMIT 1) OR EXISTS(SELECT 1 FROM doc_snapshots WHERE content_encoding = 0 LIMIT 1)").Scan(&hadLegacy).Error; err != nil {
+ return err
+ }
+ }
for _, snapshot := range []bool{false, true} {
if err := convertContentRows(ctx, tx, c, snapshot, state != nil); err != nil {
return err
}
}
if state != nil {
+ if hadLegacy {
+ return tx.Model(&contentSealing{}).Where("id = 1").Update("scrubbed", false).Error
+ }
return nil
}
check, err := c.seal(contentKeyCheckKind, uuid.Nil.String(), 0, contentEncoding, contentKeyCheck)
@@ -109,10 +121,7 @@ func convertContentRows(ctx context.Context, tx *gorm.DB, c *contentCipher, snap
return err
}
last = row.ID
- if active {
- if row.ContentEncoding != contentEncoding {
- return errContentEncoding
- }
+ if active && row.ContentEncoding == contentEncoding {
if _, err := c.open(kind, row.GUID, row.Seq, row.ABIVersion, row.Body); err != nil {
return err
}
diff --git a/internal/docs/content_runtime.go b/internal/docs/content_runtime.go
new file mode 100644
index 0000000..ce5d239
--- /dev/null
+++ b/internal/docs/content_runtime.go
@@ -0,0 +1,67 @@
+package docs
+
+import (
+ "context"
+ "errors"
+
+ "gorm.io/gorm"
+)
+
+var errContentKeyMismatch = errors.New("docs: content key does not match this database")
+var errContentMixed = errors.New("docs: mixed content encodings; run docs-seal offline")
+
+func requireSealedContent(ctx context.Context, g *gorm.DB) error {
+ var mixed bool
+ if err := g.WithContext(ctx).Raw(`SELECT EXISTS(SELECT 1 FROM doc_updates WHERE content_encoding != 1 LIMIT 1) OR EXISTS(SELECT 1 FROM doc_snapshots WHERE content_encoding != 1 LIMIT 1)`).Scan(&mixed).Error; err != nil {
+ return err
+ }
+ if mixed {
+ return errContentMixed
+ }
+ return nil
+}
+
+func refuseActivatedLegacyWrite(tx *gorm.DB) error {
+ var count int64
+ if err := tx.Model(&contentSealing{}).Where("id = 1").Count(&count).Error; err != nil {
+ return err
+ }
+ if count != 0 {
+ return errContentState
+ }
+ return nil
+}
+
+func (s *Store) prepareContentUpdate(tx *gorm.DB, guid string, row *DocUpdate) error {
+ if s.content == nil {
+ return refuseActivatedLegacyWrite(tx)
+ }
+ return s.content.sealUpdate(guid, row)
+}
+
+func (s *Store) prepareContentSnapshot(tx *gorm.DB, guid string, row *DocSnapshot) error {
+ if s.content == nil {
+ return refuseActivatedLegacyWrite(tx)
+ }
+ return s.content.sealSnapshot(guid, row)
+}
+
+func (s *Store) openContentUpdate(guid string, row *DocUpdate) error {
+ if s.content == nil {
+ if row.ContentEncoding != 0 {
+ return errContentEncoding
+ }
+ return nil
+ }
+ return s.content.openUpdate(guid, row)
+}
+
+func (s *Store) openContentSnapshot(guid string, row *DocSnapshot) error {
+ if s.content == nil {
+ if row.ContentEncoding != 0 {
+ return errContentEncoding
+ }
+ return nil
+ }
+ return s.content.openSnapshot(guid, row)
+}
diff --git a/internal/docs/content_runtime_test.go b/internal/docs/content_runtime_test.go
new file mode 100644
index 0000000..cf4209b
--- /dev/null
+++ b/internal/docs/content_runtime_test.go
@@ -0,0 +1,280 @@
+package docs
+
+import (
+ "bytes"
+ "context"
+ "encoding/base64"
+ "errors"
+ "log/slog"
+ "net/http"
+ "path/filepath"
+ "strings"
+ "testing"
+ "time"
+
+ "gorm.io/gorm"
+)
+
+func activatedContentApp(t *testing.T) (*App, string) {
+ t.Helper()
+ key := base64.StdEncoding.EncodeToString(bytes.Repeat([]byte{71}, 32))
+ cfg := Config{DBPath: filepath.Join(t.TempDir(), "docs.db"), Env: "development", ContentKey: key}
+ app, err := New(cfg)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if err := app.Close(); err != nil {
+ t.Fatal(err)
+ }
+ cipher, err := newContentCipher(key, "production")
+ if err != nil {
+ t.Fatal(err)
+ }
+ if err := MaintainContent(context.Background(), ContentMaintenanceConfig{DBPath: cfg.DBPath, ContentKey: key, ExpectedFingerprint: cipher.fingerprint}); err != nil {
+ t.Fatal(err)
+ }
+ app, err = New(cfg)
+ if err != nil {
+ t.Fatal(err)
+ }
+ t.Cleanup(func() {
+ if err := app.Close(); err != nil {
+ t.Error(err)
+ }
+ })
+ return app, key
+}
+
+func TestActivatedContentPersistsSealedAndServesPlainUpdates(t *testing.T) {
+ app, _ := activatedContentApp(t)
+ ctx := context.Background()
+ owner := app.TestPerson(t, "owner@example.com")
+ doc, err := app.Store().Create(ctx, owner, "Sealed")
+ if err != nil {
+ t.Fatal(err)
+ }
+ req := app.TestRequest(t, "owner@example.com", http.MethodPost, "/d/"+doc.Sqid+"/updates", bytes.NewReader(validUpdate(t)))
+ if rec := serve(app, req); rec.Code != http.StatusNoContent {
+ t.Fatalf("POST: %d %s", rec.Code, rec.Body.String())
+ }
+ var row DocUpdate
+ if err := app.store.db.First(&row).Error; err != nil {
+ t.Fatal(err)
+ }
+ if row.ContentEncoding != 1 || bytes.Contains(row.UpdateBin, []byte("First paragraph.")) || bytes.Equal(row.UpdateBin, validUpdate(t)) {
+ t.Fatal("persisted content is not sealed")
+ }
+ for _, suffix := range []string{"/updates", "/updates?raw=1"} {
+ rec := serve(app, app.TestRequest(t, "owner@example.com", http.MethodGet, "/d/"+doc.Sqid+suffix, nil))
+ if rec.Code != 200 || !bytes.Equal(rec.Body.Bytes(), validUpdate(t)) {
+ t.Fatalf("GET %s: %d", suffix, rec.Code)
+ }
+ }
+ h, seq, _, err := app.store.replay(ctx, doc)
+ if err != nil {
+ t.Fatal(err)
+ }
+ defer h.Close(ctx)
+ if err := app.store.writeSnapshot(ctx, doc, h, seq); err != nil {
+ t.Fatal(err)
+ }
+ var snap DocSnapshot
+ if err := app.store.db.First(&snap).Error; err != nil {
+ t.Fatal(err)
+ }
+ if snap.ContentEncoding != 1 || bytes.Contains(snap.State, []byte("First paragraph.")) {
+ t.Fatal("snapshot is not sealed")
+ }
+ rec := serve(app, app.TestRequest(t, "owner@example.com", http.MethodGet, "/d/"+doc.Sqid+"/updates", nil))
+ if rec.Code != 200 || bytes.Equal(rec.Body.Bytes(), snap.State) {
+ t.Fatalf("snapshot GET: %d", rec.Code)
+ }
+ if text, err := app.store.Text(ctx, doc); err != nil || !strings.Contains(text, "First paragraph.") {
+ t.Fatalf("sealed replay text: %q %v", text, err)
+ }
+ replayed, _, _, err := app.store.replay(ctx, doc)
+ if err != nil {
+ t.Fatal(err)
+ }
+ replayed.Close(ctx)
+ row.UpdateBin[len(row.UpdateBin)-1] ^= 1
+ if err := app.store.db.Model(&row).Update("update_bin", row.UpdateBin).Error; err != nil {
+ t.Fatal(err)
+ }
+ rec = serve(app, app.TestRequest(t, "owner@example.com", http.MethodGet, "/d/"+doc.Sqid+"/updates?raw=1", nil))
+ if rec.Code != 500 || strings.Contains(rec.Body.String(), "First paragraph.") || rec.Header().Get("Docs-Seq") != "" {
+ t.Fatal("corrupt update was exposed or acknowledged")
+ }
+}
+
+func TestLegacyContentWriterRefusesAfterActivation(t *testing.T) {
+ app, err := NewForTest(t)
+ if err != nil {
+ t.Fatal(err)
+ }
+ ctx := context.Background()
+ owner := app.TestPerson(t, "owner@example.com")
+ doc, err := app.store.Create(ctx, owner, "Legacy")
+ if err != nil {
+ t.Fatal(err)
+ }
+ h, err := app.core.NewDoc(ctx, doc.GUID, doc.ClientID)
+ if err != nil {
+ t.Fatal(err)
+ }
+ defer h.Close(ctx)
+ if err := h.Apply(ctx, validUpdate(t)); err != nil {
+ t.Fatal(err)
+ }
+ cipher, err := newContentCipher("", "development")
+ if err != nil {
+ t.Fatal(err)
+ }
+ 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 {
+ 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()}
+ err = app.store.db.Transaction(func(tx *gorm.DB) error {
+ if err := app.store.prepareContentUpdate(tx, doc.GUID, update); err != nil {
+ return err
+ }
+ return tx.Create(update).Error
+ })
+ if !errors.Is(err, errContentState) {
+ t.Fatalf("legacy update: %v", err)
+ }
+ if err := app.store.writeSnapshot(ctx, doc, h, 1); !errors.Is(err, errContentState) {
+ t.Fatalf("legacy snapshot: %v", err)
+ }
+ var count int64
+ if err := app.store.db.Model(&DocUpdate{}).Count(&count).Error; err != nil || count != 0 {
+ t.Fatalf("legacy write committed: %d %v", count, err)
+ }
+}
+
+func TestContentStartupRejectsMixedStateAndMaintenanceRepairsWithoutMarkerRewrite(t *testing.T) {
+ app, key := activatedContentApp(t)
+ ctx := context.Background()
+ doc, err := app.store.Create(ctx, app.TestPerson(t, "owner@example.com"), "Mixed")
+ if err != nil {
+ t.Fatal(err)
+ }
+ var before contentSealing
+ if err := app.store.db.First(&before).Error; err != nil {
+ t.Fatal(err)
+ }
+ row := DocUpdate{DocID: doc.ID, Seq: 1, UpdateBin: validUpdate(t), CreatedAt: time.Now().UTC()}
+ if err := app.store.db.Create(&row).Error; err != nil {
+ t.Fatal(err)
+ }
+ candidate, err := New(app.cfg)
+ if candidate != nil {
+ candidate.Close()
+ }
+ if !errors.Is(err, errContentMixed) {
+ t.Fatalf("mixed boot: %v", err)
+ }
+ if err := convertContent(ctx, app.store.db, app.store.content); err != nil {
+ t.Fatal(err)
+ }
+ var after contentSealing
+ if err := app.store.db.First(&after).Error; err != nil {
+ t.Fatal(err)
+ }
+ if before.KeyFingerprint != after.KeyFingerprint || !bytes.Equal(before.KeyCheck, after.KeyCheck) {
+ t.Fatal("repair rewrote activation marker")
+ }
+ if err := app.store.db.First(&row).Error; err != nil {
+ t.Fatal(err)
+ }
+ if row.ContentEncoding != 1 {
+ t.Fatal("repair retained legacy row")
+ }
+ cfg := app.cfg
+ cfg.ContentKey = base64.StdEncoding.EncodeToString(bytes.Repeat([]byte{72}, 32))
+ candidate, err = New(cfg)
+ if candidate != nil {
+ candidate.Close()
+ }
+ if !errors.Is(err, errContentKeyMismatch) {
+ t.Fatalf("wrong-key boot: %v", err)
+ }
+ cfg.ContentKey = key
+ candidate, err = New(cfg)
+ if err != nil {
+ t.Fatal(err)
+ }
+ candidate.Close()
+ if err := app.store.db.Delete(&contentSealing{}, 1).Error; err != nil {
+ t.Fatal(err)
+ }
+ candidate, err = New(cfg)
+ if candidate != nil {
+ candidate.Close()
+ }
+ if !errors.Is(err, errContentState) {
+ t.Fatalf("missing-marker boot: %v", err)
+ }
+}
+
+func TestLegacyBootDiagnosesUnactivatedContent(t *testing.T) {
+ var logs bytes.Buffer
+ app, err := NewForTestWith(t, Config{Logger: slog.New(slog.NewJSONHandler(&logs, nil))})
+ if err != nil {
+ t.Fatal(err)
+ }
+ if app.store.content != nil {
+ t.Fatal("legacy boot activated content")
+ }
+ if !strings.Contains(logs.String(), `"level":"ERROR"`) || !strings.Contains(logs.String(), `"content_encoding":0`) {
+ t.Fatalf("legacy diagnosis absent: %s", logs.String())
+ }
+}
+
+func TestContentRepairFailurePreservesActivatedMarkerAndLegacyRows(t *testing.T) {
+ app, _ := activatedContentApp(t)
+ doc, err := app.store.Create(context.Background(), app.TestPerson(t, "owner@example.com"), "Repair")
+ if err != nil {
+ t.Fatal(err)
+ }
+ var before contentSealing
+ if err := app.store.db.First(&before).Error; err != nil {
+ t.Fatal(err)
+ }
+ rows := []DocUpdate{{DocID: doc.ID, Seq: 1, UpdateBin: validUpdate(t), CreatedAt: time.Now().UTC()}, {DocID: doc.ID, Seq: 2, UpdateBin: validUpdate(t), ContentEncoding: 99, CreatedAt: time.Now().UTC()}}
+ if err := app.store.db.Create(&rows).Error; err != nil {
+ t.Fatal(err)
+ }
+ if err := convertContent(context.Background(), app.store.db, app.store.content); !errors.Is(err, errContentEncoding) {
+ t.Fatalf("repair failure: %v", err)
+ }
+ var after contentSealing
+ if err := app.store.db.First(&after).Error; err != nil {
+ t.Fatal(err)
+ }
+ if before.Encoding != after.Encoding || before.KeyFingerprint != after.KeyFingerprint || !bytes.Equal(before.KeyCheck, after.KeyCheck) || before.Scrubbed != after.Scrubbed {
+ t.Fatal("failed repair changed marker")
+ }
+ var row DocUpdate
+ if err := app.store.db.First(&row, rows[0].ID).Error; err != nil {
+ t.Fatal(err)
+ }
+ if row.ContentEncoding != 0 || !bytes.Equal(row.UpdateBin, validUpdate(t)) {
+ t.Fatal("failed repair partially converted legacy row")
+ }
+}
+
+func TestProductionBootRequiresContentKeyBeforeOpeningLegacyDatabase(t *testing.T) {
+ path := filepath.Join(t.TempDir(), "docs.db")
+ cfg := Config{DBPath: path, Env: "production", Origin: "https://docs.example.com", InstanceKey: "independent auth key", Mailer: &recordingMailer{}, AllowedAddresses: "owner@example.com"}
+ app, err := New(cfg)
+ if app != nil {
+ app.Close()
+ }
+ if !errors.Is(err, errContentKey) {
+ t.Fatalf("missing production content key: %v", err)
+ }
+}
diff --git a/internal/docs/handlers.go b/internal/docs/handlers.go
index 6c14a80..53061c8 100644
--- a/internal/docs/handlers.go
+++ b/internal/docs/handlers.go
@@ -738,6 +738,14 @@ func (h *handlers) updatesGet(w http.ResponseWriter, r *http.Request) {
return
}
+ for i := range updates {
+ if err := h.store.openContentUpdate(doc.GUID, &updates[i]); err != nil {
+ h.logger.Error("docs: load update log", "sqid", doc.Sqid, "err", err)
+ http.Error(w, rastrillo.T(r, "docs.error.generic"), http.StatusInternalServerError)
+ return
+ }
+ }
+
total := 0
if snapshot != nil {
total = len(snapshot.State)
diff --git a/internal/docs/models.go b/internal/docs/models.go
index 17301af..6758361 100644
--- a/internal/docs/models.go
+++ b/internal/docs/models.go
@@ -78,12 +78,6 @@ type Person struct {
// DocUpdate is one Yjs update in a document's log, applied in Seq
// order to rebuild the document's CRDT state (Store.replay).
-//
-// UpdateBin is plaintext in this table. Sealing it (spec §2 — per-row
-// AES-GCM, same as doc titles and everything else that is document
-// content) is out of scope for this plan; this comment exists so the
-// next reader does not mistake that for a decision that it stays
-// plaintext.
type DocUpdate struct {
ID int64 `gorm:"primaryKey"`
DocID int64 `gorm:"column:doc_id;not null;index"`
@@ -101,10 +95,6 @@ type DocUpdate struct {
// a snapshot that cannot be read is discarded in favour of replaying
// them (the snapshots design, D2).
//
-// State is plaintext in this table, exactly as DocUpdate.UpdateBin is.
-// Worth one caveat the sealing work should carry: a doc_updates row is a
-// fragment of an edit, while this is the entire document in one blob.
-//
// ABIVersion records the core that produced State. Without it, "discard
// a snapshot from an older ABI" is unenforceable prose.
type DocSnapshot struct {
diff --git a/internal/docs/snapshots.go b/internal/docs/snapshots.go
index e632f65..3ffe465 100644
--- a/internal/docs/snapshots.go
+++ b/internal/docs/snapshots.go
@@ -49,6 +49,13 @@ func (s *Store) newestSnapshot(ctx context.Context, docID int64) (*DocSnapshot,
if err != nil {
return nil, fmt.Errorf("docs: newest snapshot for document %d: %w", docID, err)
}
+ var doc Document
+ if err := s.db.WithContext(ctx).Select("guid").First(&doc, docID).Error; err != nil {
+ return nil, err
+ }
+ if err := s.openContentSnapshot(doc.GUID, &row); err != nil {
+ return nil, err
+ }
return &row, nil
}
@@ -102,6 +109,9 @@ func (s *Store) writeSnapshot(ctx context.Context, doc *Document, h *core.Handle
CreatedAt: time.Now().UTC(),
}
return s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
+ if err := s.prepareContentSnapshot(tx, doc.GUID, row); err != nil {
+ return err
+ }
if err := tx.Create(row).Error; err != nil {
return fmt.Errorf("docs: persist snapshot for document %d at seq %d: %w", doc.ID, seq, err)
}
diff --git a/internal/docs/store.go b/internal/docs/store.go
index 5ef83f9..d268d4a 100644
--- a/internal/docs/store.go
+++ b/internal/docs/store.go
@@ -21,8 +21,9 @@ 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 {
- db *gorm.DB
- core *core.Runtime
+ db *gorm.DB
+ core *core.Runtime
+ content *contentCipher
// sampleDocuments mirrors Config.SampleDocuments, already parsed —
// see EnsurePerson's own doc comment for what it gates and why the
@@ -451,6 +452,10 @@ func (s *Store) replay(ctx context.Context, doc *Document) (h *core.Handle, repl
}
for _, u := range updates {
+ if err := s.openContentUpdate(doc.GUID, &u); err != nil {
+ h.Close(ctx)
+ return nil, 0, 0, err
+ }
applyErr := h.Apply(ctx, u.UpdateBin)
if errors.Is(applyErr, core.ErrUpdateLogFull) {
// This handle has retained core.MaxRetainedUpdates updates
@@ -853,6 +858,9 @@ func (s *Store) ApplyUpdate(ctx context.Context, doc *Document, actor Actor, upd
UpdateBin: update,
CreatedAt: time.Now().UTC(),
}
+ if err := s.prepareContentUpdate(tx, doc.GUID, row); err != nil {
+ return err
+ }
if err := tx.Create(row).Error; err != nil {
return fmt.Errorf("docs: persist update for document %d: %w", doc.ID, err)
}