chore: docs updates e911147a
Steve Simkins · 2026-05-20 20:31 3 file(s) · +592 −531
docs/docs/pages/diy/skills.mdx +6 −0
8 8
9 9
The canonical CSS and fonts live in the [`crates-go/darkmatter`](https://github.com/stevedylandev/andromeda/tree/main/crates-go/darkmatter) module. Mount its handler set and link `/assets/darkmatter.css` from your templates — the skill guides style decisions while the module serves the actual bytes.
10 10
11 +
## andromeda-stack
12 +
13 +
[`andromeda-stack`](https://github.com/stevedylandev/andromeda/tree/main/skills/andromeda-stack/SKILL.md) scaffolds a new Go CRUD app in the monorepo using the standard project layout: `net/http` mux, `html/template`, `modernc.org/sqlite`, and the `crates-go/{auth,web,config,sqlite,darkmatter}` packages wired in via `replace` directives.
14 +
15 +
It covers the full app shape — `main.go` / `app.go` / `routes.go` / `db.go` / `handlers_*.go` / `templates/` / `static/` — plus session vs. API-key vs. bearer auth, the multi-stage `CGO_ENABLED=0` Dockerfile, and the workspace files that need updating (root `docker-compose.yml`, both `.github/workflows/docker*.yml` lists) so a new app actually ships.
16 +
11 17
## Installing
12 18
13 19
Use [`npx skills add`](https://github.com/vercel-labs/skills) to pull the skill directly from the repo:
docs/docs/pages/diy/stack.mdx +7 −2
45 45
Provides session-based password authentication used across apps that require login. It handles:
46 46
47 47
- Constant-time password verification (bcrypt + plain)
48 -
- Session cookie management with an in-memory store
48 +
- Session cookie management backed by a `sessions` table in the app's SQLite DB
49 +
- `RequireSession`, `RequireAPIKey`, and `RequireBearerOrSession` middleware
49 50
- Short-id and session-token generators
51 +
52 +
### crates-go/sqlite
53 +
54 +
Thin bootstrap around `database/sql` + `modernc.org/sqlite`. `Open(path, schema)` opens the DB with the project defaults (`PRAGMA foreign_keys=ON`, single open connection) and applies an optional schema string on first open.
50 55
51 56
### crates-go/web
52 57
73 78
import "github.com/stevedylandev/andromeda/crates-go/darkmatter"
74 79
75 80
mux := http.NewServeMux()
76 -
darkmatter.Mount(mux)
81 +
darkmatter.Mount(mux, "/assets")
77 82
```
78 83
79 84
Then reference `/assets/darkmatter.css` from your templates instead of duplicating the styles per app.
skills/andromeda-stack/SKILL.md +579 −529
1 1
---
2 2
name: Andromeda Stack
3 -
description: Scaffold a Rust CRUD web application with Axum, SQLite, Askama templates, andromeda-auth session auth, embedded static assets, and Docker deployment. Use when the user wants to build a new Rust web server with CRUD operations in the andromeda monorepo.
3 +
description: Scaffold a Go CRUD web app using net/http + html/template + modernc.org/sqlite + the shared andromeda crates-go packages (auth, web, config, sqlite, darkmatter). Use when the user wants to build a new Go web server with CRUD operations in the andromeda monorepo.
4 4
---
5 5
6 -
# Rust Andromeda Web App
6 +
# Go Andromeda Web App
7 7
8 8
## Overview
9 9
10 -
Scaffold and build Rust CRUD web applications in the andromeda workspace using Axum + SQLite + Askama templates + `andromeda-auth` for authentication. The result is a single binary web server with HTML pages, a JSON API, optional session or API key auth, and Docker deployment.
10 +
Scaffold and build Go CRUD web apps in the andromeda workspace using the
11 +
standard library `net/http` mux, `html/template`, `modernc.org/sqlite` (pure
12 +
Go, no cgo) and the shared `crates-go/*` packages. Each app ships a single Go
13 +
binary with HTML pages, a JSON API, optional session or API key auth, embedded
14 +
templates + static assets via `embed.FS`, and Docker deployment.
15 +
16 +
Apps live under `apps/<name>/`. Each app is its own Go module. Shared packages
17 +
live under `crates-go/` and are each their own module, wired in via local
18 +
`replace` directives.
11 19
12 -
All apps live under `apps/` in the workspace and share dependencies via the root `Cargo.toml`. Shared crates live under `crates/`:
20 +
Shared crates:
13 21
14 -
- `andromeda-auth` — password verify, session token, cookie helpers
15 -
- `andromeda-db` — DB connection helpers, optional `session` feature
16 -
- `andromeda-darkmatter-css` — shared CSS theme
22 +
- `crates-go/auth` — session `Store`, `RequireSession` / `RequireAPIKey` /
23 +
  `RequireBearerOrSession` middleware, `SecureEqual`, `VerifyPassword`
24 +
  (bcrypt or plaintext), `GenerateSessionToken`, `GenerateShortID`.
25 +
- `crates-go/web` — `Render`, `WriteJSON`, `WriteError`, `DecodeJSON`,
26 +
  `EmbeddedHandler`, `RedirectWithError`, `RedirectWithSuccess`, `PathInt64`.
27 +
- `crates-go/config` — `LoadDotEnv`, `Getenv`, `GetenvInt`, `GetenvBool`.
28 +
- `crates-go/sqlite` — `Open(path, schema)` opens SQLite with the project
29 +
  defaults (`PRAGMA foreign_keys=ON`, `SetMaxOpenConns(1)`).
30 +
- `crates-go/darkmatter` — embedded shared CSS + fonts plus `Mount(mux,
31 +
  "/assets")` to register routes on any `*http.ServeMux`.
17 32
18 33
## Project Structure
19 34
20 35
```
21 36
apps/app-name/
22 -
├── src/
23 -
│   ├── main.rs         # Entry point, starts the server
24 -
│   ├── server.rs       # Axum routes, middleware, handlers, static asset serving
25 -
│   ├── db.rs           # SQLite schema, CRUD functions, error types
26 -
│   └── auth.rs         # Session/cookie auth wrapper (uses andromeda-auth crate)
27 -
├── templates/          # Askama HTML templates
28 -
│   ├── base.html       # Base layout with blocks (title, content)
29 -
│   └── *.html          # Pages extend base.html
30 -
├── static/             # CSS, fonts, favicons (served via tower-http ServeDir)
31 -
│   └── styles.css
32 -
├── .env.example        # Environment variable reference
33 -
├── Dockerfile          # Multi-stage workspace build
34 -
└── docker-compose.yml  # Compose config with volume for DB persistence
37 +
├── main.go              # entry point; loads env, opens DB, builds App, starts server
38 +
├── app.go               # App struct + embed.FS + page-data structs
39 +
├── routes.go            # *http.ServeMux wiring + middleware
40 +
├── db.go                # schema, model, CRUD funcs (or thin wrapper around internal/store)
41 +
├── handlers_web.go      # HTML handlers (login, index, CRUD pages)
42 +
├── handlers_api.go      # JSON API handlers
43 +
├── templates/           # html/template files
44 +
│   ├── base.html        # layout with {{ block "title" . }} / {{ block "content" . }}
45 +
│   └── *.html           # pages that {{ template "base" . }} or define blocks
46 +
├── static/              # CSS, JS, images embedded via embed.FS
47 +
├── .env.example
48 +
├── Dockerfile           # multi-stage Go build, built from repo root
49 +
├── docker-compose.yml   # app-local dev compose
50 +
├── go.mod / go.sum
51 +
└── README.md
35 52
```
36 53
37 -
## Dependencies (Cargo.toml)
54 +
Apps with extra surface (e.g. `jotts` has `tui/` + `cmd_*.go` subcommands,
55 +
`feeds` has `subscriptions.go` + background poller) layer files on top of this
56 +
shape; do not invent new layers unless the app needs them.
57 +
58 +
## go.mod
59 +
60 +
Module path: `github.com/stevedylandev/andromeda/apps/<app-name>`. Pin
61 +
`go 1.25` (match other apps). Pull the shared crates by their canonical paths
62 +
and add `replace` directives for the local checkout:
63 +
64 +
```go
65 +
module github.com/stevedylandev/andromeda/apps/APP_NAME
38 66
39 -
Apps use workspace dependencies from the root `Cargo.toml`. Use `{ workspace = true }` for shared crates:
67 +
go 1.25
40 68
41 -
```toml
42 -
[package]
43 -
name = "app-name"
44 -
version = "0.1.0"
45 -
edition = "2024"
46 -
description = "Short app description"
47 -
license = "MIT"
48 -
repository = "https://github.com/stevedylandev/andromeda"
49 -
homepage = "https://github.com/stevedylandev/andromeda"
69 +
require (
70 +
	github.com/stevedylandev/andromeda/crates-go/auth v0.0.0
71 +
	github.com/stevedylandev/andromeda/crates-go/config v0.0.0
72 +
	github.com/stevedylandev/andromeda/crates-go/darkmatter v0.0.0
73 +
	github.com/stevedylandev/andromeda/crates-go/sqlite v0.0.0
74 +
	github.com/stevedylandev/andromeda/crates-go/web v0.0.0
75 +
)
50 76
51 -
[dependencies]
52 -
axum = { workspace = true }
53 -
tokio = { workspace = true }
54 -
serde = { workspace = true }
55 -
serde_json = { workspace = true }
56 -
rusqlite = { workspace = true }
57 -
nanoid = { workspace = true }
58 -
rust-embed = { workspace = true }
59 -
dotenvy = { workspace = true }
60 -
subtle = { workspace = true }
61 -
rand = { workspace = true }
62 -
tracing = { workspace = true }
63 -
tracing-subscriber = { workspace = true }
64 -
andromeda-auth = { workspace = true }
65 -
andromeda-db = { workspace = true, features = ["session"] }  # if using session auth
66 -
andromeda-darkmatter-css = { workspace = true }              # if using shared theme
67 -
askama = "0.15"
68 -
askama_web = { version = "0.15", features = ["axum-0.8"] }
77 +
replace (
78 +
	github.com/stevedylandev/andromeda/crates-go/auth       => ../../crates-go/auth
79 +
	github.com/stevedylandev/andromeda/crates-go/config     => ../../crates-go/config
80 +
	github.com/stevedylandev/andromeda/crates-go/darkmatter => ../../crates-go/darkmatter
81 +
	github.com/stevedylandev/andromeda/crates-go/sqlite     => ../../crates-go/sqlite
82 +
	github.com/stevedylandev/andromeda/crates-go/web        => ../../crates-go/web
83 +
)
69 84
```
70 85
71 -
After creating the app, register it in the workspace root `Cargo.toml` under `[workspace] members`. See **Wiring up a new app** below for the full set of workspace files to update.
86 +
Add `crates-go/tui` only if the app ships a TUI. Add `golang.org/x/crypto`,
87 +
`yuin/goldmark`, etc. only when the specific app needs them. The Dockerfile
88 +
must copy `crates-go/` into the build context for the replace directives.
72 89
73 -
Only add additional crates when the specific app requires them. Do NOT include TUI crates (ratatui, crossterm), CLI crates (clap), or HTTP client crates (reqwest) unless explicitly requested.
90 +
Do NOT add ORMs, web frameworks (gin, echo, chi), connection pools, or HTTP
91 +
client libs unless explicitly requested. The stdlib `net/http` mux (with
92 +
method-prefixed patterns like `GET /notes/{short_id}`) is sufficient.
74 93
75 -
- `tracing` + `tracing-subscriber` — structured logging, always include
76 -
- `rand` — needed for session token generation when using session-based auth
77 -
- `tower-http` — for serving static files from disk via `ServeDir`
94 +
## main.go
95 +
96 +
Minimal — loads env, sets up logging + DB + sessions, starts the server.
97 +
98 +
```go
99 +
package main
78 100
79 -
## Database Layer (db.rs)
101 +
import (
102 +
	"html/template"
103 +
	"log"
104 +
	"log/slog"
105 +
	"net/http"
106 +
	"os"
80 107
81 -
Pattern: single-file module with `Arc<Mutex<Connection>>` for thread-safe SQLite access.
108 +
	"github.com/stevedylandev/andromeda/crates-go/auth"
109 +
	"github.com/stevedylandev/andromeda/crates-go/config"
110 +
)
82 111
83 -
### Structure
112 +
func main() {
113 +
	config.LoadDotEnv(".env")
114 +
	logger := slog.New(slog.NewTextHandler(os.Stdout, &slog.HandlerOptions{Level: slog.LevelInfo}))
84 115
85 -
```rust
86 -
use nanoid::nanoid;
87 -
use rusqlite::{Connection, params};
88 -
use serde::{Deserialize, Serialize};
89 -
use std::fmt;
90 -
use std::sync::{Arc, Mutex};
116 +
	dbPath := config.Getenv("APP_DB_PATH", "app.sqlite")
117 +
	db, err := openDB(dbPath)
118 +
	if err != nil {
119 +
		log.Fatal(err)
120 +
	}
121 +
	defer db.Close()
91 122
92 -
pub type Db = Arc<Mutex<Connection>>;
123 +
	sessions := &auth.Store{
124 +
		DB:           db,
125 +
		CookieName:   "session",
126 +
		CookieSecure: config.GetenvBool("COOKIE_SECURE", false),
127 +
	}
128 +
	if err := sessions.EnsureSchema(); err != nil {
129 +
		log.Fatal(err)
130 +
	}
131 +
	sessions.PruneExpired()
93 132
94 -
#[derive(Debug)]
95 -
pub enum DbError {
96 -
    Sqlite(rusqlite::Error),
97 -
    LockPoisoned,
98 -
}
133 +
	tmpl := template.Must(template.ParseFS(appFS, "templates/*.html"))
99 134
100 -
impl fmt::Display for DbError {
101 -
    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
102 -
        match self {
103 -
            DbError::Sqlite(e) => write!(f, "Database error: {}", e),
104 -
            DbError::LockPoisoned => write!(f, "Database lock poisoned"),
105 -
        }
106 -
    }
107 -
}
135 +
	password := os.Getenv("APP_PASSWORD")
136 +
	if password == "" {
137 +
		logger.Warn("APP_PASSWORD not set, using default 'changeme'")
138 +
		password = "changeme"
139 +
	}
140 +
	apiKey := os.Getenv("APP_API_KEY")
141 +
	if apiKey == "" {
142 +
		logger.Info("APP_API_KEY not set, /api/* will return 403")
143 +
	}
108 144
109 -
impl std::error::Error for DbError {}
145 +
	app := &App{
146 +
		DB:           db,
147 +
		Log:          logger,
148 +
		Templates:    tmpl,
149 +
		Sessions:     sessions,
150 +
		Password:     password,
151 +
		APIKey:       apiKey,
152 +
		CookieSecure: sessions.CookieSecure,
153 +
	}
110 154
111 -
impl From<rusqlite::Error> for DbError {
112 -
    fn from(e: rusqlite::Error) -> Self {
113 -
        DbError::Sqlite(e)
114 -
    }
155 +
	addr := config.Getenv("HOST", "127.0.0.1") + ":" + config.Getenv("PORT", "3000")
156 +
	logger.Info("APP_NAME server running", "addr", addr)
157 +
	if err := http.ListenAndServe(addr, app.routes()); err != nil {
158 +
		log.Fatal(err)
159 +
	}
115 160
}
116 161
```
117 162
118 -
### Key patterns
163 +
Apps that also expose CLI subcommands (e.g. `jotts`, `sipp`) keep `main.go`
164 +
as a dispatcher and move `runServer` into `cmd_server.go`.
119 165
120 -
- **Model struct**: derive `Serialize, Deserialize`, all fields `pub`
121 -
- **ID generation**: `nanoid!(10)` for short unique IDs
122 -
- **DB path from env**: `std::env::var("APP_DB_PATH").unwrap_or_else(|_| "app.sqlite".to_string())`
123 -
- **init_db()**: opens connection, runs `CREATE TABLE IF NOT EXISTS`, returns `Arc<Mutex<Connection>>`
124 -
- **CRUD functions**: standalone functions that take `&Db` as first param
125 -
  - `create_*` — INSERT, return created model with `last_insert_rowid()`
126 -
  - `get_*_by_short_id` — SELECT, return `Result<Option<Model>, DbError>`
127 -
  - `get_all_*` — SELECT with ORDER BY id DESC
128 -
  - `delete_*_by_short_id` — DELETE, return `Result<bool, DbError>` (rows_affected > 0)
129 -
  - `update_*_by_short_id` — UPDATE then SELECT to return updated model
130 -
- **Error handling**: `QueryReturnedNoRows` maps to `Ok(None)`, not an error
166 +
## app.go — App struct + embed
131 167
132 -
## Server Layer (server.rs)
168 +
Holds dependencies, embedded FS, and per-page data structs.
133 169
134 -
### Embedded assets with rust_embed
170 +
```go
171 +
package main
135 172
136 -
```rust
137 -
use rust_embed::Embed;
173 +
import (
174 +
	"database/sql"
175 +
	"embed"
176 +
	"html/template"
177 +
	"log/slog"
138 178
139 -
#[derive(Embed)]
140 -
#[folder = "assets/"]
141 -
struct Assets;
179 +
	"github.com/stevedylandev/andromeda/crates-go/auth"
180 +
)
142 181
143 -
#[derive(Embed)]
144 -
#[folder = "static/"]
145 -
struct Static;
146 -
```
182 +
//go:embed templates/*.html static/*
183 +
var appFS embed.FS
147 184
148 -
Serve with handlers that match on file path and return correct MIME types:
149 -
150 -
```rust
151 -
fn mime_from_path(path: &str) -> &'static str {
152 -
    match path.rsplit('.').next().unwrap_or("") {
153 -
        "css" => "text/css",
154 -
        "js" => "application/javascript",
155 -
        "html" => "text/html",
156 -
        "png" => "image/png",
157 -
        "ico" => "image/x-icon",
158 -
        "svg" => "image/svg+xml",
159 -
        "woff" | "woff2" => "font/woff2",
160 -
        "ttf" => "font/ttf",
161 -
        "otf" => "font/otf",
162 -
        "json" | "webmanifest" => "application/json",
163 -
        _ => "application/octet-stream",
164 -
    }
185 +
type App struct {
186 +
	DB           *sql.DB
187 +
	Log          *slog.Logger
188 +
	Templates    *template.Template
189 +
	Sessions     *auth.Store
190 +
	Password     string
191 +
	APIKey       string
192 +
	CookieSecure bool
165 193
}
166 -
```
167 194
168 -
### App state
195 +
type indexPageData struct {
196 +
	Items []Item
197 +
}
169 198
170 -
```rust
171 -
#[derive(Clone)]
172 -
struct AppState {
173 -
    db: Db,
174 -
    server_config: ServerConfig,
199 +
type loginPageData struct {
200 +
	Error string
175 201
}
176 202
```
177 203
178 -
Add domain-specific fields as needed (e.g., a highlighter, cache, etc).
204 +
Page-data structs go here too. Keep names like `<page>PageData`.
179 205
180 -
### Askama templates
206 +
## routes.go
181 207
182 -
```rust
183 -
use askama::Template;
184 -
use askama_web::WebTemplate;
208 +
Wire routes on a `*http.ServeMux` using method-prefixed patterns. Mount
209 +
darkmatter assets and static files. Wrap protected routes with
210 +
`Sessions.RequireSession` / `auth.RequireAPIKey`.
185 211
186 -
#[derive(Template)]
187 -
#[template(path = "index.html")]
188 -
struct IndexTemplate;
212 +
```go
213 +
package main
189 214
190 -
// Templates with data:
191 -
#[derive(Template)]
192 -
#[template(path = "item.html")]
193 -
struct ItemTemplate {
194 -
    name: String,
195 -
    content: String,
196 -
}
197 -
```
215 +
import (
216 +
	"net/http"
198 217
199 -
Render with `WebTemplate(MyTemplate { ... })`.
218 +
	"github.com/stevedylandev/andromeda/crates-go/auth"
219 +
	"github.com/stevedylandev/andromeda/crates-go/darkmatter"
220 +
	"github.com/stevedylandev/andromeda/crates-go/web"
221 +
)
200 222
201 -
### Route structure
223 +
func (a *App) routes() *http.ServeMux {
224 +
	mux := http.NewServeMux()
202 225
203 -
Two sets of routes: **web routes** (HTML pages + form submissions) and **API routes** (JSON).
226 +
	mux.HandleFunc("GET /static/", web.EmbeddedHandler(appFS, "static"))
227 +
	darkmatter.Mount(mux, "/assets")
204 228
205 -
**Web routes:**
206 -
- `GET /` — index page (template)
207 -
- `GET /admin` — admin panel (template)
208 -
- `POST /items` — form submission, redirects on success
209 -
- `GET /items/{short_id}` — view single item (template)
229 +
	requireSession := func(next http.HandlerFunc) http.HandlerFunc {
230 +
		return a.Sessions.RequireSession("/login", next)
231 +
	}
232 +
	requireAPIKey := func(next http.HandlerFunc) http.HandlerFunc {
233 +
		return auth.RequireAPIKey(a.APIKey, next)
234 +
	}
210 235
211 -
**API routes:**
212 -
- `GET /api/items` — list all (JSON)
213 -
- `POST /api/items` — create (JSON body → 201 + JSON)
214 -
- `GET /api/items/{short_id}` — get one (JSON)
215 -
- `PUT /api/items/{short_id}` — update (JSON body → JSON)
216 -
- `DELETE /api/items/{short_id}` — delete (JSON)
236 +
	mux.HandleFunc("GET /login", a.loginGetHandler)
237 +
	mux.HandleFunc("POST /login", a.loginPostHandler)
238 +
	mux.HandleFunc("GET /logout", a.logoutHandler)
217 239
218 -
**Static asset routes:**
219 -
- `GET /assets/{*path}` — embedded assets (favicons, fonts, images)
220 -
- `GET /static/{*path}` — embedded static files (CSS)
240 +
	mux.HandleFunc("GET /{$}", requireSession(a.indexHandler))
241 +
	mux.HandleFunc("GET /items/new", requireSession(a.newItemGetHandler))
242 +
	mux.HandleFunc("POST /items", requireSession(a.createItemHandler))
243 +
	mux.HandleFunc("GET /items/{short_id}", requireSession(a.viewItemHandler))
244 +
	mux.HandleFunc("GET /items/{short_id}/edit", requireSession(a.editItemGetHandler))
245 +
	mux.HandleFunc("POST /items/{short_id}", requireSession(a.updateItemHandler))
246 +
	mux.HandleFunc("POST /items/{short_id}/delete", requireSession(a.deleteItemHandler))
221 247
222 -
### Form deserialization
248 +
	mux.HandleFunc("GET /api/items", requireAPIKey(a.apiListItems))
249 +
	mux.HandleFunc("POST /api/items", requireAPIKey(a.apiCreateItem))
250 +
	mux.HandleFunc("GET /api/items/{short_id}", requireAPIKey(a.apiGetItem))
251 +
	mux.HandleFunc("PUT /api/items/{short_id}", requireAPIKey(a.apiUpdateItem))
252 +
	mux.HandleFunc("DELETE /api/items/{short_id}", requireAPIKey(a.apiDeleteItem))
223 253
224 -
```rust
225 -
#[derive(Deserialize)]
226 -
struct CreateItemForm {
227 -
    name: String,
228 -
    content: String,
254 +
	return mux
229 255
}
230 256
```
231 257
232 -
Use `Form(form): Form<CreateItemForm>` for HTML forms, `Json(body): Json<CreateItem>` for API.
258 +
Notes:
259 +
- `GET /{$}` matches exactly `/` (no prefix matching).
260 +
- Path params extracted with `r.PathValue("short_id")`.
261 +
- For HTML forms (no JS), delete via `POST /items/{short_id}/delete`. The
262 +
  `/api/*` routes use real `DELETE`/`PUT`.
233 263
234 -
### Error responses
264 +
## db.go (or internal/store)
235 265
236 -
- Web handlers return `Result<..., (StatusCode, Html<String>)>`
237 -
- API handlers return `Result<..., (StatusCode, Json<serde_json::Value>)>`
238 -
- Use `serde_json::json!({"error": "message"})` for API error bodies
266 +
Single-file module: schema, model, CRUD. `crates-go/sqlite.Open` handles the
267 +
driver, pragmas, and schema bootstrap.
239 268
240 -
## Authentication
269 +
```go
270 +
package main
241 271
242 -
The workspace provides a shared `andromeda-auth` crate at `crates/auth/` with these primitives:
272 +
import (
273 +
	"database/sql"
274 +
	"errors"
243 275
244 -
```rust
245 -
// andromeda-auth public API
246 -
pub fn verify_password(input: &str, expected: &str) -> bool;
247 -
pub fn generate_session_token() -> String;
248 -
pub fn build_session_cookie(token: &str, secure: bool) -> String;
249 -
pub fn clear_session_cookie() -> String;
250 -
pub fn extract_session_cookie(headers: &axum::http::HeaderMap) -> Option<String>;
251 -
```
252 -
253 -
Apps import `andromeda-auth` via workspace dependency and wrap it in a local `auth.rs` module.
276 +
	"github.com/stevedylandev/andromeda/crates-go/auth"
277 +
	"github.com/stevedylandev/andromeda/crates-go/sqlite"
278 +
)
254 279
255 -
### Session/cookie auth (for web-facing apps)
280 +
type Item struct {
281 +
	ID        int64  `json:"id"`
282 +
	ShortID   string `json:"short_id"`
283 +
	Title     string `json:"title"`
284 +
	Content   string `json:"content"`
285 +
	CreatedAt string `json:"created_at"`
286 +
	UpdatedAt string `json:"updated_at"`
287 +
}
256 288
257 -
The standard pattern for apps that need login (e.g., jotts, parcels, feeds). Create a `src/auth.rs` that wraps the auth crate with app-specific session storage and an Axum extractor.
289 +
type ItemInput struct {
290 +
	Title   string `json:"title"`
291 +
	Content string `json:"content"`
292 +
}
258 293
259 -
**Sessions table in db.rs:**
294 +
const itemColumns = `id, short_id, title, content, created_at, updated_at`
260 295
261 -
```sql
262 -
CREATE TABLE IF NOT EXISTS sessions (
296 +
const schema = `
297 +
CREATE TABLE IF NOT EXISTS items (
263 298
    id         INTEGER PRIMARY KEY AUTOINCREMENT,
264 -
    token      TEXT NOT NULL UNIQUE,
265 -
    expires_at TEXT NOT NULL
299 +
    short_id   TEXT NOT NULL UNIQUE,
300 +
    title      TEXT NOT NULL,
301 +
    content    TEXT NOT NULL,
302 +
    created_at TEXT NOT NULL DEFAULT (datetime('now')),
303 +
    updated_at TEXT NOT NULL DEFAULT (datetime('now'))
266 304
);
267 -
```
305 +
`
268 306
269 -
**Session DB functions in db.rs:**
307 +
func openDB(path string) (*sql.DB, error) { return sqlite.Open(path, schema) }
270 308
271 -
```rust
272 -
pub fn insert_session(db: &Db, token: &str, expires_at: &str) -> Result<(), DbError> {
273 -
    let conn = db.lock().map_err(|_| DbError::LockPoisoned)?;
274 -
    conn.execute("INSERT INTO sessions (token, expires_at) VALUES (?1, ?2)", params![token, expires_at])?;
275 -
    Ok(())
309 +
func scanItem(s interface{ Scan(...any) error }) (*Item, error) {
310 +
	var it Item
311 +
	err := s.Scan(&it.ID, &it.ShortID, &it.Title, &it.Content, &it.CreatedAt, &it.UpdatedAt)
312 +
	if errors.Is(err, sql.ErrNoRows) {
313 +
		return nil, nil
314 +
	}
315 +
	if err != nil {
316 +
		return nil, err
317 +
	}
318 +
	return &it, nil
276 319
}
277 320
278 -
pub fn get_session_expiry(db: &Db, token: &str) -> Result<Option<String>, DbError> {
279 -
    let conn = db.lock().map_err(|_| DbError::LockPoisoned)?;
280 -
    match conn.query_row("SELECT expires_at FROM sessions WHERE token = ?1", params![token], |row| row.get(0)) {
281 -
        Ok(val) => Ok(Some(val)),
282 -
        Err(rusqlite::Error::QueryReturnedNoRows) => Ok(None),
283 -
        Err(e) => Err(DbError::Sqlite(e)),
284 -
    }
321 +
func createItem(db *sql.DB, title, content string) (*Item, error) {
322 +
	shortID, err := auth.GenerateShortID(10)
323 +
	if err != nil {
324 +
		return nil, err
325 +
	}
326 +
	res, err := db.Exec(`INSERT INTO items (short_id, title, content) VALUES (?, ?, ?)`, shortID, title, content)
327 +
	if err != nil {
328 +
		return nil, err
329 +
	}
330 +
	id, err := res.LastInsertId()
331 +
	if err != nil {
332 +
		return nil, err
333 +
	}
334 +
	return scanItem(db.QueryRow(`SELECT `+itemColumns+` FROM items WHERE id = ?`, id))
285 335
}
286 336
287 -
pub fn delete_session(db: &Db, token: &str) -> Result<(), DbError> {
288 -
    let conn = db.lock().map_err(|_| DbError::LockPoisoned)?;
289 -
    conn.execute("DELETE FROM sessions WHERE token = ?1", params![token])?;
290 -
    Ok(())
337 +
func getItemByShortID(db *sql.DB, shortID string) (*Item, error) {
338 +
	return scanItem(db.QueryRow(`SELECT `+itemColumns+` FROM items WHERE short_id = ?`, shortID))
291 339
}
292 340
293 -
pub fn prune_expired_sessions(db: &Db) -> Result<(), DbError> {
294 -
    let conn = db.lock().map_err(|_| DbError::LockPoisoned)?;
295 -
    conn.execute("DELETE FROM sessions WHERE expires_at < datetime('now')", [])?;
296 -
    Ok(())
341 +
func listItems(db *sql.DB) ([]Item, error) {
342 +
	rows, err := db.Query(`SELECT ` + itemColumns + ` FROM items ORDER BY id DESC`)
343 +
	if err != nil {
344 +
		return nil, err
345 +
	}
346 +
	defer rows.Close()
347 +
	var out []Item
348 +
	for rows.Next() {
349 +
		var it Item
350 +
		if err := rows.Scan(&it.ID, &it.ShortID, &it.Title, &it.Content, &it.CreatedAt, &it.UpdatedAt); err != nil {
351 +
			return nil, err
352 +
		}
353 +
		out = append(out, it)
354 +
	}
355 +
	return out, rows.Err()
356 +
}
357 +
358 +
func updateItemByShortID(db *sql.DB, shortID, title, content string) (*Item, error) {
359 +
	res, err := db.Exec(`UPDATE items SET title=?, content=?, updated_at=datetime('now') WHERE short_id=?`, title, content, shortID)
360 +
	if err != nil {
361 +
		return nil, err
362 +
	}
363 +
	if n, _ := res.RowsAffected(); n == 0 {
364 +
		return nil, nil
365 +
	}
366 +
	return getItemByShortID(db, shortID)
367 +
}
368 +
369 +
func deleteItemByShortID(db *sql.DB, shortID string) (bool, error) {
370 +
	res, err := db.Exec(`DELETE FROM items WHERE short_id = ?`, shortID)
371 +
	if err != nil {
372 +
		return false, err
373 +
	}
374 +
	n, _ := res.RowsAffected()
375 +
	return n > 0, nil
297 376
}
298 377
```
299 378
300 -
**auth.rs module — wraps andromeda-auth with session validation:**
379 +
Conventions:
380 +
- Short IDs via `auth.GenerateShortID(10)`.
381 +
- Model fields use `json:"..."` tags so the same struct serializes for the API.
382 +
- "Not found" returns `(nil, nil)`, not an error — handlers check `if x == nil`.
383 +
- DB path comes from `<APP>_DB_PATH` env (e.g. `JOTTS_DB_PATH`).
384 +
- For more complex apps, move this into `internal/store/` and keep `db.go` as
385 +
  thin pass-through wrappers (see `apps/jotts`).
301 386
302 -
```rust
303 -
use axum::{
304 -
    extract::{FromRef, FromRequestParts},
305 -
    http::request::Parts,
306 -
    response::{IntoResponse, Redirect, Response},
307 -
};
308 -
use std::sync::Arc;
387 +
## handlers_web.go
309 388
310 -
use crate::AppState;
389 +
HTML handlers. Use `web.Render` for templates and `web.RedirectWithError` for
390 +
flash-style errors via query params.
311 391
312 -
/// Axum extractor — guards routes behind login. Redirects to /login if invalid.
313 -
pub struct AuthSession;
392 +
```go
393 +
package main
314 394
315 -
impl<S> FromRequestParts<S> for AuthSession
316 -
where
317 -
    S: Send + Sync,
318 -
    Arc<AppState>: FromRef<S>,
319 -
{
320 -
    type Rejection = Response;
395 +
import (
396 +
	"net/http"
397 +
	"strings"
321 398
322 -
    async fn from_request_parts(parts: &mut Parts, state: &S) -> Result<Self, Self::Rejection> {
323 -
        let state = Arc::<AppState>::from_ref(state);
324 -
        let token = andromeda_auth::extract_session_cookie(&parts.headers);
325 -
        if let Some(token) = token {
326 -
            if is_valid_session(&state, &token) {
327 -
                return Ok(AuthSession);
328 -
            }
329 -
        }
330 -
        Err(Redirect::to("/login").into_response())
331 -
    }
332 -
}
399 +
	"github.com/stevedylandev/andromeda/crates-go/auth"
400 +
	"github.com/stevedylandev/andromeda/crates-go/web"
401 +
)
333 402
334 -
fn is_valid_session(state: &AppState, token: &str) -> bool {
335 -
    // Check DB for token expiry — return true if token exists and hasn't expired
336 -
    match crate::db::get_session_expiry(&state.db, token) {
337 -
        Ok(Some(expires_at)) => expires_at > chrono::Utc::now().to_rfc3339(),
338 -
        _ => false,
339 -
    }
403 +
func (a *App) loginGetHandler(w http.ResponseWriter, r *http.Request) {
404 +
	web.Render(a.Templates, w, "login.html", loginPageData{Error: r.URL.Query().Get("error")}, a.Log)
340 405
}
341 -
```
342 406
343 -
**Usage in handlers** — use `andromeda_auth` functions directly for login/logout:
407 +
func (a *App) loginPostHandler(w http.ResponseWriter, r *http.Request) {
408 +
	if err := r.ParseForm(); err != nil {
409 +
		web.RedirectWithError(w, r, "/login", "Invalid request")
410 +
		return
411 +
	}
412 +
	if !auth.SecureEqual(r.FormValue("password"), a.Password) {
413 +
		web.RedirectWithError(w, r, "/login", "Invalid password")
414 +
		return
415 +
	}
416 +
	token, err := a.Sessions.Create()
417 +
	if err != nil {
418 +
		a.Log.Error("create session failed", "err", err)
419 +
		web.RedirectWithError(w, r, "/login", "Server error")
420 +
		return
421 +
	}
422 +
	http.SetCookie(w, a.Sessions.SessionCookie(token))
423 +
	http.Redirect(w, r, "/", http.StatusSeeOther)
424 +
}
344 425
345 -
```rust
346 -
use andromeda_auth;
347 -
348 -
// POST /login — verify password, create session, set cookie
349 -
async fn post_login(State(state): State<Arc<AppState>>, Form(form): Form<LoginForm>) -> Response {
350 -
    if andromeda_auth::verify_password(&form.password, &state.app_password) {
351 -
        let token = andromeda_auth::generate_session_token();
352 -
        // Store token in DB with expiry...
353 -
        let cookie = andromeda_auth::build_session_cookie(&token, state.cookie_secure);
354 -
        // Set cookie header and redirect to /
355 -
    } else {
356 -
        // Redirect to /login?error=Invalid+password
357 -
    }
426 +
func (a *App) logoutHandler(w http.ResponseWriter, r *http.Request) {
427 +
	if c, err := r.Cookie(a.Sessions.CookieName); err == nil && c.Value != "" {
428 +
		a.Sessions.Delete(c.Value)
429 +
	}
430 +
	http.SetCookie(w, a.Sessions.ClearCookie())
431 +
	http.Redirect(w, r, "/login", http.StatusSeeOther)
358 432
}
359 433
360 -
// GET /logout — clear session
361 -
async fn get_logout(headers: HeaderMap, State(state): State<Arc<AppState>>) -> Response {
362 -
    if let Some(token) = andromeda_auth::extract_session_cookie(&headers) {
363 -
        let _ = crate::db::delete_session(&state.db, &token);
364 -
    }
365 -
    let cookie = andromeda_auth::clear_session_cookie();
366 -
    // Set cookie header and redirect to /login
434 +
func (a *App) createItemHandler(w http.ResponseWriter, r *http.Request) {
435 +
	if err := r.ParseForm(); err != nil {
436 +
		web.RedirectWithError(w, r, "/items/new", "Invalid request")
437 +
		return
438 +
	}
439 +
	title := strings.TrimSpace(r.FormValue("title"))
440 +
	if title == "" {
441 +
		web.RedirectWithError(w, r, "/items/new", "Title is required")
442 +
		return
443 +
	}
444 +
	it, err := createItem(a.DB, title, r.FormValue("content"))
445 +
	if err != nil {
446 +
		a.Log.Error("create item failed", "err", err)
447 +
		web.RedirectWithError(w, r, "/items/new", "Failed to create item")
448 +
		return
449 +
	}
450 +
	http.Redirect(w, r, "/items/"+it.ShortID, http.StatusSeeOther)
367 451
}
368 452
```
369 453
370 -
**Protect routes** — add `_session: auth::AuthSession` as a parameter:
454 +
Patterns:
455 +
- Always `auth.SecureEqual` for password compare (constant-time).
456 +
- Use `http.StatusSeeOther` (303) for POST-redirect-GET.
457 +
- Pre-rendered HTML (e.g. markdown) goes through `template.HTML` in the
458 +
  page-data struct, not via `|safe`-style filters.
459 +
460 +
## handlers_api.go
461 +
462 +
JSON API handlers. Use `web.WriteJSON`, `web.WriteError`, `web.DecodeJSON`.
463 +
464 +
```go
465 +
package main
466 +
467 +
import (
468 +
	"net/http"
469 +
470 +
	"github.com/stevedylandev/andromeda/crates-go/web"
471 +
)
371 472
372 -
```rust
373 -
async fn get_index(_session: auth::AuthSession, State(state): State<Arc<AppState>>) -> Response {
374 -
    // only reachable if session is valid
473 +
func (a *App) apiListItems(w http.ResponseWriter, r *http.Request) {
474 +
	items, err := listItems(a.DB)
475 +
	if err != nil {
476 +
		a.Log.Error("list items failed", "err", err)
477 +
		web.WriteError(w, http.StatusInternalServerError, "internal error")
478 +
		return
479 +
	}
480 +
	web.WriteJSON(w, http.StatusOK, items)
375 481
}
376 -
```
377 482
378 -
**AppState for session auth:**
483 +
func (a *App) apiCreateItem(w http.ResponseWriter, r *http.Request) {
484 +
	var in ItemInput
485 +
	if !web.DecodeJSON(w, r, &in) {
486 +
		return
487 +
	}
488 +
	if in.Title == "" {
489 +
		web.WriteError(w, http.StatusBadRequest, "title is required")
490 +
		return
491 +
	}
492 +
	it, err := createItem(a.DB, in.Title, in.Content)
493 +
	if err != nil {
494 +
		a.Log.Error("create item failed", "err", err)
495 +
		web.WriteError(w, http.StatusInternalServerError, "internal error")
496 +
		return
497 +
	}
498 +
	web.WriteJSON(w, http.StatusCreated, it)
499 +
}
379 500
380 -
```rust
381 -
pub struct AppState {
382 -
    pub db: Db,
383 -
    pub app_password: String,
384 -
    pub cookie_secure: bool,
501 +
func (a *App) apiGetItem(w http.ResponseWriter, r *http.Request) {
502 +
	it, err := getItemByShortID(a.DB, r.PathValue("short_id"))
503 +
	if err != nil {
504 +
		web.WriteError(w, http.StatusInternalServerError, "internal error")
505 +
		return
506 +
	}
507 +
	if it == nil {
508 +
		web.WriteError(w, http.StatusNotFound, "not found")
509 +
		return
510 +
	}
511 +
	web.WriteJSON(w, http.StatusOK, it)
385 512
}
386 513
```
387 514
388 -
### API key auth (alternative — for API-only apps)
515 +
## Authentication
516 +
517 +
`crates-go/auth` provides three middleware patterns. Pick based on app shape:
518 +
519 +
### Session/cookie auth (web-facing apps)
389 520
390 -
For apps that don't need a login page (e.g., sipp), use API key middleware instead of session auth. This pattern does NOT use `andromeda-auth` — it's self-contained in `server.rs`:
521 +
Use `*auth.Store` for password-login web apps (jotts, feeds, bookmarks, etc).
522 +
Sessions stored in the same SQLite DB via `EnsureSchema()`. Routes guarded
523 +
with `store.RequireSession("/login", handler)`.
391 524
392 -
```rust
393 -
#[derive(Clone)]
394 -
struct ServerConfig {
395 -
    api_key: Option<String>,
396 -
    auth_endpoints: HashSet<String>,
397 -
}
398 -
```
525 +
Key API:
526 +
- `store.Create() (token, err)` — issue new session row.
527 +
- `store.SessionCookie(token) *http.Cookie` — `HttpOnly`, `SameSite=Strict`,
528 +
  7-day MaxAge.
529 +
- `store.ClearCookie()` — `MaxAge=-1` cookie for logout.
530 +
- `store.HasValid(r)` — check the incoming request's cookie.
531 +
- `store.PruneExpired()` — call at boot to GC stale rows.
532 +
533 +
### API key auth (header-based)
534 +
535 +
`auth.RequireAPIKey(expectedKey, next)`. Reads `X-API-Key` header,
536 +
`SecureEqual` compare. Returns 403 if `expectedKey` is empty (auth disabled by
537 +
config), 401 on mismatch. Use this for `/api/*` routes when there's also a
538 +
session-cookie web UI.
399 539
400 -
See `apps/sipp` for the full API key middleware pattern.
540 +
### Bearer-or-Session (mixed surfaces)
401 541
402 -
### Environment variables
542 +
`auth.RequireBearerOrSession(store, expectedBearer, next)` — accepts either
543 +
`Authorization: Bearer <token>` or a valid session cookie. Used by apps that
544 +
expose the same endpoint to a CLI client AND a logged-in browser user (e.g.
545 +
`sipp`, `jotts upload`).
403 546
404 -
Prefix app-specific env vars with the app name (e.g., `JOTTS_`, `SIPP_`). Shared vars like `HOST`, `PORT`, `COOKIE_SECURE` don't need a prefix:
547 +
### Env vars
405 548
406 549
| Variable | Purpose | Default |
407 550
|----------|---------|---------|
408 -
| `HOST` | Bind address | `127.0.0.1` (set to `0.0.0.0` in Docker) |
409 -
| `PORT` | Listen port | `3000` |
410 -
| `APP_DB_PATH` | SQLite file path | `app.sqlite` |
411 -
| `APP_PASSWORD` | Single password for session auth (web apps) | None |
412 -
| `COOKIE_SECURE` | Set `true` for HTTPS-only cookies | `false` |
413 -
| `APP_API_KEY` | API key for API-key auth pattern | None (auth disabled) |
414 -
| `APP_AUTH_ENDPOINTS` | Comma-separated endpoint names, "all", or "none" | `api_delete,api_list,api_update` |
551 +
| `HOST` | bind address | `127.0.0.1` (set `0.0.0.0` in Docker) |
552 +
| `PORT` | listen port | `3000` |
553 +
| `<APP>_DB_PATH` | SQLite path | `<app>.sqlite` |
554 +
| `<APP>_PASSWORD` | session login password | none → "changeme" warning |
555 +
| `<APP>_API_KEY` | API key for `/api/*` | none → 403 |
556 +
| `COOKIE_SECURE` | HTTPS-only cookie flag | `false` |
415 557
416 -
## Templates (Askama)
417 -
418 -
HTML templates live in `templates/` and use Askama syntax. Key patterns:
558 +
App-specific env vars are prefixed with the uppercase app name
559 +
(`JOTTS_PASSWORD`, `FEEDS_API_KEY`). `HOST`, `PORT`, `COOKIE_SECURE` are not
560 +
prefixed.
419 561
420 -
- Link CSS via `/static/styles.css`
421 -
- Link assets via `/assets/filename`
422 -
- Include `<meta name="theme-color" content="#121113" />`
423 -
- Forms POST to web routes (not API routes)
424 -
- Use `{{ variable }}` for template interpolation
425 -
- Use `{{ variable|safe }}` for pre-rendered HTML (e.g., syntax highlighted content)
562 +
## Templates (html/template)
426 563
427 -
### Template inheritance
564 +
Templates live in `templates/`, parsed once at startup via
565 +
`template.ParseFS(appFS, "templates/*.html")` and rendered with
566 +
`web.Render(t, w, "name.html", data, log)`.
428 567
429 -
Use a `base.html` with block sections. All pages extend it:
568 +
Use `{{ define "..." }}` / `{{ template "..." . }}` for layout composition.
569 +
The base file defines block names; pages define the content blocks.
430 570
431 571
**templates/base.html:**
572 +
432 573
```html
574 +
{{ define "base" }}
433 575
<!DOCTYPE html>
434 576
<html lang="en">
435 577
<head>
436 578
  <meta charset="UTF-8">
437 579
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
438 -
  <title>{% block title %}APP_NAME{% endblock %}</title>
580 +
  <title>{{ template "title" . }}</title>
439 581
  <meta name="theme-color" content="#121113" />
440 -
  <style>
441 -
    /* base styles here */
442 -
  </style>
582 +
  <link rel="stylesheet" href="/assets/darkmatter.css">
583 +
  <link rel="stylesheet" href="/static/styles.css">
443 584
</head>
444 585
<body>
445 586
  <div class="container">
446 -
    {% block content %}{% endblock %}
587 +
    {{ template "content" . }}
447 588
  </div>
448 589
</body>
449 590
</html>
591 +
{{ end }}
450 592
```
451 593
452 594
**templates/index.html:**
453 -
```html
454 -
{% extends "base.html" %}
455 -
{% block title %}Items{% endblock %}
456 -
{% block content %}
457 -
  {% if let Some(error) = error %}
458 -
    <p class="error">{{ error }}</p>
459 -
  {% endif %}
460 -
  {% for item in items %}
461 -
    <div>{{ item.name }}</div>
462 -
  {% endfor %}
463 -
{% endblock %}
464 -
```
465 595
466 -
### Flash messages via query params
467 -
468 -
Pass transient error/success messages through redirects using query parameters. No session flash needed.
469 -
470 -
**Query param struct:**
471 -
```rust
472 -
#[derive(Deserialize, Default)]
473 -
pub struct FlashQuery {
474 -
    pub error: Option<String>,
475 -
}
476 -
```
477 -
478 -
**In handlers** — redirect with message:
479 -
```rust
480 -
Redirect::to("/items/add?error=Name+is+required.").into_response()
481 -
```
482 -
483 -
**In receiving handler** — extract and pass to template:
484 -
```rust
485 -
async fn get_add(Query(q): Query<FlashQuery>) -> Response {
486 -
    render(AddTemplate { error: q.error })
487 -
}
488 -
```
489 -
490 -
**In template** — conditionally render:
491 596
```html
492 -
{% if let Some(error) = error %}
493 -
  <p class="error">{{ error }}</p>
494 -
{% endif %}
597 +
{{ define "title" }}Items{{ end }}
598 +
{{ define "content" }}
599 +
  {{ range .Items }}
600 +
    <a href="/items/{{ .ShortID }}">{{ .Title }}</a>
601 +
  {{ end }}
602 +
{{ end }}
603 +
{{ template "base" . }}
495 604
```
496 605
497 -
## Logging (tracing)
498 -
499 -
Always initialize tracing in `main()` before anything else:
500 -
501 -
```rust
502 -
tracing_subscriber::fmt::init();
503 -
```
606 +
Notes:
607 +
- Link CSS via `/static/styles.css` (app-local) and `/assets/darkmatter.css`
608 +
  (shared theme served by `darkmatter.Mount`).
609 +
- Forms POST to web routes, not `/api/*`.
610 +
- Pass pre-rendered HTML as `template.HTML(...)`; `html/template` auto-escapes
611 +
  everything else.
612 +
- Flash errors via `?error=...` query param + `web.RedirectWithError` /
613 +
  `RedirectWithSuccess`.
504 614
505 -
Use throughout the app:
506 -
- `tracing::error!("DB error: {}", e)` — unrecoverable failures
507 -
- `tracing::warn!("Non-critical issue: {}", e)` — degraded but functional
508 -
- `tracing::info!("Listening on {}", addr)` — startup/lifecycle events
615 +
## Static assets
509 616
510 -
## main.rs
617 +
Embedded via `//go:embed templates/*.html static/*` on a single `appFS`.
618 +
Serve under `/static/` via `web.EmbeddedHandler(appFS, "static")`. Shared
619 +
theme files (CSS + fonts) come from `darkmatter.Mount(mux, "/assets")`.
511 620
512 -
Minimal — just loads env and starts the server:
621 +
## Logging (slog)
513 622
514 -
```rust
515 -
mod auth;
516 -
mod db;
517 -
mod server;
623 +
Standard library `log/slog`. Build once in `main`:
518 624
519 -
#[tokio::main]
520 -
async fn main() {
521 -
    dotenvy::dotenv().ok();
522 -
    tracing_subscriber::fmt::init();
523 -
    let host = std::env::var("HOST").unwrap_or_else(|_| "127.0.0.1".to_string());
524 -
    let port: u16 = std::env::var("PORT")
525 -
        .ok()
526 -
        .and_then(|v| v.parse().ok())
527 -
        .unwrap_or(3000);
528 -
    server::run(host, port).await;
529 -
}
625 +
```go
626 +
logger := slog.New(slog.NewTextHandler(os.Stdout, &slog.HandlerOptions{Level: slog.LevelInfo}))
530 627
```
531 628
532 -
Keep main.rs minimal — all logic lives in `server.rs`, `db.rs`, and `auth.rs`.
629 +
Pass it into `App` and pass it to `web.Render` (it logs template errors).
630 +
Use `logger.Error("msg", "err", err)`, `logger.Warn(...)`, `logger.Info(...)`.
533 631
534 632
## Dockerfile
535 633
536 -
Multi-stage workspace build using `cargo-chef` for dep caching. Built from repo root with `docker build -f apps/APP_NAME/Dockerfile .`:
634 +
Multi-stage, built from the repo root so `crates-go/` is available for the
635 +
`replace` directives. Pure Go (`CGO_ENABLED=0`) because the SQLite driver is
636 +
`modernc.org/sqlite`.
537 637
538 638
```dockerfile
539 639
# Build from repo root: docker build -t APP_NAME -f apps/APP_NAME/Dockerfile .
540 -
FROM lukemathwalker/cargo-chef:latest-rust-1-slim-bookworm AS chef
640 +
FROM golang:1.25-bookworm AS builder
541 641
WORKDIR /app
542 -
543 -
FROM chef AS planner
544 -
COPY . .
545 -
RUN cargo chef prepare --recipe-path recipe.json
546 -
547 -
FROM chef AS builder
548 -
RUN apt-get update && apt-get install -y pkg-config libssl-dev && rm -rf /var/lib/apt/lists/*
549 -
COPY --from=planner /app/recipe.json recipe.json
550 -
RUN cargo chef cook --release --recipe-path recipe.json -p APP_NAME
551 -
COPY . .
552 -
RUN cargo build --release -p APP_NAME
642 +
COPY crates-go/ ./crates-go/
643 +
COPY apps/APP_NAME/go.mod apps/APP_NAME/go.sum ./apps/APP_NAME/
644 +
WORKDIR /app/apps/APP_NAME
645 +
RUN go mod download
646 +
COPY apps/APP_NAME/ ./
647 +
RUN CGO_ENABLED=0 go build -o /APP_NAME .
553 648
554 649
FROM debian:bookworm-slim
555 -
COPY --from=builder /app/target/release/APP_NAME /usr/local/bin/APP_NAME
650 +
COPY --from=builder /APP_NAME /usr/local/bin/APP_NAME
556 651
WORKDIR /data
557 -
EXPOSE 3000
558 652
ENV HOST=0.0.0.0
559 653
ENV PORT=3000
654 +
EXPOSE 3000
560 655
CMD ["APP_NAME"]
561 656
```
562 657
563 -
Replace all `APP_NAME` with actual binary/package name. If app makes HTTPS requests (uses reqwest etc.), add `RUN apt-get update && apt-get install -y ca-certificates && rm -rf /var/lib/apt/lists/*` to final stage.
658 +
If the app makes outbound HTTPS requests, add to the final stage:
659 +
```dockerfile
660 +
RUN apt-get update && apt-get install -y --no-install-recommends ca-certificates && rm -rf /var/lib/apt/lists/*
661 +
```
662 +
663 +
If the app has CLI subcommands (jotts), set `CMD ["APP_NAME", "server"]`.
564 664
565 -
## docker-compose.yml
665 +
## App-local docker-compose.yml
566 666
567 667
```yaml
568 668
services:
573 673
    ports:
574 674
      - "${PORT:-3000}:${PORT:-3000}"
575 675
    environment:
576 -
      - APP_PASSWORD=${APP_PASSWORD:-changeme}
577 -
      - APP_DB_PATH=/data/APP_NAME.sqlite
676 +
      - APP_NAME_PASSWORD=${APP_NAME_PASSWORD:-changeme}
677 +
      - APP_NAME_DB_PATH=/data/APP_NAME.sqlite
578 678
      - COOKIE_SECURE=false
579 679
      - HOST=0.0.0.0
580 680
      - PORT=${PORT:-3000}
581 681
    volumes:
582 -
      - app-data:/data
682 +
      - APP_NAME-data:/data
583 683
    restart: unless-stopped
584 684
585 685
volumes:
586 -
  app-data:
686 +
  APP_NAME-data:
587 687
```
588 688
589 -
Key points:
590 -
- `context: ../..` builds from the workspace root
591 -
- Named volume persists the SQLite database across container restarts
592 -
- ENV vars use app-specific prefixes (e.g., `JOTTS_PASSWORD`, `SIPP_API_KEY`)
593 -
594 689
## .env.example
595 690
596 -
Always create one with all configurable env vars and sensible comments.
691 +
Always ship one listing every var the app reads, with short comments.
597 692
598 693
## Wiring up a new app
599 694
600 -
A new app is not done when `cargo run -p APP_NAME` works. Several workspace-level files list every app explicitly and must be updated, or CI/release/deploy break silently.
695 +
A new app is not done when `go run .` works. Several workspace-level files
696 +
list every app explicitly and must be updated, or CI / Docker images / deploy
697 +
break silently.
601 698
602 -
### 1. Workspace root `Cargo.toml`
699 +
### 1. Root `docker-compose.yml` (production)
603 700
604 -
Add to `[workspace] members`:
701 +
Pulls images from GHCR. Add a service + named volume:
605 702
606 -
```toml
607 -
[workspace]
608 -
members = [
609 -
    "apps/sipp",
610 -
    # ...
611 -
    "apps/APP_NAME",
612 -
    "crates/auth",
613 -
    "crates/db",
614 -
    "crates/darkmatter-css",
615 -
]
703 +
```yaml
704 +
services:
705 +
  APP_NAME:
706 +
    image: ghcr.io/stevedylandev/andromeda/APP_NAME:latest
707 +
    restart: unless-stopped
708 +
    ports:
709 +
      - "PORT:3000"
710 +
    volumes:
711 +
      - APP_NAME_data:/data
712 +
    env_file: apps/APP_NAME/.env
616 713
```
617 714
618 -
### 2. `dist-workspace.toml`
619 -
620 -
Add to `[workspace] members` so `cargo dist` builds release binaries + Homebrew formula:
715 +
Pick a unique host port (existing: 3000 sipp, 3030 jotts, 3456 og, 3535 posts,
716 +
3565 shrink, 4555 feeds, 4646 library, 6754 cellar). Drop the `volumes` /
717 +
`env_file` lines if stateless (see `shrink`, `og`).
621 718
622 -
```toml
623 -
[workspace]
624 -
members = [
625 -
    "cargo:apps/sipp",
626 -
    # ...
627 -
    "cargo:apps/APP_NAME",
628 -
]
629 -
```
719 +
If the app holds data worth backing up, mount its volume read-only into the
720 +
`backup` service.
630 721
631 -
### 3. `.github/workflows/docker.yml`
722 +
### 2. `.github/workflows/docker.yml`
632 723
633 724
Two spots — both list every app:
634 725
635 726
```yaml
636 -
ALL='["cellar","sipp","feeds","parcels","jotts","og","shrink","backup","posts","library","APP_NAME"]'
727 +
ALL='["backup","bookmarks","cellar","easel","feeds","jotts","library","og","posts","shrink","sipp","APP_NAME"]'
637 728
```
638 729
639 730
```yaml
640 -
for app in cellar sipp feeds parcels jotts og shrink backup posts library APP_NAME; do
731 +
for app in backup bookmarks cellar easel feeds jotts library og posts shrink sipp APP_NAME; do
641 732
```
642 733
643 -
If the cargo package name differs from directory name, add a case to `pkg_to_dir()`:
734 +
### 3. `.github/workflows/docker-test.yml`
644 735
645 -
```bash
646 -
pkg_to_dir() {
647 -
  case "$1" in
648 -
    sipp-so) echo "sipp" ;;
649 -
    APP_PKG) echo "APP_NAME" ;;
650 -
    *) echo "$1" ;;
651 -
  esac
652 -
}
653 -
```
736 +
Same two lists — keep in lockstep with `docker.yml`.
654 737
655 -
### 4. `.github/workflows/docker-test.yml`
738 +
### 4. App-local `docker-compose.yml`
656 739
657 -
Same two lists — keep in sync with `docker.yml`:
740 +
Per-app dev compose (see above).
658 741
659 -
```yaml
660 -
ALL='["cellar","sipp","feeds","parcels","jotts","og","shrink","backup","posts","library","APP_NAME"]'
661 -
```
742 +
## Useful commands
662 743
663 -
```yaml
664 -
for app in cellar sipp feeds parcels jotts og shrink backup posts library APP_NAME; do
665 -
```
744 +
Per-app (from `apps/<app>/`):
666 745
667 -
### 5. Root `docker-compose.yml`
668 -
669 -
Production compose at repo root pulls images from GHCR. Add a service + volume:
670 -
671 -
```yaml
672 -
services:
673 -
  APP_NAME:
674 -
    image: ghcr.io/stevedylandev/andromeda/APP_NAME:latest
675 -
    restart: unless-stopped
676 -
    ports:
677 -
      - "PORT:3000"
678 -
    volumes:
679 -
      - APP_NAME_data:/data
680 -
    env_file: apps/APP_NAME/.env
681 -
682 -
volumes:
683 -
  APP_NAME_data:
684 -
    external: true
685 -
    name: APP_NAME_APP_NAME-data
746 +
```bash
747 +
go mod tidy
748 +
go build ./...
749 +
go vet ./...
750 +
go test ./...
751 +
go run .                # or: go run . server  for apps with subcommands
686 752
```
687 753
688 -
Pick a unique host port (existing: 3000 sipp, 3030 jotts, 3456 og, 3535 posts, 3565 shrink, 4555 feeds, 4646 library, 6754 cellar). Drop `volumes`/`env_file` if app is stateless (see `shrink`, `og`).
689 -
690 -
If the app holds data worth backing up, add a read-only mount to the `backup` service:
754 +
Repo-wide (from root):
691 755
692 -
```yaml
693 -
backup:
694 -
  volumes:
695 -
    - APP_NAME_data:/data/APP_NAME:ro
756 +
```bash
757 +
make go-check                       # fmt + test + vet across all Go modules
758 +
make go-test
759 +
make go-vet
760 +
make go-fmt
761 +
make go-app-test APP=APP_NAME
762 +
make go-app-vet  APP=APP_NAME
763 +
make go-app-fmt  APP=APP_NAME
696 764
```
697 765
698 -
### 6. App-local `docker-compose.yml`
699 -
700 -
Per-app compose for local dev — builds from source:
701 -
702 -
```yaml
703 -
services:
704 -
  app:
705 -
    build:
706 -
      context: ../..
707 -
      dockerfile: apps/APP_NAME/Dockerfile
708 -
    ports:
709 -
      - "${PORT:-3000}:${PORT:-3000}"
710 -
    environment:
711 -
      - APP_PASSWORD=${APP_PASSWORD:-changeme}
712 -
      - APP_DB_PATH=/data/APP_NAME.sqlite
713 -
      - COOKIE_SECURE=false
714 -
      - HOST=0.0.0.0
715 -
      - PORT=${PORT:-3000}
716 -
    volumes:
717 -
      - APP_NAME-data:/data
718 -
    restart: unless-stopped
719 -
720 -
volumes:
721 -
  APP_NAME-data:
722 -
```
766 +
The shared crates (`crates-go/web`, `crates-go/auth`, `crates-go/config`,
767 +
`crates-go/sqlite`, `crates-go/darkmatter`) are each their own module — run
768 +
`go build ./...` inside any to check in isolation.
723 769
724 770
## Checklist
725 771
726 772
When scaffolding a new app:
727 773
728 -
1. Create `apps/APP_NAME/` with `cargo init`
729 -
2. Set up `apps/APP_NAME/Cargo.toml` with workspace deps + `andromeda-auth` (and `andromeda-db`, `andromeda-darkmatter-css` if used)
730 -
3. Register app in workspace root `Cargo.toml` under `[workspace] members`
731 -
4. Register app in `dist-workspace.toml` under `[workspace] members` (with `cargo:` prefix)
732 -
5. Add app to `ALL` array + `for app in ...` loop in `.github/workflows/docker.yml`
733 -
6. Add app to `ALL` array + `for app in ...` loop in `.github/workflows/docker-test.yml`
734 -
7. Write `db.rs` — schema, model, CRUD, session table if needed
735 -
8. Write `auth.rs` — wrap `andromeda-auth` with `AuthSession` extractor (if auth needed)
736 -
9. Write `server.rs` — config, state, templates, handlers, routes
737 -
10. Write `main.rs` — minimal entry point
738 -
11. Create `templates/` with `base.html` + index page
739 -
12. Create `static/styles.css`
740 -
13. Create `.env.example`
741 -
14. Create `Dockerfile` (cargo-chef multi-stage) + app-local `docker-compose.yml`
742 -
15. Add service entry + volume to root `docker-compose.yml` (and `backup` mount if stateful)
743 -
16. Test: `cargo run -p APP_NAME`, hit routes
744 -
17. Test: `docker build -f apps/APP_NAME/Dockerfile .` from repo root
774 +
1. `mkdir apps/APP_NAME && cd apps/APP_NAME && go mod init github.com/stevedylandev/andromeda/apps/APP_NAME`
775 +
2. Add shared-crate `require` + `replace` directives to `go.mod` (auth, config, sqlite, web, darkmatter).
776 +
3. `main.go` — env load, DB open, sessions bootstrap, `App` build, `ListenAndServe`.
777 +
4. `app.go` — `App` struct, `//go:embed`, page-data structs.
778 +
5. `routes.go` — mux wiring, middleware, `/static/` + `darkmatter.Mount`.
779 +
6. `db.go` — schema, model, CRUD via `crates-go/sqlite.Open`.
780 +
7. `handlers_web.go` — login/logout + HTML CRUD pages.
781 +
8. `handlers_api.go` — JSON CRUD endpoints behind `RequireAPIKey`.
782 +
9. `templates/base.html` + per-page templates using `{{ define }}` blocks.
783 +
10. `static/styles.css` + any app-specific assets.
784 +
11. `.env.example` listing every env var.
785 +
12. `Dockerfile` (multi-stage, `CGO_ENABLED=0`) + app-local `docker-compose.yml`.
786 +
13. Add service + volume to root `docker-compose.yml` (+ `backup` mount if stateful).
787 +
14. Add app name to both `ALL` array + `for app in ...` loops in
788 +
    `.github/workflows/docker.yml` AND `.github/workflows/docker-test.yml`.
789 +
15. `go mod tidy`, then `make go-app-vet APP=APP_NAME` and `make go-app-test APP=APP_NAME`.
790 +
16. `docker build -f apps/APP_NAME/Dockerfile .` from repo root to verify image.
745 791
746 792
## What NOT to include
747 793
748 -
- No external CSS frameworks unless specified 
749 -
- No ORMs — use raw rusqlite
750 -
- No connection pools — `Arc<Mutex<Connection>>` is sufficient for SQLite
751 -
- No async database drivers — rusqlite is synchronous and that's fine
794 +
- No web frameworks (gin, echo, chi, fiber) — stdlib `net/http` mux is enough.
795 +
- No ORMs — raw `database/sql` + `?` placeholders.
796 +
- No connection pools — `sqlite.Open` sets `MaxOpenConns(1)` on purpose.
797 +
- No cgo SQLite drivers — use `modernc.org/sqlite` via `crates-go/sqlite`.
798 +
- No external CSS frameworks unless specified — use `crates-go/darkmatter`.
799 +
- No JS frontend / build step — `html/template` rendered server-side.
800 +
- No CLI / TUI deps (cobra, bubbletea) unless the app actually needs them.
801 +
- No `replace` directives for anything outside `crates-go/`.