is the *base* tag: an environment variable name cannot hold the hyphen
+// in "pt-PT", and the handler routes on the base tag anyway (a request for
+// pt-PT, pt-BR or bare pt reaches the same instance, because there is only one
+// Portuguese voice loaded). A pair is ignored unless both halves are set: half
+// a configuration should read as "no voice for this language" and fall back to
+// the browser, not as an instance that answers every request with an error.
+func ttsVoices(environ []string) map[string]TTSVoice {
+ vals := make(map[string]string, len(environ))
+ for _, kv := range environ {
+ if k, v, ok := strings.Cut(kv, "="); ok {
+ vals[k] = v
+ }
+ }
+
+ voices := map[string]TTSVoice{}
+ add := func(lang, endpoint, voice string) {
+ endpoint = strings.TrimRight(strings.TrimSpace(endpoint), "/")
+ voice = strings.TrimSpace(voice)
+ if endpoint == "" || voice == "" {
+ return
+ }
+ voices[lang] = TTSVoice{Endpoint: endpoint, Voice: voice}
+ }
+
+ // The two languages that shipped before this was a map keep their voice
+ // defaults, so an existing deployment that names only the endpoints (as
+ // millenia's unit does) sounds exactly as it did.
+ voiceOr := func(key, fallback string) string {
+ if v := strings.TrimSpace(vals[key]); v != "" {
+ return v
+ }
+ return fallback
+ }
+
+ add("en", vals["TTS_ENDPOINT"], voiceOr("TTS_VOICE_EN", "en_US-amy-medium"))
+ for k, endpoint := range vals {
+ suffix, ok := strings.CutPrefix(k, "TTS_ENDPOINT_")
+ if !ok || suffix == "" {
+ continue
+ }
+ voice := vals["TTS_VOICE_"+suffix]
+ if suffix == "ZH" {
+ voice = voiceOr("TTS_VOICE_ZH", "zh_CN-huayan-medium")
+ }
+ add(strings.ToLower(suffix), endpoint, voice)
+ }
+ return voices
+}
+
func env(key, fallback string) string {
if v := os.Getenv(key); v != "" {
return v
diff --git a/internal/config/config_test.go b/internal/config/config_test.go
new file mode 100644
index 0000000..c75d616
--- /dev/null
+++ b/internal/config/config_test.go
@@ -0,0 +1,88 @@
+package config
+
+import "testing"
+
+// The Piper instances are discovered from the environment rather than named in
+// code, so that a new pair costs a compose service and two .env lines. These
+// assert the discovery rule, including the two shapes that already exist in the
+// wild (millenia's systemd unit and the VPS compose file).
+func TestTTSVoicesDiscovery(t *testing.T) {
+ voices := ttsVoices([]string{
+ "TTS_ENDPOINT=http://piper-en:5000",
+ "TTS_VOICE_EN=en_US-amy-medium",
+ "TTS_ENDPOINT_ZH=http://piper-zh:5000/",
+ "TTS_VOICE_ZH=zh_CN-huayan-medium",
+ "TTS_ENDPOINT_PT=http://piper-pt:5000",
+ "TTS_VOICE_PT=pt_PT-tugão-medium",
+ "TTS_ENDPOINT_FR=http://piper-fr:5000",
+ "TTS_VOICE_FR=fr_FR-siwis-medium",
+ // Noise that must not become a language.
+ "TTS_PATH=/synthesize",
+ "PATH=/usr/bin",
+ })
+
+ want := map[string]TTSVoice{
+ "en": {"http://piper-en:5000", "en_US-amy-medium"},
+ // The trailing slash is trimmed here so the synthesis path concatenates
+ // cleanly rather than producing a double slash at every call site.
+ "zh": {"http://piper-zh:5000", "zh_CN-huayan-medium"},
+ "pt": {"http://piper-pt:5000", "pt_PT-tugão-medium"},
+ // Phase 24's whole TTS change: a fourth language costs two lines here
+ // and a compose service, and no Go at all.
+ "fr": {"http://piper-fr:5000", "fr_FR-siwis-medium"},
+ }
+ if len(voices) != len(want) {
+ t.Fatalf("discovered %v, want %v", voices, want)
+ }
+ for lang, w := range want {
+ if voices[lang] != w {
+ t.Errorf("%s = %+v, want %+v", lang, voices[lang], w)
+ }
+ }
+}
+
+// Half a configuration is not a language. An endpoint with no voice (or the
+// reverse) must read as "no voice for this language" — a 404 the client answers
+// by falling back to Web Speech — rather than as an instance that exists and
+// errors on every request.
+func TestTTSVoicesIgnoresHalfConfiguredLanguages(t *testing.T) {
+ voices := ttsVoices([]string{
+ "TTS_ENDPOINT=http://piper-en:5000",
+ "TTS_VOICE_EN=en_US-amy-medium",
+ "TTS_ENDPOINT_FR=http://piper-fr:5000", // no TTS_VOICE_FR
+ "TTS_VOICE_ES=es_ES-davefx-medium", // no TTS_ENDPOINT_ES
+ })
+ if _, ok := voices["fr"]; ok {
+ t.Errorf("fr routed with no voice configured")
+ }
+ if _, ok := voices["es"]; ok {
+ t.Errorf("es routed with no endpoint configured")
+ }
+ if len(voices) != 1 {
+ t.Errorf("discovered %v, want English only", voices)
+ }
+}
+
+// A deployment that predates the map names only the endpoints and relies on the
+// voice defaults; it must sound exactly as it did.
+func TestTTSVoicesKeepsTheOriginalDefaults(t *testing.T) {
+ voices := ttsVoices([]string{
+ "TTS_ENDPOINT=http://127.0.0.1:5005",
+ "TTS_ENDPOINT_ZH=http://127.0.0.1:5006",
+ })
+ if got := voices["en"].Voice; got != "en_US-amy-medium" {
+ t.Errorf("en voice = %q, want the default", got)
+ }
+ if got := voices["zh"].Voice; got != "zh_CN-huayan-medium" {
+ t.Errorf("zh voice = %q, want the default", got)
+ }
+}
+
+// Read-aloud is off when no English instance is configured; nothing else may
+// switch it on. (tts.New gates on TTSEndpoint, so a stray TTS_ENDPOINT_PT with
+// no English sibling must not produce a routable map that outlives that gate.)
+func TestTTSVoicesEmptyWithoutEndpoints(t *testing.T) {
+ if voices := ttsVoices([]string{"TTS_VOICE_EN=en_US-amy-medium"}); len(voices) != 0 {
+ t.Errorf("discovered %v, want none", voices)
+ }
+}
diff --git a/internal/db/backup.go b/internal/db/backup.go
new file mode 100644
index 0000000..bd77d66
--- /dev/null
+++ b/internal/db/backup.go
@@ -0,0 +1,79 @@
+package db
+
+import (
+ "database/sql"
+ "fmt"
+ "os"
+ "path/filepath"
+)
+
+// Backup writes a consistent copy of the database at srcPath to destPath using
+// SQLite's `VACUUM INTO`.
+//
+// Why not copy the file: Petal runs in WAL mode, so at any instant the newest
+// committed pages may live in petal.db-wal rather than petal.db. Copying the
+// three files separately can capture them mid-checkpoint and produce a backup
+// that is subtly torn. `VACUUM INTO` runs inside a read transaction, so it sees
+// one coherent snapshot including the WAL, and emits a single defragmented file
+// with no -wal/-shm companions — exactly what you want to ship off-box.
+//
+// It takes no write lock, so this is safe to run against the live database
+// while someone is writing.
+//
+// destPath must not already exist: SQLite refuses to overwrite, which keeps a
+// failed run from destroying the previous good backup.
+func Backup(srcPath, destPath string) error {
+ if _, err := os.Stat(srcPath); err != nil {
+ return fmt.Errorf("source database: %w", err)
+ }
+ if _, err := os.Stat(destPath); err == nil {
+ return fmt.Errorf("destination %s already exists", destPath)
+ }
+ if dir := filepath.Dir(destPath); dir != "" && dir != "." {
+ if err := os.MkdirAll(dir, 0o755); err != nil {
+ return fmt.Errorf("create backup dir: %w", err)
+ }
+ }
+
+ // Opened directly rather than through Open: a backup must never migrate or
+ // seed the database it is copying.
+ conn, err := sql.Open("sqlite", dsn(srcPath))
+ if err != nil {
+ return fmt.Errorf("open source: %w", err)
+ }
+ defer conn.Close()
+ conn.SetMaxOpenConns(1)
+
+ if err := conn.Ping(); err != nil {
+ return fmt.Errorf("ping source: %w", err)
+ }
+
+ // The path is interpolated because VACUUM INTO takes a literal, not a bound
+ // parameter. Quotes are doubled so a path containing one can't break out.
+ quoted := "'" + escapeSQLiteString(destPath) + "'"
+ if _, err := conn.Exec("VACUUM INTO " + quoted); err != nil {
+ return fmt.Errorf("vacuum into %s: %w", destPath, err)
+ }
+
+ // A zero-byte result would mean the vacuum silently produced nothing; catch
+ // it here rather than discovering it during a restore.
+ info, err := os.Stat(destPath)
+ if err != nil {
+ return fmt.Errorf("stat backup: %w", err)
+ }
+ if info.Size() == 0 {
+ return fmt.Errorf("backup %s is empty", destPath)
+ }
+ return nil
+}
+
+func escapeSQLiteString(s string) string {
+ out := make([]byte, 0, len(s))
+ for i := 0; i < len(s); i++ {
+ if s[i] == '\'' {
+ out = append(out, '\'')
+ }
+ out = append(out, s[i])
+ }
+ return string(out)
+}
diff --git a/internal/db/backup_test.go b/internal/db/backup_test.go
new file mode 100644
index 0000000..894e204
--- /dev/null
+++ b/internal/db/backup_test.go
@@ -0,0 +1,108 @@
+package db
+
+import (
+ "database/sql"
+ "os"
+ "path/filepath"
+ "testing"
+)
+
+// The point of VACUUM INTO over a file copy is that it captures rows still
+// sitting in the WAL. This writes with the source connection open (so the WAL
+// is hot and unlikely to have been checkpointed) and asserts the backup has
+// them.
+func TestBackupCapturesLiveWrites(t *testing.T) {
+ dir := t.TempDir()
+ src := filepath.Join(dir, "petal.db")
+ dest := filepath.Join(dir, "backups", "petal-backup.db")
+
+ d, err := Open(src)
+ if err != nil {
+ t.Fatalf("open source: %v", err)
+ }
+ defer d.Close()
+
+ if _, err := d.Exec(
+ `INSERT INTO documents (id, user_id, title, content_text) VALUES ('d1', ?, '春天', 'hello 春天')`,
+ LocalUserID,
+ ); err != nil {
+ t.Fatalf("insert: %v", err)
+ }
+
+ if err := Backup(src, dest); err != nil {
+ t.Fatalf("backup: %v", err)
+ }
+
+ // VACUUM INTO emits a single self-contained file — no -wal/-shm to ship
+ // alongside it.
+ for _, suffix := range []string{"-wal", "-shm"} {
+ if _, err := os.Stat(dest + suffix); err == nil {
+ t.Errorf("backup left a %s companion file behind", suffix)
+ }
+ }
+
+ copyConn, err := sql.Open("sqlite", dsn(dest))
+ if err != nil {
+ t.Fatalf("open backup: %v", err)
+ }
+ defer copyConn.Close()
+
+ var title string
+ if err := copyConn.QueryRow(`SELECT title FROM documents WHERE id = 'd1'`).Scan(&title); err != nil {
+ t.Fatalf("row missing from backup: %v", err)
+ }
+ if title != "春天" {
+ t.Errorf("title = %q, want 春天", title)
+ }
+
+ // The seeded user has to come across too, or a restore would orphan every
+ // document's foreign key.
+ var users int
+ if err := copyConn.QueryRow(`SELECT COUNT(*) FROM users WHERE id = ?`, LocalUserID).Scan(&users); err != nil {
+ t.Fatalf("count users: %v", err)
+ }
+ if users != 1 {
+ t.Errorf("users in backup = %d, want 1", users)
+ }
+}
+
+// A second run to the same path must fail loudly rather than clobber or
+// half-write the previous good backup.
+func TestBackupRefusesExistingDestination(t *testing.T) {
+ dir := t.TempDir()
+ src := filepath.Join(dir, "petal.db")
+ dest := filepath.Join(dir, "petal-backup.db")
+
+ d, err := Open(src)
+ if err != nil {
+ t.Fatalf("open source: %v", err)
+ }
+ defer d.Close()
+
+ if err := Backup(src, dest); err != nil {
+ t.Fatalf("first backup: %v", err)
+ }
+ before, err := os.ReadFile(dest)
+ if err != nil {
+ t.Fatalf("read backup: %v", err)
+ }
+
+ if err := Backup(src, dest); err == nil {
+ t.Fatal("second backup to the same path succeeded; want an error")
+ }
+
+ after, err := os.ReadFile(dest)
+ if err != nil {
+ t.Fatalf("re-read backup: %v", err)
+ }
+ if len(before) != len(after) {
+ t.Errorf("existing backup was modified: %d bytes → %d", len(before), len(after))
+ }
+}
+
+func TestBackupMissingSource(t *testing.T) {
+ dir := t.TempDir()
+ if err := Backup(filepath.Join(dir, "nope.db"), filepath.Join(dir, "out.db")); err == nil {
+ t.Fatal("backup of a nonexistent database succeeded; want an error")
+ }
+}
diff --git a/internal/db/db.go b/internal/db/db.go
index d52dbee..8e91c31 100644
--- a/internal/db/db.go
+++ b/internal/db/db.go
@@ -365,6 +365,138 @@ SELECT id, doc_id, from_pos, to_pos, original, replacement, explanation, type, s
DROP TABLE suggestions;
ALTER TABLE suggestions_new RENAME TO suggestions;
CREATE INDEX idx_suggestions_doc_id ON suggestions(doc_id);
+`,
+ },
+ {
+ // Writing passport: evidence that a document was written, not pasted.
+ //
+ // `preserve_history` opts a document out of auto-snapshot pruning. The
+ // 40-snapshot cap is right for recovery (you want recent states) but
+ // wrong for provenance (you want the *whole* span, oldest included), so
+ // a writer who may need to defend authorship flags the doc and keeps
+ // every snapshot.
+ //
+ // `content_hash`/`prev_hash` chain each snapshot to the one before it:
+ // hash = sha256(prev_hash | doc_id | created_at | word_count | text).
+ // This proves the local history is internally consistent — no snapshot
+ // was edited, reordered, or removed after the fact without breaking
+ // every link downstream. It is NOT third-party attestation: anyone with
+ // the DB and the algorithm could forge a fresh chain. It raises the cost
+ // of a doctored history from "edit one row" to "rebuild all of them".
+ // Pre-existing snapshots keep empty hashes and are reported as
+ // unverifiable rather than as failures.
+ name: "0009_writing_passport",
+ stmt: `
+ALTER TABLE documents ADD COLUMN preserve_history INTEGER NOT NULL DEFAULT 0;
+ALTER TABLE document_versions ADD COLUMN content_hash TEXT NOT NULL DEFAULT '';
+ALTER TABLE document_versions ADD COLUMN prev_hash TEXT NOT NULL DEFAULT '';
+`,
+ },
+ {
+ // Real accounts. Three separate things land together because they are
+ // one change: Petal can now tell users apart.
+ //
+ // `sessions` backs server-side login state. The cookie carries an opaque
+ // random token and this table stores only its SHA-256 — a leaked database
+ // copy therefore yields no usable session, the same reason passwords are
+ // hashed. Server-side rows (rather than a signed stateless cookie) are
+ // what make logout and revocation actually revoke.
+ //
+ // `images` gives the content-addressed image store an owner. Until now it
+ // was a flat directory with no database row at all: any caller holding a
+ // hash could fetch anyone's image, which is capability-URL security, not
+ // access control. The primary key is (name, user_id), so the same picture
+ // uploaded by two people is still stored once on disk and simply has two
+ // rows — deduplication survives; the file is deleted only with its last
+ // row. Rows for images already on disk are backfilled at startup by the
+ // images package, which is the only code that knows the storage path.
+ //
+ // `users.pair_lang` is the writer's language pair (English + X). It is
+ // unused until the langpack work, but it belongs to provisioning and
+ // costs nothing to add while the users table is already being touched.
+ name: "0010_sessions_images_and_pair_lang",
+ stmt: `
+CREATE TABLE sessions (
+ id TEXT PRIMARY KEY,
+ user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
+ expires_at DATETIME NOT NULL,
+ created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
+ user_agent TEXT NOT NULL DEFAULT ''
+);
+CREATE INDEX idx_sessions_user_id ON sessions(user_id);
+
+CREATE TABLE images (
+ name TEXT NOT NULL,
+ user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
+ content_type TEXT NOT NULL DEFAULT '',
+ size INTEGER NOT NULL DEFAULT 0,
+ created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
+ PRIMARY KEY (name, user_id)
+);
+CREATE INDEX idx_images_user_id ON images(user_id);
+
+ALTER TABLE users ADD COLUMN pair_lang TEXT NOT NULL DEFAULT 'zh';
+`,
+ },
+ {
+ // The personal spelling dictionary moves off the browser. It used to be a
+ // single `petal.spell.personal` key in localStorage, which meant two
+ // people sharing a device shared a word list built from one person's
+ // private writing — and one person writing on two devices had two
+ // unrelated lists.
+ //
+ // `lang` is the *dictionary's* language, not the writer's: a word is only
+ // ever added while a particular Hunspell dictionary flagged it, and an
+ // en-US personal word must not silence a pt-PT flag (or vice versa) once
+ // the second pair ships. `word` is stored as typed; matching is exact,
+ // because case carries meaning to a speller ("polish" vs "Polish").
+ name: "0011_personal_dictionary",
+ stmt: `
+CREATE TABLE personal_words (
+ user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
+ lang TEXT NOT NULL,
+ word TEXT NOT NULL,
+ created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
+ PRIMARY KEY (user_id, lang, word)
+);
+`,
+ },
+ {
+ // The growth journal reads the suggestions table as a record of what the
+ // writer has been learning, and that reading only works if a row is dated
+ // by *her decision* rather than by the model's proposal. `created_at` is
+ // when a checkpoint offered the edit; a suggestion offered in April and
+ // accepted in June is June's growth, not April's.
+ //
+ // Existing rows are backfilled to created_at — which is exactly the
+ // approximation the journal would have had to make anyway, and is very
+ // nearly right in practice since edits are settled minutes after a
+ // checkpoint. Only pending rows keep a NULL: nothing has been decided.
+ name: "0012_suggestion_resolved_at",
+ stmt: `
+ALTER TABLE suggestions ADD COLUMN resolved_at DATETIME;
+UPDATE suggestions SET resolved_at = created_at WHERE status != 'pending';
+CREATE INDEX idx_suggestions_resolved ON suggestions(status, resolved_at);
+`,
+ },
+ {
+ // Which engine proposed a row. Until now `type` doubled as that answer —
+ // 'mechanics' meant "the offline rule pack found this" and everything else
+ // meant "the model did". That breaks the moment an offline rule proposes a
+ // *collocation*: the miscollocation list (SUGGESTIONS §6) is the same
+ // family, the same rail and the same warm phrasing as the LLM coach, and it
+ // must stay type='collocation' so an accepted chunk still plants in the
+ // garden and still counts in the journal. With type no longer naming the
+ // engine, the two passes could not scope their own DELETEs — the coach
+ // would wipe the offline flags, and the offline pass would leave the
+ // coach's behind to accumulate.
+ //
+ // Existing mechanics rows are local by definition; everything else came
+ // from a model.
+ name: "0013_suggestion_source",
+ stmt: `
+ALTER TABLE suggestions ADD COLUMN source TEXT NOT NULL DEFAULT 'llm';
+UPDATE suggestions SET source = 'local' WHERE type = 'mechanics';
`,
},
}
diff --git a/internal/db/db_test.go b/internal/db/db_test.go
index 1eb12b8..e705c0e 100644
--- a/internal/db/db_test.go
+++ b/internal/db/db_test.go
@@ -83,3 +83,147 @@ func TestOpenMigratesAndSeeds(t *testing.T) {
t.Errorf("expected exactly 1 local user after reopen, got %d", users)
}
}
+
+// TestResolvedAtBackfill runs migration 0012 against a database that predates
+// it, which is the only shape that matters: on the live box the suggestions
+// table is years of settled edits with no resolved_at to their name. Backfilling
+// to created_at is exactly the approximation the growth journal would otherwise
+// have had to make, and a pending row must stay NULL — nothing has been decided.
+func TestResolvedAtBackfill(t *testing.T) {
+ path := filepath.Join(t.TempDir(), "old.db")
+ d, err := Open(path)
+ if err != nil {
+ t.Fatalf("open: %v", err)
+ }
+
+ // Rewind to the state before 0012: drop the column and forget the migration.
+ if _, err := d.Exec(`DROP INDEX idx_suggestions_resolved`); err != nil {
+ t.Fatalf("rewind index: %v", err)
+ }
+ if _, err := d.Exec(`ALTER TABLE suggestions DROP COLUMN resolved_at`); err != nil {
+ t.Fatalf("rewind schema: %v", err)
+ }
+ if _, err := d.Exec(`DELETE FROM schema_migrations WHERE name = '0012_suggestion_resolved_at'`); err != nil {
+ t.Fatalf("rewind migration record: %v", err)
+ }
+ if _, err := d.Exec(`INSERT INTO documents (id, user_id) VALUES ('d1', ?)`, LocalUserID); err != nil {
+ t.Fatalf("insert document: %v", err)
+ }
+ for _, s := range []struct{ id, status string }{
+ {"s-old", "accepted"},
+ {"s-open", "pending"},
+ } {
+ if _, err := d.Exec(
+ `INSERT INTO suggestions (id, doc_id, from_pos, to_pos, original, replacement, explanation, type, status, created_at)
+ VALUES (?, 'd1', 0, 3, 'teh', 'the', 'x', 'grammar', ?, '2026-01-02 03:04:05')`,
+ s.id, s.status,
+ ); err != nil {
+ t.Fatalf("seed %s: %v", s.id, err)
+ }
+ }
+ d.Close()
+
+ d2, err := Open(path)
+ if err != nil {
+ t.Fatalf("reopen (migrate): %v", err)
+ }
+ defer d2.Close()
+
+ // Compared against created_at read back the same way: the driver renders a
+ // DATETIME column itself, so the assertion is "the same instant", not a
+ // particular text format.
+ var settled, created *string
+ if err := d2.QueryRow(
+ `SELECT resolved_at, created_at FROM suggestions WHERE id = 's-old'`,
+ ).Scan(&settled, &created); err != nil {
+ t.Fatalf("read settled row: %v", err)
+ }
+ if settled == nil || created == nil || *settled != *created {
+ t.Errorf("resolved_at = %v, want it backfilled from created_at (%v)", settled, created)
+ }
+
+ var pending *string
+ if err := d2.QueryRow(`SELECT resolved_at FROM suggestions WHERE id = 's-open'`).Scan(&pending); err != nil {
+ t.Fatalf("read pending row: %v", err)
+ }
+ if pending != nil {
+ t.Errorf("pending row got resolved_at = %v, want NULL — nothing was decided", *pending)
+ }
+}
+
+// TestSuggestionSourceBackfill runs migration 0013 against a database that
+// predates it — the shape the live box is actually in. `source` is the column
+// that lets the offline rule pack and the model share the collocation family
+// without deleting each other's rows, and it can only do that if the existing
+// rows are labelled correctly on the way in: everything the old deterministic
+// pass wrote is local, and everything else came from a model.
+func TestSuggestionSourceBackfill(t *testing.T) {
+ path := filepath.Join(t.TempDir(), "old.db")
+ d, err := Open(path)
+ if err != nil {
+ t.Fatalf("open: %v", err)
+ }
+
+ // Rewind to the state before 0013.
+ if _, err := d.Exec(`ALTER TABLE suggestions DROP COLUMN source`); err != nil {
+ t.Fatalf("rewind schema: %v", err)
+ }
+ if _, err := d.Exec(`DELETE FROM schema_migrations WHERE name = '0013_suggestion_source'`); err != nil {
+ t.Fatalf("rewind migration record: %v", err)
+ }
+ if _, err := d.Exec(`INSERT INTO documents (id, user_id) VALUES ('d1', ?)`, LocalUserID); err != nil {
+ t.Fatalf("insert document: %v", err)
+ }
+ for _, s := range []struct{ id, typ string }{
+ {"s-mech", SuggestionTypeMechanics},
+ {"s-gram", SuggestionTypeGrammar},
+ {"s-coll", SuggestionTypeCollocation},
+ } {
+ if _, err := d.Exec(
+ `INSERT INTO suggestions (id, doc_id, from_pos, to_pos, original, replacement, explanation, type)
+ VALUES (?, 'd1', 0, 3, 'teh', 'the', 'x', ?)`,
+ s.id, s.typ,
+ ); err != nil {
+ t.Fatalf("seed %s: %v", s.id, err)
+ }
+ }
+ d.Close()
+
+ d2, err := Open(path)
+ if err != nil {
+ t.Fatalf("reopen (migrate): %v", err)
+ }
+ defer d2.Close()
+
+ // A pre-0013 collocation row can only have come from the coach — the offline
+ // miscollocation list did not exist yet — so it must NOT be claimed as local.
+ for id, want := range map[string]string{
+ "s-mech": SuggestionSourceLocal,
+ "s-gram": SuggestionSourceLLM,
+ "s-coll": SuggestionSourceLLM,
+ } {
+ var got string
+ if err := d2.QueryRow(`SELECT source FROM suggestions WHERE id = ?`, id).Scan(&got); err != nil {
+ t.Fatalf("read %s: %v", id, err)
+ }
+ if got != want {
+ t.Errorf("%s: source = %q, want %q", id, got, want)
+ }
+ }
+
+ // And a row written after the migration defaults to the model, so a code path
+ // that forgets to name a source can never silently claim to be offline.
+ if _, err := d2.Exec(
+ `INSERT INTO suggestions (id, doc_id, from_pos, to_pos, original, replacement, explanation, type)
+ VALUES ('s-new', 'd1', 0, 3, 'teh', 'the', 'x', 'grammar')`,
+ ); err != nil {
+ t.Fatalf("insert new row: %v", err)
+ }
+ var fresh string
+ if err := d2.QueryRow(`SELECT source FROM suggestions WHERE id = 's-new'`).Scan(&fresh); err != nil {
+ t.Fatalf("read new row: %v", err)
+ }
+ if fresh != SuggestionSourceLLM {
+ t.Errorf("default source = %q, want %q", fresh, SuggestionSourceLLM)
+ }
+}
diff --git a/internal/db/models.go b/internal/db/models.go
index 635d7cb..1c9e073 100644
--- a/internal/db/models.go
+++ b/internal/db/models.go
@@ -2,14 +2,19 @@ package db
import "time"
-// User is an account. With auth deferred, the app runs as a single hardcoded
-// `local` user (see LocalUserID); the user_id columns and this type exist so
-// real auth can drop in later without a schema migration.
+// User is an account. Its ID is the OIDC subject for anyone who signed in, or
+// LocalUserID for the pre-auth single user (and for local development, where
+// StaticResolver still hands out that id).
type User struct {
ID string `json:"id"`
Email string `json:"email"`
DisplayName string `json:"display_name"`
CreatedAt time.Time `json:"created_at"`
+
+ // PairLang is the X in this writer's (English + X) language pair — "zh"
+ // today, "pt-PT"/"fr"/"es" once the langpacks land. It selects the UI copy
+ // and dictionary set, not the language they may type in.
+ PairLang string `json:"pair_lang"`
}
// Document is a single piece of writing. `Content` is the Tiptap JSON document
@@ -25,6 +30,10 @@ type Document struct {
WordCount int `json:"word_count"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
+
+ // PreserveHistory opts this document out of auto-snapshot pruning so its
+ // full writing trail survives as authorship evidence (see the passport).
+ PreserveHistory bool `json:"preserve_history"`
}
// DocumentVersion is a point-in-time snapshot of a document's body, captured so
@@ -42,6 +51,13 @@ type DocumentVersion struct {
WordCount int `json:"word_count"`
Kind string `json:"kind"` // auto | manual | pre_restore
CreatedAt time.Time `json:"created_at"`
+
+ // ContentHash chains this snapshot to the previous one (PrevHash), so a
+ // history that was edited or thinned after the fact fails verification.
+ // Both are empty for snapshots taken before the chain existed. Omitted from
+ // list responses; the passport loads them explicitly.
+ ContentHash string `json:"content_hash,omitempty"`
+ PrevHash string `json:"prev_hash,omitempty"`
}
// Document version kinds, mirrored from the schema CHECK constraint.
@@ -90,7 +106,11 @@ type Suggestion struct {
Explanation string `json:"explanation"`
Type string `json:"type"` // grammar | phrasing | idiom | clarity | voice | collocation
Status string `json:"status"` // pending | accepted | rejected
- CreatedAt time.Time `json:"created_at"`
+ // Source names the engine that proposed the edit, not its family: an offline
+ // rule and the model can both propose a collocation, and the writer is never
+ // told which one spoke. It exists so each pass can replace its own rows.
+ Source string `json:"source"` // llm | local
+ CreatedAt time.Time `json:"created_at"`
}
// Suggestion type and status values, mirrored from the schema CHECK constraints.
@@ -103,6 +123,12 @@ const (
SuggestionTypeCollocation = "collocation"
SuggestionTypeMechanics = "mechanics" // deterministic rule-based pass (no LLM)
+ // Who proposed it. The offline rule pack ('local') runs on every edit inside
+ // the browser and survives a VPN-down box; the model ('llm') adds the long
+ // tail when it is reachable.
+ SuggestionSourceLLM = "llm"
+ SuggestionSourceLocal = "local"
+
SuggestionStatusPending = "pending"
SuggestionStatusAccepted = "accepted"
SuggestionStatusRejected = "rejected"
diff --git a/internal/docs/export.go b/internal/docs/export.go
index 8eafdd9..f2c49e7 100644
--- a/internal/docs/export.go
+++ b/internal/docs/export.go
@@ -13,6 +13,7 @@ import (
"github.com/go-chi/chi/v5"
+ "gitea.parodia.dev/drwily/petal/internal/auth"
"gitea.parodia.dev/drwily/petal/internal/db"
"gitea.parodia.dev/drwily/petal/internal/httputil"
)
@@ -46,7 +47,7 @@ func (h *Handler) exportAll(w http.ResponseWriter, r *http.Request) {
FROM documents
WHERE user_id = ?
ORDER BY updated_at DESC`,
- db.LocalUserID,
+ auth.UserID(r.Context()),
)
if err != nil {
httputil.ServerError(w, err)
@@ -143,7 +144,7 @@ func (h *Handler) export(w http.ResponseWriter, r *http.Request) {
return
}
- doc, err := h.fetch(chi.URLParam(r, "id"))
+ doc, err := h.fetch(auth.UserID(r.Context()), chi.URLParam(r, "id"))
if errors.Is(err, sql.ErrNoRows) {
notFound(w)
return
diff --git a/internal/docs/handlers.go b/internal/docs/handlers.go
index a946460..42f5a28 100644
--- a/internal/docs/handlers.go
+++ b/internal/docs/handlers.go
@@ -1,6 +1,7 @@
// Package docs implements the document CRUD HTTP handlers — the create / list /
// read / update / delete surface that backs the editor and its 1.5s auto-save.
-// All access is scoped to the single hardcoded local user while auth is deferred.
+// Every query is scoped to the caller resolved by the auth middleware, so a
+// document is only ever reachable by the user who owns it.
package docs
import (
@@ -12,6 +13,7 @@ import (
"github.com/go-chi/chi/v5"
+ "gitea.parodia.dev/drwily/petal/internal/auth"
"gitea.parodia.dev/drwily/petal/internal/db"
"gitea.parodia.dev/drwily/petal/internal/httputil"
)
@@ -52,15 +54,16 @@ type docSummary struct {
Tags []db.Tag `json:"tags"`
}
-// list returns the local user's documents, most-recently-updated first, each
+// list returns the caller's documents, most-recently-updated first, each
// decorated with its tags.
func (h *Handler) list(w http.ResponseWriter, r *http.Request) {
+ userID := auth.UserID(r.Context())
rows, err := h.DB.Query(
`SELECT id, title, word_count, updated_at
FROM documents
WHERE user_id = ?
ORDER BY updated_at DESC`,
- db.LocalUserID,
+ userID,
)
if err != nil {
httputil.ServerError(w, err)
@@ -84,7 +87,7 @@ func (h *Handler) list(w http.ResponseWriter, r *http.Request) {
return
}
- byDoc, err := h.tagsByDoc(ids)
+ byDoc, err := h.tagsByDoc(userID, ids)
if err != nil {
httputil.ServerError(w, err)
return
@@ -104,7 +107,7 @@ func (h *Handler) create(w http.ResponseWriter, r *http.Request) {
err := h.DB.QueryRow(
`INSERT INTO documents (user_id) VALUES (?)
RETURNING id, user_id, title, content, content_text, tone, word_count, created_at, updated_at`,
- db.LocalUserID,
+ auth.UserID(r.Context()),
).Scan(
&doc.ID, &doc.UserID, &doc.Title, &doc.Content, &doc.ContentText,
&doc.Tone, &doc.WordCount, &doc.CreatedAt, &doc.UpdatedAt,
@@ -118,7 +121,7 @@ func (h *Handler) create(w http.ResponseWriter, r *http.Request) {
// get returns a single full document by id.
func (h *Handler) get(w http.ResponseWriter, r *http.Request) {
- doc, err := h.fetch(chi.URLParam(r, "id"))
+ doc, err := h.fetch(auth.UserID(r.Context()), chi.URLParam(r, "id"))
if errors.Is(err, sql.ErrNoRows) {
notFound(w)
return
@@ -139,12 +142,17 @@ type updateRequest struct {
ContentText *string `json:"content_text"`
Tone *string `json:"tone"`
WordCount *int `json:"word_count"`
+
+ // PreserveHistory toggles the passport's keep-everything mode. Sent alone
+ // by the History panel's toggle, never by the auto-save path.
+ PreserveHistory *bool `json:"preserve_history"`
}
// update applies the provided fields to a document and returns the saved row.
// content and content_text are kept in sync by the client and written together.
func (h *Handler) update(w http.ResponseWriter, r *http.Request) {
id := chi.URLParam(r, "id")
+ userID := auth.UserID(r.Context())
var req updateRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
@@ -159,9 +167,11 @@ func (h *Handler) update(w http.ResponseWriter, r *http.Request) {
content_text = COALESCE(?, content_text),
tone = COALESCE(?, tone),
word_count = COALESCE(?, word_count),
+ preserve_history = COALESCE(?, preserve_history),
updated_at = CURRENT_TIMESTAMP
WHERE id = ? AND user_id = ?`,
- req.Title, req.Content, req.ContentText, req.Tone, req.WordCount, id, db.LocalUserID,
+ req.Title, req.Content, req.ContentText, req.Tone, req.WordCount,
+ req.PreserveHistory, id, userID,
)
if err != nil {
httputil.ServerError(w, err)
@@ -172,7 +182,7 @@ func (h *Handler) update(w http.ResponseWriter, r *http.Request) {
return
}
- doc, err := h.fetch(id)
+ doc, err := h.fetch(userID, id)
if err != nil {
httputil.ServerError(w, err)
return
@@ -194,7 +204,7 @@ func (h *Handler) update(w http.ResponseWriter, r *http.Request) {
func (h *Handler) delete(w http.ResponseWriter, r *http.Request) {
res, err := h.DB.Exec(
`DELETE FROM documents WHERE id = ? AND user_id = ?`,
- chi.URLParam(r, "id"), db.LocalUserID,
+ chi.URLParam(r, "id"), auth.UserID(r.Context()),
)
if err != nil {
httputil.ServerError(w, err)
@@ -207,17 +217,21 @@ func (h *Handler) delete(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusNoContent)
}
-// fetch loads one full document scoped to the local user.
-func (h *Handler) fetch(id string) (db.Document, error) {
+// fetch loads one full document, scoped to its owner. Callers pass the id from
+// [auth.UserID]; a document belonging to anyone else comes back as
+// sql.ErrNoRows, which handlers surface as a 404 rather than a 403 (a stranger's
+// document should be indistinguishable from one that doesn't exist).
+func (h *Handler) fetch(userID, id string) (db.Document, error) {
var doc db.Document
err := h.DB.QueryRow(
- `SELECT id, user_id, title, content, content_text, tone, word_count, created_at, updated_at
+ `SELECT id, user_id, title, content, content_text, tone, word_count,
+ created_at, updated_at, preserve_history
FROM documents
WHERE id = ? AND user_id = ?`,
- id, db.LocalUserID,
+ id, userID,
).Scan(
&doc.ID, &doc.UserID, &doc.Title, &doc.Content, &doc.ContentText,
- &doc.Tone, &doc.WordCount, &doc.CreatedAt, &doc.UpdatedAt,
+ &doc.Tone, &doc.WordCount, &doc.CreatedAt, &doc.UpdatedAt, &doc.PreserveHistory,
)
return doc, err
}
diff --git a/internal/docs/handlers_test.go b/internal/docs/handlers_test.go
index 1d0e35e..d80c9e7 100644
--- a/internal/docs/handlers_test.go
+++ b/internal/docs/handlers_test.go
@@ -8,10 +8,14 @@ import (
"path/filepath"
"testing"
+ "gitea.parodia.dev/drwily/petal/internal/auth"
"gitea.parodia.dev/drwily/petal/internal/db"
)
-// newTestServer spins up an isolated on-disk database and the docs router.
+// newTestServer spins up an isolated on-disk database and the docs router,
+// behind the same auth middleware main.go installs. Tests must go through it:
+// handlers read the caller from the request context, so a router mounted bare
+// would see an empty user id and match no rows.
func newTestServer(t *testing.T) http.Handler {
t.Helper()
database, err := db.Open(filepath.Join(t.TempDir(), "test.db"))
@@ -19,7 +23,13 @@ func newTestServer(t *testing.T) http.Handler {
t.Fatalf("open db: %v", err)
}
t.Cleanup(func() { database.Close() })
- return New(database).Routes()
+ return withAuth(New(database).Routes())
+}
+
+// withAuth wraps a router so every test request arrives authenticated as the
+// seeded local user — the stand-in for a real session until Authentik lands.
+func withAuth(h http.Handler) http.Handler {
+ return auth.Middleware(auth.StaticResolver(db.LocalUserID))(h)
}
func do(t *testing.T, srv http.Handler, method, path, body string) *httptest.ResponseRecorder {
diff --git a/internal/docs/isolation_test.go b/internal/docs/isolation_test.go
new file mode 100644
index 0000000..8622cda
--- /dev/null
+++ b/internal/docs/isolation_test.go
@@ -0,0 +1,222 @@
+package docs
+
+import (
+ "encoding/json"
+ "net/http"
+ "path/filepath"
+ "testing"
+
+ "github.com/go-chi/chi/v5"
+
+ "gitea.parodia.dev/drwily/petal/internal/auth"
+ "gitea.parodia.dev/drwily/petal/internal/db"
+)
+
+// This file is the point of the auth plumbing: it proves that swapping the
+// hardcoded user for a request-scoped one actually isolates accounts. Every
+// handler resolves its user from the request, so mounting the same routers twice
+// behind two different resolvers gives us two "logged-in" users over one
+// database — which is exactly the situation a real login will create.
+
+// newTwoUserServer opens one database holding two users and returns a router for
+// each, identical but for who the auth middleware says is calling.
+func newTwoUserServer(t *testing.T) (alice, bob http.Handler) {
+ t.Helper()
+ database, err := db.Open(filepath.Join(t.TempDir(), "test.db"))
+ if err != nil {
+ t.Fatalf("open db: %v", err)
+ }
+ t.Cleanup(func() { database.Close() })
+
+ // db.Open seeds the local user; add a second so both sides have a valid FK.
+ if _, err := database.Exec(
+ `INSERT INTO users (id, email, display_name) VALUES (?, ?, ?)`,
+ "bob", "bob@petal.local", "Bob",
+ ); err != nil {
+ t.Fatalf("seed second user: %v", err)
+ }
+
+ mount := func(userID string) http.Handler {
+ h := New(database)
+ r := chi.NewRouter()
+ r.Mount("/docs", h.Routes())
+ r.Mount("/tags", h.TagRoutes())
+ r.Mount("/search", h.SearchRoutes())
+ return auth.Middleware(auth.StaticResolver(userID))(r)
+ }
+ return mount(db.LocalUserID), mount("bob")
+}
+
+// TestDocumentIsolation walks every read and write path that takes a document id
+// and asserts Bob cannot reach Alice's document through any of them. A stranger's
+// document must be indistinguishable from a nonexistent one — 404, never 403.
+func TestDocumentIsolation(t *testing.T) {
+ alice, bob := newTwoUserServer(t)
+
+ docID := createDoc(t, alice, "Alice's diary", "a private sentence about my day")
+
+ t.Run("not in list", func(t *testing.T) {
+ rec := do(t, bob, http.MethodGet, "/docs", "")
+ var out []docSummary
+ if err := json.Unmarshal(rec.Body.Bytes(), &out); err != nil {
+ t.Fatalf("decode list: %v", err)
+ }
+ if len(out) != 0 {
+ t.Fatalf("bob sees %d of alice's documents, want 0", len(out))
+ }
+ })
+
+ t.Run("not in search", func(t *testing.T) {
+ rec := do(t, bob, http.MethodGet, "/search?q=private", "")
+ var out []searchResult
+ if err := json.Unmarshal(rec.Body.Bytes(), &out); err != nil {
+ t.Fatalf("decode search: %v", err)
+ }
+ if len(out) != 0 {
+ t.Fatalf("search leaked %d of alice's documents", len(out))
+ }
+ })
+
+ // The FTS index is a separate table joined back to documents; a missing
+ // user_id filter there would leak content even though the list query is
+ // scoped, so assert the owner still finds her own document.
+ t.Run("owner still finds it", func(t *testing.T) {
+ rec := do(t, alice, http.MethodGet, "/search?q=private", "")
+ var out []searchResult
+ if err := json.Unmarshal(rec.Body.Bytes(), &out); err != nil {
+ t.Fatalf("decode search: %v", err)
+ }
+ if len(out) != 1 {
+ t.Fatalf("alice found %d results for her own document, want 1", len(out))
+ }
+ })
+
+ for _, tc := range []struct {
+ name, method, path, body string
+ }{
+ {"get", http.MethodGet, "/docs/" + docID, ""},
+ {"update", http.MethodPut, "/docs/" + docID, `{"title":"defaced"}`},
+ {"delete", http.MethodDelete, "/docs/" + docID, ""},
+ {"export", http.MethodGet, "/docs/" + docID + "/export?format=md", ""},
+ {"passport", http.MethodGet, "/docs/" + docID + "/passport", ""},
+ {"snapshot", http.MethodPost, "/docs/" + docID + "/versions", ""},
+ } {
+ t.Run(tc.name, func(t *testing.T) {
+ rec := do(t, bob, tc.method, tc.path, tc.body)
+ if rec.Code != http.StatusNotFound {
+ t.Fatalf("%s %s as bob = %d, want 404 (body: %s)",
+ tc.method, tc.path, rec.Code, rec.Body)
+ }
+ })
+ }
+
+ // The document must have survived every attempt above unchanged.
+ rec := do(t, alice, http.MethodGet, "/docs/"+docID, "")
+ if rec.Code != http.StatusOK {
+ t.Fatalf("alice lost access to her own document: %d %s", rec.Code, rec.Body)
+ }
+ var doc db.Document
+ if err := json.Unmarshal(rec.Body.Bytes(), &doc); err != nil {
+ t.Fatalf("decode doc: %v", err)
+ }
+ if doc.Title != "Alice's diary" {
+ t.Fatalf("title = %q, want %q — bob's update went through", doc.Title, "Alice's diary")
+ }
+}
+
+// TestVersionIsolation covers the history endpoints, which scope through a join
+// to documents rather than a direct user_id column — an easy place to forget the
+// filter, and one where the leak would be the full text of every draft.
+func TestVersionIsolation(t *testing.T) {
+ alice, bob := newTwoUserServer(t)
+
+ docID := createDoc(t, alice, "Draft", "the first version of my essay")
+ rec := do(t, alice, http.MethodPost, "/docs/"+docID+"/versions", "")
+ if rec.Code != http.StatusCreated {
+ t.Fatalf("snapshot: %d %s", rec.Code, rec.Body)
+ }
+ var v db.DocumentVersion
+ if err := json.Unmarshal(rec.Body.Bytes(), &v); err != nil {
+ t.Fatalf("decode version: %v", err)
+ }
+
+ t.Run("list is empty for stranger", func(t *testing.T) {
+ rec := do(t, bob, http.MethodGet, "/docs/"+docID+"/versions", "")
+ var out []db.DocumentVersion
+ if err := json.Unmarshal(rec.Body.Bytes(), &out); err != nil {
+ t.Fatalf("decode: %v", err)
+ }
+ if len(out) != 0 {
+ t.Fatalf("bob sees %d of alice's snapshots, want 0", len(out))
+ }
+ })
+
+ for _, tc := range []struct{ name, method, path string }{
+ {"preview", http.MethodGet, "/docs/" + docID + "/versions/" + v.ID},
+ {"restore", http.MethodPost, "/docs/" + docID + "/versions/" + v.ID + "/restore"},
+ } {
+ t.Run(tc.name, func(t *testing.T) {
+ rec := do(t, bob, tc.method, tc.path, "")
+ if rec.Code != http.StatusNotFound {
+ t.Fatalf("%s as bob = %d, want 404 (body: %s)", tc.name, rec.Code, rec.Body)
+ }
+ })
+ }
+}
+
+// TestTagIsolation checks the tag roster and, more importantly, that a document
+// and a tag can't be cross-linked across accounts — the assignment endpoint takes
+// two ids from different tables and must own-check both.
+func TestTagIsolation(t *testing.T) {
+ alice, bob := newTwoUserServer(t)
+
+ docID := createDoc(t, alice, "Essay", "some words")
+
+ rec := do(t, alice, http.MethodPost, "/tags", `{"name":"school","color":"mint"}`)
+ if rec.Code != http.StatusCreated {
+ t.Fatalf("create tag: %d %s", rec.Code, rec.Body)
+ }
+ var aliceTag db.Tag
+ if err := json.Unmarshal(rec.Body.Bytes(), &aliceTag); err != nil {
+ t.Fatalf("decode tag: %v", err)
+ }
+
+ rec = do(t, bob, http.MethodPost, "/tags", `{"name":"bobs","color":"sky"}`)
+ var bobTag db.Tag
+ if err := json.Unmarshal(rec.Body.Bytes(), &bobTag); err != nil {
+ t.Fatalf("decode bob tag: %v", err)
+ }
+
+ t.Run("roster is per user", func(t *testing.T) {
+ rec := do(t, bob, http.MethodGet, "/tags", "")
+ var out []db.Tag
+ if err := json.Unmarshal(rec.Body.Bytes(), &out); err != nil {
+ t.Fatalf("decode: %v", err)
+ }
+ if len(out) != 1 || out[0].Name != "bobs" {
+ t.Fatalf("bob's roster = %+v, want just his own tag", out)
+ }
+ })
+
+ t.Run("cannot tag a stranger's document", func(t *testing.T) {
+ body, _ := json.Marshal(map[string]string{"tag_id": bobTag.ID})
+ rec := do(t, bob, http.MethodPost, "/docs/"+docID+"/tags", string(body))
+ if rec.Code != http.StatusNotFound {
+ t.Fatalf("bob tagging alice's doc = %d, want 404", rec.Code)
+ }
+ })
+
+ t.Run("cannot rename a stranger's tag", func(t *testing.T) {
+ rec := do(t, bob, http.MethodPatch, "/tags/"+aliceTag.ID, `{"name":"stolen"}`)
+ if rec.Code != http.StatusNotFound {
+ t.Fatalf("bob renaming alice's tag = %d, want 404", rec.Code)
+ }
+ })
+
+ t.Run("cannot delete a stranger's tag", func(t *testing.T) {
+ rec := do(t, bob, http.MethodDelete, "/tags/"+aliceTag.ID, "")
+ if rec.Code != http.StatusNotFound {
+ t.Fatalf("bob deleting alice's tag = %d, want 404", rec.Code)
+ }
+ })
+}
diff --git a/internal/docs/passport.go b/internal/docs/passport.go
new file mode 100644
index 0000000..d5fe298
--- /dev/null
+++ b/internal/docs/passport.go
@@ -0,0 +1,283 @@
+package docs
+
+// Writing passport: a standalone, printable report showing *how* a document was
+// written — when each snapshot landed, how the word count grew, how the work
+// broke into sessions. It exists because automated "AI detector" verdicts are
+// unreliable and skew against non-native English writers, so the useful thing to
+// hand someone who doubts your authorship is not a score but a record.
+//
+// The report is deliberately modest about what it proves (see passportLimits):
+// it evidences a plausible writing process, it does not certify one.
+
+import (
+ "crypto/sha256"
+ "database/sql"
+ "encoding/hex"
+ "errors"
+ "fmt"
+ "net/http"
+ "time"
+
+ "github.com/go-chi/chi/v5"
+
+ "gitea.parodia.dev/drwily/petal/internal/auth"
+ "gitea.parodia.dev/drwily/petal/internal/db"
+ "gitea.parodia.dev/drwily/petal/internal/httputil"
+)
+
+// Passport tuning.
+const (
+ // sessionGap is the idle time that separates one writing session from the
+ // next. Auto-snapshots fire at most every 3 minutes while typing, so any
+ // gap far above that means the writer stepped away. 45 minutes keeps a
+ // coffee break inside one session but splits morning from evening work.
+ sessionGap = 45 * time.Minute
+
+ // jumpNoteThreshold is the share of the final word count a single
+ // snapshot-to-snapshot increase must exceed before the report calls it out.
+ // A large jump is the first thing a skeptical reader will ask about, so the
+ // report raises it rather than leaving it to be discovered.
+ jumpNoteThreshold = 0.25
+)
+
+// chainHash links a snapshot to its predecessor. Covering prev_hash makes each
+// hash depend on the entire history before it, so altering any earlier snapshot
+// invalidates every later one; covering created_at means a row cannot be
+// silently backdated.
+//
+// This detects tampering with the local database. It is not third-party
+// attestation — someone with the database and this function could regenerate a
+// consistent chain from scratch.
+func chainHash(prevHash, docID string, createdAt time.Time, wordCount int, text string) string {
+ h := sha256.New()
+ fmt.Fprintf(h, "%s\x00%s\x00%d\x00%d\x00%s",
+ prevHash, docID, createdAt.UTC().UnixNano(), wordCount, text)
+ return hex.EncodeToString(h.Sum(nil))
+}
+
+// --- report model -----------------------------------------------------------
+
+// passportSession is one continuous stretch of work — snapshots with no
+// sessionGap-sized pause between them.
+type passportSession struct {
+ Start, End time.Time
+ Snapshots int
+ WordsAdded int // net change across the session; negative when trimming
+}
+
+// Duration is the observed length of the session: first snapshot to last. A
+// single-snapshot session reports zero, which is why total active time is
+// described as a lower bound.
+func (s passportSession) Duration() time.Duration { return s.End.Sub(s.Start) }
+
+// chain verification outcomes, in the order the report prefers to report them.
+const (
+ chainVerified = "verified" // every hash recomputes and every link holds
+ chainGaps = "gaps" // hashes valid, links broken — consistent with pruning
+ chainPartial = "partial" // some snapshots predate the hash chain
+ chainUnverifiable = "unverifiable" // no snapshot carries a hash
+ chainBroken = "broken" // a hash does not match its own contents
+)
+
+// passportData is everything the template renders.
+type passportData struct {
+ Doc db.Document
+ Versions []db.DocumentVersion // ascending by time
+
+ Sessions []passportSession
+ FirstAt time.Time
+ LastAt time.Time
+ Span time.Duration // wall-clock first snapshot → last
+ ActiveTime time.Duration // summed session durations; a lower bound
+
+ LargestJump int // biggest single snapshot-to-snapshot word increase
+ LargestJumpAt time.Time
+ LargestJumpIdx int // index into Versions, so the chart can mark it
+ NoteJump bool // jump is large enough to be worth pre-empting
+
+ ChainStatus string
+ UnhashedCount int
+ GeneratedAt time.Time
+}
+
+// buildPassport derives the report from a document and its snapshots, which must
+// be ordered oldest-first. It assumes nothing about snapshot spacing.
+func buildPassport(doc db.Document, versions []db.DocumentVersion) passportData {
+ d := passportData{
+ Doc: doc,
+ Versions: versions,
+ GeneratedAt: time.Now(),
+ }
+ if len(versions) == 0 {
+ d.ChainStatus = chainUnverifiable
+ return d
+ }
+
+ d.FirstAt = versions[0].CreatedAt
+ d.LastAt = versions[len(versions)-1].CreatedAt
+ d.Span = d.LastAt.Sub(d.FirstAt)
+
+ cur := passportSession{Start: versions[0].CreatedAt, End: versions[0].CreatedAt, Snapshots: 1}
+
+ // Baseline for the running session's net-words figure. Later sessions
+ // measure from the *previous* session's final count, not from their own
+ // first snapshot, because that first snapshot already contains the few
+ // minutes of typing that preceded it — measuring from it would drop that
+ // work. The first session is the exception: it measures from its own first
+ // snapshot rather than from zero, so a history whose early snapshots were
+ // pruned understates session one instead of reporting the words it never
+ // saw as a sudden addition.
+ startWords := versions[0].WordCount
+
+ for i := 1; i < len(versions); i++ {
+ v, prev := versions[i], versions[i-1]
+
+ if delta := v.WordCount - prev.WordCount; delta > d.LargestJump {
+ d.LargestJump, d.LargestJumpAt, d.LargestJumpIdx = delta, v.CreatedAt, i
+ }
+
+ if v.CreatedAt.Sub(prev.CreatedAt) > sessionGap {
+ cur.WordsAdded = prev.WordCount - startWords
+ d.Sessions = append(d.Sessions, cur)
+ cur = passportSession{Start: v.CreatedAt, End: v.CreatedAt, Snapshots: 1}
+ startWords = prev.WordCount
+ continue
+ }
+ cur.End = v.CreatedAt
+ cur.Snapshots++
+ }
+ cur.WordsAdded = versions[len(versions)-1].WordCount - startWords
+ d.Sessions = append(d.Sessions, cur)
+
+ for _, s := range d.Sessions {
+ d.ActiveTime += s.Duration()
+ }
+
+ final := versions[len(versions)-1].WordCount
+ d.NoteJump = final > 0 && float64(d.LargestJump)/float64(final) > jumpNoteThreshold
+
+ d.ChainStatus, d.UnhashedCount = verifyChain(doc, versions)
+ return d
+}
+
+// verifyChain recomputes every snapshot's hash and checks that each links to the
+// one before it. Returns the outcome and how many snapshots predate the chain.
+//
+// Broken *links* are not evidence of tampering on their own: auto-snapshot
+// pruning legitimately removes rows from the middle of the history, which severs
+// the links across the hole. So a link break is reported as a gap unless the
+// document is in preserve-history mode, where nothing should ever be removed. A
+// hash that fails to match its *own* contents is unambiguous, and always broken.
+func verifyChain(doc db.Document, versions []db.DocumentVersion) (status string, unhashed int) {
+ var (
+ hashed int
+ linkBreak bool
+ prevHash string
+ havePrev bool
+ )
+
+ for _, v := range versions {
+ if v.ContentHash == "" {
+ unhashed++
+ havePrev = false // can't vouch for what follows an unhashed row
+ continue
+ }
+ hashed++
+
+ want := chainHash(v.PrevHash, v.DocID, v.CreatedAt, v.WordCount, v.ContentText)
+ if want != v.ContentHash {
+ return chainBroken, unhashed
+ }
+ if havePrev && v.PrevHash != prevHash {
+ linkBreak = true
+ }
+ prevHash, havePrev = v.ContentHash, true
+ }
+
+ switch {
+ case hashed == 0:
+ return chainUnverifiable, unhashed
+ case linkBreak && doc.PreserveHistory:
+ // Nothing should have been removed from a preserved history.
+ return chainBroken, unhashed
+ case linkBreak:
+ return chainGaps, unhashed
+ case unhashed > 0:
+ return chainPartial, unhashed
+ default:
+ return chainVerified, unhashed
+ }
+}
+
+// --- HTTP -------------------------------------------------------------------
+
+// passport renders the report for one document as a standalone HTML download.
+// HTML rather than PDF for the same reason as the other exports: a CJK-safe PDF
+// needs an embedded Unicode font or a headless browser. The page is styled for
+// printing, so "Save as PDF" in the browser produces the handoff artifact.
+func (h *Handler) passport(w http.ResponseWriter, r *http.Request) {
+ docID := chi.URLParam(r, "id")
+ userID := auth.UserID(r.Context())
+
+ doc, err := h.fetch(userID, docID)
+ if errors.Is(err, sql.ErrNoRows) {
+ notFound(w)
+ return
+ }
+ if err != nil {
+ httputil.ServerError(w, err)
+ return
+ }
+
+ versions, err := h.passportVersions(userID, docID)
+ if err != nil {
+ httputil.ServerError(w, err)
+ return
+ }
+
+ body := renderPassport(buildPassport(doc, versions))
+
+ filename := sanitizeFilename(doc.Title)
+ if filename == "" {
+ filename = "untitled"
+ }
+ filename += " - writing passport.html"
+
+ w.Header().Set("Content-Type", "text/html; charset=utf-8")
+ w.Header().Set("Content-Disposition",
+ fmt.Sprintf("attachment; filename*=UTF-8''%s", urlEscapeFilename(filename)))
+ w.Header().Set("Content-Length", fmt.Sprintf("%d", len(body)))
+ _, _ = w.Write(body)
+}
+
+// passportVersions loads every snapshot oldest-first with the fields the report
+// and the chain check need — including content_text, which the list endpoint
+// omits as too heavy but verification cannot do without.
+func (h *Handler) passportVersions(userID, docID string) ([]db.DocumentVersion, error) {
+ rows, err := h.DB.Query(
+ `SELECT v.id, v.doc_id, v.title, v.content_text, v.word_count, v.kind,
+ v.created_at, v.content_hash, v.prev_hash
+ FROM document_versions v
+ JOIN documents d ON d.id = v.doc_id
+ WHERE v.doc_id = ? AND d.user_id = ?
+ ORDER BY v.created_at ASC, v.rowid ASC`,
+ docID, userID,
+ )
+ if err != nil {
+ return nil, err
+ }
+ defer rows.Close()
+
+ var out []db.DocumentVersion
+ for rows.Next() {
+ var v db.DocumentVersion
+ if err := rows.Scan(
+ &v.ID, &v.DocID, &v.Title, &v.ContentText, &v.WordCount, &v.Kind,
+ &v.CreatedAt, &v.ContentHash, &v.PrevHash,
+ ); err != nil {
+ return nil, err
+ }
+ out = append(out, v)
+ }
+ return out, rows.Err()
+}
diff --git a/internal/docs/passport_render.go b/internal/docs/passport_render.go
new file mode 100644
index 0000000..501d0b6
--- /dev/null
+++ b/internal/docs/passport_render.go
@@ -0,0 +1,387 @@
+package docs
+
+// HTML rendering for the writing passport. Self-contained (no external assets)
+// and styled for print, so the browser's "Save as PDF" turns it into the file a
+// writer actually hands over.
+
+import (
+ "fmt"
+ "math"
+ "strings"
+ "time"
+)
+
+// Chart geometry. The plot is wide and short on purpose: the report's question
+// is "what shape did this document grow in", and a wide aspect makes a steady
+// climb read as steady rather than dramatic.
+const (
+ chartW, chartH = 760, 260
+ padL, padR, padT, padB = 52, 20, 18, 34
+ plotW, plotH = chartW - padL - padR, chartH - padT - padB
+ minBandW = 2.0 // so a single-snapshot session still shows
+
+ // gutterSlots is the space between sessions, in snapshot-slot widths. Wide
+ // enough to read as a break and to seat its duration label.
+ gutterSlots = 2.5
+)
+
+// Palette — the export stylesheet's tokens, reused so a passport looks like it
+// came from the same application as the document it describes.
+const (
+ rose = "#b04a6a"
+ roseLight = "#f6d6e0"
+ roseWash = "#fdeef3"
+ surface = "#fffafb"
+)
+
+func renderPassport(d passportData) []byte {
+ var b strings.Builder
+
+ fmt.Fprintf(&b, passportHead, htmlEscape(d.Doc.Title))
+
+ fmt.Fprintf(&b, `
+Writing passport
+%s
+Generated %s
+
+`, htmlEscape(d.Doc.Title), htmlEscape(formatWhen(d.GeneratedAt)))
+
+ if len(d.Versions) == 0 {
+ b.WriteString(`This document has no saved history yet, so there is
+nothing to report. History builds up automatically as you write.
+