Skip to content

Commit 5914203

Browse files
Merge pull request #10 from crydensync/feat/tier6-sqlite-backend
Feat/tier6 sqlite backend
2 parents f706b50 + 5574c6a commit 5914203

31 files changed

Lines changed: 1566 additions & 84 deletions

.env.example

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,9 @@
1+
# Which backend. Set exactly one — starting with both or neither is a
2+
# startup error. DATABASE_URL runs everything; SQLITE_PATH runs core auth
3+
# only, with the whole admin console answering 501 (its tables are
4+
# Postgres-only). See README's "The two backends".
15
DATABASE_URL=postgresql://postgres.xxxxxxxx:your-password@aws-1-eu-west-3.pooler.supabase.com:5432/postgres?sslmode=require
6+
# SQLITE_PATH=/var/lib/cryden/api.db
27
JWT_SECRET=
38
CORS_ORIGINS=http://localhost:5173,https://yourapp.com
49
PORT=8080

README.md

Lines changed: 34 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@ Every consumer talks to this over plain HTTP — no Go required. This is what a
77
## Prerequisites
88

99
- Go 1.22+ (check `go.mod` for exact version)
10-
- A running Postgres instance (local, Docker, or hosted — e.g. Supabase, Neon, RDS)
10+
- A running Postgres instance (local, Docker, or hosted — e.g. Supabase, Neon, RDS) — or nothing but a writable path, if you run on SQLite (see [The two backends](#the-two-backends))
1111

1212
## Getting started
1313

@@ -20,6 +20,8 @@ go run .
2020

2121
Run the migrations in `migrations/` against your database first, in order (copies of CrydenSync's own migrations, kept here so this repo is self-contained for local dev and CI — same as `typebook` keeps its own copy). `002_oauth_identities` is required even if you don't use OAuth yet — `NewOAuthStore` is wired into the engine config unconditionally. `004` through `008` are the TOTP, WebAuthn, recovery-code, login-attempt and API-key tables; run them even if you leave `ENCRYPTION_KEY` unset, since `007` is what the engine's credential-stuffing detection reads once Tier 2 wires it up and `008` is what the API-key work will use.
2222

23+
That paragraph is the Postgres path only. On SQLite there is nothing to run by hand — `main.go` calls cryden's own `sqlite.Migrate` at startup. See [The two backends](#the-two-backends).
24+
2325
OAuth is optional. To enable a provider, set its client ID/secret plus `BASE_URL` (used to build the callback URL registered in that provider's console):
2426

2527
```
@@ -57,6 +59,35 @@ APPLE_PRIVATE_KEY=-----BEGIN PRIVATE KEY-----\nMIG...\n-----END PRIVATE KEY-----
5759

5860
All four `APPLE_*` values are required; a partially configured Apple is simply unavailable, like any other unconfigured provider.
5961

62+
## The two backends
63+
64+
This API runs on Postgres or on SQLite, chosen by exactly one environment variable:
65+
66+
```
67+
DATABASE_URL=postgres://... # everything
68+
SQLITE_PATH=/var/lib/cryden/api.db # core auth only
69+
```
70+
71+
Setting both is a startup error, and so is setting neither. That is deliberate rather than a convenience: `DATABASE_URL` is what the admin console needs and `SQLITE_PATH` is what the store wiring reads, so quietly preferring one would run a deployment on a backend its own configuration does not describe.
72+
73+
**SQLite runs core auth and nothing under `/v1/admin`.** Signup, login, refresh, sessions, password change, email change, OAuth, TOTP, passkeys, recovery codes, magic links and API keys all work. Every admin route — the whole console: the user surface, metadata, webhook and log history, the digest, support diagnosis, config tuning, flagged-event review, AI settings — answers:
74+
75+
```json
76+
{"error": {"code": "not_implemented_on_sqlite", "message": "the admin console requires a Postgres backend; this deployment runs on SQLite"}}
77+
```
78+
79+
with `501`, before the token is looked at. This is one decision applied in one place (`httpapi.AdminOnly`), not a list of routes to maintain: the admin console's tables are this repo's own and Postgres-only, and `RequireAdmin` itself depends on the `operators` table, so there is no partial console to offer and no way for a SQLite deployment to have an operator at all. A `403 not_operator` would have been the easy answer and the wrong one — it tells a legitimate operator they personally lack access when the truth is that this backend has no console.
80+
81+
The one AI surface that is *not* under `/v1/admin` is `POST /v1/ask-ai`, which serves the widget to a signed-in end user. It is still unavailable on SQLite, because the provider behind it lives in the Postgres-only `settings` table: it answers `404 not_configured`, the same shape it gives on a Postgres deployment that has not configured a provider. One rule, stated once: **the AI-assisted surface needs `DATABASE_URL`.**
82+
83+
These variables are accepted but inert on SQLite, and the server says so at startup rather than letting an operator wonder — `SETTINGS_ENCRYPTION_KEY`, `WEBHOOK_URL`, `CLOUD_LOGGING`, `DIGEST_INTERVAL_HOURS`. `ENCRYPTION_KEY` is *not* in that list: TOTP and passkeys are cryden's own tables and work on both backends.
84+
85+
No migration step exists on SQLite. `main.go` calls cryden's own `sqlite.Migrate` at startup, which embeds its migrations and records what it applied, so a second boot is a no-op. The copy of those files under `migrations/sqlite/` is reference material, not what runs — see the README in that directory.
86+
87+
The connection is opened with three pragmas, all of them load-bearing: `foreign_keys(1)` (off by default, so the schema's `ON DELETE` clauses would silently not run), `busy_timeout(5000)` (zero by default, so a concurrent writer gets an immediate `SQLITE_BUSY` instead of waiting), and `journal_mode(WAL)`. The server verifies the first two on every boot with cryden's own `CheckPragmas` and refuses to start if the DSN and the driver have drifted apart.
88+
89+
**Backing up a SQLite deployment means copying `api.db`, `api.db-wal` and `api.db-shm` together**, or checkpointing first. With WAL, recent writes — including, on a fresh deployment, the entire schema — live in the `-wal` file until a checkpoint folds them into the main file, and there is no graceful shutdown here yet to force one on exit. Copying `api.db` alone can silently produce an empty database.
90+
6091
## Second factors
6192

6293
TOTP and passkeys are optional and all-or-nothing on `ENCRYPTION_KEY`: cryden refuses to construct an engine with a TOTP or WebAuthn store set and no encryption key (a TOTP secret must be recoverable in plaintext to check a code, so it is encrypted rather than hashed). With the key unset, both methods answer `404 totp_not_configured` / `404 passkeys_not_configured` per request, the same shape an unconfigured OAuth provider uses, rather than the server refusing to start.
@@ -209,6 +240,8 @@ Authenticated endpoints expect `Authorization: Bearer <access_token>`.
209240

210241
Everything under `/v1/admin` requires an **operator** token: a valid access token whose `role` claim is `admin`. Operator status is this repo's own concept, not cryden's — it lives in its own `operators` table (`migrations/003_operators.*.sql`), and the claim is attached to the token at issue time by the `AccessTokenClaims` provider in `main.go`. An ordinary user's token carries no `role` claim at all, so "revoked operator", "never was one" and "no such user" are indistinguishable to a caller, deliberately.
211242

243+
On a SQLite deployment this entire section is unavailable and every route in it answers `501 not_implemented_on_sqlite` — the console's tables are Postgres-only. See [The two backends](#the-two-backends).
244+
212245
The first operator is created with `cmd/grant-operator`, from a machine with direct database access — deliberately not an HTTP bootstrap route, which would be needless attack surface reachable over the network:
213246

214247
```

config/config.go

Lines changed: 22 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,10 @@ import (
1414
)
1515

1616
type Config struct {
17+
// DatabaseURL and SQLitePath select the backend and are mutually
18+
// exclusive — Load refuses both or neither. See UsesSQLite.
1719
DatabaseURL string
20+
SQLitePath string
1821
JWTSecret string
1922
Port string
2023
CORSOrigins []string
@@ -294,6 +297,19 @@ const (
294297
CloudLogRedactionHash = "hash"
295298
)
296299

300+
// UsesSQLite reports which backend this deployment runs on.
301+
//
302+
// It is a method rather than a field so there is exactly one expression
303+
// of the rule, and every place that needs to branch — main.go's store
304+
// wiring, RequireAdmin's 501 — asks the same question rather than
305+
// re-deriving it from SQLitePath and drifting.
306+
//
307+
// A SQLite deployment serves core auth only. The admin console's tables
308+
// are this repo's own and Postgres-only by decision (see NEXT.md Tier
309+
// 6), and RequireAdmin itself depends on the operators table, so the
310+
// whole of /v1/admin is unavailable rather than parts of it.
311+
func (c Config) UsesSQLite() bool { return c.SQLitePath != "" }
312+
297313
// Load reads .env (if present, filling only gaps — real env vars
298314
// always win) then reads the actual environment. No external
299315
// dependency for .env parsing — same minimal-loader approach as csax.
@@ -302,6 +318,7 @@ func Load() (Config, error) {
302318

303319
cfg := Config{
304320
DatabaseURL: os.Getenv("DATABASE_URL"),
321+
SQLitePath: os.Getenv("SQLITE_PATH"),
305322
JWTSecret: os.Getenv("JWT_SECRET"),
306323
Port: os.Getenv("PORT"),
307324
}
@@ -328,8 +345,11 @@ func Load() (Config, error) {
328345
cfg.AccessTokenTTL = time.Duration(n) * time.Minute
329346
}
330347

331-
if cfg.DatabaseURL == "" {
332-
return cfg, fmt.Errorf("DATABASE_URL is required")
348+
if cfg.DatabaseURL == "" && cfg.SQLitePath == "" {
349+
return cfg, fmt.Errorf("one of DATABASE_URL or SQLITE_PATH is required — see README's note on the two backends")
350+
}
351+
if cfg.DatabaseURL != "" && cfg.SQLitePath != "" {
352+
return cfg, fmt.Errorf("DATABASE_URL and SQLITE_PATH are mutually exclusive — set one; the admin console's tables are Postgres-only, so a SQLite deployment serves core auth and nothing under /v1/admin")
333353
}
334354
if cfg.JWTSecret == "" {
335355
return cfg, fmt.Errorf("JWT_SECRET is required")

config/config_test.go

Lines changed: 86 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -52,6 +52,13 @@ var tieredEnvVars = []string{
5252
func loadForTest(t *testing.T, env map[string]string) (Config, error) {
5353
t.Helper()
5454
t.Setenv("DATABASE_URL", "postgres://user:pw@localhost/db")
55+
// Cleared as well as set, so this helper describes exactly one thing:
56+
// a Postgres deployment. Without it a SQLite_PATH set by an earlier
57+
// call in the same test would survive into the next one and turn it
58+
// into the mutually-exclusive case by accident. A caller wanting the
59+
// other backend passes DATABASE_URL:"" and a SQLITE_PATH, which the
60+
// env map below applies last.
61+
t.Setenv("SQLITE_PATH", "")
5562
t.Setenv("JWT_SECRET", "test-secret")
5663
t.Setenv("CORS_ORIGINS", "http://localhost:5173")
5764
for _, name := range tieredEnvVars {
@@ -404,6 +411,85 @@ func TestTier3WebhookMaxAttemptsIsBounded(t *testing.T) {
404411
}
405412
}
406413

414+
// Tier 6: the backend is chosen by exactly one variable, and Load refuses
415+
// every other combination at startup rather than letting a deployment come
416+
// up pointed at neither or at both.
417+
//
418+
// Both refusals matter for different reasons. Neither set is a deployment
419+
// that would otherwise reach a nil *sql.DB somewhere deep in startup. Both
420+
// set is the dangerous one: DATABASE_URL is what the admin console needs
421+
// and SQLITE_PATH is what the store wiring would read, so silently
422+
// preferring either would run a deployment on the wrong backend while its
423+
// configuration said otherwise.
424+
func TestTier6TheBackendIsSelectedByExactlyOneVariable(t *testing.T) {
425+
cfg, err := loadForTest(t, map[string]string{
426+
"DATABASE_URL": "",
427+
"SQLITE_PATH": "/var/lib/cryden/api.db",
428+
})
429+
if err != nil {
430+
t.Fatalf("SQLITE_PATH on its own was rejected: %v", err)
431+
}
432+
if !cfg.UsesSQLite() {
433+
t.Error("UsesSQLite() = false with SQLITE_PATH set")
434+
}
435+
if cfg.SQLitePath != "/var/lib/cryden/api.db" {
436+
t.Errorf("SQLitePath = %q, want the value that was set", cfg.SQLitePath)
437+
}
438+
439+
// loadForTest sets DATABASE_URL and no SQLITE_PATH: the Postgres
440+
// deployment every other test in this file already describes.
441+
cfg, err = loadForTest(t, nil)
442+
if err != nil {
443+
t.Fatalf("Load() failed with DATABASE_URL and no SQLITE_PATH: %v", err)
444+
}
445+
if cfg.UsesSQLite() {
446+
t.Error("UsesSQLite() = true on a deployment with no SQLITE_PATH")
447+
}
448+
449+
for name, tc := range map[string]struct {
450+
env map[string]string
451+
want string
452+
}{
453+
"neither backend": {
454+
map[string]string{"DATABASE_URL": ""},
455+
"one of DATABASE_URL or SQLITE_PATH is required",
456+
},
457+
"both backends": {
458+
map[string]string{"SQLITE_PATH": "/var/lib/cryden/api.db"},
459+
"mutually exclusive",
460+
},
461+
} {
462+
t.Run(name, func(t *testing.T) {
463+
_, err := loadForTest(t, tc.env)
464+
if err == nil {
465+
t.Fatalf("%v was accepted, want a startup failure", tc.env)
466+
}
467+
if !strings.Contains(err.Error(), tc.want) {
468+
t.Errorf("error = %q, want it to contain %q", err, tc.want)
469+
}
470+
})
471+
}
472+
}
473+
474+
// UsesSQLite is the single expression of the rule, so it is pinned
475+
// directly: an empty string is "unset", which is what the mutual-exclusion
476+
// check above treats it as, and a Config built by hand rather than by Load
477+
// has to answer the same way.
478+
func TestTier6UsesSQLiteIsTheEmptyCheck(t *testing.T) {
479+
if (Config{}).UsesSQLite() {
480+
t.Error("a zero Config reports SQLite")
481+
}
482+
if !(Config{SQLitePath: "api.db"}).UsesSQLite() {
483+
t.Error("a Config with SQLitePath set reports Postgres")
484+
}
485+
// A Postgres URL with no path set is the Postgres backend — the case
486+
// that would break if this ever became "SQLitePath == '' means
487+
// Postgres OR ..." rather than a plain emptiness check.
488+
if (Config{DatabaseURL: "postgres://localhost/db"}).UsesSQLite() {
489+
t.Error("a Config with only DatabaseURL set reports SQLite")
490+
}
491+
}
492+
407493
// The Tier 4 defaults: cryden's own lockout numbers restated, because the
408494
// engine takes them straight off its config with no defaulting of its own
409495
// and both zero values are wrong in the same direction — a zero threshold

docs/development/CURRENT-STATE.md

Lines changed: 102 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,14 @@ suggestion by itself. The widget's serving endpoint is not an admin
3232
endpoint at all, and is the one AI-assisted surface here that answers an
3333
end user rather than an operator.
3434

35+
Tier 6 made the database a choice: this API runs on Postgres or on
36+
SQLite, picked by exactly one of `DATABASE_URL` and `SQLITE_PATH`. A
37+
SQLite deployment serves core auth and nothing else — the whole admin
38+
console answers `501 not_implemented_on_sqlite`, because every table it
39+
reads is Postgres-only. See that section below; the one-sentence version
40+
is that a small deployment can now skip Postgres entirely, and skipping
41+
it costs the console.
42+
3543
Tier 1 also added the second-factor surface: TOTP enroll/confirm/
3644
disable, passkey registration/list/delete, magic-link request/complete,
3745
recovery-code generation, and the three public completion endpoints a
@@ -659,3 +667,97 @@ live path from a question to a real model is exercised only through the
659667
`Providers` seam with doubles; the wire shape is covered by
660668
`aiprovider`'s own tests against a local fake.
661669

670+
## Tier 6 — SQLite backend, core auth only
671+
672+
Built on `feat/tier6-sqlite-backend`. This repo runs on Postgres or on
673+
SQLite, chosen by exactly one variable. The scope decision was made in
674+
advance rather than here — `NEXT.md`'s Tier 6 section carries it — and
675+
this is the resulting state.
676+
677+
**The switch.** `DATABASE_URL` or `SQLITE_PATH`, mutually exclusive,
678+
with `config.Load` refusing both or neither as a startup error.
679+
`Config.UsesSQLite()` is the single expression of the rule; `main.go`'s
680+
`openStores` returns one `stores` struct built from cryden's
681+
`store/postgres` or `store/sqlite` constructors, and nothing downstream
682+
of it knows which ran. The ten engine stores (`Users`, `Sessions`,
683+
`Audit`, `Verifications`, `OAuth`, `TOTP`, `WebAuthn`, `RecoveryCodes`,
684+
`APIKeys`, `Anomalies`) exist in both cryden packages, so this is wiring
685+
rather than engine work — the one asymmetry is this repo's own three
686+
Postgres-only stores, which are nil on SQLite.
687+
688+
**The whole admin console answers `501 not_implemented_on_sqlite`**, and
689+
that is one decision in one place rather than a list of routes:
690+
`httpapi.AdminOnly` is `RequireAdmin` on Postgres and a flat 501 on
691+
SQLite, and all 25 admin registrations go through the value it returns.
692+
The reason it is a 501 and not the 403 the existing gate would have
693+
produced is the part worth keeping: `RequireAdmin` depends on the
694+
`operators` table, so a SQLite deployment has no operators, so *every*
695+
caller — including a legitimate operator — would have been told
696+
`403 not_operator`. That reads as "you personally lack access" when the
697+
truth is "this backend has no console". 501 is a statement about the
698+
deployment, which is what this is.
699+
700+
- The console's tables (`operators`, `user_metadata`,
701+
`webhook_deliveries`, `shipped_log_events`, `digest_runs`, `settings`,
702+
`reviewed_anomalies`) remain Postgres-only, by the scope decision.
703+
Nothing was built twice.
704+
- **`POST /v1/ask-ai` is not under `/v1/admin` and is still unavailable
705+
on SQLite** — the provider it reads lives in the `settings` table. It
706+
answers `404 not_configured`, the same shape it gives on a Postgres
707+
deployment with no provider stored, so a client never has to know
708+
which backend it is talking to. One rule: the AI-assisted surface
709+
needs `DATABASE_URL`.
710+
- **The `AccessTokenClaims` provider is skipped entirely on SQLite.**
711+
This is not an optimisation. `usermeta.ClaimsProvider` dereferences
712+
its metadata store on every login, and a nil `*usermeta.PostgresStore`
713+
passed through the interface is a non-nil interface holding a nil
714+
pointer — it would pass its own nil check and panic on the first
715+
query, on every login and every refresh. `claimsProvider` returns nil
716+
for the SQLite case, which is the correct answer rather than a
717+
workaround: cryden treats a nil provider as "this host attaches no
718+
extra claims", and on SQLite there is nothing to attach. The
719+
consequence is the same fact as the 501 seen from the other end — a
720+
SQLite deployment issues tokens no admin route would accept anyway.
721+
- **Nothing is inert silently.** `SETTINGS_ENCRYPTION_KEY`,
722+
`WEBHOOK_URL`, `CLOUD_LOGGING` and `DIGEST_INTERVAL_HOURS` are named
723+
in a startup warning when set on SQLite. `ENCRYPTION_KEY` gets its own
724+
positive log line precisely so it cannot be misread as part of that
725+
list: second factors are cryden's own tables and work on both
726+
backends.
727+
728+
**Migrations on SQLite are cryden's, not this repo's.** This is the
729+
discovery that shaped the tier: cryden ships `sqlite.Migrate`, which
730+
embeds its own `migrations/*.sql` and records what it applied in
731+
`cryden_schema_migrations`, so `main.go` calls that at startup and there
732+
is no migrate step for an operator on this backend. The copy under
733+
`migrations/sqlite/` — cryden's `0001``0007`, verbatim, cryden's
734+
filenames kept rather than renumbered into this repo's `001``014`
735+
Postgres sequence — is therefore **reference material, not what runs**.
736+
The mirror image of Postgres, where this repo's copies are exactly what
737+
an operator pipes through `psql`. `migrations/sqlite/README.md` says so
738+
at the point of use. Three DSN pragmas are load-bearing:
739+
`foreign_keys(1)`, `busy_timeout(5000)` and `journal_mode(WAL)`; the
740+
server checks the first two on every boot with cryden's own
741+
`CheckPragmas` and refuses to start if the DSN and the driver have
742+
drifted apart.
743+
744+
**What is still owed, said plainly.** The Postgres path is **not**
745+
newly verified: `openStores`'s Postgres arm is asserted to construct
746+
every store, but no Postgres was reachable in this environment, so
747+
migrations `001``014` still have never been applied to a real database
748+
and `anomalyreview.PostgresStore` still has never run — the same
749+
constraint Tiers 4 and 5 recorded. The SQLite path, by contrast, is
750+
verified end to end: cryden's own `store/sqlite` suite passes (run as
751+
the spec asked, as reference for the pragmas and type mappings), and
752+
this repo's server was started on a real SQLite file and passed the full
753+
`internal/smoketest` run — health, signup, duplicate rejection, login,
754+
wrong password, verify, session list, missing-header rejection, refresh
755+
rotation, reuse detection, family revocation, and both OAuth refusals.
756+
`-race` was still not run. Graceful shutdown is still unbuilt, and on
757+
SQLite it now has a second reason to exist: with no `Close()` there is
758+
no checkpoint on exit, so a fresh deployment's entire schema can sit in
759+
the `-wal` file — durable, but a backup that copies `api.db` alone can
760+
silently produce an empty database. `README.md` warns about that where
761+
an operator will see it. Per-user rate limiting on `POST /v1/ask-ai` is
762+
unchanged.
763+

0 commit comments

Comments
 (0)