The last couple months were quiet, then a lot landed in October. The theme was getting the demo app ready to actually run somewhere: real configuration, metrics, a deploy path, and a long list of hardening work.

Config and Secrets

Config is now a directory of merged TOML files. Secrets live in their own named store and are referenced from config as secret:path values, resolved at load time. An unresolved secret is a hard failure, so the app doesn't boot with a half-empty config. Key rotation is supported, and the envelope encryption package was updated to match.

boot.Load builds one Config from files, flags, and env, and that is the only input the app sees. Env var names are derived from flag names, so there's no separate list to keep aligned.

Metrics

Metrics got their own SQLite store, an atlas9 metrics CLI next to the existing traces one, and the dev panel moved into core/panel. Queue metrics come from the worker config, so adding a worker adds its metrics.

Deploy

There's now a public atlas9 deploy client (up, status, list). It builds an OCI image locally with go-containerregistry, then sends the image, the config directory, and the deploy intent to a control plane over HTTP. The client has no node or ssh code. A target can be an HTTP control plane or an ssh:// address. The demo has deploy configs for local (arm64) and prod (amd64).

The dev runner also grew up. atlas9 run -tmp starts a throwaway instance (temp data dir, free ports, deleted on exit), -json emits one event per line, and -control serves a Unix socket (status, rebuild, reseed, reset, stop). There's also an atlas9 reset command to delete the data directory.

Graceful Shutdown

The app handles SIGINT and SIGTERM, and every drain step shares one deadline (Server.ShutdownTimeout, 15s by default). Anything that doesn't finish is closed. Task workers stop claiming, finish the task in flight, and release claimed-but-unstarted tasks. Metrics write a final snapshot on the way out.

Hardening

  • Security headers: HSTS (with includeSubDomains and preload), nosniff, frame deny, a referrer policy, and an enforced CSP. To make script-src and style-src 'self' only, I moved all inline styles and scripts out of the dashboard and the marketing site. Violation reports post to /csp-report and are logged with whitelisted, truncated fields.
  • HTTP server: timeouts and a request body size cap.
  • Throttling: a per-IP throttle on anonymous account endpoints, with a configurable client IP header. Throttle counts are snapshotted to a separate SQLite file every 30s and on shutdown, so restarts and deploys no longer reset rate limits.
  • Migrations: the SHA-256 of each applied migration is recorded, and startup fails if an applied file was edited or went missing. That replaces a confusing no such column 500 later with an error that names the file.
  • Tokens: bearer tokens for all key types are now separator-free.
  • Passwords: the pool has a single SQLite connection, and comparing a bcrypt hash inside the login transaction stalled every other request. The hash comparison now runs outside the write transaction.

Tenants, Roles, and Cleanup

Every tenant now points at a root tenant (a root points at itself), and an entitlement on the root caps how many tenants can be created under it. Triggers enforce the cap. Every user gets a personal root at signup, and creating a tenant requires Tenants_Create scoped to the root, which owners hold.

Roles can no longer carry platform-only capabilities, and creating or updating a role requires the caller to hold every capability in it. That closes another privilege-escalation path.

Related cleanup:

  • Tasks tied to a tenant (invitation emails, domain verification) now cascade on tenant delete.
  • The unused invitation_tokens table is gone.
  • Users who are still unverified after 7 days are pruned by a daily task.
  • The in-app admin panel was removed, and the dev UI served by the runner stays.

Generated Clients

The API client generators now emit only per-endpoint methods and types. Sending, auth, timeouts, and errors live in a hand-written runtime per language (Go and JS). Both runtimes are held to shared, language-neutral conformance cases, and non-2xx responses are typed errors with status and message. Endpoints marked //atlas9:NoClientApi (the SSO routes, for now) are left out of the clients and CLI.

The dashboard also now shows server errors and failed loads. Before, a failed list load was an endless spinner or an empty list.


Check out the demo app code in apps/demo (git clone https://git.atlas9.dev/atlas9.git).