Initial commit: project setup and db package
- Project scaffolding: go.mod, Makefile, .golangci.yaml, doc.go - README, ARCHITECTURE, PROJECT_PLAN, PROGRESS documentation - db package: Open (WAL, FK, busy timeout, 0600 permissions), Migrate (sequential, transactional, idempotent), SchemaVersion, Snapshot (VACUUM INTO) - 11 tests covering open, migrate, and snapshot Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
6
.gitignore
vendored
Normal file
6
.gitignore
vendored
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
# runtime data
|
||||||
|
/srv
|
||||||
|
|
||||||
|
# IDE
|
||||||
|
.idea/
|
||||||
|
.vscode/
|
||||||
94
.golangci.yaml
Normal file
94
.golangci.yaml
Normal file
@@ -0,0 +1,94 @@
|
|||||||
|
# golangci-lint v2 configuration for mcdsl.
|
||||||
|
# Principle: fail loudly. Security and correctness issues are errors, not warnings.
|
||||||
|
|
||||||
|
version: "2"
|
||||||
|
|
||||||
|
run:
|
||||||
|
timeout: 5m
|
||||||
|
tests: true
|
||||||
|
|
||||||
|
linters:
|
||||||
|
default: none
|
||||||
|
enable:
|
||||||
|
# --- Correctness ---
|
||||||
|
- errcheck
|
||||||
|
- govet
|
||||||
|
- ineffassign
|
||||||
|
- unused
|
||||||
|
|
||||||
|
# --- Error handling ---
|
||||||
|
- errorlint
|
||||||
|
|
||||||
|
# --- Security ---
|
||||||
|
- gosec
|
||||||
|
- staticcheck
|
||||||
|
|
||||||
|
# --- Style / conventions ---
|
||||||
|
- revive
|
||||||
|
|
||||||
|
settings:
|
||||||
|
errcheck:
|
||||||
|
check-blank: false
|
||||||
|
check-type-assertions: true
|
||||||
|
|
||||||
|
govet:
|
||||||
|
enable-all: true
|
||||||
|
disable:
|
||||||
|
- shadow
|
||||||
|
- fieldalignment
|
||||||
|
|
||||||
|
gosec:
|
||||||
|
severity: medium
|
||||||
|
confidence: medium
|
||||||
|
excludes:
|
||||||
|
- G104
|
||||||
|
|
||||||
|
errorlint:
|
||||||
|
errorf: true
|
||||||
|
asserts: true
|
||||||
|
comparison: true
|
||||||
|
|
||||||
|
revive:
|
||||||
|
rules:
|
||||||
|
- name: error-return
|
||||||
|
severity: error
|
||||||
|
- name: unexported-return
|
||||||
|
severity: error
|
||||||
|
- name: error-strings
|
||||||
|
severity: warning
|
||||||
|
- name: if-return
|
||||||
|
severity: warning
|
||||||
|
- name: increment-decrement
|
||||||
|
severity: warning
|
||||||
|
- name: var-naming
|
||||||
|
severity: warning
|
||||||
|
- name: range
|
||||||
|
severity: warning
|
||||||
|
- name: time-naming
|
||||||
|
severity: warning
|
||||||
|
- name: indent-error-flow
|
||||||
|
severity: warning
|
||||||
|
- name: early-return
|
||||||
|
severity: warning
|
||||||
|
# exported and package-comments enabled — this is a shared library,
|
||||||
|
# exported symbols should have documentation.
|
||||||
|
- name: exported
|
||||||
|
severity: warning
|
||||||
|
|
||||||
|
formatters:
|
||||||
|
enable:
|
||||||
|
- gofmt
|
||||||
|
- goimports
|
||||||
|
|
||||||
|
issues:
|
||||||
|
max-issues-per-linter: 0
|
||||||
|
max-same-issues: 0
|
||||||
|
|
||||||
|
exclusions:
|
||||||
|
paths:
|
||||||
|
- vendor
|
||||||
|
rules:
|
||||||
|
- path: "_test\\.go"
|
||||||
|
linters:
|
||||||
|
- gosec
|
||||||
|
text: "G101"
|
||||||
611
ARCHITECTURE.md
Normal file
611
ARCHITECTURE.md
Normal file
@@ -0,0 +1,611 @@
|
|||||||
|
# MCDSL Architecture
|
||||||
|
|
||||||
|
Metacircular Dynamics Standard Library — Technical Design Document
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. Overview
|
||||||
|
|
||||||
|
MCDSL is a Go module providing the shared infrastructure that every
|
||||||
|
Metacircular service needs. It is not a framework — services are not structured
|
||||||
|
around it. It is a collection of well-tested packages that each solve one
|
||||||
|
problem, usable independently or together.
|
||||||
|
|
||||||
|
### Design Principles
|
||||||
|
|
||||||
|
- **Extract, don't invent.** Every package in MCDSL is extracted from patterns
|
||||||
|
already proven across multiple services. No speculative abstractions.
|
||||||
|
- **Optional composition.** Services import only the packages they need. No
|
||||||
|
package depends on another MCDSL package unless absolutely necessary.
|
||||||
|
- **Minimal API surface.** Each package exposes the smallest possible public
|
||||||
|
API. Configuration is via structs with sensible defaults.
|
||||||
|
- **Zero magic.** No init() functions, no global state, no reflection-based
|
||||||
|
wiring. Explicit construction, explicit errors.
|
||||||
|
- **Stdlib-compatible types.** Functions accept and return `*sql.DB`,
|
||||||
|
`http.Handler`, `*slog.Logger`, `context.Context` — not custom wrappers.
|
||||||
|
|
||||||
|
### Module Path
|
||||||
|
|
||||||
|
```
|
||||||
|
git.wntrmute.dev/kyle/mcdsl
|
||||||
|
```
|
||||||
|
|
||||||
|
### Dependencies
|
||||||
|
|
||||||
|
| Dependency | Purpose |
|
||||||
|
|------------|---------|
|
||||||
|
| `modernc.org/sqlite` | Pure-Go SQLite driver |
|
||||||
|
| `github.com/go-chi/chi/v5` | HTTP router |
|
||||||
|
| `github.com/pelletier/go-toml/v2` | TOML config parsing |
|
||||||
|
| `google.golang.org/grpc` | gRPC server |
|
||||||
|
| `github.com/klauspost/compress/zstd` | Zstandard compression for archives |
|
||||||
|
| `git.wntrmute.dev/kyle/mcias/clients/go` | MCIAS client library |
|
||||||
|
|
||||||
|
All dependencies are already used by existing services. MCDSL adds no new
|
||||||
|
dependencies to the platform.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. Package: `auth`
|
||||||
|
|
||||||
|
MCIAS token validation with caching. Extracted from the `internal/auth/`
|
||||||
|
packages in metacrypt, mcr, and mcat.
|
||||||
|
|
||||||
|
### Types
|
||||||
|
|
||||||
|
```go
|
||||||
|
// TokenInfo holds the validated identity of an authenticated caller.
|
||||||
|
type TokenInfo struct {
|
||||||
|
Username string
|
||||||
|
Roles []string
|
||||||
|
IsAdmin bool
|
||||||
|
}
|
||||||
|
|
||||||
|
// Config holds MCIAS connection settings. Matches the standard [mcias]
|
||||||
|
// TOML section used by all services.
|
||||||
|
type Config struct {
|
||||||
|
ServerURL string `toml:"server_url"`
|
||||||
|
CACert string `toml:"ca_cert"`
|
||||||
|
ServiceName string `toml:"service_name"`
|
||||||
|
Tags []string `toml:"tags"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// Authenticator validates MCIAS bearer tokens with a short-lived cache.
|
||||||
|
type Authenticator struct { /* unexported fields */ }
|
||||||
|
```
|
||||||
|
|
||||||
|
### API
|
||||||
|
|
||||||
|
```go
|
||||||
|
func New(cfg Config, logger *slog.Logger) (*Authenticator, error)
|
||||||
|
func (a *Authenticator) ValidateToken(token string) (*TokenInfo, error)
|
||||||
|
func (a *Authenticator) Login(username, password, totpCode string) (token string, expiresAt time.Time, err error)
|
||||||
|
func (a *Authenticator) Logout(token string) error
|
||||||
|
```
|
||||||
|
|
||||||
|
### Cache Behavior
|
||||||
|
|
||||||
|
- Key: SHA-256 of the raw token, stored as `[32]byte`.
|
||||||
|
- TTL: 30 seconds (hardcoded, matches platform standard).
|
||||||
|
- Eviction: Lazy — expired entries are replaced on next lookup. No background
|
||||||
|
goroutine.
|
||||||
|
- Thread safety: `sync.RWMutex`. Reads take a read lock; cache misses
|
||||||
|
promote to write lock.
|
||||||
|
|
||||||
|
### Admin Detection
|
||||||
|
|
||||||
|
`IsAdmin` is set by scanning the roles list for the string `"admin"`. This
|
||||||
|
matches MCIAS's role model — admin is a role, not a flag.
|
||||||
|
|
||||||
|
### Errors
|
||||||
|
|
||||||
|
```go
|
||||||
|
var (
|
||||||
|
ErrInvalidToken = errors.New("auth: invalid token")
|
||||||
|
ErrInvalidCredentials = errors.New("auth: invalid credentials")
|
||||||
|
ErrForbidden = errors.New("auth: forbidden by policy")
|
||||||
|
)
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. Package: `db`
|
||||||
|
|
||||||
|
SQLite connection setup, migration runner, and snapshot utilities. Extracted
|
||||||
|
from the `internal/db/` packages across all services.
|
||||||
|
|
||||||
|
### Opening a Database
|
||||||
|
|
||||||
|
```go
|
||||||
|
func Open(path string) (*sql.DB, error)
|
||||||
|
```
|
||||||
|
|
||||||
|
Opens a SQLite database with the standard Metacircular pragmas:
|
||||||
|
|
||||||
|
```sql
|
||||||
|
PRAGMA journal_mode = WAL;
|
||||||
|
PRAGMA foreign_keys = ON;
|
||||||
|
PRAGMA busy_timeout = 5000;
|
||||||
|
```
|
||||||
|
|
||||||
|
File permissions are set to `0600` (owner read/write only). The function
|
||||||
|
creates the file if it does not exist.
|
||||||
|
|
||||||
|
Returns a standard `*sql.DB` — no wrapper type. Services use it directly
|
||||||
|
with `database/sql`.
|
||||||
|
|
||||||
|
### Migrations
|
||||||
|
|
||||||
|
```go
|
||||||
|
type Migration struct {
|
||||||
|
Version int
|
||||||
|
Name string
|
||||||
|
SQL string
|
||||||
|
}
|
||||||
|
|
||||||
|
func Migrate(db *sql.DB, migrations []Migration) error
|
||||||
|
```
|
||||||
|
|
||||||
|
Migrations are applied sequentially in a transaction. Each migration is
|
||||||
|
recorded in a `schema_migrations` table:
|
||||||
|
|
||||||
|
```sql
|
||||||
|
CREATE TABLE IF NOT EXISTS schema_migrations (
|
||||||
|
version INTEGER PRIMARY KEY,
|
||||||
|
name TEXT NOT NULL,
|
||||||
|
applied_at TEXT NOT NULL
|
||||||
|
);
|
||||||
|
```
|
||||||
|
|
||||||
|
Already-applied migrations (by version number) are skipped. Timestamps are
|
||||||
|
stored as RFC 3339 UTC.
|
||||||
|
|
||||||
|
Services define their migrations as a `[]Migration` slice — no embedded SQL
|
||||||
|
files, no migration DSL. The slice is the schema history.
|
||||||
|
|
||||||
|
### Snapshots
|
||||||
|
|
||||||
|
```go
|
||||||
|
func Snapshot(db *sql.DB, destPath string) error
|
||||||
|
```
|
||||||
|
|
||||||
|
Executes `VACUUM INTO` to create a consistent, standalone copy of the
|
||||||
|
database at `destPath`. This is the standard backup mechanism for all
|
||||||
|
Metacircular services.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. Package: `config`
|
||||||
|
|
||||||
|
TOML configuration loading with environment variable overrides. Extracted from
|
||||||
|
the `internal/config/` packages across all services.
|
||||||
|
|
||||||
|
### Standard Sections
|
||||||
|
|
||||||
|
```go
|
||||||
|
// Base contains the configuration sections common to all services.
|
||||||
|
// Services embed this in their own config struct.
|
||||||
|
type Base struct {
|
||||||
|
Server ServerConfig `toml:"server"`
|
||||||
|
Database DatabaseConfig `toml:"database"`
|
||||||
|
MCIAS auth.Config `toml:"mcias"`
|
||||||
|
Log LogConfig `toml:"log"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type ServerConfig struct {
|
||||||
|
ListenAddr string `toml:"listen_addr"`
|
||||||
|
GRPCAddr string `toml:"grpc_addr"`
|
||||||
|
TLSCert string `toml:"tls_cert"`
|
||||||
|
TLSKey string `toml:"tls_key"`
|
||||||
|
ReadTimeout time.Duration `toml:"read_timeout"`
|
||||||
|
WriteTimeout time.Duration `toml:"write_timeout"`
|
||||||
|
IdleTimeout time.Duration `toml:"idle_timeout"`
|
||||||
|
ShutdownTimeout time.Duration `toml:"shutdown_timeout"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type DatabaseConfig struct {
|
||||||
|
Path string `toml:"path"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type LogConfig struct {
|
||||||
|
Level string `toml:"level"`
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Loading
|
||||||
|
|
||||||
|
```go
|
||||||
|
func Load[T any](path string, envPrefix string) (*T, error)
|
||||||
|
```
|
||||||
|
|
||||||
|
1. Reads the TOML file at `path`.
|
||||||
|
2. Unmarshals into `*T`.
|
||||||
|
3. Applies environment variable overrides using `envPrefix` (e.g., prefix
|
||||||
|
`"MCR"` maps `MCR_SERVER_LISTEN_ADDR` to `Server.ListenAddr`).
|
||||||
|
4. Applies defaults for unset optional fields.
|
||||||
|
5. Validates required fields.
|
||||||
|
|
||||||
|
Environment overrides use reflection to walk the struct, converting field
|
||||||
|
paths to `PREFIX_SECTION_FIELD` format. Only string, int, bool, duration,
|
||||||
|
and string slice fields are supported.
|
||||||
|
|
||||||
|
### Defaults
|
||||||
|
|
||||||
|
| Field | Default |
|
||||||
|
|-------|---------|
|
||||||
|
| `Log.Level` | `"info"` |
|
||||||
|
| `Server.ReadTimeout` | `30s` |
|
||||||
|
| `Server.WriteTimeout` | `30s` |
|
||||||
|
| `Server.IdleTimeout` | `120s` |
|
||||||
|
| `Server.ShutdownTimeout` | `60s` |
|
||||||
|
|
||||||
|
### Validation
|
||||||
|
|
||||||
|
`Load` returns an error if any required field is empty:
|
||||||
|
- `Server.ListenAddr`
|
||||||
|
- `Server.TLSCert`
|
||||||
|
- `Server.TLSKey`
|
||||||
|
- `Database.Path`
|
||||||
|
- `MCIAS.ServerURL` (if the MCIAS section is present)
|
||||||
|
|
||||||
|
Services add their own validation by implementing an optional `Validate()`
|
||||||
|
method on their config type.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. Package: `httpserver`
|
||||||
|
|
||||||
|
TLS HTTP server setup with chi, standard middleware, and graceful shutdown.
|
||||||
|
Extracted from `internal/server/` across all services.
|
||||||
|
|
||||||
|
### Types
|
||||||
|
|
||||||
|
```go
|
||||||
|
type Server struct {
|
||||||
|
Router *chi.Mux
|
||||||
|
Logger *slog.Logger
|
||||||
|
// unexported: httpSrv, cfg
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### API
|
||||||
|
|
||||||
|
```go
|
||||||
|
func New(cfg config.ServerConfig, logger *slog.Logger) *Server
|
||||||
|
func (s *Server) ListenAndServeTLS(certFile, keyFile string) error
|
||||||
|
func (s *Server) Shutdown(ctx context.Context) error
|
||||||
|
```
|
||||||
|
|
||||||
|
`New` creates a chi router and configures the underlying `http.Server` with:
|
||||||
|
- TLS 1.3 minimum (`tls.VersionTLS13`)
|
||||||
|
- Read/write/idle timeouts from config
|
||||||
|
- The chi router as handler
|
||||||
|
|
||||||
|
Services access `s.Router` to register their routes.
|
||||||
|
|
||||||
|
### Standard Middleware
|
||||||
|
|
||||||
|
```go
|
||||||
|
func (s *Server) LoggingMiddleware(next http.Handler) http.Handler
|
||||||
|
```
|
||||||
|
|
||||||
|
Wraps the response writer to capture the status code, logs after the
|
||||||
|
request completes:
|
||||||
|
|
||||||
|
```
|
||||||
|
level=INFO msg=http method=GET path=/v1/status status=200 remote=10.0.0.1:54321
|
||||||
|
```
|
||||||
|
|
||||||
|
### StatusWriter
|
||||||
|
|
||||||
|
```go
|
||||||
|
type StatusWriter struct {
|
||||||
|
http.ResponseWriter
|
||||||
|
Status int
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Exported for use in custom middleware that needs the response status code.
|
||||||
|
|
||||||
|
### JSON Helpers
|
||||||
|
|
||||||
|
```go
|
||||||
|
func WriteJSON(w http.ResponseWriter, status int, v any)
|
||||||
|
func WriteError(w http.ResponseWriter, status int, message string)
|
||||||
|
```
|
||||||
|
|
||||||
|
`WriteError` writes `{"error": "message"}` — the standard Metacircular
|
||||||
|
error format.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 6. Package: `grpcserver`
|
||||||
|
|
||||||
|
gRPC server setup with TLS, interceptor chain, and method-map
|
||||||
|
authentication. Extracted from `internal/grpcserver/` in mcias, mcr, and
|
||||||
|
mc-proxy.
|
||||||
|
|
||||||
|
### Types
|
||||||
|
|
||||||
|
```go
|
||||||
|
// MethodMap classifies gRPC methods for access control.
|
||||||
|
type MethodMap struct {
|
||||||
|
Public map[string]bool // No auth required
|
||||||
|
AuthRequired map[string]bool // Valid MCIAS token required
|
||||||
|
AdminRequired map[string]bool // Admin role required
|
||||||
|
}
|
||||||
|
|
||||||
|
type Server struct {
|
||||||
|
GRPCServer *grpc.Server
|
||||||
|
Logger *slog.Logger
|
||||||
|
// unexported: listener, auth
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### API
|
||||||
|
|
||||||
|
```go
|
||||||
|
func New(cfg config.ServerConfig, auth *auth.Authenticator, methods MethodMap, logger *slog.Logger) (*Server, error)
|
||||||
|
func (s *Server) Serve() error
|
||||||
|
func (s *Server) Stop()
|
||||||
|
```
|
||||||
|
|
||||||
|
`New` builds a gRPC server with:
|
||||||
|
- TLS 1.3 from cert/key in config
|
||||||
|
- Unary interceptor chain: logging → auth (using MethodMap) → user handler
|
||||||
|
- Services register their implementations on `s.GRPCServer`
|
||||||
|
|
||||||
|
### Auth Interceptor
|
||||||
|
|
||||||
|
The auth interceptor uses the `MethodMap` to determine the required access
|
||||||
|
level for each RPC:
|
||||||
|
|
||||||
|
1. If the method is in `Public` — pass through, no auth.
|
||||||
|
2. If the method is in `AuthRequired` — validate the bearer token from
|
||||||
|
metadata, populate `TokenInfo` in context.
|
||||||
|
3. If the method is in `AdminRequired` — validate token and require
|
||||||
|
`IsAdmin == true`.
|
||||||
|
4. If the method is not in any map — **deny by default**. This is a safety
|
||||||
|
net: forgetting to register a new RPC results in a denied request, not
|
||||||
|
an open one.
|
||||||
|
|
||||||
|
### Context Helpers
|
||||||
|
|
||||||
|
```go
|
||||||
|
func TokenInfoFromContext(ctx context.Context) *auth.TokenInfo
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 7. Package: `csrf`
|
||||||
|
|
||||||
|
HMAC-SHA256 double-submit cookie CSRF protection. Extracted from the web
|
||||||
|
server packages in metacrypt, mcr, and mcat.
|
||||||
|
|
||||||
|
### Types
|
||||||
|
|
||||||
|
```go
|
||||||
|
type Protect struct {
|
||||||
|
// unexported: secret, cookieName, fieldName
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### API
|
||||||
|
|
||||||
|
```go
|
||||||
|
func New(secret []byte, cookieName, fieldName string) *Protect
|
||||||
|
func (p *Protect) Middleware(next http.Handler) http.Handler
|
||||||
|
func (p *Protect) SetToken(w http.ResponseWriter) string
|
||||||
|
func (p *Protect) TemplateFunc(w http.ResponseWriter) template.FuncMap
|
||||||
|
```
|
||||||
|
|
||||||
|
### Token Format
|
||||||
|
|
||||||
|
```
|
||||||
|
base64(nonce) "." base64(HMAC-SHA256(secret, nonce))
|
||||||
|
```
|
||||||
|
|
||||||
|
Nonce is 32 bytes from `crypto/rand`. The token is set as a cookie and must
|
||||||
|
be submitted as a form field on mutating requests (POST, PUT, PATCH, DELETE).
|
||||||
|
|
||||||
|
### Validation
|
||||||
|
|
||||||
|
The middleware:
|
||||||
|
1. Skips safe methods (GET, HEAD, OPTIONS).
|
||||||
|
2. Reads the token from the cookie and the form field.
|
||||||
|
3. Verifies both are present and equal.
|
||||||
|
4. Verifies the HMAC signature is valid.
|
||||||
|
5. Returns 403 on any failure.
|
||||||
|
|
||||||
|
### Template Integration
|
||||||
|
|
||||||
|
`TemplateFunc` returns a `template.FuncMap` with a `csrfField` function that
|
||||||
|
renders the hidden input:
|
||||||
|
|
||||||
|
```html
|
||||||
|
<input type="hidden" name="csrf_token" value="...">
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 8. Package: `web`
|
||||||
|
|
||||||
|
Session cookie management, auth middleware, and template rendering helpers
|
||||||
|
for htmx web UIs. Extracted from `internal/webserver/` across services.
|
||||||
|
|
||||||
|
### Session Cookies
|
||||||
|
|
||||||
|
```go
|
||||||
|
func SetSessionCookie(w http.ResponseWriter, name, token string)
|
||||||
|
func ClearSessionCookie(w http.ResponseWriter, name string)
|
||||||
|
func GetSessionToken(r *http.Request, name string) string
|
||||||
|
```
|
||||||
|
|
||||||
|
All session cookies are set with: `HttpOnly`, `Secure`,
|
||||||
|
`SameSite=Strict`, `Path=/`.
|
||||||
|
|
||||||
|
### Auth Middleware
|
||||||
|
|
||||||
|
```go
|
||||||
|
func RequireAuth(auth *auth.Authenticator, cookieName string, loginPath string) func(http.Handler) http.Handler
|
||||||
|
```
|
||||||
|
|
||||||
|
Extracts the session token from the cookie, validates it via the
|
||||||
|
Authenticator, and either:
|
||||||
|
- Sets `TokenInfo` in the request context and calls the next handler.
|
||||||
|
- Redirects to `loginPath` if the token is missing or invalid.
|
||||||
|
|
||||||
|
### Context Helpers
|
||||||
|
|
||||||
|
```go
|
||||||
|
func TokenInfoFromContext(ctx context.Context) *auth.TokenInfo
|
||||||
|
```
|
||||||
|
|
||||||
|
### Template Helpers
|
||||||
|
|
||||||
|
```go
|
||||||
|
func RenderTemplate(w http.ResponseWriter, fs embed.FS, name string, data any, funcs ...template.FuncMap)
|
||||||
|
```
|
||||||
|
|
||||||
|
Parses `templates/layout.html` and `templates/<name>` from the embedded FS,
|
||||||
|
merges any provided FuncMaps, and executes the `layout` template.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 9. Package: `archive`
|
||||||
|
|
||||||
|
Service directory snapshot and restore using tar.zst, with SQLite-aware
|
||||||
|
handling. This is new functionality for MCP, not extracted from existing
|
||||||
|
services.
|
||||||
|
|
||||||
|
### Snapshot
|
||||||
|
|
||||||
|
```go
|
||||||
|
type SnapshotOptions struct {
|
||||||
|
ServiceDir string // e.g., /srv/myservice
|
||||||
|
DBPath string // e.g., /srv/myservice/myservice.db (for VACUUM INTO)
|
||||||
|
DB *sql.DB // live database connection (for VACUUM INTO)
|
||||||
|
ExcludePatterns []string // additional glob patterns to exclude
|
||||||
|
}
|
||||||
|
|
||||||
|
func Snapshot(opts SnapshotOptions) (io.ReadCloser, error)
|
||||||
|
```
|
||||||
|
|
||||||
|
1. Runs `VACUUM INTO` to create a consistent DB copy in a temp file.
|
||||||
|
2. Walks the service directory, excluding:
|
||||||
|
- `*.db`, `*.db-wal`, `*.db-shm` (live database files)
|
||||||
|
- `backups/` directory
|
||||||
|
- Any patterns in `ExcludePatterns`
|
||||||
|
3. Adds the VACUUM INTO copy as `<basename>.db` in the archive.
|
||||||
|
4. Returns a streaming tar.zst reader.
|
||||||
|
|
||||||
|
The archive is produced as a stream — it does not need to be fully buffered
|
||||||
|
in memory. This allows piping directly over a network connection.
|
||||||
|
|
||||||
|
### Restore
|
||||||
|
|
||||||
|
```go
|
||||||
|
func Restore(r io.Reader, destDir string) error
|
||||||
|
```
|
||||||
|
|
||||||
|
Extracts a tar.zst archive into `destDir`. Creates the directory if it does
|
||||||
|
not exist. Overwrites existing files. Preserves file permissions.
|
||||||
|
|
||||||
|
### Compression
|
||||||
|
|
||||||
|
Zstandard compression via `github.com/klauspost/compress/zstd`. Default
|
||||||
|
compression level (3) balances speed and ratio for the typical service
|
||||||
|
directory size (SQLite DB + config + certs, usually under 100 MB).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 10. Package: `health`
|
||||||
|
|
||||||
|
Standard health check implementation for both gRPC and REST. New
|
||||||
|
functionality to standardize what services already do ad-hoc.
|
||||||
|
|
||||||
|
### gRPC
|
||||||
|
|
||||||
|
Implements `grpc.health.v1.Health` (the standard gRPC health checking
|
||||||
|
protocol). Services register it on their gRPC server:
|
||||||
|
|
||||||
|
```go
|
||||||
|
health.RegisterGRPC(grpcServer)
|
||||||
|
```
|
||||||
|
|
||||||
|
### REST
|
||||||
|
|
||||||
|
```go
|
||||||
|
func Handler(db *sql.DB) http.HandlerFunc
|
||||||
|
```
|
||||||
|
|
||||||
|
Returns a handler for `GET /healthz` (or whatever path the service mounts
|
||||||
|
it on) that:
|
||||||
|
1. Pings the database.
|
||||||
|
2. Returns `200 {"status": "ok"}` or `503 {"status": "unhealthy", "error": "..."}`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 11. Inter-Package Dependencies
|
||||||
|
|
||||||
|
```
|
||||||
|
archive ──→ db (for Snapshot)
|
||||||
|
auth ──→ (mcias client library)
|
||||||
|
config ──→ auth (for auth.Config type)
|
||||||
|
csrf ──→ (stdlib only)
|
||||||
|
db ──→ (modernc.org/sqlite)
|
||||||
|
grpcserver ──→ auth, config
|
||||||
|
health ──→ db
|
||||||
|
httpserver ──→ config
|
||||||
|
web ──→ auth, csrf
|
||||||
|
```
|
||||||
|
|
||||||
|
No circular dependencies. Each package can be imported independently except
|
||||||
|
where noted above.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 12. What MCDSL Does Not Provide
|
||||||
|
|
||||||
|
- **Business logic.** Policy engines, engine registries, OCI handlers — these
|
||||||
|
are service-specific and stay in each service's `internal/` packages.
|
||||||
|
- **Proto definitions.** Each service owns its own proto files and generated
|
||||||
|
code.
|
||||||
|
- **CLI scaffolding.** Cobra command wiring is minimal and service-specific.
|
||||||
|
- **Database schemas.** Each service defines its own migrations. MCDSL
|
||||||
|
provides the runner, not the SQL.
|
||||||
|
- **Templates and static assets.** Each service's web UI is its own. MCDSL
|
||||||
|
provides rendering helpers, not content.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 13. Migration Path
|
||||||
|
|
||||||
|
Existing services adopt MCDSL incrementally — one package at a time:
|
||||||
|
|
||||||
|
1. Replace `internal/auth/` with `mcdsl/auth`.
|
||||||
|
2. Replace database open/pragma code with `mcdsl/db.Open`.
|
||||||
|
3. Replace migration runner with `mcdsl/db.Migrate`.
|
||||||
|
4. Replace config loading with `mcdsl/config.Load`.
|
||||||
|
5. Replace CSRF implementation with `mcdsl/csrf`.
|
||||||
|
6. Replace server setup with `mcdsl/httpserver` and `mcdsl/grpcserver`.
|
||||||
|
|
||||||
|
Each step is independent. Services can adopt one package without adopting
|
||||||
|
all of them. The old `internal/` code can be removed after each migration.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 14. Security Considerations
|
||||||
|
|
||||||
|
- **Token caching** uses SHA-256 of the token as the cache key. The raw token
|
||||||
|
is never used as a map key to prevent timing attacks on map lookup.
|
||||||
|
- **CSRF secrets** must be generated from `crypto/rand` and should be unique
|
||||||
|
per service instance. MCDSL does not generate them — the service provides
|
||||||
|
them.
|
||||||
|
- **Session cookies** are always `HttpOnly`, `Secure`, `SameSite=Strict`.
|
||||||
|
These flags are not configurable — relaxing them would be a security defect.
|
||||||
|
- **gRPC method maps** default to deny. An unregistered method is rejected,
|
||||||
|
not allowed. This is the most important safety property in the library.
|
||||||
|
- **File permissions** on databases are `0600`. This is not configurable.
|
||||||
|
- **TLS 1.3 minimum** is not configurable. Services that need TLS 1.2 (there
|
||||||
|
should be none) cannot use `httpserver` or `grpcserver`.
|
||||||
18
Makefile
Normal file
18
Makefile
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
.PHONY: build test vet lint clean all
|
||||||
|
|
||||||
|
build:
|
||||||
|
go build ./...
|
||||||
|
|
||||||
|
test:
|
||||||
|
go test ./...
|
||||||
|
|
||||||
|
vet:
|
||||||
|
go vet ./...
|
||||||
|
|
||||||
|
lint:
|
||||||
|
golangci-lint run ./...
|
||||||
|
|
||||||
|
clean:
|
||||||
|
go clean ./...
|
||||||
|
|
||||||
|
all: vet lint test build
|
||||||
34
PROGRESS.md
Normal file
34
PROGRESS.md
Normal file
@@ -0,0 +1,34 @@
|
|||||||
|
# MCDSL Progress
|
||||||
|
|
||||||
|
## Current State
|
||||||
|
|
||||||
|
Phase 1 complete. The `db` package is implemented and tested.
|
||||||
|
|
||||||
|
## Completed
|
||||||
|
|
||||||
|
### Phase 0: Project Setup (2026-03-25)
|
||||||
|
- Initialized Go module (`git.wntrmute.dev/kyle/mcdsl`)
|
||||||
|
- Created `.golangci.yaml` matching platform standard (with `exported` rule
|
||||||
|
enabled since this is a shared library)
|
||||||
|
- Created `Makefile` with standard targets (build, test, vet, lint, all)
|
||||||
|
- Created `.gitignore`
|
||||||
|
- Created `doc.go` package doc
|
||||||
|
- `make all` passes clean
|
||||||
|
|
||||||
|
### Phase 1: `db` — SQLite Foundation (2026-03-25)
|
||||||
|
- `Open(path string) (*sql.DB, error)` — opens with WAL, FK, busy timeout
|
||||||
|
5000ms, 0600 permissions, creates parent dirs
|
||||||
|
- `Migration` type with Version, Name, SQL fields
|
||||||
|
- `Migrate(database *sql.DB, migrations []Migration) error` — sequential,
|
||||||
|
transactional, idempotent, records name and timestamp in schema_migrations
|
||||||
|
- `SchemaVersion(database *sql.DB) (int, error)` — highest applied version
|
||||||
|
- `Snapshot(database *sql.DB, destPath string) error` — VACUUM INTO with
|
||||||
|
0600 permissions, creates parent dirs
|
||||||
|
- 11 tests: open (pragmas, permissions, parent dir, existing DB), migrate
|
||||||
|
(fresh, idempotent, incremental, records name), schema version (empty),
|
||||||
|
snapshot (data integrity, permissions, parent dir)
|
||||||
|
- `make all` passes clean (vet, lint 0 issues, 11/11 tests, build)
|
||||||
|
|
||||||
|
## Next Steps
|
||||||
|
|
||||||
|
- Phase 2: `auth` package (MCIAS token validation with caching)
|
||||||
192
PROJECT_PLAN.md
Normal file
192
PROJECT_PLAN.md
Normal file
@@ -0,0 +1,192 @@
|
|||||||
|
# MCDSL Project Plan
|
||||||
|
|
||||||
|
Implementation phases for the Metacircular Dynamics Standard Library.
|
||||||
|
|
||||||
|
Each phase produces a usable, tested package. Phases are ordered by
|
||||||
|
dependency (foundational packages first) and by value (most-duplicated
|
||||||
|
code first).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Phase 0: Project Setup
|
||||||
|
|
||||||
|
- [ ] Initialize Go module (`git.wntrmute.dev/kyle/mcdsl`)
|
||||||
|
- [ ] Create `.golangci.yaml` (matching platform standard)
|
||||||
|
- [ ] Create `Makefile` with standard targets (build, test, vet, lint, all)
|
||||||
|
- [ ] Create `.gitignore`
|
||||||
|
|
||||||
|
**Acceptance criteria:** `make all` passes on an empty module.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Phase 1: `db` — SQLite Foundation
|
||||||
|
|
||||||
|
The most universally needed package. Every service with a database uses
|
||||||
|
identical open/pragma/migration code.
|
||||||
|
|
||||||
|
- [ ] `Open(path string) (*sql.DB, error)` — open with WAL, FK, busy timeout,
|
||||||
|
0600 permissions
|
||||||
|
- [ ] `Migration` type and `Migrate(db *sql.DB, migrations []Migration) error`
|
||||||
|
— sequential, transactional, idempotent, schema_migrations tracking
|
||||||
|
- [ ] `Snapshot(db *sql.DB, destPath string) error` — VACUUM INTO wrapper
|
||||||
|
- [ ] Tests: open, migrate fresh DB, migrate existing DB (idempotent), snapshot
|
||||||
|
produces valid DB, file permissions
|
||||||
|
|
||||||
|
**Acceptance criteria:** A service can replace its `internal/db/` open and
|
||||||
|
migrate code with `mcdsl/db` and pass its existing tests.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Phase 2: `auth` — MCIAS Token Validation
|
||||||
|
|
||||||
|
The second most duplicated package. Every authenticated service has its own
|
||||||
|
copy of the cache-and-validate logic.
|
||||||
|
|
||||||
|
- [ ] `Config` type matching `[mcias]` TOML section
|
||||||
|
- [ ] `TokenInfo` type (Username, Roles, IsAdmin)
|
||||||
|
- [ ] `New(cfg Config, logger *slog.Logger) (*Authenticator, error)`
|
||||||
|
- [ ] `ValidateToken(token string) (*TokenInfo, error)` with 30s SHA-256 cache
|
||||||
|
- [ ] `Login(username, password, totpCode string) (token string, expiresAt time.Time, err error)`
|
||||||
|
- [ ] `Logout(token string) error`
|
||||||
|
- [ ] Error types: `ErrInvalidToken`, `ErrInvalidCredentials`, `ErrForbidden`
|
||||||
|
- [ ] Tests: cache hit, cache miss, cache expiry, admin detection, concurrent
|
||||||
|
access, error propagation
|
||||||
|
|
||||||
|
**Acceptance criteria:** A service can replace its `internal/auth/` with
|
||||||
|
`mcdsl/auth` and pass its existing tests.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Phase 3: `config` — TOML Configuration
|
||||||
|
|
||||||
|
- [ ] `Base` type with standard sections (Server, Database, MCIAS, Log)
|
||||||
|
- [ ] `ServerConfig`, `DatabaseConfig`, `LogConfig` types
|
||||||
|
- [ ] `Load[T any](path string, envPrefix string) (*T, error)` — generic
|
||||||
|
loader with TOML parse, env overrides, defaults, validation
|
||||||
|
- [ ] Environment override via reflection (`PREFIX_SECTION_FIELD`)
|
||||||
|
- [ ] Required field validation (listen addr, TLS paths, DB path)
|
||||||
|
- [ ] Default application (timeouts, log level)
|
||||||
|
- [ ] Optional `Validate()` interface for service-specific validation
|
||||||
|
- [ ] Tests: load valid config, missing required fields, env overrides, defaults,
|
||||||
|
custom validation
|
||||||
|
|
||||||
|
**Acceptance criteria:** A service can replace its `internal/config/` with
|
||||||
|
`mcdsl/config` embedding `config.Base`, and pass its existing tests.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Phase 4: `httpserver` — HTTP Server Setup
|
||||||
|
|
||||||
|
- [ ] `Server` type wrapping chi + `http.Server`
|
||||||
|
- [ ] `New(cfg config.ServerConfig, logger *slog.Logger) *Server`
|
||||||
|
- [ ] `ListenAndServeTLS(certFile, keyFile string) error`
|
||||||
|
- [ ] `Shutdown(ctx context.Context) error`
|
||||||
|
- [ ] `LoggingMiddleware` — captures status code, logs request metadata
|
||||||
|
- [ ] `StatusWriter` — exported response writer wrapper
|
||||||
|
- [ ] `WriteJSON` and `WriteError` helpers
|
||||||
|
- [ ] Tests: server starts and shuts down cleanly, logging middleware captures
|
||||||
|
status, JSON helpers produce correct output
|
||||||
|
|
||||||
|
**Acceptance criteria:** A service can replace its server setup and middleware
|
||||||
|
boilerplate with `mcdsl/httpserver`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Phase 5: `csrf` — CSRF Protection
|
||||||
|
|
||||||
|
- [ ] `New(secret []byte, cookieName, fieldName string) *Protect`
|
||||||
|
- [ ] `Middleware(next http.Handler) http.Handler`
|
||||||
|
- [ ] `SetToken(w http.ResponseWriter) string`
|
||||||
|
- [ ] `TemplateFunc(w http.ResponseWriter) template.FuncMap`
|
||||||
|
- [ ] Token format: `base64(nonce).base64(HMAC-SHA256(secret, nonce))`
|
||||||
|
- [ ] Tests: token generation, validation, middleware rejects missing/invalid
|
||||||
|
tokens, safe methods pass through
|
||||||
|
|
||||||
|
**Acceptance criteria:** A service can replace its CSRF implementation with
|
||||||
|
`mcdsl/csrf` and its web UI continues to work.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Phase 6: `web` — Session and Template Helpers
|
||||||
|
|
||||||
|
- [ ] `SetSessionCookie`, `ClearSessionCookie`, `GetSessionToken`
|
||||||
|
- [ ] `RequireAuth` middleware (validates token, redirects to login)
|
||||||
|
- [ ] `TokenInfoFromContext` context helper
|
||||||
|
- [ ] `RenderTemplate` helper for layout + page template pattern
|
||||||
|
- [ ] Tests: cookie setting/clearing, auth middleware redirect, template
|
||||||
|
rendering
|
||||||
|
|
||||||
|
**Acceptance criteria:** A service can replace its web session/auth
|
||||||
|
boilerplate with `mcdsl/web`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Phase 7: `grpcserver` — gRPC Server Setup
|
||||||
|
|
||||||
|
- [ ] `MethodMap` type (Public, AuthRequired, AdminRequired)
|
||||||
|
- [ ] `New(cfg config.ServerConfig, auth *auth.Authenticator, methods MethodMap, logger *slog.Logger) (*Server, error)`
|
||||||
|
- [ ] `Serve() error` and `Stop()`
|
||||||
|
- [ ] Auth interceptor using MethodMap (default deny for unmapped methods)
|
||||||
|
- [ ] Logging interceptor
|
||||||
|
- [ ] `TokenInfoFromContext` context helper
|
||||||
|
- [ ] Tests: public method allowed, auth method requires token, admin method
|
||||||
|
requires admin, unmapped method denied, logging
|
||||||
|
|
||||||
|
**Acceptance criteria:** A service can replace its gRPC server setup and
|
||||||
|
interceptor logic with `mcdsl/grpcserver`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Phase 8: `health` — Health Checks
|
||||||
|
|
||||||
|
- [ ] `RegisterGRPC(srv *grpc.Server)` — register `grpc.health.v1.Health`
|
||||||
|
- [ ] `Handler(db *sql.DB) http.HandlerFunc` — REST health endpoint
|
||||||
|
- [ ] Tests: healthy response, unhealthy response (closed DB)
|
||||||
|
|
||||||
|
**Acceptance criteria:** Services have a standard health check that MCP can
|
||||||
|
query.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Phase 9: `archive` — Service Directory Snapshots
|
||||||
|
|
||||||
|
- [ ] `SnapshotOptions` type
|
||||||
|
- [ ] `Snapshot(opts SnapshotOptions) (io.ReadCloser, error)` — streaming
|
||||||
|
tar.zst with DB exclusion/injection
|
||||||
|
- [ ] `Restore(r io.Reader, destDir string) error`
|
||||||
|
- [ ] Exclude patterns: `*.db`, `*.db-wal`, `*.db-shm`, `backups/`
|
||||||
|
- [ ] Tests: snapshot roundtrip (snapshot then restore produces identical
|
||||||
|
files), DB consistency (VACUUM INTO copy matches), excludes work,
|
||||||
|
streaming (no full buffer)
|
||||||
|
|
||||||
|
**Acceptance criteria:** MCP agent can snapshot and restore a service
|
||||||
|
directory using this package.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Phase 10: Service Migration (First Adopter)
|
||||||
|
|
||||||
|
Pick one service (mcat is the simplest) and migrate it to use MCDSL:
|
||||||
|
|
||||||
|
- [ ] Replace `internal/auth/` with `mcdsl/auth`
|
||||||
|
- [ ] Replace `internal/config/` with `mcdsl/config`
|
||||||
|
- [ ] Replace web session/CSRF code with `mcdsl/csrf` and `mcdsl/web`
|
||||||
|
- [ ] Verify `make all` passes
|
||||||
|
- [ ] Document the migration process for other services
|
||||||
|
|
||||||
|
**Acceptance criteria:** mcat works identically using MCDSL, with its
|
||||||
|
`internal/` packages reduced to service-specific logic only.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Phase 11: Broader Adoption
|
||||||
|
|
||||||
|
Migrate remaining services one at a time:
|
||||||
|
|
||||||
|
- [ ] metacrypt
|
||||||
|
- [ ] mcr
|
||||||
|
- [ ] mc-proxy (subset: db, config — no web/csrf)
|
||||||
|
- [ ] mcias (subset: db, config, httpserver — owns the auth client, not a consumer)
|
||||||
|
|
||||||
|
Each migration follows the same pattern as Phase 10. Services are migrated
|
||||||
|
independently — there is no big-bang cutover.
|
||||||
96
README.md
Normal file
96
README.md
Normal file
@@ -0,0 +1,96 @@
|
|||||||
|
# MCDSL — Metacircular Dynamics Standard Library
|
||||||
|
|
||||||
|
MCDSL is a shared Go library for Metacircular Dynamics services. It extracts
|
||||||
|
the common patterns that every service implements independently — MCIAS
|
||||||
|
authentication, SQLite database setup, TLS server bootstrapping, CSRF
|
||||||
|
protection, configuration loading, and service data snapshots — into a single,
|
||||||
|
tested, reusable module.
|
||||||
|
|
||||||
|
## Why
|
||||||
|
|
||||||
|
Every Metacircular service follows the same patterns (see
|
||||||
|
`engineering-standards.md`). Today, each service copy-pastes these patterns
|
||||||
|
into its own `internal/` packages. This means:
|
||||||
|
|
||||||
|
- Bug fixes must be applied N times (once per service).
|
||||||
|
- Subtle divergences accumulate (e.g., CSRF tokens use base64 in one service,
|
||||||
|
hex in another; auth cache keys are `[32]byte` in some, hex strings in
|
||||||
|
others).
|
||||||
|
- New services require copying and adapting boilerplate from an existing
|
||||||
|
service.
|
||||||
|
|
||||||
|
MCDSL extracts the 95%+ identical code into a shared library. Services import
|
||||||
|
it and provide only their service-specific logic.
|
||||||
|
|
||||||
|
## Module Path
|
||||||
|
|
||||||
|
```
|
||||||
|
git.wntrmute.dev/kyle/mcdsl
|
||||||
|
```
|
||||||
|
|
||||||
|
## Packages
|
||||||
|
|
||||||
|
| Package | Purpose |
|
||||||
|
|---------|---------|
|
||||||
|
| `auth` | MCIAS token validation with 30-second SHA-256 cache |
|
||||||
|
| `db` | SQLite connection setup (WAL, FK, busy timeout), migration runner, VACUUM INTO snapshots |
|
||||||
|
| `config` | TOML config loading with environment variable overrides, standard section types |
|
||||||
|
| `httpserver` | TLS 1.3 HTTP server setup with chi, graceful shutdown, logging middleware |
|
||||||
|
| `grpcserver` | gRPC server setup with TLS, interceptor chain helpers, method map auth |
|
||||||
|
| `csrf` | HMAC-SHA256 double-submit cookie CSRF protection |
|
||||||
|
| `web` | Session cookie management, template rendering helpers, auth middleware |
|
||||||
|
| `archive` | tar.zst service directory snapshot and restore with SQLite-aware handling |
|
||||||
|
| `health` | Standard health check implementation (gRPC Health/v1 + REST) |
|
||||||
|
|
||||||
|
## Quick Start
|
||||||
|
|
||||||
|
```go
|
||||||
|
import (
|
||||||
|
"git.wntrmute.dev/kyle/mcdsl/auth"
|
||||||
|
"git.wntrmute.dev/kyle/mcdsl/db"
|
||||||
|
"git.wntrmute.dev/kyle/mcdsl/config"
|
||||||
|
"git.wntrmute.dev/kyle/mcdsl/httpserver"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Load config with standard sections + service-specific fields.
|
||||||
|
type MyConfig struct {
|
||||||
|
config.Base
|
||||||
|
MyService MyServiceConfig `toml:"my_service"`
|
||||||
|
}
|
||||||
|
|
||||||
|
cfg, err := config.Load[MyConfig]("my-service.toml", "MYSERVICE")
|
||||||
|
|
||||||
|
// Open database with standard pragmas and run migrations.
|
||||||
|
database, err := db.Open(cfg.Database.Path)
|
||||||
|
migrations := []db.Migration{
|
||||||
|
{Version: 1, Name: "initial schema", SQL: `CREATE TABLE ...`},
|
||||||
|
}
|
||||||
|
db.Migrate(database, migrations)
|
||||||
|
|
||||||
|
// Set up MCIAS authentication with token caching.
|
||||||
|
mcauth, err := auth.New(cfg.MCIAS)
|
||||||
|
|
||||||
|
// Start TLS server with standard middleware.
|
||||||
|
srv := httpserver.New(cfg.Server, logger)
|
||||||
|
srv.Route(func(r chi.Router) {
|
||||||
|
r.Use(srv.LoggingMiddleware)
|
||||||
|
// register routes...
|
||||||
|
})
|
||||||
|
srv.ListenAndServeTLS()
|
||||||
|
```
|
||||||
|
|
||||||
|
## Build and Test
|
||||||
|
|
||||||
|
```bash
|
||||||
|
go build ./...
|
||||||
|
go test ./...
|
||||||
|
go vet ./...
|
||||||
|
golangci-lint run ./...
|
||||||
|
```
|
||||||
|
|
||||||
|
## Documentation
|
||||||
|
|
||||||
|
- [ARCHITECTURE.md](ARCHITECTURE.md) — full library specification
|
||||||
|
- [PROJECT_PLAN.md](PROJECT_PLAN.md) — implementation phases
|
||||||
|
- [PROGRESS.md](PROGRESS.md) — development status
|
||||||
|
- [../engineering-standards.md](../engineering-standards.md) — platform-wide standards
|
||||||
181
db/db.go
Normal file
181
db/db.go
Normal file
@@ -0,0 +1,181 @@
|
|||||||
|
// Package db provides SQLite database setup, migrations, and snapshots
|
||||||
|
// for Metacircular services.
|
||||||
|
//
|
||||||
|
// All databases are opened with the standard Metacircular pragmas (WAL mode,
|
||||||
|
// foreign keys, busy timeout) and restrictive file permissions (0600).
|
||||||
|
package db
|
||||||
|
|
||||||
|
import (
|
||||||
|
"database/sql"
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
_ "modernc.org/sqlite" // SQLite driver (pure Go, no CGo).
|
||||||
|
)
|
||||||
|
|
||||||
|
// Open opens or creates a SQLite database at path with the standard
|
||||||
|
// Metacircular pragmas:
|
||||||
|
//
|
||||||
|
// PRAGMA journal_mode = WAL;
|
||||||
|
// PRAGMA foreign_keys = ON;
|
||||||
|
// PRAGMA busy_timeout = 5000;
|
||||||
|
//
|
||||||
|
// The file is created with 0600 permissions (owner read/write only).
|
||||||
|
// The parent directory is created if it does not exist.
|
||||||
|
//
|
||||||
|
// Open returns a standard [*sql.DB] — no wrapper types. Services use it
|
||||||
|
// directly with database/sql.
|
||||||
|
func Open(path string) (*sql.DB, error) {
|
||||||
|
dir := filepath.Dir(path)
|
||||||
|
if err := os.MkdirAll(dir, 0700); err != nil {
|
||||||
|
return nil, fmt.Errorf("db: create directory %s: %w", dir, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Pre-create the file with restrictive permissions if it does not exist.
|
||||||
|
if _, err := os.Stat(path); os.IsNotExist(err) {
|
||||||
|
f, createErr := os.OpenFile(path, os.O_CREATE|os.O_WRONLY, 0600) //nolint:gosec // path is caller-provided config, not user input
|
||||||
|
if createErr != nil {
|
||||||
|
return nil, fmt.Errorf("db: create file %s: %w", path, createErr)
|
||||||
|
}
|
||||||
|
_ = f.Close()
|
||||||
|
}
|
||||||
|
|
||||||
|
database, err := sql.Open("sqlite", path)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("db: open %s: %w", path, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
pragmas := []string{
|
||||||
|
"PRAGMA journal_mode = WAL",
|
||||||
|
"PRAGMA foreign_keys = ON",
|
||||||
|
"PRAGMA busy_timeout = 5000",
|
||||||
|
}
|
||||||
|
for _, p := range pragmas {
|
||||||
|
if _, execErr := database.Exec(p); execErr != nil {
|
||||||
|
_ = database.Close()
|
||||||
|
return nil, fmt.Errorf("db: %s: %w", p, execErr)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Ensure permissions are correct even if the file already existed.
|
||||||
|
if err := os.Chmod(path, 0600); err != nil {
|
||||||
|
_ = database.Close()
|
||||||
|
return nil, fmt.Errorf("db: chmod %s: %w", path, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return database, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Migration is a numbered, named schema change. Services define their
|
||||||
|
// migrations as a []Migration slice — the slice is the schema history.
|
||||||
|
type Migration struct {
|
||||||
|
// Version is the migration number. Must be unique and should be
|
||||||
|
// sequential starting from 1.
|
||||||
|
Version int
|
||||||
|
|
||||||
|
// Name is a short human-readable description (e.g., "initial schema").
|
||||||
|
Name string
|
||||||
|
|
||||||
|
// SQL is the DDL/DML to execute. Multiple statements are allowed
|
||||||
|
// (separated by semicolons). Each migration runs in a transaction.
|
||||||
|
SQL string
|
||||||
|
}
|
||||||
|
|
||||||
|
// Migrate applies all pending migrations from the given slice. It creates
|
||||||
|
// the schema_migrations tracking table if it does not exist.
|
||||||
|
//
|
||||||
|
// Each migration runs in its own transaction. Already-applied migrations
|
||||||
|
// (identified by version number) are skipped. Timestamps are stored as
|
||||||
|
// RFC 3339 UTC.
|
||||||
|
func Migrate(database *sql.DB, migrations []Migration) error {
|
||||||
|
_, err := database.Exec(`CREATE TABLE IF NOT EXISTS schema_migrations (
|
||||||
|
version INTEGER PRIMARY KEY,
|
||||||
|
name TEXT NOT NULL DEFAULT '',
|
||||||
|
applied_at TEXT NOT NULL DEFAULT ''
|
||||||
|
)`)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("db: create schema_migrations: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, m := range migrations {
|
||||||
|
applied, checkErr := migrationApplied(database, m.Version)
|
||||||
|
if checkErr != nil {
|
||||||
|
return checkErr
|
||||||
|
}
|
||||||
|
if applied {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
tx, txErr := database.Begin()
|
||||||
|
if txErr != nil {
|
||||||
|
return fmt.Errorf("db: begin migration %d (%s): %w", m.Version, m.Name, txErr)
|
||||||
|
}
|
||||||
|
|
||||||
|
if _, execErr := tx.Exec(m.SQL); execErr != nil {
|
||||||
|
_ = tx.Rollback()
|
||||||
|
return fmt.Errorf("db: migration %d (%s): %w", m.Version, m.Name, execErr)
|
||||||
|
}
|
||||||
|
|
||||||
|
now := time.Now().UTC().Format(time.RFC3339)
|
||||||
|
if _, execErr := tx.Exec(
|
||||||
|
`INSERT INTO schema_migrations (version, name, applied_at) VALUES (?, ?, ?)`,
|
||||||
|
m.Version, m.Name, now,
|
||||||
|
); execErr != nil {
|
||||||
|
_ = tx.Rollback()
|
||||||
|
return fmt.Errorf("db: record migration %d: %w", m.Version, execErr)
|
||||||
|
}
|
||||||
|
|
||||||
|
if commitErr := tx.Commit(); commitErr != nil {
|
||||||
|
return fmt.Errorf("db: commit migration %d: %w", m.Version, commitErr)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// SchemaVersion returns the highest applied migration version, or 0 if
|
||||||
|
// no migrations have been applied.
|
||||||
|
func SchemaVersion(database *sql.DB) (int, error) {
|
||||||
|
var version sql.NullInt64
|
||||||
|
err := database.QueryRow(`SELECT MAX(version) FROM schema_migrations`).Scan(&version)
|
||||||
|
if err != nil {
|
||||||
|
return 0, fmt.Errorf("db: schema version: %w", err)
|
||||||
|
}
|
||||||
|
if !version.Valid {
|
||||||
|
return 0, nil
|
||||||
|
}
|
||||||
|
return int(version.Int64), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Snapshot creates a consistent backup of the database at destPath using
|
||||||
|
// SQLite's VACUUM INTO. The destination file is created with 0600
|
||||||
|
// permissions.
|
||||||
|
func Snapshot(database *sql.DB, destPath string) error {
|
||||||
|
dir := filepath.Dir(destPath)
|
||||||
|
if err := os.MkdirAll(dir, 0700); err != nil {
|
||||||
|
return fmt.Errorf("db: create snapshot directory %s: %w", dir, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if _, err := database.Exec("VACUUM INTO ?", destPath); err != nil {
|
||||||
|
return fmt.Errorf("db: snapshot: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := os.Chmod(destPath, 0600); err != nil {
|
||||||
|
return fmt.Errorf("db: chmod snapshot %s: %w", destPath, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func migrationApplied(database *sql.DB, version int) (bool, error) {
|
||||||
|
var count int
|
||||||
|
err := database.QueryRow(
|
||||||
|
`SELECT COUNT(*) FROM schema_migrations WHERE version = ?`, version,
|
||||||
|
).Scan(&count)
|
||||||
|
if err != nil {
|
||||||
|
return false, fmt.Errorf("db: check migration %d: %w", version, err)
|
||||||
|
}
|
||||||
|
return count > 0, nil
|
||||||
|
}
|
||||||
304
db/db_test.go
Normal file
304
db/db_test.go
Normal file
@@ -0,0 +1,304 @@
|
|||||||
|
package db
|
||||||
|
|
||||||
|
import (
|
||||||
|
"database/sql"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestOpen(t *testing.T) {
|
||||||
|
dir := t.TempDir()
|
||||||
|
path := filepath.Join(dir, "test.db")
|
||||||
|
|
||||||
|
database, err := Open(path)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Open: %v", err)
|
||||||
|
}
|
||||||
|
defer func() { _ = database.Close() }()
|
||||||
|
|
||||||
|
// Verify WAL mode is enabled.
|
||||||
|
var journalMode string
|
||||||
|
if err := database.QueryRow("PRAGMA journal_mode").Scan(&journalMode); err != nil {
|
||||||
|
t.Fatalf("query journal_mode: %v", err)
|
||||||
|
}
|
||||||
|
if journalMode != "wal" {
|
||||||
|
t.Fatalf("journal_mode = %q, want %q", journalMode, "wal")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify foreign keys are enabled.
|
||||||
|
var fk int
|
||||||
|
if err := database.QueryRow("PRAGMA foreign_keys").Scan(&fk); err != nil {
|
||||||
|
t.Fatalf("query foreign_keys: %v", err)
|
||||||
|
}
|
||||||
|
if fk != 1 {
|
||||||
|
t.Fatalf("foreign_keys = %d, want 1", fk)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify busy timeout.
|
||||||
|
var timeout int
|
||||||
|
if err := database.QueryRow("PRAGMA busy_timeout").Scan(&timeout); err != nil {
|
||||||
|
t.Fatalf("query busy_timeout: %v", err)
|
||||||
|
}
|
||||||
|
if timeout != 5000 {
|
||||||
|
t.Fatalf("busy_timeout = %d, want 5000", timeout)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestOpenFilePermissions(t *testing.T) {
|
||||||
|
dir := t.TempDir()
|
||||||
|
path := filepath.Join(dir, "test.db")
|
||||||
|
|
||||||
|
database, err := Open(path)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Open: %v", err)
|
||||||
|
}
|
||||||
|
_ = database.Close()
|
||||||
|
|
||||||
|
info, err := os.Stat(path)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Stat: %v", err)
|
||||||
|
}
|
||||||
|
perm := info.Mode().Perm()
|
||||||
|
if perm != 0600 {
|
||||||
|
t.Fatalf("permissions = %o, want 0600", perm)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestOpenCreatesParentDir(t *testing.T) {
|
||||||
|
dir := t.TempDir()
|
||||||
|
path := filepath.Join(dir, "sub", "dir", "test.db")
|
||||||
|
|
||||||
|
database, err := Open(path)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Open: %v", err)
|
||||||
|
}
|
||||||
|
_ = database.Close()
|
||||||
|
|
||||||
|
if _, err := os.Stat(path); err != nil {
|
||||||
|
t.Fatalf("database file does not exist: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestOpenExistingDB(t *testing.T) {
|
||||||
|
dir := t.TempDir()
|
||||||
|
path := filepath.Join(dir, "test.db")
|
||||||
|
|
||||||
|
// Create and populate.
|
||||||
|
db1, err := Open(path)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Open (first): %v", err)
|
||||||
|
}
|
||||||
|
if _, err := db1.Exec("CREATE TABLE t (id INTEGER PRIMARY KEY)"); err != nil {
|
||||||
|
t.Fatalf("create table: %v", err)
|
||||||
|
}
|
||||||
|
if _, err := db1.Exec("INSERT INTO t (id) VALUES (42)"); err != nil {
|
||||||
|
t.Fatalf("insert: %v", err)
|
||||||
|
}
|
||||||
|
_ = db1.Close()
|
||||||
|
|
||||||
|
// Reopen and verify data persists.
|
||||||
|
db2, err := Open(path)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Open (second): %v", err)
|
||||||
|
}
|
||||||
|
defer func() { _ = db2.Close() }()
|
||||||
|
|
||||||
|
var id int
|
||||||
|
if err := db2.QueryRow("SELECT id FROM t").Scan(&id); err != nil {
|
||||||
|
t.Fatalf("select: %v", err)
|
||||||
|
}
|
||||||
|
if id != 42 {
|
||||||
|
t.Fatalf("id = %d, want 42", id)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var testMigrations = []Migration{
|
||||||
|
{
|
||||||
|
Version: 1,
|
||||||
|
Name: "create users",
|
||||||
|
SQL: `CREATE TABLE users (id INTEGER PRIMARY KEY, name TEXT NOT NULL)`,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Version: 2,
|
||||||
|
Name: "add email",
|
||||||
|
SQL: `ALTER TABLE users ADD COLUMN email TEXT NOT NULL DEFAULT ''`,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestMigrate(t *testing.T) {
|
||||||
|
database := openTestDB(t)
|
||||||
|
|
||||||
|
if err := Migrate(database, testMigrations); err != nil {
|
||||||
|
t.Fatalf("Migrate: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify both migrations applied.
|
||||||
|
version, err := SchemaVersion(database)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("SchemaVersion: %v", err)
|
||||||
|
}
|
||||||
|
if version != 2 {
|
||||||
|
t.Fatalf("schema version = %d, want 2", version)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify schema is correct.
|
||||||
|
if _, err := database.Exec("INSERT INTO users (name, email) VALUES ('a', 'a@b.c')"); err != nil {
|
||||||
|
t.Fatalf("insert into migrated schema: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestMigrateIdempotent(t *testing.T) {
|
||||||
|
database := openTestDB(t)
|
||||||
|
|
||||||
|
// Run twice.
|
||||||
|
if err := Migrate(database, testMigrations); err != nil {
|
||||||
|
t.Fatalf("Migrate (first): %v", err)
|
||||||
|
}
|
||||||
|
if err := Migrate(database, testMigrations); err != nil {
|
||||||
|
t.Fatalf("Migrate (second): %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
version, err := SchemaVersion(database)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("SchemaVersion: %v", err)
|
||||||
|
}
|
||||||
|
if version != 2 {
|
||||||
|
t.Fatalf("schema version = %d, want 2", version)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestMigrateIncremental(t *testing.T) {
|
||||||
|
database := openTestDB(t)
|
||||||
|
|
||||||
|
// Apply only the first migration.
|
||||||
|
if err := Migrate(database, testMigrations[:1]); err != nil {
|
||||||
|
t.Fatalf("Migrate (first only): %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
version, err := SchemaVersion(database)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("SchemaVersion: %v", err)
|
||||||
|
}
|
||||||
|
if version != 1 {
|
||||||
|
t.Fatalf("schema version = %d, want 1", version)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Now apply all — should pick up only migration 2.
|
||||||
|
if err := Migrate(database, testMigrations); err != nil {
|
||||||
|
t.Fatalf("Migrate (all): %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
version, err = SchemaVersion(database)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("SchemaVersion: %v", err)
|
||||||
|
}
|
||||||
|
if version != 2 {
|
||||||
|
t.Fatalf("schema version = %d, want 2", version)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestMigrateRecordsName(t *testing.T) {
|
||||||
|
database := openTestDB(t)
|
||||||
|
|
||||||
|
if err := Migrate(database, testMigrations); err != nil {
|
||||||
|
t.Fatalf("Migrate: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
var name string
|
||||||
|
err := database.QueryRow(
|
||||||
|
`SELECT name FROM schema_migrations WHERE version = 1`,
|
||||||
|
).Scan(&name)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("query migration name: %v", err)
|
||||||
|
}
|
||||||
|
if name != "create users" {
|
||||||
|
t.Fatalf("migration name = %q, want %q", name, "create users")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSchemaVersionEmpty(t *testing.T) {
|
||||||
|
database := openTestDB(t)
|
||||||
|
|
||||||
|
// Create the table but apply no migrations.
|
||||||
|
if err := Migrate(database, nil); err != nil {
|
||||||
|
t.Fatalf("Migrate(nil): %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
version, err := SchemaVersion(database)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("SchemaVersion: %v", err)
|
||||||
|
}
|
||||||
|
if version != 0 {
|
||||||
|
t.Fatalf("schema version = %d, want 0", version)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSnapshot(t *testing.T) {
|
||||||
|
database := openTestDB(t)
|
||||||
|
|
||||||
|
// Create some data.
|
||||||
|
if _, err := database.Exec("CREATE TABLE t (id INTEGER PRIMARY KEY, val TEXT)"); err != nil {
|
||||||
|
t.Fatalf("create table: %v", err)
|
||||||
|
}
|
||||||
|
if _, err := database.Exec("INSERT INTO t (val) VALUES ('hello')"); err != nil {
|
||||||
|
t.Fatalf("insert: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Snapshot.
|
||||||
|
dir := t.TempDir()
|
||||||
|
snapPath := filepath.Join(dir, "snap.db")
|
||||||
|
if err := Snapshot(database, snapPath); err != nil {
|
||||||
|
t.Fatalf("Snapshot: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify snapshot file permissions.
|
||||||
|
info, err := os.Stat(snapPath)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Stat snapshot: %v", err)
|
||||||
|
}
|
||||||
|
if perm := info.Mode().Perm(); perm != 0600 {
|
||||||
|
t.Fatalf("snapshot permissions = %o, want 0600", perm)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Open snapshot and verify data.
|
||||||
|
snapDB, err := sql.Open("sqlite", snapPath)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("open snapshot: %v", err)
|
||||||
|
}
|
||||||
|
defer func() { _ = snapDB.Close() }()
|
||||||
|
|
||||||
|
var val string
|
||||||
|
if err := snapDB.QueryRow("SELECT val FROM t").Scan(&val); err != nil {
|
||||||
|
t.Fatalf("select from snapshot: %v", err)
|
||||||
|
}
|
||||||
|
if val != "hello" {
|
||||||
|
t.Fatalf("val = %q, want %q", val, "hello")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSnapshotCreatesParentDir(t *testing.T) {
|
||||||
|
database := openTestDB(t)
|
||||||
|
|
||||||
|
dir := t.TempDir()
|
||||||
|
snapPath := filepath.Join(dir, "sub", "snap.db")
|
||||||
|
if err := Snapshot(database, snapPath); err != nil {
|
||||||
|
t.Fatalf("Snapshot: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if _, err := os.Stat(snapPath); err != nil {
|
||||||
|
t.Fatalf("snapshot file does not exist: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func openTestDB(t *testing.T) *sql.DB {
|
||||||
|
t.Helper()
|
||||||
|
dir := t.TempDir()
|
||||||
|
path := filepath.Join(dir, "test.db")
|
||||||
|
database, err := Open(path)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Open: %v", err)
|
||||||
|
}
|
||||||
|
t.Cleanup(func() { _ = database.Close() })
|
||||||
|
return database
|
||||||
|
}
|
||||||
7
doc.go
Normal file
7
doc.go
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
// Package mcdsl is the Metacircular Dynamics Standard Library.
|
||||||
|
//
|
||||||
|
// It provides shared infrastructure packages for Metacircular services:
|
||||||
|
// authentication, database setup, configuration loading, HTTP/gRPC server
|
||||||
|
// bootstrapping, CSRF protection, session management, health checks, and
|
||||||
|
// service directory snapshots.
|
||||||
|
package mcdsl
|
||||||
17
go.mod
Normal file
17
go.mod
Normal file
@@ -0,0 +1,17 @@
|
|||||||
|
module git.wntrmute.dev/kyle/mcdsl
|
||||||
|
|
||||||
|
go 1.25.7
|
||||||
|
|
||||||
|
require modernc.org/sqlite v1.47.0
|
||||||
|
|
||||||
|
require (
|
||||||
|
github.com/dustin/go-humanize v1.0.1 // indirect
|
||||||
|
github.com/google/uuid v1.6.0 // 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.42.0 // indirect
|
||||||
|
modernc.org/libc v1.70.0 // indirect
|
||||||
|
modernc.org/mathutil v1.7.1 // indirect
|
||||||
|
modernc.org/memory v1.11.0 // indirect
|
||||||
|
)
|
||||||
51
go.sum
Normal file
51
go.sum
Normal file
@@ -0,0 +1,51 @@
|
|||||||
|
github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
|
||||||
|
github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
|
||||||
|
github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e h1:ijClszYn+mADRFY17kjQEVQ1XRhq2/JR1M3sGqeJoxs=
|
||||||
|
github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e/go.mod h1:boTsfXsheKC2y+lKOCMpSfarhxDeIzfZG1jqGcPl3cA=
|
||||||
|
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
|
||||||
|
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
|
||||||
|
github.com/hashicorp/golang-lru/v2 v2.0.7 h1:a+bsQ5rvGLjzHuww6tVxozPZFVghXaHOwFs4luLUK2k=
|
||||||
|
github.com/hashicorp/golang-lru/v2 v2.0.7/go.mod h1:QeFd9opnmA6QUJc5vARoKUSoFhyfM2/ZepoAG6RGpeM=
|
||||||
|
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
|
||||||
|
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
|
||||||
|
github.com/ncruces/go-strftime v1.0.0 h1:HMFp8mLCTPp341M/ZnA4qaf7ZlsbTc+miZjCLOFAw7w=
|
||||||
|
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.33.0 h1:tHFzIWbBifEmbwtGz65eaWyGiGZatSrT9prnU8DbVL8=
|
||||||
|
golang.org/x/mod v0.33.0/go.mod h1:swjeQEj+6r7fODbD2cqrnje9PnziFuw4bmLbBZFrQ5w=
|
||||||
|
golang.org/x/sync v0.19.0 h1:vV+1eWNmZ5geRlYjzm2adRgW2/mcpevXNg50YZtPCE4=
|
||||||
|
golang.org/x/sync v0.19.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI=
|
||||||
|
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||||
|
golang.org/x/sys v0.42.0 h1:omrd2nAlyT5ESRdCLYdm3+fMfNFE/+Rf4bDIQImRJeo=
|
||||||
|
golang.org/x/sys v0.42.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
|
||||||
|
golang.org/x/tools v0.42.0 h1:uNgphsn75Tdz5Ji2q36v/nsFSfR/9BRFvqhGBaJGd5k=
|
||||||
|
golang.org/x/tools v0.42.0/go.mod h1:Ma6lCIwGZvHK6XtgbswSoWroEkhugApmsXyrUmBhfr0=
|
||||||
|
modernc.org/cc/v4 v4.27.1 h1:9W30zRlYrefrDV2JE2O8VDtJ1yPGownxciz5rrbQZis=
|
||||||
|
modernc.org/cc/v4 v4.27.1/go.mod h1:uVtb5OGqUKpoLWhqwNQo/8LwvoiEBLvZXIQ/SmO6mL0=
|
||||||
|
modernc.org/ccgo/v4 v4.32.0 h1:hjG66bI/kqIPX1b2yT6fr/jt+QedtP2fqojG2VrFuVw=
|
||||||
|
modernc.org/ccgo/v4 v4.32.0/go.mod h1:6F08EBCx5uQc38kMGl+0Nm0oWczoo1c7cgpzEry7Uc0=
|
||||||
|
modernc.org/fileutil v1.4.0 h1:j6ZzNTftVS054gi281TyLjHPp6CPHr2KCxEXjEbD6SM=
|
||||||
|
modernc.org/fileutil v1.4.0/go.mod h1:EqdKFDxiByqxLk8ozOxObDSfcVOv/54xDs/DUHdvCUU=
|
||||||
|
modernc.org/gc/v2 v2.6.5 h1:nyqdV8q46KvTpZlsw66kWqwXRHdjIlJOhG6kxiV/9xI=
|
||||||
|
modernc.org/gc/v2 v2.6.5/go.mod h1:YgIahr1ypgfe7chRuJi2gD7DBQiKSLMPgBQe9oIiito=
|
||||||
|
modernc.org/gc/v3 v3.1.2 h1:ZtDCnhonXSZexk/AYsegNRV1lJGgaNZJuKjJSWKyEqo=
|
||||||
|
modernc.org/gc/v3 v3.1.2/go.mod h1:HFK/6AGESC7Ex+EZJhJ2Gni6cTaYpSMmU/cT9RmlfYY=
|
||||||
|
modernc.org/goabi0 v0.2.0 h1:HvEowk7LxcPd0eq6mVOAEMai46V+i7Jrj13t4AzuNks=
|
||||||
|
modernc.org/goabi0 v0.2.0/go.mod h1:CEFRnnJhKvWT1c1JTI3Avm+tgOWbkOu5oPA8eH8LnMI=
|
||||||
|
modernc.org/libc v1.70.0 h1:U58NawXqXbgpZ/dcdS9kMshu08aiA6b7gusEusqzNkw=
|
||||||
|
modernc.org/libc v1.70.0/go.mod h1:OVmxFGP1CI/Z4L3E0Q3Mf1PDE0BucwMkcXjjLntvHJo=
|
||||||
|
modernc.org/mathutil v1.7.1 h1:GCZVGXdaN8gTqB1Mf/usp1Y/hSqgI2vAGGP4jZMCxOU=
|
||||||
|
modernc.org/mathutil v1.7.1/go.mod h1:4p5IwJITfppl0G4sUEDtCr4DthTaT47/N3aT6MhfgJg=
|
||||||
|
modernc.org/memory v1.11.0 h1:o4QC8aMQzmcwCK3t3Ux/ZHmwFPzE6hf2Y5LbkRs+hbI=
|
||||||
|
modernc.org/memory v1.11.0/go.mod h1:/JP4VbVC+K5sU2wZi9bHoq2MAkCnrt2r98UGeSK7Mjw=
|
||||||
|
modernc.org/opt v0.1.4 h1:2kNGMRiUjrp4LcaPuLY2PzUfqM/w9N23quVwhKt5Qm8=
|
||||||
|
modernc.org/opt v0.1.4/go.mod h1:03fq9lsNfvkYSfxrfUhZCWPk1lm4cq4N+Bh//bEtgns=
|
||||||
|
modernc.org/sortutil v1.2.1 h1:+xyoGf15mM3NMlPDnFqrteY07klSFxLElE2PVuWIJ7w=
|
||||||
|
modernc.org/sortutil v1.2.1/go.mod h1:7ZI3a3REbai7gzCLcotuw9AC4VZVpYMjDzETGsSMqJE=
|
||||||
|
modernc.org/sqlite v1.47.0 h1:R1XyaNpoW4Et9yly+I2EeX7pBza/w+pmYee/0HJDyKk=
|
||||||
|
modernc.org/sqlite v1.47.0/go.mod h1:hWjRO6Tj/5Ik8ieqxQybiEOUXy0NJFNp2tpvVpKlvig=
|
||||||
|
modernc.org/strutil v1.2.1 h1:UneZBkQA+DX2Rp35KcM69cSsNES9ly8mQWD71HKlOA0=
|
||||||
|
modernc.org/strutil v1.2.1/go.mod h1:EHkiggD70koQxjVdSBM3JKM7k6L0FbGE5eymy9i3B9A=
|
||||||
|
modernc.org/token v1.1.0 h1:Xl7Ap9dKaEs5kLoOQeQmPWevfnk/DM5qcLcYlA8ys6Y=
|
||||||
|
modernc.org/token v1.1.0/go.mod h1:UGzOrNV1mAFSEB63lOFHIpNRUVMvYTc6yu1SMY/XTDM=
|
||||||
Reference in New Issue
Block a user