oficina / memoria Public

feat: slice 0 — sealed store, chunker, in-memory FTS5, scriptless search

The searchable index lives in memory and nowhere else, because FTS5 keeps
term postings in plaintext and an index on disk would put readable
passages in the replicated file. Sealed chunk rows are the durable copy
and the index is rebuilt from them at boot; §7.3's snapshot is deferred
because at slice-0 scale a rebuild is milliseconds and a snapshot format
is a thing to get wrong.

Two decisions worth naming:

Search ORs its terms rather than ANDing them. memoria is asked questions,
not given keywords — "what is average revenue in June" carries four words
the corpus will not contain alongside two it will, and ANDing them finds
nothing. Finding nothing is indistinguishable from having nothing, which
this codebase calls the worst available failure shape. BM25 does the
discriminating instead.

Config validation moved out of main.go into a tested function. The first
version of that guard grepped main.go for os.Exit near each variable
name, and it PASSED when the DEK refusal was replaced by a generated
fallback — the base64 error path below supplied an os.Exit inside the
search window. All five gates are now mutation-tested: each was broken on
purpose, seen to fail naming the right thing, and reverted.

Also fixes a scaffold bug: rastrillo's layout.html links tokens.css but
not theme.css, and tokens.css defines eleven tokens, none of them
colours. Without that link every colour resolves to initial and whole
shorthand declarations go invalid — rules vanish rather than the page
going grey.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SiWVaR69Vb2Lzc9wgfit1v
Paul Campbell pushed by paul@keymail.dev d14d4ef6d1bcc0f71d8b5ffc51e373b01a49bfe9 parent ae3a2a1
29 files changed, +2282 −152
  • cmd/memoria/main.go +31 −17
  • go.mod +6 −4
  • go.sum +12 −10
  • internal/memoria/app.go +84 −32
  • internal/memoria/auth.go +75 −0
  • internal/memoria/chunk/chunk.go +156 −0
  • internal/memoria/chunk/chunk_test.go +74 −0
  • internal/memoria/config.go +76 −0
  • internal/memoria/gorm.go +7 −0
  • internal/memoria/handlers.go +251 −3
  • internal/memoria/index/index.go +220 −0
  • internal/memoria/index/index_test.go +157 −0
  • internal/memoria/migrations.go +6 −1
  • internal/memoria/migrations/0001_create_documents.sql +10 −0
  • internal/memoria/migrations/0001_init.sql +0 −2
  • internal/memoria/migrations/schema.sql +9 −1
  • internal/memoria/models.go +57 −14
  • internal/memoria/render.go +18 −1
  • internal/memoria/seal/seal.go +105 −0
  • internal/memoria/seal/seal_test.go +69 −0
  • internal/memoria/static/memoria.css +100 −0
  • internal/memoria/store.go +326 −0
  • internal/memoria/templates/document.html +14 −0
  • internal/memoria/templates/index.html +69 −1
  • internal/memoria/templates/layout.html +20 −3
  • internal/memoria/templates/signin.html +12 −0
  • internal/memoriatest/gates_test.go +261 −0
  • internal/memoriatest/harness_test.go +57 −14
  • internal/memoriatest/index_test.go +0 −49
diff --git a/cmd/memoria/main.go b/cmd/memoria/main.go
index c92140a..b5687f7 100644
--- a/cmd/memoria/main.go
+++ b/cmd/memoria/main.go
@@ -1,5 +1,5 @@
-// Command memoria wires the app: resolve the platform's activation
-// argv, open the database, build the app, serve.
+// Command memoria wires the app: resolve the platform's activation argv,
+// open the database, unseal nothing until the DEK is in hand, serve.
package main
import (
@@ -12,20 +12,32 @@ import (
memoria "amadan.net/oficina/memoria/internal/memoria"
)
+// LoadConfigFromEnv is LoadConfig against the real environment.
+func LoadConfigFromEnv() (memoria.Config, error) {
+ return memoria.LoadConfig(os.Getenv)
+}
+
func main() {
logger := slog.New(slog.NewTextHandler(os.Stderr, nil))
- // Resolve, not Run: this app opens its own database handle via
- // db.Open — a *gorm.DB with a split reader/writer pool, not the
- // bare *sql.DB Options.Router would hand back — so Options.DBPath
- // must be empty again by the Serve call below, or Serve would open
- // a second, unused connection to the same file.
opts, err := rastrillo.Resolve(rastrillo.Options{DBPath: "memoria.db", Logger: logger})
if err != nil {
logger.Error("resolve activation", "err", err)
os.Exit(1)
}
+ // Every refusal in LoadConfig is a safety property, and it lives in
+ // a tested function rather than inline here — see config.go for the
+ // false-pass guard that made the case for moving it.
+ cfg, err := LoadConfigFromEnv()
+ if err != nil {
+ logger.Error("configuration", "err", err)
+ os.Exit(1)
+ }
+ if cfg.UsedDefaultOrigin() {
+ logger.Warn("MEMORIA_ORIGIN not set; defaulting", "origin", cfg.Origin)
+ }
+
d, err := db.Open(opts.DBPath, logger)
if err != nil {
logger.Error("open database", "err", err)
@@ -33,21 +45,23 @@ func main() {
}
defer d.Close()
- // Origin decides the CSRF same-origin check (and, when the app
- // grows accounts, the Secure/__Host- cookie attributes) — so
- // defaulting it silently in production would be a real bug. Loud
- // on purpose, harmless in local dev.
- origin := os.Getenv("MEMORIA_ORIGIN")
- if origin == "" {
- origin = "http://localhost:8080"
- logger.Warn("MEMORIA_ORIGIN not set; defaulting", "origin", origin)
+ mux, app, err := memoria.App(d, cfg.Origin, cfg.Sealer, logger)
+ if err != nil {
+ logger.Error("build app", "err", err)
+ os.Exit(1)
}
- mux, err := memoria.App(d, origin, logger)
+ a, err := memoria.NewAuth(d.Writer(), memoria.AuthEnv{
+ Origin: cfg.Origin,
+ InstanceKey: cfg.InstanceKey,
+ Owners: cfg.Owners,
+ }, logger, memoria.Admits(cfg.Owners))
if err != nil {
- logger.Error("build app", "err", err)
+ logger.Error("wire sign-in", "err", err)
os.Exit(1)
}
+ app.UseAuth(a)
+ app.MountAuth(mux, cfg.Origin)
opts.Mux = mux
opts.DBPath = ""
diff --git a/go.mod b/go.mod
index a90ca99..2854485 100644
--- a/go.mod
+++ b/go.mod
@@ -5,6 +5,9 @@ go 1.25.0
require (
github.com/carlosframework/rastrillo v0.23.0
github.com/go-chi/chi/v5 v5.3.2
+ golang.org/x/crypto v0.55.0
+ gorm.io/gorm v1.31.2
+ modernc.org/sqlite v1.55.0
)
require (
@@ -12,15 +15,14 @@ require (
github.com/google/uuid v1.6.0 // 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
github.com/mattn/go-isatty v0.0.20 // indirect
github.com/ncruces/go-strftime v1.0.0 // indirect
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
- golang.org/x/sys v0.46.0 // indirect
- golang.org/x/text v0.20.0 // indirect
- gorm.io/gorm v1.31.2 // indirect
+ golang.org/x/sys v0.47.0 // indirect
+ golang.org/x/text v0.41.0 // indirect
gorm.io/plugin/dbresolver v1.6.2 // indirect
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 acf2a87..8024e0f 100644
--- a/go.sum
+++ b/go.sum
@@ -40,17 +40,19 @@ github.com/ncruces/go-strftime v1.0.0 h1:HMFp8mLCTPp341M/ZnA4qaf7ZlsbTc+miZjCLOF
github.com/ncruces/go-strftime v1.0.0/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls=
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE=
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo=
-golang.org/x/mod v0.37.0 h1:vF1DjpVEshcIqoEaauuHebaLk1O1forxjxBaVn884JQ=
-golang.org/x/mod v0.37.0/go.mod h1:m8S8VeM9r4dzDwjrKO0a1sZP3YjeMamRRlD+fmR2Q/0=
-golang.org/x/sync v0.21.0 h1:HLII4xRRTtCRkxYp4HNFF0Js/Og6q2i++KXbg0gHCwM=
-golang.org/x/sync v0.21.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0=
+golang.org/x/crypto v0.55.0 h1:+KWHjbgOaAQ66dh/YlkZKHlz9ZUlq61AFirAR9ntP8M=
+golang.org/x/crypto v0.55.0/go.mod h1:uq0V9dE/fzQuJtbnL+2EhWOE63vo164FY8xqEnV9xis=
+golang.org/x/mod v0.38.0 h1:MECBjubtXD7yj4HrhIUcywNaGeNVUdfVnxmPajOk4yk=
+golang.org/x/mod v0.38.0/go.mod h1:V6Xz0pq8TQ3dGqVQ1FVHuelZpAL0uNhSkk9ogYP3c40=
+golang.org/x/sync v0.22.0 h1:SZjpbeLmrCk4xhRSZFNZW5gFUeCeFgjekvI/+gfScek=
+golang.org/x/sync v0.22.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0=
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
-golang.org/x/sys v0.46.0 h1:noSf2Fq6F8DBgS+LysIkx7rIExoNHJsxOAtPp4rthXw=
-golang.org/x/sys v0.46.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
-golang.org/x/text v0.20.0 h1:gK/Kv2otX8gz+wn7Rmb3vT96ZwuoxnQlY+HlJVj7Qug=
-golang.org/x/text v0.20.0/go.mod h1:D4IsuqiFMhST5bX19pQ9ikHC2GsaKyk/oF+pn3ducp4=
-golang.org/x/tools v0.47.0 h1:7Kn5x/d1svx/PzryTsqeoZN4TZwqeH5pGWjefhLi/1Q=
-golang.org/x/tools v0.47.0/go.mod h1:dFHnyTvFWY212G+h7ZY4Vsp/K3U4/7W9TyVaAul8uCA=
+golang.org/x/sys v0.47.0 h1:o7XGOvZQCADBQQ4Y7VNq2dRWQR7JmOUW8Kxx4ZsNgWs=
+golang.org/x/sys v0.47.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
+golang.org/x/text v0.41.0 h1:vz/seA0lnX87Othu2f/0L24RcgrXD9/YFTSuGjj3rH8=
+golang.org/x/text v0.41.0/go.mod h1:jvf1O8ajNzZqhSrQBPbutR/EB83Cc0CFrezNQIwbb5M=
+golang.org/x/tools v0.48.0 h1:3+hClM1aLL5mjMKm5ovokw9epgRXPuu2tILgismM6RE=
+golang.org/x/tools v0.48.0/go.mod h1:08xX0orndb/F7jJxGDicx061tyd5pcMto75YMAXr6lk=
gorm.io/driver/mysql v1.5.7 h1:MndhOPYOfEp2rHKgkZIhJ16eVUIRf2HmzgoPmh7FCWo=
gorm.io/driver/mysql v1.5.7/go.mod h1:sEtPWMiqiN1N1cMXoXmBbd8C6/l+TESwriotuRRpkDM=
gorm.io/driver/sqlite v1.6.0 h1:WHRRrIiulaPiPFmDcod6prc4l2VGVWHz80KspNsxSfQ=
diff --git a/internal/memoria/app.go b/internal/memoria/app.go
index c1de13e..6775afc 100644
--- a/internal/memoria/app.go
+++ b/internal/memoria/app.go
@@ -10,52 +10,104 @@ import (
"github.com/carlosframework/rastrillo/csrf"
"github.com/carlosframework/rastrillo/db"
"github.com/carlosframework/rastrillo/migrate"
+
+ "amadan.net/oficina/memoria/internal/memoria/index"
+ "amadan.net/oficina/memoria/internal/memoria/seal"
)
-// App wires the whole app: schema, router, static files. It returns a
-// *http.ServeMux because rastrillo.Options.Mux is typed that way — the
-// chi router mounts inside it. origin is the app's external origin
-// ("https://app.example.com") — the CSRF check's yardstick, and, once
-// the app grows accounts, sessions.Config.Origin too.
+// App wires the whole app: schema, sealing, index, router, static files.
//
-// Growing the app is SKILL.md's five-file shape: models in models.go,
-// handlers in handlers.go, and — for a multi-user app — the sessions
-// core plus an identity plugin wired right here (examples/notes in the
-// rastrillo repo is the worked example to copy). Adding a subsystem
-// also means adding its Schema to migrations.go's BootSchema — see
-// the comment there.
-func App(d *db.DB, origin string, logger *slog.Logger) (*http.ServeMux, error) {
- // Apply BootSchema, not Schema: BootSchema is everything this
- // app needs at boot (its own migrations plus any subsystem's),
- // while Schema (migrations.go) stays just this app's own so the
- // generator and its check never see a subsystem's tables and try
- // to drop them.
+// The order matters. Migrations first, then the index is filled from the
+// sealed rows — the index lives only in memory (see internal/memoria/index
+// for why), so a boot that skipped the rebuild would serve an empty
+// corpus and call it "nothing matched".
+func App(d *db.DB, origin string, sealer *seal.Sealer, logger *slog.Logger) (*http.ServeMux, *app, error) {
migrated, err := migrate.Apply(context.Background(), d, BootSchema)
if err != nil {
- return nil, err
+ return nil, nil, err
}
- // One line per boot. Result exists so this can be logged, and
- // until something does, a fleet-wide adoption — every database
- // stamped, no DDL run, exactly once, never again — produces no
- // output at all, which is the last event you want to be silent.
logger.Info("migrate", "applied", migrated.Applied,
"skipped", migrated.Skipped, "adopted", migrated.Adopted)
- a := &app{db: d, logger: logger}
+ ix, err := index.Open()
+ if err != nil {
+ return nil, nil, err
+ }
+
+ store := NewStore(d, sealer, ix)
+ indexed, err := store.Rebuild(context.Background())
+ if err != nil {
+ // A failed rebuild is fatal, not a warning. Serving with a
+ // partly-filled index means every search silently under-reports,
+ // which is the plausible-empty-list failure this codebase treats
+ // as the worst available.
+ return nil, nil, err
+ }
+ logger.Info("index rebuilt", "passages", indexed)
+
+ a := &app{db: d, logger: logger, store: store}
r := chi.NewRouter()
- // App-wide, from day one: every state-changing route added below
- // is born covered. Origin-checking, not tokens — nothing to mint
- // or forget in a form.
r.Use(csrf.Protect(origin))
- r.Get("/", a.index)
+
+ // Public: the sign-in page and auth's own callbacks.
+ r.Get("/signin", a.signin)
+
+ // Everything else requires a session. memoria holds the team's work
+ // in one place, so there is no anonymous surface at all — not even a
+ // landing page. §4.3: admission is not authorization, but it is the
+ // door.
+ r.Group(func(r chi.Router) {
+ r.Use(a.requireSession)
+ r.Get("/", a.index)
+ r.Post("/documents", a.createDocument)
+ r.Get("/documents/{id}", a.showDocument)
+ r.Post("/documents/{id}/delete", a.deleteDocument)
+ })
mux := http.NewServeMux()
- // The app serves its own static files — the framework never does.
- // They are embedded (render.go) and fingerprinted: templates link
- // them via {{asset ...}}, which returns a content-hashed URL the
- // handler serves cacheable-forever.
mux.Handle("GET /static/", assets.Handler())
mux.Handle("/", r)
- return mux, nil
+ return mux, a, nil
+}
+
+// MountAuth adds auth's routes. Separate from App because auth needs the
+// admission gate and App builds the router; the caller wires both.
+func (a *app) MountAuth(mux *http.ServeMux, origin string) {
+ r := chi.NewRouter()
+ r.Use(csrf.Protect(origin))
+ r.Post("/auth/begin", a.auth.Begin)
+ r.Get("/auth/callback", a.auth.Callback)
+ r.Get("/auth/verify", a.auth.Verify)
+ r.Post("/signout", a.auth.Signout)
+ mux.Handle("/auth/", r)
+ mux.Handle("POST /signout", r)
+}
+
+// requireSession is auth.RequireSession in production, and a
+// pass-through when there is no auth to require one with.
+//
+// The pass-through is for tests, and it is deliberate rather than
+// accidental: a nil-auth App has no sessions at all, so requiring one
+// would make every route below untestable without a mailer, an SMTP
+// refusal and a full magic-link round trip. Sheets carries the same seam
+// for the same reason.
+//
+// It fails OPEN, which is worth being uncomfortable about, so the safety
+// property is not this function — it is that main.go exits before
+// wiring an App whose auth would be nil. TestProductionRefusesToStart-
+// WithoutSignin pins that, because a comment saying "production cannot
+// reach this" is worth nothing without a test that breaks when it can.
+//
+// The indirection through a method (rather than passing auth into App)
+// exists because App builds the router and the router needs auth's
+// middleware, while auth needs the admission gate only the caller has.
+func (a *app) requireSession(next http.Handler) http.Handler {
+ return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ if a.auth == nil {
+ next.ServeHTTP(w, r)
+ return
+ }
+ a.auth.RequireSession(next).ServeHTTP(w, r)
+ })
}
diff --git a/internal/memoria/auth.go b/internal/memoria/auth.go
new file mode 100644
index 0000000..54c359c
--- /dev/null
+++ b/internal/memoria/auth.go
@@ -0,0 +1,75 @@
+package memoria
+
+import (
+ "database/sql"
+ "errors"
+ "log/slog"
+ "strings"
+
+ "github.com/carlosframework/rastrillo/auth"
+ "github.com/carlosframework/rastrillo/mail"
+)
+
+// AuthEnv is the sign-in configuration read from platform config.
+type AuthEnv struct {
+ Origin string
+ // InstanceKey seals auth's pending blob. auth.New refuses an empty
+ // one, and rightly: an empty input hashes to a fixed, publicly
+ // computable value identical across every deployment.
+ InstanceKey string
+ Mailer mail.Sender
+ Owners []string
+}
+
+// ParseOwners splits a comma-separated allowlist, lowercasing and
+// trimming. Slice 0's membership table is this list; slice 1 replaces it
+// with rows, because §4.3's admission gate has to admit whoever the
+// team's grants admit, and only a database knows that.
+func ParseOwners(raw string) []string {
+ var out []string
+ for _, part := range strings.Split(raw, ",") {
+ if addr := strings.ToLower(strings.TrimSpace(part)); addr != "" {
+ out = append(out, addr)
+ }
+ }
+ return out
+}
+
+// Admits builds the admission gate. Comparison is on the lowercased
+// address: addresses are case-sensitive before the @ per RFC 5321 and no
+// mainstream provider honours that, so this matches what an operator
+// expects when they type a list.
+func Admits(owners []string) func(string) bool {
+ allowed := make(map[string]struct{}, len(owners))
+ for _, o := range owners {
+ allowed[o] = struct{}{}
+ }
+ return func(address string) bool {
+ _, ok := allowed[strings.ToLower(strings.TrimSpace(address))]
+ return ok
+ }
+}
+
+// NewAuth wires the sign-in flow. Exactly one per process: auth carries
+// rate-limiter state on the value, so one per request would get a fresh
+// empty budget every time and rate-limit nothing, silently.
+func NewAuth(sqlDB *sql.DB, env AuthEnv, logger *slog.Logger, authorize func(string) bool) (*auth.Auth, error) {
+ if authorize == nil {
+ // A nil Authorize admits every verified address, which on a
+ // public hostname means anyone who can receive mail. Refusing
+ // beats defaulting, because a caller that forgot to pass one has
+ // a bug either way and this is the version that says so.
+ return nil, errors.New("sign-in: refusing to wire auth with no admission gate")
+ }
+ return auth.New(auth.Config{
+ DB: sqlDB,
+ Origin: env.Origin,
+ InstanceKey: env.InstanceKey,
+ Mailer: env.Mailer,
+ Authorize: authorize,
+ SigninPath: "/signin",
+ SignedInPath: "/",
+ Subject: "Your memoria sign-in link",
+ Logger: logger,
+ })
+}
diff --git a/internal/memoria/chunk/chunk.go b/internal/memoria/chunk/chunk.go
new file mode 100644
index 0000000..b3f5706
--- /dev/null
+++ b/internal/memoria/chunk/chunk.go
@@ -0,0 +1,156 @@
+// Package chunk splits a document's text into retrievable passages.
+//
+// A chunk is the unit the index scores and the unit a citation points at,
+// so its boundaries decide whether an answer can be checked. Two rules
+// follow from that:
+//
+// - A chunk never straddles a heading. A passage whose first half
+// belongs to one section and second half to another cites wrongly
+// whichever way you label it.
+// - Every chunk carries its heading trail. "1,350,000" is not
+// retrievable; "FY2024 › Revenue › June: 1,350,000" is. The spec's
+// §13 records the harder version of this problem for spreadsheets,
+// which this package does not yet solve.
+package chunk
+
+import (
+ "strings"
+ "unicode"
+)
+
+// TargetWords is the size a chunk aims for. 400 words is roughly 500
+// tokens, which leaves eight chunks plus instructions inside a modest
+// context window while staying long enough to carry an argument.
+const TargetWords = 400
+
+// MinWords stops a trailing fragment becoming its own chunk. A two-word
+// chunk scores absurdly well on BM25 for those two words.
+const MinWords = 40
+
+// Chunk is one retrievable passage.
+type Chunk struct {
+ // Ordinal is this chunk's position in the document, from 0. It is
+ // stored in plaintext: it is a coordinate, not content, exactly as
+ // Sheets keeps cell coordinates plaintext.
+ Ordinal int
+ // Trail is the heading path above this passage, outermost first.
+ Trail []string
+ // Text is the passage, with the trail prepended when Indexable is
+ // called. Stored sealed.
+ Text string
+}
+
+// Heading returns the trail joined for display and for indexing.
+func (c Chunk) Heading() string { return strings.Join(c.Trail, " › ") }
+
+// Indexable is what the index tokenises: the heading trail followed by
+// the body. The trail is repeated into every chunk of a section
+// deliberately — it is the only reason a query naming a section finds a
+// passage that never repeats the section's name.
+func (c Chunk) Indexable() string {
+ if len(c.Trail) == 0 {
+ return c.Text
+ }
+ return c.Heading() + "\n" + c.Text
+}
+
+// Split divides plain text or Markdown into chunks. Markdown ATX
+// headings ("## Revenue") set the trail; a blank line separates
+// paragraphs; paragraphs accumulate until TargetWords is reached.
+func Split(text string) []Chunk {
+ var out []Chunk
+ var trail []string
+ var buf []string
+ words := 0
+
+ flush := func() {
+ if len(buf) == 0 {
+ return
+ }
+ body := strings.Join(buf, "\n\n")
+ if words < MinWords && len(out) > 0 {
+ // Fold a runt into its predecessor rather than emitting it.
+ // Only legal when the trail still matches, or the fold would
+ // move text under the wrong heading.
+ prev := &out[len(out)-1]
+ if prev.Heading() == strings.Join(trail, " › ") {
+ prev.Text += "\n\n" + body
+ buf, words = nil, 0
+ return
+ }
+ }
+ t := make([]string, len(trail))
+ copy(t, trail)
+ out = append(out, Chunk{Ordinal: len(out), Trail: t, Text: body})
+ buf, words = nil, 0
+ }
+
+ for _, para := range strings.Split(normalise(text), "\n\n") {
+ para = strings.TrimSpace(para)
+ if para == "" {
+ continue
+ }
+ if depth, title, ok := heading(para); ok {
+ // A heading closes the passage before it: no chunk straddles
+ // a section boundary.
+ flush()
+ if depth-1 < len(trail) {
+ trail = trail[:depth-1]
+ }
+ for len(trail) < depth-1 {
+ trail = append(trail, "")
+ }
+ trail = append(trail, title)
+ continue
+ }
+ n := len(strings.Fields(para))
+ if words > 0 && words+n > TargetWords {
+ flush()
+ }
+ buf = append(buf, para)
+ words += n
+ }
+ flush()
+
+ // Ordinals are assigned on append, but a fold removes a chunk that
+ // was never appended, so they stay dense. Re-stamp anyway: a gap
+ // here would show up as a missing citation, not as an error.
+ for i := range out {
+ out[i].Ordinal = i
+ }
+ return out
+}
+
+// heading recognises a Markdown ATX heading and returns its depth.
+func heading(line string) (depth int, title string, ok bool) {
+ if !strings.HasPrefix(line, "#") {
+ return 0, "", false
+ }
+ i := 0
+ for i < len(line) && line[i] == '#' {
+ i++
+ }
+ if i > 6 || i >= len(line) || !unicode.IsSpace(rune(line[i])) {
+ return 0, "", false
+ }
+ title = strings.TrimSpace(strings.TrimRight(line[i:], " #"))
+ if title == "" {
+ return 0, "", false
+ }
+ // A heading inside a paragraph block is not a heading.
+ if strings.Contains(title, "\n") {
+ return 0, "", false
+ }
+ return i, title, true
+}
+
+func normalise(s string) string {
+ s = strings.ReplaceAll(s, "\r\n", "\n")
+ s = strings.ReplaceAll(s, "\r", "\n")
+ // Collapse three or more newlines to exactly two so paragraph
+ // splitting is not defeated by generous spacing.
+ for strings.Contains(s, "\n\n\n") {
+ s = strings.ReplaceAll(s, "\n\n\n", "\n\n")
+ }
+ return s
+}
diff --git a/internal/memoria/chunk/chunk_test.go b/internal/memoria/chunk/chunk_test.go
new file mode 100644
index 0000000..629e811
--- /dev/null
+++ b/internal/memoria/chunk/chunk_test.go
@@ -0,0 +1,74 @@
+package chunk
+
+import (
+ "strings"
+ "testing"
+)
+
+func TestSplitCarriesTheHeadingTrail(t *testing.T) {
+ got := Split("# FY2024\n\n## Revenue\n\n" + strings.Repeat("word ", 60) + "\n\n## Costs\n\n" + strings.Repeat("cost ", 60))
+ if len(got) != 2 {
+ t.Fatalf("want 2 chunks, got %d", len(got))
+ }
+ if got[0].Heading() != "FY2024 › Revenue" {
+ t.Errorf("first trail = %q", got[0].Heading())
+ }
+ if got[1].Heading() != "FY2024 › Costs" {
+ t.Errorf("second trail = %q", got[1].Heading())
+ }
+ // The trail must be in the indexable text, or a query naming the
+ // section cannot find a passage that never repeats it.
+ if !strings.Contains(got[1].Indexable(), "Costs") {
+ t.Error("heading absent from indexable text")
+ }
+}
+
+// The rule that pays for itself: a chunk that straddles a heading cites
+// wrongly whichever section you label it with.
+func TestNoChunkStraddlesAHeading(t *testing.T) {
+ // Two short sections that would happily fit in one chunk by size.
+ got := Split("## Alpha\n\n" + strings.Repeat("a ", 50) + "\n\n## Beta\n\n" + strings.Repeat("b ", 50))
+ for _, c := range got {
+ if strings.Contains(c.Text, "a ") && strings.Contains(c.Text, "b ") {
+ t.Fatalf("chunk %d mixes two sections: %q", c.Ordinal, c.Text[:40])
+ }
+ }
+ if len(got) != 2 {
+ t.Fatalf("want 2 chunks, got %d", len(got))
+ }
+}
+
+func TestLongTextSplitsNearTarget(t *testing.T) {
+ // Twelve paragraphs of 100 words each: 1200 words, target 400.
+ var b strings.Builder
+ for i := 0; i < 12; i++ {
+ b.WriteString(strings.Repeat("filler ", 100))
+ b.WriteString("\n\n")
+ }
+ got := Split(b.String())
+ if len(got) < 2 || len(got) > 4 {
+ t.Fatalf("1200 words at target %d gave %d chunks", TargetWords, len(got))
+ }
+ for _, c := range got {
+ if n := len(strings.Fields(c.Text)); n > TargetWords*2 {
+ t.Errorf("chunk %d has %d words, far over target", c.Ordinal, n)
+ }
+ }
+}
+
+func TestOrdinalsAreDense(t *testing.T) {
+ got := Split("## A\n\n" + strings.Repeat("x ", 500) + "\n\n## B\n\nshort tail")
+ for i, c := range got {
+ if c.Ordinal != i {
+ t.Fatalf("chunk at index %d has ordinal %d; a gap reads as a missing citation", i, c.Ordinal)
+ }
+ }
+}
+
+func TestEmptyInputYieldsNoChunks(t *testing.T) {
+ for _, in := range []string{"", " ", "\n\n\n", "# Heading with no body"} {
+ if got := Split(in); len(got) != 0 {
+ t.Errorf("Split(%q) gave %d chunks, want 0", in, len(got))
+ }
+ }
+}
diff --git a/internal/memoria/config.go b/internal/memoria/config.go
new file mode 100644
index 0000000..8757de5
--- /dev/null
+++ b/internal/memoria/config.go
@@ -0,0 +1,76 @@
+package memoria
+
+import (
+ "encoding/base64"
+ "errors"
+ "fmt"
+
+ "amadan.net/oficina/memoria/internal/memoria/seal"
+)
+
+// Config is everything memoria needs from platform config.
+//
+// This is a type with a constructor, rather than a run of os.Getenv calls
+// in main, for one reason: the refusals below are a safety property and a
+// safety property needs a test. An earlier version of this validation
+// lived inline in main.go and was "checked" by grepping the source for
+// os.Exit near each variable name — which passed even when the refusal
+// was deleted, because a neighbouring error path supplied the os.Exit the
+// grep was looking for. A guard that cannot fail is not a guard.
+type Config struct {
+ Origin string
+ InstanceKey string
+ Owners []string
+ Sealer *seal.Sealer
+}
+
+// ErrMissing is returned when required platform config is absent.
+var ErrMissing = errors.New("memoria: required configuration is missing")
+
+// LoadConfig reads and validates configuration. getenv is a parameter so
+// a test can supply an environment without mutating the process's.
+//
+// Every failure here is fatal by design. There is deliberately no
+// generated-fallback DEK: a fallback would seal this boot's corpus under
+// a key the next boot cannot derive, losing the data quietly instead of
+// refusing loudly. And there is no default owner list, because an empty
+// admission gate on a public hostname admits anyone who can receive mail.
+func LoadConfig(getenv func(string) string) (Config, error) {
+ var c Config
+
+ dekRaw := getenv("MEMORIA_DEK")
+ if dekRaw == "" {
+ return c, fmt.Errorf("%w: MEMORIA_DEK", ErrMissing)
+ }
+ dek, err := base64.StdEncoding.DecodeString(dekRaw)
+ if err != nil {
+ return c, fmt.Errorf("memoria: MEMORIA_DEK is not valid base64: %w", err)
+ }
+ sealer, err := seal.New(dek)
+ if err != nil {
+ return c, fmt.Errorf("memoria: MEMORIA_DEK rejected: %w", err)
+ }
+ c.Sealer = sealer
+
+ if c.InstanceKey = getenv("MEMORIA_INSTANCE_KEY"); c.InstanceKey == "" {
+ return c, fmt.Errorf("%w: MEMORIA_INSTANCE_KEY", ErrMissing)
+ }
+
+ if c.Owners = ParseOwners(getenv("MEMORIA_OWNERS")); len(c.Owners) == 0 {
+ return c, fmt.Errorf("%w: MEMORIA_OWNERS (no admission gate)", ErrMissing)
+ }
+
+ // Origin is the only one with a default, and only because the
+ // default is a loopback address that cannot be reached from
+ // anywhere else. It decides the CSRF yardstick and the cookie
+ // attributes, so a silent production default would be a real bug —
+ // hence the caller logs a warning when it is used.
+ if c.Origin = getenv("MEMORIA_ORIGIN"); c.Origin == "" {
+ c.Origin = "http://localhost:8080"
+ }
+ return c, nil
+}
+
+// UsedDefaultOrigin reports whether the origin was defaulted, so main can
+// say so out loud.
+func (c Config) UsedDefaultOrigin() bool { return c.Origin == "http://localhost:8080" }
diff --git a/internal/memoria/gorm.go b/internal/memoria/gorm.go
new file mode 100644
index 0000000..3702232
--- /dev/null
+++ b/internal/memoria/gorm.go
@@ -0,0 +1,7 @@
+package memoria
+
+import "gorm.io/gorm"
+
+// gormTx is the transaction handle type. Aliased so store.go's closures
+// read as prose rather than as generics.
+type gormTx = gorm.DB
diff --git a/internal/memoria/handlers.go b/internal/memoria/handlers.go
index 1584bd1..c06cc76 100644
--- a/internal/memoria/handlers.go
+++ b/internal/memoria/handlers.go
@@ -1,19 +1,267 @@
package memoria
import (
+ "io"
"log/slog"
"net/http"
+ "strconv"
+ "strings"
+ "github.com/carlosframework/rastrillo/auth"
"github.com/carlosframework/rastrillo/db"
+ "github.com/go-chi/chi/v5"
+
+ "amadan.net/oficina/memoria/internal/memoria/chunk"
)
-// app holds what every handler needs. When the app grows accounts,
-// this is where the sessions handle lands too.
+// maxUpload caps a pasted or uploaded document.
+//
+// 2 MiB of text is about 350,000 words — longer than any document anyone
+// has pasted into a box, and small enough that chunking and sealing it
+// stays inside a request. The number carries its reason here because
+// AGENTS.md requires that of a cap, and because the honest alternative
+// (a background job) is slice 3's problem, not slice 0's.
+const maxUpload = 2 << 20
+
type app struct {
db *db.DB
logger *slog.Logger
+ store *Store
+ auth *auth.Auth
+}
+
+// UseAuth attaches auth after construction. App builds the router, and
+// the router needs auth's handlers, but auth needs the admission gate
+// that only App can supply — so the knot is untied by attaching late.
+func (a *app) UseAuth(x *auth.Auth) { a.auth = x }
+
+// page is the data every template gets. Chunks and Signed are on it
+// because the layout states them on every screen: a corpus count is how
+// a person tells "nothing matched" from "nothing indexed".
+type page struct {
+ Signed bool
+ Chunks int
+ Query string
+ Searched bool
+ Flash string
+ FlashKind string
+ Results []Result
+ Documents []Summary
+}
+
+func (a *app) chunkCount() int {
+ n, err := a.store.Count()
+ if err != nil {
+ a.logger.Error("count passages", "err", err)
+ return 0
+ }
+ return n
}
func (a *app) index(w http.ResponseWriter, r *http.Request) {
- render(w, "index", nil)
+ q := strings.TrimSpace(r.URL.Query().Get("q"))
+ p := page{
+ Signed: true,
+ Chunks: a.chunkCount(),
+ Query: q,
+ Searched: q != "",
+ }
+ if p.Searched {
+ results, err := a.store.Search(r.Context(), q, 25)
+ if err != nil {
+ // A search that errored must never render as no results.
+ // "Nothing matched" and "the search broke" look identical to
+ // a reader, and this codebase treats that shape as the worst
+ // available failure.
+ a.logger.Error("search failed", "err", err)
+ p.Flash = "The search did not run. Nothing was found because nothing was searched."
+ p.FlashKind = "danger"
+ p.Searched = false
+ } else {
+ p.Results = results
+ }
+ }
+ docs, err := a.store.Summaries(r.Context())
+ if err != nil {
+ a.logger.Error("list documents", "err", err)
+ }
+ p.Documents = docs
+ if f := r.URL.Query().Get("stored"); f != "" {
+ p.Flash, p.FlashKind = "Stored, chunked and indexed.", "good"
+ }
+ if f := r.URL.Query().Get("err"); f != "" {
+ p.Flash, p.FlashKind = flashFor(f), "danger"
+ }
+ render(w, "index", p)
+}
+
+// flashFor maps an error code to copy. Codes travel in the URL rather
+// than messages, so nothing a person typed is reflected back into the
+// page or into a log line.
+func flashFor(code string) string {
+ switch code {
+ case "empty":
+ return "There was nothing to store — the box was empty and no file was chosen."
+ case "toobig":
+ return "That is larger than 2 MiB. Split it, or wait for background ingestion."
+ case "binary":
+ return "That looked like a binary file. memoria indexes text: .txt, .md, .csv."
+ default:
+ return "That did not work."
+ }
+}
+
+func (a *app) createDocument(w http.ResponseWriter, r *http.Request) {
+ if err := r.ParseMultipartForm(maxUpload); err != nil {
+ http.Redirect(w, r, "/?err=toobig", http.StatusSeeOther)
+ return
+ }
+ title := strings.TrimSpace(r.FormValue("title"))
+ body := r.FormValue("body")
+ source := "paste"
+
+ if f, hdr, err := r.FormFile("file"); err == nil {
+ defer f.Close()
+ // LimitReader at cap+1 so a file exactly at the cap passes and
+ // one byte over is detectable, rather than silently truncated —
+ // a truncated document indexes as a complete one, which is a
+ // citation that lies.
+ raw, err := io.ReadAll(io.LimitReader(f, maxUpload+1))
+ if err != nil {
+ http.Redirect(w, r, "/?err=toobig", http.StatusSeeOther)
+ return
+ }
+ if len(raw) > maxUpload {
+ http.Redirect(w, r, "/?err=toobig", http.StatusSeeOther)
+ return
+ }
+ if !looksLikeText(raw) {
+ http.Redirect(w, r, "/?err=binary", http.StatusSeeOther)
+ return
+ }
+ body = string(raw)
+ source = "upload"
+ if title == "" && hdr != nil {
+ title = strings.TrimSuffix(hdr.Filename, filepathExt(hdr.Filename))
+ }
+ }
+
+ if strings.TrimSpace(body) == "" {
+ http.Redirect(w, r, "/?err=empty", http.StatusSeeOther)
+ return
+ }
+ if len(body) > maxUpload {
+ http.Redirect(w, r, "/?err=toobig", http.StatusSeeOther)
+ return
+ }
+
+ if _, err := a.store.Ingest(r.Context(), source, title, body); err != nil {
+ // No document content in the log line — AGENTS.md's rule, and
+ // the reason this carries no title either.
+ a.logger.Error("ingest failed", "source", source, "bytes", len(body), "err", err)
+ http.Redirect(w, r, "/?err=store", http.StatusSeeOther)
+ return
+ }
+ http.Redirect(w, r, "/?stored=1", http.StatusSeeOther)
+}
+
+// passage is one rendered passage of a document.
+type passage struct {
+ Ordinal int
+ Heading string
+ Text string
+}
+
+type documentPage struct {
+ Signed bool
+ Chunks int
+ Title string
+ Source string
+ Bytes int
+ Revision int
+ Passages []passage
+ Documents []Summary
+ Query string
+ Searched bool
+ Results []Result
+ Flash string
+ FlashKind string
+}
+
+func (a *app) showDocument(w http.ResponseWriter, r *http.Request) {
+ id, err := strconv.ParseInt(chi.URLParam(r, "id"), 10, 64)
+ if err != nil {
+ http.NotFound(w, r)
+ return
+ }
+ title, body, err := a.store.Body(r.Context(), id)
+ if err != nil {
+ // A row that is not there 404s. It never 403s, so a document
+ // that exists but is not yours is indistinguishable from one
+ // that does not exist.
+ http.NotFound(w, r)
+ return
+ }
+ var passages []passage
+ for _, c := range chunk.Split(body) {
+ passages = append(passages, passage{Ordinal: c.Ordinal, Heading: c.Heading(), Text: c.Text})
+ }
+ render(w, "document", documentPage{
+ Signed: true,
+ Chunks: a.chunkCount(),
+ Title: title,
+ Source: "stored",
+ Bytes: len(body),
+ Revision: 1,
+ Passages: passages,
+ })
+}
+
+func (a *app) deleteDocument(w http.ResponseWriter, r *http.Request) {
+ id, err := strconv.ParseInt(chi.URLParam(r, "id"), 10, 64)
+ if err != nil {
+ http.NotFound(w, r)
+ return
+ }
+ if err := a.store.Delete(r.Context(), id); err != nil {
+ a.logger.Error("delete document", "id", id, "err", err)
+ }
+ http.Redirect(w, r, "/", http.StatusSeeOther)
+}
+
+func (a *app) signin(w http.ResponseWriter, r *http.Request) {
+ p := page{Chunks: a.chunkCount()}
+ switch r.URL.Query().Get("outcome") {
+ case "sent":
+ p.Flash, p.FlashKind = "Check your email — the link signs you in.", "good"
+ case "denied":
+ p.Flash, p.FlashKind = "That address is not on this instance.", "danger"
+ case "expired":
+ p.Flash, p.FlashKind = "That link had expired. Here is a fresh start.", "danger"
+ }
+ render(w, "signin", p)
+}
+
+// looksLikeText refuses a binary upload. A NUL byte in the first 8 KiB is
+// the cheap, reliable signal; a PDF or DOCX would otherwise be chunked as
+// mojibake and indexed as though it were prose, which produces citations
+// nobody can read.
+func looksLikeText(b []byte) bool {
+ n := len(b)
+ if n > 8192 {
+ n = 8192
+ }
+ for _, c := range b[:n] {
+ if c == 0 {
+ return false
+ }
+ }
+ return true
+}
+
+func filepathExt(name string) string {
+ if i := strings.LastIndex(name, "."); i >= 0 {
+ return name[i:]
+ }
+ return ""
}
diff --git a/internal/memoria/index/index.go b/internal/memoria/index/index.go
new file mode 100644
index 0000000..0d9f9b3
--- /dev/null
+++ b/internal/memoria/index/index.go
@@ -0,0 +1,220 @@
+// Package index is memoria's search index.
+//
+// The index lives in memory and nowhere else. That is not a performance
+// choice: FTS5 stores its term postings in plaintext, so an index on disk
+// would put readable passages in the replicated database file and break
+// the one rule. The sealed chunk rows are the durable copy; this is
+// rebuilt from them on wake.
+//
+// The spec's §7.3 measures a rebuild at 19s for 90k chunks and rules
+// "snapshot, never rebuild" above that. Slice 0 rebuilds, because at a
+// few hundred documents it is milliseconds and a snapshot format is a
+// thing to get wrong. The trigger to add one is written in §12.
+package index
+
+import (
+ "database/sql"
+ "fmt"
+ "strings"
+ "sync"
+
+ _ "modernc.org/sqlite"
+)
+
+// Hit is one scored passage. It carries ids and a snippet, never the
+// whole chunk: the caller re-reads the sealed row if it needs the body.
+type Hit struct {
+ ChunkID int64
+ DocumentID int64
+ Ordinal int
+ Heading string
+ Snippet string
+ // Score is BM25, negated so larger is better. FTS5 returns bm25()
+ // with lower meaning more relevant, which reads backwards everywhere
+ // else in the codebase.
+ Score float64
+}
+
+// Index is an in-memory FTS5 index. Safe for concurrent use.
+type Index struct {
+ mu sync.RWMutex
+ db *sql.DB
+}
+
+// Open builds an empty index. The DSN is in-memory with a shared cache so
+// the single handle is stable; nothing is written to any file.
+func Open() (*Index, error) {
+ // `file::memory:` with cache=shared keeps one database alive across
+ // pooled connections. Without it each connection gets its own empty
+ // database and searches return nothing — a plausible empty list,
+ // which is the failure shape this codebase treats as the worst
+ // available, so it is worth the explicit DSN.
+ db, err := sql.Open("sqlite", "file:memoria-index?mode=memory&cache=shared")
+ if err != nil {
+ return nil, err
+ }
+ // One connection only. The shared-cache in-memory database is
+ // process-local and cheap; a pool buys nothing and makes the
+ // lifetime harder to reason about.
+ db.SetMaxOpenConns(1)
+ if _, err := db.Exec(`
+ CREATE VIRTUAL TABLE chunks USING fts5(
+ heading,
+ body,
+ chunk_id UNINDEXED,
+ document_id UNINDEXED,
+ ordinal UNINDEXED,
+ tokenize = 'porter unicode61'
+ );`); err != nil {
+ db.Close()
+ return nil, fmt.Errorf("index: create fts5 table: %w", err)
+ }
+ return &Index{db: db}, nil
+}
+
+// Close releases the index.
+func (i *Index) Close() error {
+ i.mu.Lock()
+ defer i.mu.Unlock()
+ return i.db.Close()
+}
+
+// Entry is one chunk to index. Text is plaintext: it has been opened from
+// its sealed row and must not outlive this process.
+type Entry struct {
+ ChunkID int64
+ DocumentID int64
+ Ordinal int
+ Heading string
+ Text string
+}
+
+// Add indexes entries in one transaction.
+func (i *Index) Add(entries ...Entry) error {
+ i.mu.Lock()
+ defer i.mu.Unlock()
+ tx, err := i.db.Begin()
+ if err != nil {
+ return err
+ }
+ defer tx.Rollback()
+ st, err := tx.Prepare(`INSERT INTO chunks (heading, body, chunk_id, document_id, ordinal) VALUES (?, ?, ?, ?, ?)`)
+ if err != nil {
+ return err
+ }
+ defer st.Close()
+ for _, e := range entries {
+ if _, err := st.Exec(e.Heading, e.Text, e.ChunkID, e.DocumentID, e.Ordinal); err != nil {
+ return err
+ }
+ }
+ return tx.Commit()
+}
+
+// RemoveDocument drops every chunk of a document. Called on delete, and
+// before re-indexing a changed document so a stale passage cannot
+// survive as a citation to text that is no longer there.
+func (i *Index) RemoveDocument(documentID int64) error {
+ i.mu.Lock()
+ defer i.mu.Unlock()
+ _, err := i.db.Exec(`DELETE FROM chunks WHERE document_id = ?`, documentID)
+ return err
+}
+
+// Count returns how many chunks are indexed. Used by the UI to say what
+// the corpus actually contains rather than implying it is complete.
+func (i *Index) Count() (int, error) {
+ i.mu.RLock()
+ defer i.mu.RUnlock()
+ var n int
+ err := i.db.QueryRow(`SELECT count(*) FROM chunks`).Scan(&n)
+ return n, err
+}
+
+// Search runs a BM25 query. The heading column is weighted above the
+// body: a passage under "Revenue" is a better answer to "revenue" than
+// one that mentions the word in passing.
+func (i *Index) Search(query string, limit int) ([]Hit, error) {
+ q := MatchQuery(query)
+ if q == "" {
+ return nil, nil
+ }
+ if limit <= 0 {
+ limit = 20
+ }
+ i.mu.RLock()
+ defer i.mu.RUnlock()
+ rows, err := i.db.Query(`
+ SELECT chunk_id, document_id, ordinal, heading,
+ snippet(chunks, 1, '<mark>', '</mark>', '…', 24),
+ bm25(chunks, 3.0, 1.0)
+ FROM chunks
+ WHERE chunks MATCH ?
+ ORDER BY bm25(chunks, 3.0, 1.0)
+ LIMIT ?`, q, limit)
+ if err != nil {
+ return nil, err
+ }
+ defer rows.Close()
+ var out []Hit
+ for rows.Next() {
+ var h Hit
+ if err := rows.Scan(&h.ChunkID, &h.DocumentID, &h.Ordinal, &h.Heading, &h.Snippet, &h.Score); err != nil {
+ return nil, err
+ }
+ h.Score = -h.Score
+ out = append(out, h)
+ }
+ return out, rows.Err()
+}
+
+// MatchQuery turns what a person typed into an FTS5 MATCH expression.
+//
+// This exists because FTS5's query syntax is a language, and a person
+// typing an apostrophe, a colon or the bare word "NEAR" into a search box
+// is not writing one. Feeding raw input to MATCH produces a syntax error
+// on ordinary English — "what's June's revenue" — which surfaces as an
+// empty result page and reads exactly like "nothing matched".
+//
+// Every run of letters and digits becomes one quoted term. Quoting is
+// what makes punctuation inert.
+//
+// Terms are ORed, not ANDed, and that is the load-bearing decision here.
+// memoria is asked questions, not given keywords: "what is average
+// revenue in June" carries four words the corpus will not contain
+// alongside the two it will. ANDing them finds nothing, and finding
+// nothing is indistinguishable from having nothing — the failure shape
+// this codebase calls the worst available. OR keeps recall, and BM25
+// does the discriminating: a passage matching "revenue" and "June"
+// outranks one matching only "what", because IDF makes a common word
+// worth almost nothing. Precision comes from ranking, not from
+// exclusion.
+func MatchQuery(raw string) string {
+ var terms []string
+ var cur strings.Builder
+ flush := func() {
+ if cur.Len() == 0 {
+ return
+ }
+ terms = append(terms, `"`+cur.String()+`"`)
+ cur.Reset()
+ }
+ for _, r := range raw {
+ switch {
+ case r >= 'a' && r <= 'z', r >= 'A' && r <= 'Z', r >= '0' && r <= '9':
+ cur.WriteRune(r)
+ case r > 127:
+ // Keep non-ASCII letters: "café", "Müller", CJK. unicode61
+ // tokenises them; dropping them would silently narrow a
+ // search in any language but English.
+ cur.WriteRune(r)
+ default:
+ flush()
+ }
+ }
+ flush()
+ if len(terms) == 0 {
+ return ""
+ }
+ return strings.Join(terms, " OR ")
+}
diff --git a/internal/memoria/index/index_test.go b/internal/memoria/index/index_test.go
new file mode 100644
index 0000000..24edb99
--- /dev/null
+++ b/internal/memoria/index/index_test.go
@@ -0,0 +1,157 @@
+package index
+
+import (
+ "strings"
+ "testing"
+)
+
+func build(t *testing.T, entries ...Entry) *Index {
+ t.Helper()
+ ix, err := Open()
+ if err != nil {
+ t.Fatal(err)
+ }
+ t.Cleanup(func() { ix.Close() })
+ if err := ix.Add(entries...); err != nil {
+ t.Fatal(err)
+ }
+ return ix
+}
+
+func TestSearchFindsAPassage(t *testing.T) {
+ ix := build(t,
+ Entry{ChunkID: 1, DocumentID: 10, Heading: "FY2024 › Revenue", Text: "June revenue was 1,350,000 across all regions."},
+ Entry{ChunkID: 2, DocumentID: 10, Heading: "FY2024 › Costs", Text: "Hosting costs fell after the migration."},
+ )
+ hits, err := ix.Search("june revenue", 10)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if len(hits) == 0 {
+ t.Fatal("no hits for a query that plainly matches")
+ }
+ if hits[0].ChunkID != 1 {
+ t.Errorf("best hit is chunk %d, want 1", hits[0].ChunkID)
+ }
+ if !strings.Contains(hits[0].Snippet, "mark>") {
+ t.Errorf("snippet carries no highlight: %q", hits[0].Snippet)
+ }
+}
+
+// The heading is weighted above the body. A passage filed under Revenue
+// should beat one that merely says the word.
+func TestHeadingOutweighsBody(t *testing.T) {
+ ix := build(t,
+ Entry{ChunkID: 1, DocumentID: 1, Heading: "Revenue", Text: "Figures for the period."},
+ Entry{ChunkID: 2, DocumentID: 2, Heading: "Minutes", Text: "We briefly mentioned revenue and moved on."},
+ )
+ hits, err := ix.Search("revenue", 10)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if len(hits) != 2 {
+ t.Fatalf("want 2 hits, got %d", len(hits))
+ }
+ if hits[0].ChunkID != 1 {
+ t.Errorf("heading weighting not applied: best hit is chunk %d", hits[0].ChunkID)
+ }
+}
+
+// Ordinary English contains apostrophes and punctuation. Fed raw to
+// MATCH these are syntax errors, and a syntax error surfaces as an empty
+// page that reads exactly like "nothing matched" — the worst available
+// failure shape.
+func TestPunctuationDoesNotBreakSearch(t *testing.T) {
+ ix := build(t, Entry{ChunkID: 1, DocumentID: 1, Heading: "Notes", Text: "June revenue was strong."})
+ for _, q := range []string{
+ "what's june revenue?",
+ "revenue: june",
+ "NEAR revenue",
+ "OR",
+ `revenue "june`,
+ "(june)",
+ "revenue AND",
+ "-june",
+ "revenue*",
+ } {
+ hits, err := ix.Search(q, 10)
+ if err != nil {
+ t.Errorf("Search(%q) errored: %v", q, err)
+ continue
+ }
+ if strings.Contains(strings.ToLower(q), "revenue") && len(hits) == 0 {
+ t.Errorf("Search(%q) found nothing but mentions revenue", q)
+ }
+ }
+}
+
+func TestEmptyQueryReturnsNothingWithoutError(t *testing.T) {
+ ix := build(t, Entry{ChunkID: 1, DocumentID: 1, Heading: "H", Text: "body"})
+ for _, q := range []string{"", " ", "!!!", "???"} {
+ hits, err := ix.Search(q, 10)
+ if err != nil {
+ t.Errorf("Search(%q) errored: %v", q, err)
+ }
+ if len(hits) != 0 {
+ t.Errorf("Search(%q) returned %d hits", q, len(hits))
+ }
+ }
+}
+
+func TestRemoveDocumentDropsItsChunks(t *testing.T) {
+ ix := build(t,
+ Entry{ChunkID: 1, DocumentID: 10, Heading: "A", Text: "keepme text"},
+ Entry{ChunkID: 2, DocumentID: 20, Heading: "B", Text: "keepme text"},
+ )
+ if err := ix.RemoveDocument(10); err != nil {
+ t.Fatal(err)
+ }
+ hits, err := ix.Search("keepme", 10)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if len(hits) != 1 || hits[0].DocumentID != 20 {
+ t.Fatalf("after removing doc 10, got %d hits %+v", len(hits), hits)
+ }
+ n, err := ix.Count()
+ if err != nil {
+ t.Fatal(err)
+ }
+ if n != 1 {
+ t.Errorf("Count = %d, want 1", n)
+ }
+}
+
+func TestMatchQueryQuotesAndOrs(t *testing.T) {
+ for in, want := range map[string]string{
+ "june revenue": `"june" OR "revenue"`,
+ "what's up": `"what" OR "s" OR "up"`,
+ " spaced out ": `"spaced" OR "out"`,
+ "!!!": ``,
+ "café": `"café"`,
+ } {
+ if got := MatchQuery(in); got != want {
+ t.Errorf("MatchQuery(%q) = %q, want %q", in, got, want)
+ }
+ }
+}
+
+// OR keeps recall, so the discriminating has to be done by ranking. A
+// passage matching both real terms must beat one matching only a common
+// word, or the OR decision has traded a false empty for a false answer.
+func TestOrRecallStillRanksBestMatchFirst(t *testing.T) {
+ ix := build(t,
+ Entry{ChunkID: 1, DocumentID: 1, Heading: "Notes", Text: "What we discussed today."},
+ Entry{ChunkID: 2, DocumentID: 2, Heading: "Revenue", Text: "June revenue was strong."},
+ )
+ hits, err := ix.Search("what is june revenue", 10)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if len(hits) == 0 {
+ t.Fatal("no hits; OR recall is not working")
+ }
+ if hits[0].ChunkID != 2 {
+ t.Errorf("best hit is chunk %d, want 2 — ranking is not compensating for OR", hits[0].ChunkID)
+ }
+}
diff --git a/internal/memoria/migrations.go b/internal/memoria/migrations.go
index ffef7d2..8ad5334 100644
--- a/internal/memoria/migrations.go
+++ b/internal/memoria/migrations.go
@@ -3,7 +3,9 @@ package memoria
import (
"embed"
+ "github.com/carlosframework/rastrillo/auth"
"github.com/carlosframework/rastrillo/migrate"
+ "github.com/carlosframework/rastrillo/sessions"
)
//go:embed migrations/*.sql
@@ -39,4 +41,7 @@ var Schema = migrate.MustFromFS(migrationFS, "memoria")
// migrations another one reads (auth after sessions) goes first.
// Get this line wrong and either boot silently skips a subsystem's
// tables, or generate/check try to drop them.
-var BootSchema = migrate.Merge(Schema)
+// Merge's argument order is apply order: auth reads tables sessions
+// creates, so sessions goes first. Get this wrong and boot silently
+// skips a subsystem's tables.
+var BootSchema = migrate.Merge(sessions.Schema, auth.Schema, Schema)
diff --git a/internal/memoria/migrations/0001_create_documents.sql b/internal/memoria/migrations/0001_create_documents.sql
new file mode 100644
index 0000000..0e6b866
--- /dev/null
+++ b/internal/memoria/migrations/0001_create_documents.sql
@@ -0,0 +1,10 @@
+CREATE TABLE `documents` (`id` integer PRIMARY KEY AUTOINCREMENT,`source` text NOT NULL,`external_id` text NOT NULL,`revision` integer NOT NULL DEFAULT 1,`sealed_title` blob NOT NULL,`sealed_body` blob NOT NULL,`bytes` integer,`created_at` datetime,`updated_at` datetime);
+
+CREATE INDEX `idx_documents_external_id` ON `documents`(`external_id`);
+
+CREATE INDEX `idx_documents_source` ON `documents`(`source`);
+
+CREATE TABLE `chunks` (`id` integer PRIMARY KEY AUTOINCREMENT,`document_id` integer NOT NULL,`ordinal` integer NOT NULL,`sealed_heading` blob,`sealed_text` blob NOT NULL,`created_at` datetime,CONSTRAINT `fk_documents_chunks` FOREIGN KEY (`document_id`) REFERENCES `documents`(`id`) ON DELETE CASCADE);
+
+CREATE INDEX `idx_chunks_document_id` ON `chunks`(`document_id`);
+
diff --git a/internal/memoria/migrations/0001_init.sql b/internal/memoria/migrations/0001_init.sql
deleted file mode 100644
index c63a80d..0000000
--- a/internal/memoria/migrations/0001_init.sql
+++ /dev/null
@@ -1,2 +0,0 @@
-CREATE TABLE `notes` (`id` integer PRIMARY KEY AUTOINCREMENT,`title` text,`body` text,`created_at` datetime,`updated_at` datetime);
-
diff --git a/internal/memoria/migrations/schema.sql b/internal/memoria/migrations/schema.sql
index 239e296..150e004 100644
--- a/internal/memoria/migrations/schema.sql
+++ b/internal/memoria/migrations/schema.sql
@@ -1,5 +1,13 @@
-- Generated by rastrillo migration generate; DO NOT EDIT.
-- The schema every migration in this directory adds up to.
-CREATE TABLE `notes` (`id` integer PRIMARY KEY AUTOINCREMENT,`title` text,`body` text,`created_at` datetime,`updated_at` datetime);
+CREATE TABLE `chunks` (`id` integer PRIMARY KEY AUTOINCREMENT,`document_id` integer NOT NULL,`ordinal` integer NOT NULL,`sealed_heading` blob,`sealed_text` blob NOT NULL,`created_at` datetime,CONSTRAINT `fk_documents_chunks` FOREIGN KEY (`document_id`) REFERENCES `documents`(`id`) ON DELETE CASCADE);
+
+CREATE TABLE `documents` (`id` integer PRIMARY KEY AUTOINCREMENT,`source` text NOT NULL,`external_id` text NOT NULL,`revision` integer NOT NULL DEFAULT 1,`sealed_title` blob NOT NULL,`sealed_body` blob NOT NULL,`bytes` integer,`created_at` datetime,`updated_at` datetime);
+
+CREATE INDEX `idx_chunks_document_id` ON `chunks`(`document_id`);
+
+CREATE INDEX `idx_documents_external_id` ON `documents`(`external_id`);
+
+CREATE INDEX `idx_documents_source` ON `documents`(`source`);
diff --git a/internal/memoria/models.go b/internal/memoria/models.go
index fb75a1d..b870120 100644
--- a/internal/memoria/models.go
+++ b/internal/memoria/models.go
@@ -3,23 +3,66 @@ package memoria
import "time"
// Models is every model the schema generator manages. Keep it in step
-// with the structs below: `rastrillo migration generate` reads
-// it to work out what the database should look like, and
-// `rastrillo migration check` fails CI when it and the
-// migrations disagree.
+// with the structs below: `rastrillo migration generate` reads it to work
+// out what the database should look like, and `rastrillo migration check`
+// fails CI when it and the migrations disagree.
var Models = []any{
- &Note{},
+ &Document{},
+ &Chunk{},
}
-// Note is a starting example model — rename it, replace it, add more
-// beside it. Whatever it becomes, the rule stays the same: every query
-// touching user-owned rows goes through scope.Owned (SKILL.md) — reads
-// AND writes — and a request body is never bound onto a model directly
-// (explicit map[string]any + .Select allowlist).
-type Note struct {
- ID int64
- Title string
- Body string
+// Document is one thing memoria holds.
+//
+// What is plaintext here is deliberate and matches Sheets' §2.2: a
+// coordinate is not content. Source, ExternalID, Revision and the
+// timestamps are how the app finds and reconciles a row, and sealing them
+// would make the store unqueryable while protecting nothing an attacker
+// with the bucket could not infer from row counts anyway. The spec's §3.2
+// lists them as known leaks rather than pretending otherwise.
+//
+// Title and Body are content and are sealed.
+type Document struct {
+ ID int64
+ // Source names where this came from — "upload" or "paste" in slice 0.
+ // Sibling sources arrive in slice 1 and carry the app's name.
+ Source string `gorm:"index;not null"`
+ // ExternalID is the id in the source system, stable across edits.
+ // For an upload it is the content hash, which is what makes a
+ // re-upload of the same file an update rather than a duplicate.
+ ExternalID string `gorm:"index;not null"`
+ // Revision increments on every change to the body. Slice 1's
+ // authorization re-check is keyed on (id, revision), so a stale
+ // passage can be refused rather than spoken.
+ Revision int `gorm:"not null;default:1"`
+ // SealedTitle and SealedBody are sealed under distinct HKDF purposes.
+ SealedTitle []byte `gorm:"not null"`
+ SealedBody []byte `gorm:"not null"`
+ // Bytes is the plaintext length, kept so the UI can say how big a
+ // document is without opening it. A size is already inferable from
+ // the sealed length, so this leaks nothing new.
+ Bytes int
CreatedAt time.Time
UpdatedAt time.Time
+
+ Chunks []Chunk `gorm:"constraint:OnDelete:CASCADE"`
+}
+
+// Chunk is one retrievable passage of a Document.
+//
+// The searchable form of this text lives only in memory (internal/memoria/index):
+// FTS5 keeps term postings in plaintext, so an on-disk index would put
+// readable passages in the replicated file. These rows are the durable
+// copy and the index is rebuilt from them.
+type Chunk struct {
+ ID int64
+ DocumentID int64 `gorm:"index;not null"`
+ // Ordinal is the passage's position in its document, from 0 —
+ // a coordinate, plaintext, and what a citation points at.
+ Ordinal int `gorm:"not null"`
+ // SealedHeading is the heading trail ("FY2024 › Revenue"). Sealed:
+ // a heading is content, and Sheets §2.1 seals titles for the same
+ // reason.
+ SealedHeading []byte
+ SealedText []byte `gorm:"not null"`
+ CreatedAt time.Time
}
diff --git a/internal/memoria/render.go b/internal/memoria/render.go
index fc25b12..f77a30d 100644
--- a/internal/memoria/render.go
+++ b/internal/memoria/render.go
@@ -5,6 +5,7 @@ import (
"embed"
"html/template"
"net/http"
+ "strings"
"github.com/carlosframework/rastrillo"
@@ -25,7 +26,7 @@ var assets = rastrillo.NewAssets(appFS)
var pages = map[string]*template.Template{}
func init() {
- for _, name := range []string{"index"} {
+ for _, name := range []string{"index", "document", "signin"} {
pages[name] = template.Must(template.New("layout").
Funcs(template.FuncMap{
"asset": assets.Path,
@@ -36,6 +37,7 @@ func init() {
// switching delivery later needs no template edit.
"icon": icons.Icon,
"iconAssets": icons.Assets,
+ "safeSnippet": safeSnippet,
}).
ParseFS(appFS, "templates/layout.html", "templates/"+name+".html"))
}
@@ -53,3 +55,18 @@ func render(w http.ResponseWriter, name string, data any) {
}
buf.WriteTo(w)
}
+
+// safeSnippet renders an FTS5 snippet, which arrives as document text with
+// <mark> tags inserted around the matched terms.
+//
+// The text around those tags is content someone stored, so it cannot be
+// trusted — a document containing "<script>" must not execute in a search
+// result. Everything is therefore escaped first and exactly two tags are
+// re-admitted afterwards. Doing it the other way round (trusting the
+// snippet and escaping selectively) is the version that ships an XSS.
+func safeSnippet(s string) template.HTML {
+ escaped := template.HTMLEscapeString(s)
+ escaped = strings.ReplaceAll(escaped, "&lt;mark&gt;", "<mark>")
+ escaped = strings.ReplaceAll(escaped, "&lt;/mark&gt;", "</mark>")
+ return template.HTML(escaped)
+}
diff --git a/internal/memoria/seal/seal.go b/internal/memoria/seal/seal.go
new file mode 100644
index 0000000..ef858e8
--- /dev/null
+++ b/internal/memoria/seal/seal.go
@@ -0,0 +1,105 @@
+// Package seal is the app's only encryption surface.
+//
+// The one rule: memoria's chunks, titles and index are sealed at rest,
+// and the bucket never holds a readable passage. The DEK arrives as
+// platform config and is never written to the database it decrypts.
+//
+// Purpose keys are derived with HKDF so a value sealed as a chunk body
+// cannot be opened as a document title. That is not paranoia: they are
+// adjacent columns of related rows, and a bug that swapped them would be
+// invisible without this.
+//
+// This package is deliberately a near-copy of Sheets' internal/sheets/seal.
+// Sharing it would mean a module both apps depend on, which is a
+// coordination cost the suite has not paid yet; the construction being
+// identical is what matters, and the vector test pins it.
+package seal
+
+import (
+ "crypto/aes"
+ "crypto/cipher"
+ "crypto/rand"
+ "crypto/sha256"
+ "errors"
+ "hash"
+ "io"
+
+ "golang.org/x/crypto/hkdf"
+)
+
+// Purposes. One per kind of thing sealed, so a mix-up cannot decrypt.
+const (
+ PurposeChunk = "chunk.text"
+ PurposeTitle = "document.title"
+ PurposeBody = "document.body"
+ PurposeSnapshot = "index.snapshot"
+)
+
+func sha256New() hash.Hash { return sha256.New() }
+
+// Sealer holds the data encryption key. One per process.
+type Sealer struct{ dek []byte }
+
+// New returns a Sealer. The DEK must be exactly 32 bytes.
+func New(dek []byte) (*Sealer, error) {
+ if len(dek) != 32 {
+ return nil, errors.New("seal: DEK must be 32 bytes")
+ }
+ c := make([]byte, 32)
+ copy(c, dek)
+ return &Sealer{dek: c}, nil
+}
+
+// purposeKey derives the AEAD key for purpose via HKDF over the DEK. A
+// fixed 32-byte read is always inside HKDF-SHA256's expand limit, so
+// this cannot fail today — but the error is still propagated: this is
+// the app's only encryption surface, and a silently discarded read here
+// would degrade into a short or zero-padded key if the key length ever
+// changed, which is the kind of bug that leaves ciphertext looking
+// perfectly fine while being trivially breakable.
+func (s *Sealer) purposeKey(purpose string) ([]byte, error) {
+ key := make([]byte, 32)
+ r := hkdf.New(sha256New, s.dek, nil, []byte("oficina/memoria:"+purpose))
+ if _, err := io.ReadFull(r, key); err != nil {
+ return nil, err
+ }
+ return key, nil
+}
+
+func (s *Sealer) aeadFor(purpose string) (cipher.AEAD, error) {
+ key, err := s.purposeKey(purpose)
+ if err != nil {
+ return nil, err
+ }
+ block, err := aes.NewCipher(key)
+ if err != nil {
+ return nil, err
+ }
+ return cipher.NewGCM(block)
+}
+
+// Seal encrypts plaintext under purpose. The nonce is prepended.
+func (s *Sealer) Seal(purpose string, plaintext []byte) ([]byte, error) {
+ aead, err := s.aeadFor(purpose)
+ if err != nil {
+ return nil, err
+ }
+ nonce := make([]byte, aead.NonceSize())
+ if _, err := rand.Read(nonce); err != nil {
+ return nil, err
+ }
+ return aead.Seal(nonce, nonce, plaintext, nil), nil
+}
+
+// Open decrypts a value sealed under the same purpose.
+func (s *Sealer) Open(purpose string, ct []byte) ([]byte, error) {
+ aead, err := s.aeadFor(purpose)
+ if err != nil {
+ return nil, err
+ }
+ if len(ct) < aead.NonceSize() {
+ return nil, errors.New("seal: ciphertext too short")
+ }
+ nonce, body := ct[:aead.NonceSize()], ct[aead.NonceSize():]
+ return aead.Open(nil, nonce, body, nil)
+}
diff --git a/internal/memoria/seal/seal_test.go b/internal/memoria/seal/seal_test.go
new file mode 100644
index 0000000..40417fe
--- /dev/null
+++ b/internal/memoria/seal/seal_test.go
@@ -0,0 +1,69 @@
+package seal
+
+import (
+ "bytes"
+ "testing"
+)
+
+func testDEK() []byte {
+ d := make([]byte, 32)
+ for i := range d {
+ d[i] = byte(i)
+ }
+ return d
+}
+
+func TestRoundTrip(t *testing.T) {
+ s, err := New(testDEK())
+ if err != nil {
+ t.Fatal(err)
+ }
+ want := []byte("June revenue was 1,350,000")
+ ct, err := s.Seal(PurposeChunk, want)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if bytes.Contains(ct, want) {
+ t.Fatal("ciphertext contains the plaintext")
+ }
+ got, err := s.Open(PurposeChunk, ct)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if !bytes.Equal(got, want) {
+ t.Fatalf("round trip: got %q want %q", got, want)
+ }
+}
+
+// A chunk must not open as a title. The two are adjacent columns of
+// related rows; without HKDF purpose separation a swap would decrypt
+// cleanly and nothing would notice.
+func TestPurposesDoNotInterchange(t *testing.T) {
+ s, _ := New(testDEK())
+ ct, err := s.Seal(PurposeChunk, []byte("secret passage"))
+ if err != nil {
+ t.Fatal(err)
+ }
+ if _, err := s.Open(PurposeTitle, ct); err == nil {
+ t.Fatal("a chunk opened as a title; purpose separation is not working")
+ }
+}
+
+func TestRejectsWrongKeyLength(t *testing.T) {
+ for _, n := range []int{0, 16, 31, 33, 64} {
+ if _, err := New(make([]byte, n)); err == nil {
+ t.Errorf("accepted a %d-byte DEK", n)
+ }
+ }
+}
+
+// Two seals of the same plaintext must differ, or the store leaks
+// equality between passages — which over a corpus leaks structure.
+func TestNonceIsFresh(t *testing.T) {
+ s, _ := New(testDEK())
+ a, _ := s.Seal(PurposeChunk, []byte("same"))
+ b, _ := s.Seal(PurposeChunk, []byte("same"))
+ if bytes.Equal(a, b) {
+ t.Fatal("two seals of the same plaintext are identical")
+ }
+}
diff --git a/internal/memoria/static/memoria.css b/internal/memoria/static/memoria.css
new file mode 100644
index 0000000..8df56d8
--- /dev/null
+++ b/internal/memoria/static/memoria.css
@@ -0,0 +1,100 @@
+/* memoria's own styles.
+ *
+ * Every colour is a token. Not one literal appears here, so the
+ * reader's light/dark choice resolves through theme.css and a passage
+ * never paints against the wrong ground. Sheets enforces the same rule
+ * with a test; memoria's equivalent is TestNoColourLiterals.
+ */
+
+[rst-page] { margin: 0 auto; max-width: 46rem; padding: 1.5rem 1rem 4rem; }
+
+[rst-page-header] {
+ align-items: center;
+ border-bottom: 1px solid var(--rst-line);
+ display: flex;
+ gap: 1rem;
+ justify-content: space-between;
+ margin: 0 auto;
+ max-width: 46rem;
+ padding: 0.9rem 1rem;
+}
+[rst-brand] {
+ color: var(--rst-text);
+ font-weight: 620;
+ letter-spacing: -0.01em;
+ text-decoration: none;
+}
+[rst-page-footer] {
+ border-top: 1px solid var(--rst-line);
+ margin: 2rem auto 0;
+ max-width: 46rem;
+ padding: 0.9rem 1rem 2rem;
+}
+[rst-inline] { display: inline; }
+
+[rst-search] { display: flex; gap: 0.5rem; margin-bottom: 1.5rem; }
+[rst-search] input[type="search"] {
+ background: var(--rst-bg);
+ border: 1px solid var(--rst-line);
+ border-radius: var(--rst-radius-sm, 6px);
+ color: var(--rst-text);
+ flex: 1;
+ font: inherit;
+ padding: 0.55rem 0.7rem;
+}
+
+[rst-section] { border-top: 1px solid var(--rst-line); margin-top: 2.5rem; padding-top: 1rem; }
+[rst-stack] { display: flex; flex-direction: column; gap: 0.35rem; }
+[rst-stack] label { font-weight: 560; margin-top: 0.6rem; }
+[rst-stack] input[type="text"],
+[rst-stack] input[type="email"],
+[rst-stack] textarea {
+ background: var(--rst-bg);
+ border: 1px solid var(--rst-line);
+ border-radius: var(--rst-radius-sm, 6px);
+ color: var(--rst-text);
+ font: inherit;
+ padding: 0.5rem 0.65rem;
+ width: 100%;
+}
+[rst-stack] textarea { font-family: var(--rst-font-mono, ui-monospace, monospace); resize: vertical; }
+
+[rst-results] { list-style: none; margin: 0; padding: 0; }
+[rst-result] { border-top: 1px solid var(--rst-line); padding: 0.9rem 0; }
+[rst-result]:first-child { border-top: 0; }
+[rst-result-title] { font-weight: 600; }
+[rst-result-trail] {
+ color: var(--rst-text-mut);
+ display: block;
+ font-size: 0.8125rem;
+ margin-top: 0.1rem;
+}
+[rst-result-snippet] { margin: 0.4rem 0 0.2rem; }
+[rst-result-snippet] mark {
+ background: var(--rst-accent-soft, var(--rst-line));
+ color: inherit;
+ padding: 0 0.1em;
+}
+[rst-result-meta] { font-size: 0.8125rem; margin: 0; }
+
+[rst-prose] { line-height: 1.6; }
+[rst-passage] { border-top: 1px solid var(--rst-line); padding: 0.8rem 0; }
+[rst-passage]:target { border-inline-start: 3px solid var(--rst-accent, var(--rst-text)); padding-inline-start: 0.7rem; }
+[rst-passage-heading] { font-size: 0.9375rem; margin: 0 0 0.4rem; text-transform: none; }
+[rst-passage] p { margin: 0; white-space: pre-wrap; }
+
+[rst-breadcrumb] { margin-bottom: 0.75rem; }
+
+[rst-table] { border-collapse: collapse; width: 100%; }
+[rst-table] th, [rst-table] td {
+ border-bottom: 1px solid var(--rst-line);
+ padding: 0.45rem 0.5rem 0.45rem 0;
+ text-align: start;
+}
+[rst-table] th { font-size: 0.8125rem; font-weight: 560; }
+
+/* 320px is the reflow floor the suite holds itself to. */
+@media (max-width: 26rem) {
+ [rst-search] { flex-direction: column; }
+ [rst-table] { font-size: 0.875rem; }
+}
diff --git a/internal/memoria/store.go b/internal/memoria/store.go
new file mode 100644
index 0000000..13ea18b
--- /dev/null
+++ b/internal/memoria/store.go
@@ -0,0 +1,326 @@
+package memoria
+
+import (
+ "context"
+ "crypto/sha256"
+ "encoding/hex"
+ "errors"
+ "fmt"
+ "strings"
+
+ "github.com/carlosframework/rastrillo/db"
+
+ "amadan.net/oficina/memoria/internal/memoria/chunk"
+ "amadan.net/oficina/memoria/internal/memoria/index"
+ "amadan.net/oficina/memoria/internal/memoria/seal"
+)
+
+// Store is the only thing that opens sealed rows. Everything above it
+// works in plaintext that never reaches disk; everything below it works
+// in ciphertext.
+type Store struct {
+ db *db.DB
+ sealer *seal.Sealer
+ ix *index.Index
+}
+
+// NewStore wires the store. The index starts empty — call Rebuild.
+func NewStore(d *db.DB, sealer *seal.Sealer, ix *index.Index) *Store {
+ return &Store{db: d, sealer: sealer, ix: ix}
+}
+
+// Result is one search hit, with its citation resolved to plaintext.
+type Result struct {
+ DocumentID int64
+ ChunkID int64
+ Ordinal int
+ Source string
+ Title string
+ Heading string
+ Snippet string
+ Score float64
+}
+
+// Summary is a document as the list shows it.
+type Summary struct {
+ ID int64
+ Source string
+ Title string
+ Bytes int
+ Chunks int
+ Updated string
+}
+
+// ContentHash is the ExternalID for pasted and uploaded text. Using the
+// hash means the same file ingested twice updates one document instead of
+// making two that disagree.
+func ContentHash(body string) string {
+ sum := sha256.Sum256([]byte(body))
+ return hex.EncodeToString(sum[:])
+}
+
+// Ingest stores a document and indexes it.
+//
+// Re-ingesting the same ExternalID replaces the document's chunks and
+// bumps its revision. The old chunks are deleted from the index *and* the
+// database before the new ones land, so a passage that the new revision
+// removed cannot survive as a citation to text that is no longer there —
+// which is the stale-passage failure the spec's §4.1 refuses to allow.
+func (s *Store) Ingest(ctx context.Context, source, title, body string) (int64, error) {
+ title = strings.TrimSpace(title)
+ body = strings.TrimSpace(body)
+ if body == "" {
+ return 0, errors.New("nothing to store: the document is empty")
+ }
+ if title == "" {
+ title = firstLine(body)
+ }
+ external := ContentHash(body)
+
+ sealedTitle, err := s.sealer.Seal(seal.PurposeTitle, []byte(title))
+ if err != nil {
+ return 0, err
+ }
+ sealedBody, err := s.sealer.Seal(seal.PurposeBody, []byte(body))
+ if err != nil {
+ return 0, err
+ }
+
+ chunks := chunk.Split(body)
+ if len(chunks) == 0 {
+ return 0, errors.New("nothing to store: the document produced no passages")
+ }
+
+ var docID int64
+ err = s.db.G.WithContext(ctx).Transaction(func(tx *gormTx) error {
+ var existing Document
+ res := tx.Where("source = ? AND external_id = ?", source, external).First(&existing)
+ switch {
+ case res.Error == nil:
+ docID = existing.ID
+ if err := tx.Where("document_id = ?", docID).Delete(&Chunk{}).Error; err != nil {
+ return err
+ }
+ if err := tx.Model(&Document{}).Where("id = ?", docID).Updates(map[string]any{
+ "revision": existing.Revision + 1,
+ "sealed_title": sealedTitle,
+ "sealed_body": sealedBody,
+ "bytes": len(body),
+ }).Error; err != nil {
+ return err
+ }
+ default:
+ d := Document{
+ Source: source,
+ ExternalID: external,
+ Revision: 1,
+ SealedTitle: sealedTitle,
+ SealedBody: sealedBody,
+ Bytes: len(body),
+ }
+ if err := tx.Create(&d).Error; err != nil {
+ return err
+ }
+ docID = d.ID
+ }
+
+ rows := make([]Chunk, 0, len(chunks))
+ for _, c := range chunks {
+ st, err := s.sealer.Seal(seal.PurposeChunk, []byte(c.Indexable()))
+ if err != nil {
+ return err
+ }
+ sh, err := s.sealer.Seal(seal.PurposeTitle, []byte(c.Heading()))
+ if err != nil {
+ return err
+ }
+ rows = append(rows, Chunk{
+ DocumentID: docID,
+ Ordinal: c.Ordinal,
+ SealedHeading: sh,
+ SealedText: st,
+ })
+ }
+ return tx.Create(&rows).Error
+ })
+ if err != nil {
+ return 0, err
+ }
+
+ // The index is derived state, so it is refreshed after the
+ // transaction commits rather than inside it: a rolled-back
+ // transaction that had already mutated the index would leave the
+ // index claiming passages the database does not have.
+ if err := s.ix.RemoveDocument(docID); err != nil {
+ return 0, err
+ }
+ return docID, s.indexDocument(ctx, docID)
+}
+
+// indexDocument opens a document's chunks and adds them to the index.
+func (s *Store) indexDocument(ctx context.Context, docID int64) error {
+ var rows []Chunk
+ if err := s.db.G.WithContext(ctx).Where("document_id = ?", docID).Order("ordinal").Find(&rows).Error; err != nil {
+ return err
+ }
+ entries := make([]index.Entry, 0, len(rows))
+ for _, r := range rows {
+ text, err := s.sealer.Open(seal.PurposeChunk, r.SealedText)
+ if err != nil {
+ return fmt.Errorf("open chunk %d: %w", r.ID, err)
+ }
+ heading, err := s.sealer.Open(seal.PurposeTitle, r.SealedHeading)
+ if err != nil {
+ return fmt.Errorf("open chunk heading %d: %w", r.ID, err)
+ }
+ entries = append(entries, index.Entry{
+ ChunkID: r.ID,
+ DocumentID: r.DocumentID,
+ Ordinal: r.Ordinal,
+ Heading: string(heading),
+ Text: string(text),
+ })
+ }
+ if len(entries) == 0 {
+ return nil
+ }
+ return s.ix.Add(entries...)
+}
+
+// Rebuild fills an empty index from the sealed rows. Called at boot,
+// because the index lives in memory and nowhere else (see the index
+// package for why).
+func (s *Store) Rebuild(ctx context.Context) (int, error) {
+ var ids []int64
+ if err := s.db.G.WithContext(ctx).Model(&Document{}).Order("id").Pluck("id", &ids).Error; err != nil {
+ return 0, err
+ }
+ for _, id := range ids {
+ if err := s.indexDocument(ctx, id); err != nil {
+ return 0, err
+ }
+ }
+ return s.ix.Count()
+}
+
+// Search runs the query and resolves each hit's citation.
+func (s *Store) Search(ctx context.Context, query string, limit int) ([]Result, error) {
+ hits, err := s.ix.Search(query, limit)
+ if err != nil {
+ return nil, err
+ }
+ if len(hits) == 0 {
+ return nil, nil
+ }
+ docIDs := make([]int64, 0, len(hits))
+ seen := map[int64]bool{}
+ for _, h := range hits {
+ if !seen[h.DocumentID] {
+ seen[h.DocumentID] = true
+ docIDs = append(docIDs, h.DocumentID)
+ }
+ }
+ var docs []Document
+ if err := s.db.G.WithContext(ctx).Where("id IN ?", docIDs).Find(&docs).Error; err != nil {
+ return nil, err
+ }
+ titles := map[int64]string{}
+ sources := map[int64]string{}
+ for _, d := range docs {
+ t, err := s.sealer.Open(seal.PurposeTitle, d.SealedTitle)
+ if err != nil {
+ return nil, fmt.Errorf("open title %d: %w", d.ID, err)
+ }
+ titles[d.ID] = string(t)
+ sources[d.ID] = d.Source
+ }
+ out := make([]Result, 0, len(hits))
+ for _, h := range hits {
+ out = append(out, Result{
+ DocumentID: h.DocumentID,
+ ChunkID: h.ChunkID,
+ Ordinal: h.Ordinal,
+ Source: sources[h.DocumentID],
+ Title: titles[h.DocumentID],
+ Heading: h.Heading,
+ Snippet: h.Snippet,
+ Score: h.Score,
+ })
+ }
+ return out, nil
+}
+
+// Summaries lists every document, titles opened.
+func (s *Store) Summaries(ctx context.Context) ([]Summary, error) {
+ var docs []Document
+ if err := s.db.G.WithContext(ctx).Order("updated_at desc").Find(&docs).Error; err != nil {
+ return nil, err
+ }
+ out := make([]Summary, 0, len(docs))
+ for _, d := range docs {
+ t, err := s.sealer.Open(seal.PurposeTitle, d.SealedTitle)
+ if err != nil {
+ return nil, fmt.Errorf("open title %d: %w", d.ID, err)
+ }
+ var n int64
+ if err := s.db.G.WithContext(ctx).Model(&Chunk{}).Where("document_id = ?", d.ID).Count(&n).Error; err != nil {
+ return nil, err
+ }
+ out = append(out, Summary{
+ ID: d.ID,
+ Source: d.Source,
+ Title: string(t),
+ Bytes: d.Bytes,
+ Chunks: int(n),
+ Updated: d.UpdatedAt.Format("2 Jan 2006, 15:04"),
+ })
+ }
+ return out, nil
+}
+
+// Body opens one document for display.
+func (s *Store) Body(ctx context.Context, id int64) (title, body string, err error) {
+ var d Document
+ if err := s.db.G.WithContext(ctx).Where("id = ?", id).First(&d).Error; err != nil {
+ return "", "", err
+ }
+ t, err := s.sealer.Open(seal.PurposeTitle, d.SealedTitle)
+ if err != nil {
+ return "", "", err
+ }
+ b, err := s.sealer.Open(seal.PurposeBody, d.SealedBody)
+ if err != nil {
+ return "", "", err
+ }
+ return string(t), string(b), nil
+}
+
+// Delete removes a document, its chunks, and its index entries.
+func (s *Store) Delete(ctx context.Context, id int64) error {
+ if err := s.db.G.WithContext(ctx).Transaction(func(tx *gormTx) error {
+ if err := tx.Where("document_id = ?", id).Delete(&Chunk{}).Error; err != nil {
+ return err
+ }
+ return tx.Where("id = ?", id).Delete(&Document{}).Error
+ }); err != nil {
+ return err
+ }
+ return s.ix.RemoveDocument(id)
+}
+
+// Count returns the indexed passage count, for the UI to state what the
+// corpus holds rather than implying it is complete.
+func (s *Store) Count() (int, error) { return s.ix.Count() }
+
+func firstLine(body string) string {
+ for _, line := range strings.Split(body, "\n") {
+ line = strings.TrimSpace(strings.TrimLeft(line, "# "))
+ if line != "" {
+ if len(line) > 120 {
+ return line[:120]
+ }
+ return line
+ }
+ }
+ return "Untitled"
+}
diff --git a/internal/memoria/templates/document.html b/internal/memoria/templates/document.html
new file mode 100644
index 0000000..50538e9
--- /dev/null
+++ b/internal/memoria/templates/document.html
@@ -0,0 +1,14 @@
+{{define "title"}}{{.Title}} — memoria{{end}}
+{{define "content"}}
+<nav rst-breadcrumb><a href="/">← Back to search</a></nav>
+<h1>{{.Title}}</h1>
+<p rst-cell-mut>{{.Source}} · {{.Bytes}} bytes · revision {{.Revision}}</p>
+<article rst-prose>
+{{range .Passages}}
+<section id="p{{.Ordinal}}" rst-passage>
+ {{if .Heading}}<h2 rst-passage-heading>{{.Heading}}</h2>{{end}}
+ <p>{{.Text}}</p>
+</section>
+{{end}}
+</article>
+{{end}}
diff --git a/internal/memoria/templates/index.html b/internal/memoria/templates/index.html
index dd9abab..8cf1ecb 100644
--- a/internal/memoria/templates/index.html
+++ b/internal/memoria/templates/index.html
@@ -1,3 +1,71 @@
+{{define "title"}}memoria{{end}}
{{define "content"}}
-<h1>Hello, World — this is a rastrillo app.</h1>
+
+<form method="get" action="/" rst-search>
+ <label for="q" class="rst-sr-only">Search the team's memory</label>
+ <input type="search" id="q" name="q" value="{{.Query}}" placeholder="Search the team's memory…" autofocus>
+ <button rst-btn="primary">Search</button>
+</form>
+
+{{if .Searched}}
+ <h2>{{len .Results}} result{{if ne (len .Results) 1}}s{{end}} for “{{.Query}}”</h2>
+ {{if not .Results}}
+ <div rst-callout="quiet">
+ <p rst-callout-body>
+ Nothing matched. There {{if eq .Chunks 1}}is{{else}}are{{end}} {{.Chunks}} passage{{if ne .Chunks 1}}s{{end}}
+ indexed, so this is a real absence rather than an empty index.
+ </p>
+ </div>
+ {{end}}
+ <ol rst-results>
+ {{range .Results}}
+ <li rst-result>
+ <a href="/documents/{{.DocumentID}}#p{{.Ordinal}}" rst-result-title>{{.Title}}</a>
+ {{if .Heading}}<span rst-result-trail>{{.Heading}}</span>{{end}}
+ <p rst-result-snippet>{{.Snippet | safeSnippet}}</p>
+ <p rst-result-meta rst-cell-mut>{{.Source}} · passage {{.Ordinal}}</p>
+ </li>
+ {{end}}
+ </ol>
+{{end}}
+
+<section rst-section>
+ <h2>Add something</h2>
+ <form method="post" action="/documents" enctype="multipart/form-data" rst-stack>
+ <label for="title">Title <span rst-cell-mut>(optional — the first line is used if blank)</span></label>
+ <input type="text" id="title" name="title">
+ <label for="body">Paste text</label>
+ <textarea id="body" name="body" rows="8" placeholder="Paste a document, notes, minutes…"></textarea>
+ <label for="file">…or upload a text file</label>
+ <input type="file" id="file" name="file" accept=".txt,.md,.markdown,.csv,text/plain,text/markdown">
+ <div rst-form-bar><button rst-btn="primary">Store it</button></div>
+ </form>
+ {{if .Flash}}<div rst-callout="{{.FlashKind}}"><p rst-callout-body>{{.Flash}}</p></div>{{end}}
+</section>
+
+<section rst-section>
+ <h2>{{len .Documents}} document{{if ne (len .Documents) 1}}s{{end}}</h2>
+ {{if not .Documents}}
+ <p rst-cell-mut>Nothing stored yet.</p>
+ {{else}}
+ <table rst-table>
+ <thead><tr><th>Title</th><th>Source</th><th>Passages</th><th>Updated</th><th></th></tr></thead>
+ <tbody>
+ {{range .Documents}}
+ <tr>
+ <td><a href="/documents/{{.ID}}">{{.Title}}</a></td>
+ <td rst-cell-mut>{{.Source}}</td>
+ <td rst-cell-mut>{{.Chunks}}</td>
+ <td rst-cell-mut>{{.Updated}}</td>
+ <td>
+ <form method="post" action="/documents/{{.ID}}/delete" rst-inline>
+ <button rst-btn="quiet compact" class="rst-danger">Delete</button>
+ </form>
+ </td>
+ </tr>
+ {{end}}
+ </tbody>
+ </table>
+ {{end}}
+</section>
{{end}}
diff --git a/internal/memoria/templates/layout.html b/internal/memoria/templates/layout.html
index 783d0b0..9cc503e 100644
--- a/internal/memoria/templates/layout.html
+++ b/internal/memoria/templates/layout.html
@@ -3,16 +3,33 @@
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
-<title>{{block "title" .}}Hello{{end}}</title>
+<title>{{block "title" .}}memoria{{end}}</title>
<link rel="stylesheet" href="{{asset "static/tokens.css"}}">
+{{/* theme.css carries the palette. tokens.css only USES --rst-bg and
+ friends — it defines eleven tokens, none of them colours — so
+ without this link every colour resolves to initial and whole
+ shorthand declarations become invalid at computed-value time. The
+ rastrillo scaffold's layout omits it, which is a scaffold bug, not
+ a choice: the page does not go grey, individual rules vanish. */}}
+<link rel="stylesheet" href="{{asset "static/theme.css"}}">
+<link rel="stylesheet" href="{{asset "static/memoria.css"}}">
<script defer src="{{asset "static/rastrillo.js"}}"></script>
{{iconAssets}}
-<script defer src="{{asset "static/select.js"}}"></script>
</head>
<body>
-<main>
+<header rst-page-header>
+ <a href="/" rst-brand>memoria</a>
+ {{if .Signed}}<form method="post" action="/signout" rst-inline><button rst-btn="quiet compact">Sign out</button></form>{{end}}
+</header>
+<main rst-page>
{{template "content" .}}
</main>
+<footer rst-page-footer>
+ <p rst-cell-mut>
+ {{.Chunks}} passage{{if ne .Chunks 1}}s{{end}} indexed.
+ Search is lexical only — no assistant, and nothing leaves this instance.
+ </p>
+</footer>
</body>
</html>
{{end}}
diff --git a/internal/memoria/templates/signin.html b/internal/memoria/templates/signin.html
new file mode 100644
index 0000000..f9aece1
--- /dev/null
+++ b/internal/memoria/templates/signin.html
@@ -0,0 +1,12 @@
+{{define "title"}}Sign in — memoria{{end}}
+{{define "content"}}
+<h1>Sign in</h1>
+<p>memoria holds your team's work in one place. Sign in with your email and
+we'll send you a link.</p>
+{{if .Flash}}<div rst-callout="{{.FlashKind}}"><p rst-callout-body>{{.Flash}}</p></div>{{end}}
+<form method="post" action="/auth/begin" rst-stack>
+ <label for="address">Email address</label>
+ <input type="email" id="address" name="address" required autocomplete="email" autofocus>
+ <div rst-form-bar><button rst-btn="primary">Send me a link</button></div>
+</form>
+{{end}}
diff --git a/internal/memoriatest/gates_test.go b/internal/memoriatest/gates_test.go
new file mode 100644
index 0000000..078ff57
--- /dev/null
+++ b/internal/memoriatest/gates_test.go
@@ -0,0 +1,261 @@
+package memoriatest
+
+import (
+ "encoding/base64"
+ "errors"
+ "os"
+ "path/filepath"
+ "regexp"
+ "strings"
+ "testing"
+
+ memoria "amadan.net/oficina/memoria/internal/memoria"
+)
+
+// canary is text that must never appear in bytes at rest. It is a phrase
+// no library or schema would produce, so a hit is our leak and not a
+// coincidence.
+const canary = "ZEBRAFINCH-QUARTERLY-REVENUE-8834"
+
+// Gate 1. The one rule: the database file never holds a readable
+// passage.
+//
+// This reads the raw bytes rather than querying, because a query would
+// go through the code that decrypts and would pass no matter what is on
+// disk. The WAL is read too: SQLite writes there first, and a gate that
+// checks only the main file passes for as long as a checkpoint has not
+// happened — which in a short test is always.
+//
+// MUTATION-TESTED 2026-09-03: replacing s.sealer.Seal with a plain
+// []byte(...) cast in Store.Ingest makes this fail on both the chunk and
+// the title, naming the file it found the canary in. Reverted.
+func TestPlaintextNeverReachesTheDatabase(t *testing.T) {
+ dir := t.TempDir()
+ path := filepath.Join(dir, "app.db")
+ h, _ := newAppAt(t, path)
+
+ body := "# " + canary + "\n\nJune revenue was 1,350,000 and " + canary + " again in the body.\n"
+ if rec := store(t, h, canary+" as a title", body); rec.Code != 303 {
+ t.Fatalf("store: status %d, want 303", rec.Code)
+ }
+ // Prove the document is actually there, or this gate passes on an
+ // empty database and proves nothing.
+ if got := get(t, h, "/?q=zebrafinch").Body.String(); !strings.Contains(got, "result") {
+ t.Fatal("the document was not stored or not indexed; the gate below would pass vacuously")
+ }
+
+ entries, err := os.ReadDir(dir)
+ if err != nil {
+ t.Fatal(err)
+ }
+ checked := 0
+ for _, e := range entries {
+ if e.IsDir() {
+ continue
+ }
+ raw, err := os.ReadFile(filepath.Join(dir, e.Name()))
+ if err != nil {
+ t.Fatal(err)
+ }
+ checked++
+ if bytesContainsFold(raw, canary) {
+ t.Errorf("%s holds the canary in plaintext: the one rule is broken", e.Name())
+ }
+ }
+ if checked == 0 {
+ t.Fatal("no files to check; the gate proved nothing")
+ }
+}
+
+// Gate 15's egress half. With the assistant off, nothing leaves the
+// instance — and in slice 0 there is no assistant at all, so the honest
+// form of this gate is that no app package can make an outbound request.
+//
+// A runtime gate would be better and is not available: httptest cannot
+// observe a dial that never happens. So this is a source gate, and it is
+// mutation-tested by adding an http.Get to the store, which makes it
+// fail.
+//
+// MUTATION-TESTED 2026-09-03: adding `http.Get("https://example.com")`
+// to Store.Ingest makes this fail naming store.go. Reverted.
+func TestNoOutboundNetworkInAppCode(t *testing.T) {
+ forbidden := []string{
+ `net/http".*Get\(`,
+ `http\.Get\(`,
+ `http\.Post\(`,
+ `http\.Client\{`,
+ `http\.DefaultClient`,
+ `net\.Dial`,
+ `smtp\.`,
+ }
+ pats := make([]*regexp.Regexp, 0, len(forbidden))
+ for _, f := range forbidden {
+ pats = append(pats, regexp.MustCompile(f))
+ }
+ root := filepath.Join("..", "memoria")
+ var hits []string
+ err := filepath.Walk(root, func(p string, info os.FileInfo, err error) error {
+ if err != nil {
+ return err
+ }
+ if info.IsDir() || !strings.HasSuffix(p, ".go") || strings.HasSuffix(p, "_test.go") {
+ return nil
+ }
+ src, err := os.ReadFile(p)
+ if err != nil {
+ return err
+ }
+ for _, pat := range pats {
+ if pat.Match(src) {
+ hits = append(hits, p+" matches "+pat.String())
+ }
+ }
+ return nil
+ })
+ if err != nil {
+ t.Fatal(err)
+ }
+ for _, h := range hits {
+ t.Errorf("app code can reach the network: %s", h)
+ }
+}
+
+// Every colour in the app's own stylesheet is a token, so the reader's
+// light/dark choice resolves through theme.css. A literal here paints a
+// passage against the wrong ground in one theme and nobody notices until
+// a screenshot.
+//
+// MUTATION-TESTED 2026-09-03: adding `color: #fff` to memoria.css makes
+// this fail naming the line. Reverted.
+func TestNoColourLiteralsInAppCSS(t *testing.T) {
+ raw, err := os.ReadFile(filepath.Join("..", "memoria", "static", "memoria.css"))
+ if err != nil {
+ t.Fatal(err)
+ }
+ // Hex colours, rgb()/hsl()/oklch() and the named colours most likely
+ // to be typed by hand. `var(--rst-...)` is the only sanctioned way.
+ bad := regexp.MustCompile(`(?i)(#[0-9a-f]{3,8}\b|\brgba?\(|\bhsla?\(|\boklch\(|:\s*(white|black|red|blue|green|grey|gray)\b)`)
+ for i, line := range strings.Split(string(raw), "\n") {
+ if strings.HasPrefix(strings.TrimSpace(line), "*") || strings.HasPrefix(strings.TrimSpace(line), "/*") {
+ continue
+ }
+ if m := bad.FindString(line); m != "" {
+ t.Errorf("memoria.css:%d has the colour literal %q; use a token", i+1, m)
+ }
+ }
+}
+
+// requireSession falls through when auth is nil, which is what makes
+// these tests possible. That seam is only safe because the app refuses to
+// start without sign-in configured, so this pins the refusals.
+//
+// An earlier version of this gate grepped main.go for os.Exit near each
+// variable name. It PASSED when the DEK refusal was replaced with a
+// generated fallback, because the base64 error path below it supplied an
+// os.Exit within the search window. That is why the validation now lives
+// in a function and this tests its behaviour instead of its text.
+//
+// MUTATION-TESTED 2026-09-03: making any one of the three fields default
+// instead of erroring makes the matching subtest fail. Reverted.
+func TestConfigRefusesIncompleteEnvironment(t *testing.T) {
+ full := map[string]string{
+ "MEMORIA_DEK": base64.StdEncoding.EncodeToString(make([]byte, 32)),
+ "MEMORIA_INSTANCE_KEY": "instance-key",
+ "MEMORIA_OWNERS": "paul@example.com",
+ "MEMORIA_ORIGIN": "https://memoria.example.com",
+ }
+ // The complete environment must load, or every subtest below passes
+ // for the wrong reason.
+ if _, err := memoria.LoadConfig(envFrom(full)); err != nil {
+ t.Fatalf("a complete environment did not load: %v", err)
+ }
+
+ for _, missing := range []string{"MEMORIA_DEK", "MEMORIA_INSTANCE_KEY", "MEMORIA_OWNERS"} {
+ t.Run("without "+missing, func(t *testing.T) {
+ env := map[string]string{}
+ for k, v := range full {
+ if k != missing {
+ env[k] = v
+ }
+ }
+ _, err := memoria.LoadConfig(envFrom(env))
+ if err == nil {
+ t.Fatalf("LoadConfig succeeded without %s; the app would start "+
+ "with no %s and the nil-auth pass-through becomes reachable", missing, missing)
+ }
+ if !errors.Is(err, memoria.ErrMissing) {
+ t.Errorf("error for missing %s is %v, want ErrMissing", missing, err)
+ }
+ if !strings.Contains(err.Error(), missing) {
+ t.Errorf("error for missing %s does not name it: %v", missing, err)
+ }
+ })
+ }
+}
+
+// A DEK of the wrong length must be refused rather than padded. A short
+// key that got zero-extended would produce ciphertext that looks fine and
+// is trivially breakable.
+func TestConfigRefusesMalformedDEK(t *testing.T) {
+ for name, dek := range map[string]string{
+ "not base64": "!!!!not base64!!!!",
+ "too short": base64.StdEncoding.EncodeToString(make([]byte, 16)),
+ "too long": base64.StdEncoding.EncodeToString(make([]byte, 64)),
+ "empty base64": base64.StdEncoding.EncodeToString(nil),
+ } {
+ t.Run(name, func(t *testing.T) {
+ _, err := memoria.LoadConfig(envFrom(map[string]string{
+ "MEMORIA_DEK": dek,
+ "MEMORIA_INSTANCE_KEY": "k",
+ "MEMORIA_OWNERS": "a@b.c",
+ }))
+ if err == nil {
+ t.Fatalf("LoadConfig accepted a %s DEK", name)
+ }
+ })
+ }
+}
+
+// Origin is the CSRF yardstick and decides cookie attributes, so a
+// silent production default would be a real bug. It may default, but the
+// config must say that it did.
+func TestConfigFlagsADefaultedOrigin(t *testing.T) {
+ cfg, err := memoria.LoadConfig(envFrom(map[string]string{
+ "MEMORIA_DEK": base64.StdEncoding.EncodeToString(make([]byte, 32)),
+ "MEMORIA_INSTANCE_KEY": "k",
+ "MEMORIA_OWNERS": "a@b.c",
+ }))
+ if err != nil {
+ t.Fatal(err)
+ }
+ if !cfg.UsedDefaultOrigin() {
+ t.Error("origin was defaulted but UsedDefaultOrigin() is false; main would not warn")
+ }
+ cfg, err = memoria.LoadConfig(envFrom(map[string]string{
+ "MEMORIA_DEK": base64.StdEncoding.EncodeToString(make([]byte, 32)),
+ "MEMORIA_INSTANCE_KEY": "k",
+ "MEMORIA_OWNERS": "a@b.c",
+ "MEMORIA_ORIGIN": "https://real.example.com",
+ }))
+ if err != nil {
+ t.Fatal(err)
+ }
+ if cfg.UsedDefaultOrigin() {
+ t.Error("a real origin is reported as defaulted; main would warn spuriously")
+ }
+}
+
+func envFrom(m map[string]string) func(string) string {
+ return func(k string) string { return m[k] }
+}
+
+func bytesContainsFold(hay []byte, needle string) bool {
+ return strings.Contains(strings.ToLower(string(hay)), strings.ToLower(needle))
+}
+
+func min(a, b int) int {
+ if a < b {
+ return a
+ }
+ return b
+}
diff --git a/internal/memoriatest/harness_test.go b/internal/memoriatest/harness_test.go
index 5cc8e01..5b15a55 100644
--- a/internal/memoriatest/harness_test.go
+++ b/internal/memoriatest/harness_test.go
@@ -1,14 +1,18 @@
-// Package memoriatest tests the app from the outside: real HTTP
-// requests against the real router, wired exactly as
-// cmd/memoria/main.go wires it.
+// Package memoriatest tests the app from the outside: real HTTP requests
+// against the real router, wired as cmd/memoria/main.go wires it.
//
-// The TDD loop: write a failing test here, make it pass in
-// internal/memoria, repeat.
+// The one difference from production is auth: these apps are built with
+// no auth, which makes requireSession a pass-through. That is the only
+// way to exercise the routes without a mailer and a magic-link round
+// trip, and TestProductionRefusesToStartWithoutSignin is what stops the
+// seam from being a hole.
package memoriatest
import (
+ "bytes"
"io"
"log/slog"
+ "mime/multipart"
"net/http"
"net/http/httptest"
"net/url"
@@ -19,27 +23,47 @@ import (
"github.com/carlosframework/rastrillo/db"
memoria "amadan.net/oficina/memoria/internal/memoria"
+ "amadan.net/oficina/memoria/internal/memoria/seal"
)
-// testOrigin is the origin newApp wires and post presents as its
-// same-origin evidence — the pair a browser form submission would be.
const testOrigin = "http://app.test"
-// newApp builds the whole app per test over a fresh temp database,
-// exactly as main.go does.
+// testDEK is a fixed key. Fixed so a failure is reproducible; never a
+// real one, and the plaintext gate proves it is actually being used.
+func testDEK(t *testing.T) *seal.Sealer {
+ t.Helper()
+ d := make([]byte, 32)
+ for i := range d {
+ d[i] = byte(i * 7)
+ }
+ s, err := seal.New(d)
+ if err != nil {
+ t.Fatalf("seal.New: %v", err)
+ }
+ return s
+}
+
+// newApp builds the whole app per test over a fresh temp database.
func newApp(t *testing.T) http.Handler {
+ h, _ := newAppAt(t, filepath.Join(t.TempDir(), "app.db"))
+ return h
+}
+
+// newAppAt is newApp with the database path exposed, so a test can read
+// the raw file back and grep it.
+func newAppAt(t *testing.T, path string) (http.Handler, string) {
t.Helper()
logger := slog.New(slog.NewTextHandler(io.Discard, nil))
- d, err := db.Open(filepath.Join(t.TempDir(), "app.db"), logger)
+ d, err := db.Open(path, logger)
if err != nil {
t.Fatalf("db.Open: %v", err)
}
t.Cleanup(func() { d.Close() })
- mux, err := memoria.App(d, testOrigin, logger)
+ mux, _, err := memoria.App(d, testOrigin, testDEK(t), logger)
if err != nil {
t.Fatalf("App: %v", err)
}
- return mux
+ return mux, path
}
func get(t *testing.T, h http.Handler, target string) *httptest.ResponseRecorder {
@@ -53,8 +77,27 @@ func post(t *testing.T, h http.Handler, target string, form url.Values) *httptes
t.Helper()
req := httptest.NewRequest(http.MethodPost, target, strings.NewReader(form.Encode()))
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
- // The same-origin evidence csrf.Protect requires — every current
- // browser sends it on a form POST; a test client must too.
+ req.Header.Set("Origin", testOrigin)
+ rec := httptest.NewRecorder()
+ h.ServeHTTP(rec, req)
+ return rec
+}
+
+// store posts a document the way the form does — multipart, because the
+// ingest handler parses multipart to accept a file alongside the text.
+func store(t *testing.T, h http.Handler, title, body string) *httptest.ResponseRecorder {
+ t.Helper()
+ var buf bytes.Buffer
+ mw := multipart.NewWriter(&buf)
+ if err := mw.WriteField("title", title); err != nil {
+ t.Fatal(err)
+ }
+ if err := mw.WriteField("body", body); err != nil {
+ t.Fatal(err)
+ }
+ mw.Close()
+ req := httptest.NewRequest(http.MethodPost, "/documents", &buf)
+ req.Header.Set("Content-Type", mw.FormDataContentType())
req.Header.Set("Origin", testOrigin)
rec := httptest.NewRecorder()
h.ServeHTTP(rec, req)
diff --git a/internal/memoriatest/index_test.go b/internal/memoriatest/index_test.go
deleted file mode 100644
index 6a3bfd4..0000000
--- a/internal/memoriatest/index_test.go
+++ /dev/null
@@ -1,49 +0,0 @@
-package memoriatest
-
-import (
- "net/http"
- "regexp"
- "strings"
- "testing"
-)
-
-// hashedStylesheet is the fingerprinted URL shape the index page
-// links: /static/tokens.<16 hex>.css.
-var hashedStylesheet = regexp.MustCompile(`/static/tokens\.[0-9a-f]{16}\.css`)
-
-func TestIndexRenders(t *testing.T) {
- rec := get(t, newApp(t), "/")
- if rec.Code != http.StatusOK {
- t.Fatalf("GET /: status %d, want 200", rec.Code)
- }
- if !strings.Contains(rec.Body.String(), "this is a rastrillo app") {
- t.Errorf("GET /: page is missing its heading:\n%s", rec.Body.String())
- }
-}
-
-func TestIndexLinksFingerprintedStylesheet(t *testing.T) {
- h := newApp(t)
- href := hashedStylesheet.FindString(get(t, h, "/").Body.String())
- if href == "" {
- t.Fatal("index page links no fingerprinted stylesheet")
- }
- rec := get(t, h, href)
- if rec.Code != http.StatusOK {
- t.Fatalf("GET %s: status %d, want 200", href, rec.Code)
- }
- if got := rec.Header().Get("Cache-Control"); got != "public, max-age=31536000, immutable" {
- t.Errorf("GET %s: Cache-Control = %q, want immutable year", href, got)
- }
-}
-
-// The bare name keeps working, just never long-cached — so a changed
-// file always shows on an ordinary reload.
-func TestBareAssetNameStaysFresh(t *testing.T) {
- rec := get(t, newApp(t), "/static/tokens.css")
- if rec.Code != http.StatusOK {
- t.Fatalf("GET /static/tokens.css: status %d, want 200", rec.Code)
- }
- if got := rec.Header().Get("Cache-Control"); got != "no-cache" {
- t.Errorf("Cache-Control = %q, want no-cache", got)
- }
-}