Files
steve f55747a281 phase 1 foundations: api types, store, crypto, auth
Lands the bottom three layers of Phase 1:

P1-08 internal/api: protocol_version + envelope + every WS message
  shape from spec.md §6.2 (Hello, Heartbeat, Job*, Schedule*, etc).
  Wire-format tests pin the JSON shape so a rename here breaks
  tests instead of silently breaking the agent.

P1-02 + P1-03 internal/store: SQLite via modernc.org/sqlite,
  embed.FS + a tiny version table for hand-rolled migrations.
  0001_initial.sql covers every table from spec.md §5 plus
  enrollment_tokens and host_schedule_version. Typed accessors
  for users / sessions / enrollment / audit. WAL + foreign_keys
  + busy_timeout on by default.

P1-06 internal/crypto: XChaCha20-Poly1305 AEAD wrapper with
  per-message random nonce. Key file lifecycle (generate +
  refuse-to-overwrite, load with size validation). Optional
  additionalData binds ciphertext to the row that owns it.

P1-04 internal/auth (partial — passwords + tokens; sessions
  middleware lands with the HTTP handlers): argon2id following
  RFC 9106 (64 MiB / t=3 / p=4 / 32B), constant-time verify.
  HashToken stores SHA-256 of session/agent/enrollment tokens
  so a stolen DB doesn't hand over credentials.

Build floor moves to Go 1.25 (modernc.org/sqlite v1.50+ requires
it); CI + Dockerfile + README updated. Markdown lint diagnostics
on tasks.md cleared.

All packages tested. ~70 new tests pass in <1s.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-01 00:24:40 +01:00

85 lines
2.7 KiB
Go

// Package store is the SQLite persistence layer (modernc.org/sqlite,
// no CGo). It owns the schema, exposes typed accessors, and hides
// the database/sql plumbing from the rest of the server.
package store
import (
"context"
"database/sql"
"errors"
"fmt"
"net/url"
"time"
_ "modernc.org/sqlite" // register the "sqlite" driver
)
// ErrNotFound is returned by accessors when a lookup misses.
var ErrNotFound = errors.New("store: not found")
// Store is a thin wrapper around *sql.DB that exposes the typed
// accessors used by the rest of the server. Callers should use the
// provided methods rather than reaching into DB() directly.
type Store struct {
db *sql.DB
}
// Open opens (or creates) the SQLite database at path, applies all
// pending migrations, and returns a ready-to-use Store.
//
// The DSN sets:
// - _pragma=foreign_keys(1) — referential integrity is on
// - _pragma=journal_mode(WAL) — concurrent reads vs writes
// - _pragma=busy_timeout(5000) — wait 5s on lock contention
// - _time_format=sqlite — RFC 3339 read/write of TEXT timestamps
//
// Empty path uses an in-memory DB (useful for tests).
func Open(ctx context.Context, path string) (*Store, error) {
dsn := buildDSN(path)
db, err := sql.Open("sqlite", dsn)
if err != nil {
return nil, fmt.Errorf("open %q: %w", path, err)
}
// modernc.org/sqlite is not safe for arbitrary high parallelism on
// a single file. WAL helps, but 1 writer + multiple readers is the
// only safe shape. Cap connections to keep that property explicit.
db.SetMaxOpenConns(8)
db.SetMaxIdleConns(4)
db.SetConnMaxLifetime(time.Hour)
pingCtx, cancel := context.WithTimeout(ctx, 5*time.Second)
defer cancel()
if err := db.PingContext(pingCtx); err != nil {
_ = db.Close()
return nil, fmt.Errorf("ping: %w", err)
}
if err := migrate(ctx, db); err != nil {
_ = db.Close()
return nil, fmt.Errorf("migrate: %w", err)
}
return &Store{db: db}, nil
}
// Close releases the underlying DB handle.
func (s *Store) Close() error { return s.db.Close() }
// DB returns the underlying *sql.DB. Reserved for tests and migrations
// — production code should add a typed method to this package instead.
func (s *Store) DB() *sql.DB { return s.db }
func buildDSN(path string) string {
if path == "" {
// Shared cache + named in-memory db so multiple connections see
// the same data — needed because we cap MaxOpenConns above.
return "file::memory:?cache=shared&_pragma=foreign_keys(1)&_pragma=journal_mode(WAL)&_pragma=busy_timeout(5000)"
}
q := url.Values{}
q.Add("_pragma", "foreign_keys(1)")
q.Add("_pragma", "journal_mode(WAL)")
q.Add("_pragma", "busy_timeout(5000)")
q.Add("_pragma", "synchronous(NORMAL)")
return "file:" + path + "?" + q.Encode()
}