Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
17 changes: 17 additions & 0 deletions .dockerignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
# Everything the build stage does not need. The image copies the whole
# source tree (it has to — Go builds from source), so this is what keeps a
# developer's local state out of it.
.git
.github
*.md
docs
.env
.env.*
# A local SQLite database is the one thing that must never end up baked
# into an image: it is a deployment's live data, and a stale copy in a
# layer is both a leak and a confusing bug.
*.db
*.db-wal
*.db-shm
api
api_server
9 changes: 9 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,15 @@ PORT=8080
ACCESS_TOKEN_TTL_MINUTES=15
BASE_URL=http://localhost:8080

# Migrations run automatically on boot, so there is no migrate step in the
# setup instructions. Set this to true if you would rather schema changes
# be a reviewed step of their own — then `api migrate` is that step, and
# the server starts without touching the schema. See README's "Migrations".
# If your database already has this schema but no schema_migrations table
# (i.e. you applied the SQL by hand), run `api migrate --baseline` once
# before your first start with this setting either way.
SKIP_AUTO_MIGRATE=false

# OAuth providers are optional; one missing its ID or secret is simply
# unavailable (404 oauth_provider_not_configured), not a startup error.
GOOGLE_CLIENT_ID=
Expand Down
34 changes: 23 additions & 11 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -46,18 +46,20 @@ jobs:
exit 1
fi

- name: Install postgresql-client
run: sudo apt-get update && sudo apt-get install -y postgresql-client
- name: Run tests
run: go test ./...

- name: Run migrations
run: |
for f in migrations/*.up.sql; do
psql "$DATABASE_URL" -f "$f"
done
env:
DATABASE_URL: postgres://api:api_test@localhost:5432/api_test?sslmode=disable

- name: Start server
# The postgresql-client install and the psql loop that used to run
# here are both gone on purpose. The server migrates itself on boot
# now, so this step is the test: the database below is empty when
# the server starts, and everything after this point depends on the
# runner having applied all fourteen migrations correctly.
#
# That is a stronger check than the psql loop was. Piping files
# through psql proved the SQL was valid; it could not have caught a
# runner that applied them in the wrong order, twice, or not at all
# — which is exactly what this repo had never verified.
- name: Start server (which migrates the empty database on boot)
run: |
go build -o api_server .
./api_server &
Expand All @@ -70,3 +72,13 @@ jobs:

- name: Run smoke test against the live server
run: cd internal/smoketest && go run . http://localhost:8080

# `api migrate` on an already-migrated database is the no-op that
# every boot of every existing deployment will perform, so it is
# worth one step of its own rather than being implied by the above.
- name: Migrate is a no-op on an up-to-date database
run: ./api_server migrate
env:
DATABASE_URL: postgres://api:api_test@localhost:5432/api_test?sslmode=disable
JWT_SECRET: ci-test-secret-not-for-prod
CORS_ORIGINS: http://localhost:5173
57 changes: 46 additions & 11 deletions .github/workflows/release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,12 @@ on:

permissions:
contents: write
# packages: write is what lets the image below be pushed to ghcr.io with
# the workflow's own GITHUB_TOKEN. Without it the push is a 403, and
# with it no extra repository secret is needed to publish an image —
# which matters because a registry credential someone has to remember to
# rotate is one more thing that can quietly expire.
packages: write

jobs:
release:
Expand Down Expand Up @@ -43,17 +49,12 @@ jobs:
- name: Vet
run: go vet ./...

- name: Install postgresql-client
run: sudo apt-get update && sudo apt-get install -y postgresql-client

- name: Run migrations
run: |
for f in migrations/*.up.sql; do
psql "$DATABASE_URL" -f "$f"
done
env:
DATABASE_URL: postgres://api:api_test@localhost:5432/api_test?sslmode=disable

# The psql step that used to live here is gone on purpose. The
# server now migrates itself on boot, so the smoke test below is
# what proves the migration runner works — against a completely
# empty database, which is the only way a fresh deployment ever
# sees it. Keeping the psql step would have applied the schema by
# hand and left the runner on the happy path untested.
- name: Start server and run smoke test
run: |
go build -o api_server .
Expand All @@ -66,10 +67,44 @@ jobs:
CORS_ORIGINS: http://localhost:5173
PORT: 8080

# Five platforms, all CGO_ENABLED=0. windows/amd64 is built from
# ubuntu because nothing here needs a host toolchain — see the
# Dockerfile's note on why that is possible.
- name: Cross-compile release binaries
run: |
set -eu
mkdir -p dist
for target in \
linux/amd64 linux/arm64 \
darwin/amd64 darwin/arm64 \
windows/amd64
do
GOOS="${target%/*}"
GOARCH="${target#*/}"
name="api_${GOOS}_${GOARCH}"
[ "$GOOS" = windows ] && name="${name}.exe"
echo "building $name"
CGO_ENABLED=0 GOOS="$GOOS" GOARCH="$GOARCH" \
go build -trimpath -ldflags="-s -w" -o "dist/$name" .
done
cd dist && sha256sum * > SHA256SUMS

- name: Build and push the container image
run: |
set -eu
image="ghcr.io/${GITHUB_REPOSITORY,,}"
echo "${{ secrets.GITHUB_TOKEN }}" \
| docker login ghcr.io -u "${{ github.actor }}" --password-stdin
docker build -t "$image:${GITHUB_REF_NAME}" -t "$image:latest" .
docker push "$image:${GITHUB_REF_NAME}"
docker push "$image:latest"

- name: Create Release
uses: softprops/action-gh-release@v2
with:
generate_release_notes: true
name: Release ${{ github.ref_name }}
files: |
dist/*
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
55 changes: 55 additions & 0 deletions Dockerfile
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
# The PostgreSQL migration runner and the SQLite driver this repo uses are
# both pure Go, so the whole thing cross-compiles with CGO_ENABLED=0. That
# is what makes the final image small and what makes the release binaries
# below buildable for five platforms from one runner.
FROM golang:1.25-alpine AS build

WORKDIR /src

# go.mod/go.sum first, so a source-only change does not re-download the
# module graph. This is the whole reason the layer exists.
COPY go.mod go.sum ./
RUN go mod download

COPY . .

# -trimpath keeps the build directory out of the binary; -s -w drop the
# symbol table and DWARF data. Together they are most of the size, and
# nothing here debugs a stripped binary by hand.
RUN CGO_ENABLED=0 go build -trimpath -ldflags="-s -w" -o /api .

# alpine rather than scratch or distroless, for one concrete reason: this
# image has to be able to write a SQLite database to a mounted volume, and
# a distroless nonroot image cannot be handed a writable directory without
# a COPY --chown trick that is harder to read than it is worth. alpine
# also brings ca-certificates, which the OAuth providers, the webhook
# sender and the Anthropic API all need.
FROM alpine:3.20

# The CA bundle, and nothing else. No shell, no package manager beyond
# what alpine ships — anything that gets added here is attack surface in a
# container that only ever runs one binary.
RUN apk add --no-cache ca-certificates && \
adduser -D -u 10001 -h /home/api api

COPY --from=build /api /usr/local/bin/api

# A SQLite deployment needs somewhere to put the database that is not the
# container's writable layer, or every `docker compose up --build` is a
# new database. /data is that place; a Postgres deployment ignores it.
RUN mkdir -p /data && chown api:api /data
VOLUME ["/data"]

USER api

# 8080 is config.Load's default for PORT, so an image run with no PORT set
# listens where this line says it does.
EXPOSE 8080

# Exec form, deliberately, and the reason is SIGTERM. Shell form
# (`CMD /usr/local/bin/api`) runs the binary as a child of /bin/sh, which
# means `docker stop` signals the shell and the binary — and its
# in-flight requests — never see it. That would undo the whole point of
# main.go's graceful shutdown, so this line is load-bearing rather than
# stylistic.
ENTRYPOINT ["/usr/local/bin/api"]
37 changes: 35 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -18,9 +18,20 @@ cp .env.example .env # fill in DATABASE_URL, JWT_SECRET, CORS_ORIGINS
go run .
```

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.
That is the whole setup. Migrations are applied automatically on boot — see [Migrations](#migrations) — so there is no separate step to run and nothing to remember after a `git pull`. Or with Docker, which needs no Go toolchain at all:

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).
```bash
docker run --env-file .env -p 8080:8080 ghcr.io/crydensync/api:latest
```

A SQLite deployment can skip the database server entirely by setting `SQLITE_PATH` and mounting a volume for it:

```bash
docker run --env-file .env -p 8080:8080 -v api-data:/data \
-e SQLITE_PATH=/data/api.db ghcr.io/crydensync/api:latest
```

On SQLite, note that the container runs as a non-root user (uid 10001); a host directory mounted into `/data` must be writable by it.

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):

Expand Down Expand Up @@ -90,6 +101,28 @@ The connection is opened with three pragmas, all of them load-bearing: `foreign_

A clean stop is what closes that window: on `SIGTERM` or `SIGINT` the server stops accepting connections, waits up to 30 seconds for the requests already in flight, stops the webhook worker and digest scheduler, and closes the database — which on SQLite is the checkpoint that folds the `-wal` file back into `api.db` and removes the sidecar files. So `systemctl stop`, `docker stop` and Ctrl-C all leave a `api.db` that is complete on its own. A `kill -9`, a crash or a power loss does not, which is why the paragraph above still stands.

## Migrations

There is no migrate step in the setup instructions because there does not need to be one. Migrations are compiled into the binary and applied on boot, so a `git pull` or a new container image brings its own schema change with it.

The two backends are migrated by different code, and that is deliberate rather than an inconsistency:

- **Postgres** — this repo's own runner (`migrate.go`) over this repo's own `migrations/*.sql`, embedding all fourteen into the binary. It records what it applied in a `schema_migrations` table it creates itself, so a second boot is a no-op.
- **SQLite** — cryden's runner over cryden's embedded migrations, because cryden owns that schema. See [The two backends](#the-two-backends).

Both follow the same rules: `NNN_*.up.sql` in filename order, one transaction per file, and `.down.sql` files are never run automatically — an automatic rollback of a schema holding live credentials is not something a boot path should be able to do by accident.

```
api migrate # apply pending migrations and exit; starts no server
api migrate --baseline # record every embedded migration as already applied
```

`api migrate` is for teams who would rather schema changes be a reviewed step than something that happens during a rolling deploy. Set `SKIP_AUTO_MIGRATE=true` and the server starts without touching the schema; `api migrate` is then the step, in your pipeline, before the new version goes out.

**If your database already has this schema but no `schema_migrations` table** — you applied the SQL by hand, or an earlier version of this repo had CI do it for you via `psql` — then run `api migrate --baseline` once before your first start. Without it, auto-migration starts at `001` and stops on `relation "users" already exists`: a deployment that cannot start, caused by the feature meant to make starting easier. Baseline records every embedded migration as applied and runs none of them.

It is an explicit command rather than something the boot path detects, because the alternative is guessing. "The tracking table is missing but `users` exists, so assume everything ran" is right for the case above and silently wrong for a database that is genuinely half-migrated — it would mark unrun migrations as applied, and the next deploy would look for columns that were never created.

## Second factors

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.
Expand Down
16 changes: 15 additions & 1 deletion config/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ type Config struct {
// exclusive — Load refuses both or neither. See UsesSQLite.
DatabaseURL string
SQLitePath string
SkipAutoMigrate bool
JWTSecret string
Port string
CORSOrigins []string
Expand Down Expand Up @@ -351,6 +352,7 @@ func Load() (Config, error) {
if cfg.DatabaseURL != "" && cfg.SQLitePath != "" {
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")
}

if cfg.JWTSecret == "" {
return cfg, fmt.Errorf("JWT_SECRET is required")
}
Expand Down Expand Up @@ -415,12 +417,24 @@ func Load() (Config, error) {
// separate secrets with separate lifetimes.
cfg.SettingsEncryptionKey = os.Getenv("SETTINGS_ENCRYPTION_KEY")

// Declared here rather than just above its first use because the
// auto-migration switch below needs it too, and that one belongs next
// to the rest of what a deployment says about its database.
var err error

// Auto-migration. Defaults to false, so a deployment migrates on boot
// unless it says otherwise; the opt-out is for teams who want schema
// changes to be a reviewed, separate step rather than something that
// happens silently during a rolling deploy. `api migrate` is the step.
if cfg.SkipAutoMigrate, err = envBool("SKIP_AUTO_MIGRATE", false); err != nil {
return cfg, err
}

// Anomaly detection and credential-stuffing detection — one switch,
// because they are one store. Both threshold sets begin as the
// engine's defaults and every knob below only replaces the one it
// names (see the field comments for why that ordering is not
// cosmetic).
var err error
if cfg.AnomalyDetection, err = envBool("ANOMALY_DETECTION", false); err != nil {
return cfg, err
}
Expand Down
Loading
Loading