feat: magic-link sign-in with allowlist, users
Ports oficina/docs' internal/docs/auth.go to rastrillo v0.26.0: an emailed single-use link, no password, no passkey second factor (spec §8.1 — a passkey's relying party derives from this instance's own host, so a person on three team instances would enrol three credentials). The allowlist gates POST /signin in front of rastrillo/auth's Begin as well as behind it as Authorize. Only the front gate stops the mail: Authorize fires inside admit(), after a link has already been sent, so Authorize alone would leave this instance's sending identity an open relay for any address a stranger types into the form. A non-permitted address gets the permitted answer byte for byte, or the endpoint becomes a membership oracle. Origin, InstanceKey, Mailer and AllowedAddresses are each a startup refusal when missing, unless Env is exactly "development" — and only an explicit -env flag can select that (chooseEnv), never $SLIDES_ENV, so a platform env-var block can't reach the public dev instance key, the admit-everyone allowlist, or magic links landing in the log. The scaffold's App(d, origin, logger) becomes New(d, cfg): sign-in needs four more settings and each of them is a refusal. Under rastrillo/auth the Subject is the email, so currentUser maps it to a users row — created on first sign-in, upserted on the unique email index — and sessions.UserID is never called. Every user-visible string is a catalog key in locales/en.toml, resolved through rastrillo.T; main.go declares Options.Locales/LocaleFS and the test harness wraps the same middleware, since without it every lookup would render its own key.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01563XGCgxcSZToEXwuTBsKE
21 files changed,
+1479
−122
AGENTS.md+11 −8cmd/slides/main.go+172 −14go.mod+2 −1internal/slides/app.go+70 −18internal/slides/auth.go+306 −0internal/slides/config.go+165 −0internal/slides/handlers.go+44 −3internal/slides/locales/en.toml+45 −0internal/slides/migrations.go+7 −9internal/slides/migrations/0001_init.sql+2 −1internal/slides/migrations/schema.sql+3 −1internal/slides/models.go+30 −11internal/slides/render.go+24 −2internal/slides/templates/index.html+50 −1internal/slides/templates/layout.html+8 −2internal/slides/templates/signin.html+104 −0internal/slidestest/browser_test.go+17 −10internal/slidestest/harness_test.go+162 −8internal/slidestest/index_test.go+24 −33internal/slidestest/signin_test.go+187 −0scripts/run-dev.sh+46 −0
diff --git a/AGENTS.md b/AGENTS.md| index 6bc0130..ba0d0eb 100644 |
| --- a/AGENTS.md |
| +++ b/AGENTS.md |
| @@ -40,14 +40,17 @@ mechanically. |
| - The gate is `make ci` (vet + gofmt + tests) — the same definition |
| CI runs. Run it before every push. `CGO_ENABLED=0` throughout: the |
| stack is cgo-free by design. |
| -- Two of the scaffolded tests are about the **placeholder index page** |
| - and are yours to rewrite: `TestIndexRenders` and |
| - `TestIndexLinksFingerprintedStylesheet`. Putting `/` behind sign-in |
| - turns a signed-out GET into a 303 and both fail — that is the app |
| - working, not the gate finding a fault. Repoint them at a page that |
| - is public in your app, or delete them. The gate is a definition of |
| - done for *your* tests; it does not promise the ones you were handed |
| - are still describing your app. |
| +- `/` is behind sign-in (`internal/slides/auth.go`), so `/signin` and |
| + `/static/*` are the only screens a signed-out request reaches. The |
| + scaffold's two placeholder-index tests were repointed there when |
| + sign-in landed; `signedIn(t, app, address)` in |
| + `internal/slidestest/harness_test.go` is how a test gets a session — |
| + it runs the real magic-link flow against a fake mailer, never a |
| + hand-minted cookie. |
| +- Under `rastrillo/auth` the session Subject is the **email address**, |
| + not a user id. Never call `sessions.UserID`: it answers `(0, false)` |
| + and would scope every owned query to `user_id = 0`. Map the address |
| + to a `users` row first — `(*app).currentUser(r)`. |
| ## Serving |
diff --git a/cmd/slides/main.go b/cmd/slides/main.go| index 05f5a2c..945173b 100644 |
| --- a/cmd/slides/main.go |
| +++ b/cmd/slides/main.go |
| @@ -1,20 +1,135 @@ |
| // Command slides wires the app: resolve the platform's activation |
| -// argv, open the database, build the app, serve. |
| +// argv, read the environment, open the database, build the app, serve. |
| package main |
| import ( |
| "log/slog" |
| "os" |
| + "path/filepath" |
| + "strings" |
| "amadan.net/rastrillo/rastrillo" |
| + "amadan.net/rastrillo/rastrillo/blobs" |
| "amadan.net/rastrillo/rastrillo/db" |
| + "amadan.net/rastrillo/rastrillo/mail" |
| slides "amadan.net/oficina/slides/internal/slides" |
| ) |
| +// extractEnvFlag pulls "-env value"/"-env=value" (or the "--env" |
| +// spelling) out of args, returning the value ("" if absent) and |
| +// everything else unchanged, order preserved. |
| +// |
| +// It has to run before rastrillo.Resolve: Resolve parses os.Args[1:] |
| +// with its own flag.FlagSet, which knows only -socket/-addr/-db and |
| +// errors on anything else, so a flag this binary adds on top must be |
| +// stripped out first. |
| +func extractEnvFlag(args []string) (env string, rest []string) { |
| + for i := 0; i < len(args); i++ { |
| + a := args[i] |
| + switch { |
| + case a == "-env" || a == "--env": |
| + if i+1 < len(args) { |
| + env = args[i+1] |
| + i++ |
| + } |
| + continue |
| + case strings.HasPrefix(a, "-env="): |
| + env = strings.TrimPrefix(a, "-env=") |
| + continue |
| + case strings.HasPrefix(a, "--env="): |
| + env = strings.TrimPrefix(a, "--env=") |
| + continue |
| + } |
| + rest = append(rest, a) |
| + } |
| + return env, rest |
| +} |
| + |
| +// chooseEnv decides slides.Config.Env from -env's extracted value |
| +// (flagEnv, "" when the flag was absent) and $SLIDES_ENV (osEnv). |
| +// |
| +// "development" is honoured ONLY from the flag. This is the rule Docs |
| +// arrived at the hard way, and it is the whole safety story for every |
| +// fail-closed default in config.go: platform instance configuration IS |
| +// environment variables — $SLIDES_ORIGIN, $SLIDES_INSTANCE_KEY, |
| +// $SLIDES_SMTP_* — so a bare $SLIDES_ENV=development sitting one line |
| +// away from its neighbours in the same block would be a |
| +// production-reachable path to the public constant standing in for the |
| +// HMAC key that seals auth's pending blob, to an allowlist that admits |
| +// everyone, and to every magic link — token included — landing in the |
| +// instance's own log instead of being mailed. |
| +// |
| +// osEnv is still consulted for any OTHER value (a future "staging", |
| +// say): every check in slides.New reads as "== development" or "!= |
| +// development", so any non-"development" string already behaves exactly |
| +// like production and there is nothing for $SLIDES_ENV to unlock. |
| +func chooseEnv(flagEnv, osEnv string) string { |
| + if flagEnv != "" { |
| + return flagEnv |
| + } |
| + if osEnv != "" && osEnv != slides.EnvDevelopment { |
| + return osEnv |
| + } |
| + return slides.EnvProduction |
| +} |
| + |
| +// newMailer builds the magic-link sender from $SLIDES_SMTP_* — the |
| +// same vars rastrillo/mail.FromEnv reads (HOST, PORT, USER, PASS, |
| +// FROM). It is built only when the two values with no sane default are |
| +// both set: FromEnv itself falls back to a warning-and-log sender when |
| +// they are missing, which is the wrong answer outside development, so |
| +// that decision is left to slides.New. A nil return here is what lets |
| +// New refuse to start rather than quietly logging live credentials. |
| +func newMailer(logger *slog.Logger) mail.Sender { |
| + if strings.TrimSpace(os.Getenv("SLIDES_SMTP_HOST")) == "" { |
| + return nil |
| + } |
| + if strings.TrimSpace(os.Getenv("SLIDES_SMTP_FROM")) == "" { |
| + return nil |
| + } |
| + return mail.FromEnv("SLIDES", logger) |
| +} |
| + |
| +// newBlobs binds the object store: the platform's bucket when it |
| +// delivered one, a directory beside the database in development, and |
| +// nothing otherwise. |
| +// |
| +// S3FromEnv answers (nil, nil) — not an error — when the platform |
| +// delivered no bucket, and a typed nil assigned straight into the |
| +// blobs.Store interface would be a non-nil interface holding a nil |
| +// pointer, panicking on first use rather than reading as absent. Hence |
| +// the explicit nil check rather than `if err == nil { return s }`. |
| +func newBlobs(env, dbPath string, logger *slog.Logger) (blobs.Store, error) { |
| + s3, err := blobs.S3FromEnv() |
| + if err != nil { |
| + return nil, err |
| + } |
| + if s3 != nil { |
| + return s3, nil |
| + } |
| + if env != slides.EnvDevelopment { |
| + // No bucket outside development is not a refusal here: nothing |
| + // in this slice stores bytes yet, and the screens that will do |
| + // so are what should refuse. Said out loud so the silence is a |
| + // decision rather than an oversight. |
| + logger.Warn("slides: no object store bound (CARLOS_STORE_* unset)") |
| + return nil, nil |
| + } |
| + root := filepath.Join(filepath.Dir(dbPath), "blobs") |
| + return blobs.Dir(root) |
| +} |
| + |
| func main() { |
| logger := slog.New(slog.NewTextHandler(os.Stderr, nil)) |
| + // -env is stripped before Resolve sees argv (extractEnvFlag), and |
| + // it is the only thing that can ever select "development" |
| + // (chooseEnv). |
| + envFlag, rest := extractEnvFlag(os.Args[1:]) |
| + os.Args = append(os.Args[:1], rest...) |
| + env := chooseEnv(envFlag, os.Getenv("SLIDES_ENV")) |
| + |
| // Resolve, not Run: this app opens its own database handle via |
| // db.Open — a *gorm.DB with a split reader/writer pool, not the |
| // bare *sql.DB Options.Router would hand back — so Options.DBPath |
| @@ -26,6 +141,26 @@ func main() { |
| os.Exit(1) |
| } |
| + // $SLIDES_ORIGIN is the deploy-time origin — the platform's real |
| + // per-instance hostname belongs here. Falling back to a TCP -addr |
| + // helps only the local path (scripts/run-dev.sh, or a person |
| + // passing -addr by hand); that is exactly the host a browser |
| + // loading such an instance sends back as its own Origin. The |
| + // platform's real activation path is a unix socket with no -addr to |
| + // derive anything from, so with $SLIDES_ORIGIN unset there this |
| + // stays empty and slides.New refuses to start — rather than mailing |
| + // sign-in links pointing at a placeholder. |
| + origin := os.Getenv("SLIDES_ORIGIN") |
| + if origin == "" && opts.Addr != "" { |
| + origin = "http://" + opts.Addr |
| + } |
| + |
| + store, err := newBlobs(env, opts.DBPath, logger) |
| + if err != nil { |
| + logger.Error("bind object store", "err", err) |
| + os.Exit(1) |
| + } |
| + |
| d, err := db.Open(opts.DBPath, logger) |
| if err != nil { |
| logger.Error("open database", "err", err) |
| @@ -33,17 +168,20 @@ func main() { |
| } |
| defer d.Close() |
| - // Origin decides the CSRF same-origin check (and, when the app |
| - // grows accounts, the Secure/__Host- cookie attributes) — so |
| - // defaulting it silently in production would be a real bug. Loud |
| - // on purpose, harmless in local dev. |
| - origin := os.Getenv("SLIDES_ORIGIN") |
| - if origin == "" { |
| - origin = "http://localhost:8080" |
| - logger.Warn("SLIDES_ORIGIN not set; defaulting", "origin", origin) |
| - } |
| - |
| - mux, err := slides.App(d, origin, logger) |
| + // Every empty value below is a startup refusal inside slides.New |
| + // unless env is "development" — see config.go's package comment. |
| + mux, err := slides.New(d, slides.Config{ |
| + Origin: origin, |
| + Env: env, |
| + InstanceKey: os.Getenv("SLIDES_INSTANCE_KEY"), |
| + Mailer: newMailer(logger), |
| + // Split here rather than inside New so Config carries a value |
| + // that is already the shape it means: a list of addresses, not |
| + // a string that happens to have commas in it. |
| + AllowedAddresses: splitList(os.Getenv("SLIDES_ALLOWED_ADDRESSES")), |
| + Blobs: store, |
| + Logger: logger, |
| + }) |
| if err != nil { |
| logger.Error("build app", "err", err) |
| os.Exit(1) |
| @@ -51,12 +189,32 @@ func main() { |
| opts.Mux = mux |
| opts.DBPath = "" |
| + // Declaring locales is what installs the middleware rastrillo.T |
| + // reads through; without these three every catalog lookup would |
| + // return its own key onto the page. |
| + opts.Locales = slides.Locales |
| + opts.DefaultLocale = slides.DefaultLocale |
| + opts.LocaleFS = slides.LocaleFS |
| // The same page a handler's own error path renders (render.go's |
| - // ErrorPage — wire it to Ctx.ErrorPage too as the app grows |
| - // handlers), so a panic and a handled failure look identical. |
| + // ErrorPage), so a panic and a handled failure look identical. |
| opts.ErrorPage = slides.ErrorPage |
| if err := rastrillo.Serve(opts); err != nil { |
| logger.Error("serve failed", "err", err) |
| os.Exit(1) |
| } |
| } |
| + |
| +// splitList parses a comma-separated environment value into the entries |
| +// it names, dropping empties so a trailing comma or a stray space is |
| +// not an entry. Normalization (case, surrounding whitespace) is |
| +// slides.Config's own job, so that a caller filling it directly gets |
| +// the same treatment. |
| +func splitList(raw string) []string { |
| + var out []string |
| + for _, part := range strings.Split(raw, ",") { |
| + if part = strings.TrimSpace(part); part != "" { |
| + out = append(out, part) |
| + } |
| + } |
| + return out |
| +} |
diff --git a/go.mod b/go.mod| index 0d1884a..aa3f75a 100644 |
| --- a/go.mod |
| +++ b/go.mod |
| @@ -6,6 +6,7 @@ require ( |
| amadan.net/rastrillo/rastrillo v0.26.0 |
| github.com/chromedp/chromedp v0.14.2 |
| github.com/go-chi/chi/v5 v5.3.2 |
| + gorm.io/gorm v1.31.2 |
| ) |
| require ( |
| @@ -20,12 +21,12 @@ require ( |
| github.com/google/uuid v1.6.0 // indirect |
| github.com/jinzhu/inflection v1.0.0 // indirect |
| github.com/jinzhu/now v1.1.5 // indirect |
| + github.com/keymaildev/signin v0.1.1 // indirect |
| github.com/mattn/go-isatty v0.0.20 // indirect |
| github.com/ncruces/go-strftime v1.0.0 // indirect |
| github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect |
| golang.org/x/sys v0.46.0 // indirect |
| golang.org/x/text v0.20.0 // indirect |
| - gorm.io/gorm v1.31.2 // indirect |
| gorm.io/plugin/dbresolver v1.6.2 // indirect |
| modernc.org/libc v1.74.1 // indirect |
| modernc.org/mathutil v1.7.1 // indirect |
diff --git a/internal/slides/app.go b/internal/slides/app.go| index 2c647f2..49cec6c 100644 |
| --- a/internal/slides/app.go |
| +++ b/internal/slides/app.go |
| @@ -12,23 +12,51 @@ import ( |
| "amadan.net/rastrillo/rastrillo/migrate" |
| ) |
| -// App wires the whole app: schema, router, static files. It returns a |
| -// *http.ServeMux because rastrillo.Options.Mux is typed that way — the |
| -// chi router mounts inside it. origin is the app's external origin |
| -// ("https://app.example.com") — the CSRF check's yardstick, and, once |
| -// the app grows accounts, sessions.Config.Origin too. |
| +// New wires the whole app: schema, sign-in, router, static files. It |
| +// returns a *http.ServeMux because rastrillo.Options.Mux is typed that |
| +// way — the chi router mounts inside it. |
| // |
| -// Growing the app is SKILL.md's five-file shape: models in models.go, |
| -// handlers in handlers.go, and — for a multi-user app — the sessions |
| -// core plus an identity plugin wired right here (examples/notes is the |
| -// worked example to copy — it lives in the repository at |
| -// amadan.net/rastrillo/rastrillo, not in the module you depend on, so |
| -// read it there rather than looking for the directory). Adding a subsystem |
| -// also means adding its Schema to migrations.go's BootSchema — see |
| -// the comment there. |
| -func App(d *db.DB, origin string, logger *slog.Logger) (*http.ServeMux, error) { |
| +// It is SKILL.md §1's app.go shape with one change: the origin and |
| +// logger arguments the scaffold took have grown into Config (config.go), |
| +// because sign-in needs four more settings and each of them is a |
| +// startup refusal when it is missing outside development. Every one of |
| +// those refusals happens here, before the database is touched, so a |
| +// misconfigured instance never reaches the point of serving a request. |
| +// |
| +// The database handle is the caller's (cmd/slides/main.go's db.Open), |
| +// not one this opens: SKILL.md §1's Resolve/Serve split depends on |
| +// exactly one connection to the file. |
| +func New(d *db.DB, cfg Config) (*http.ServeMux, error) { |
| + logger := cfg.Logger |
| + if logger == nil { |
| + logger = slog.Default() |
| + } |
| + if cfg.Env == "" { |
| + cfg.Env = EnvProduction |
| + } |
| + |
| + // Fail closed, loudly, and before anything opens: each of these |
| + // four decides whether sign-in is safe, and each may default only |
| + // when Env is exactly "development" (config.go's package comment). |
| + origin, err := resolveOrigin(cfg) |
| + if err != nil { |
| + return nil, err |
| + } |
| + instanceKey, err := resolveInstanceKey(cfg) |
| + if err != nil { |
| + return nil, err |
| + } |
| + mailer, err := resolveMailer(cfg, logger) |
| + if err != nil { |
| + return nil, err |
| + } |
| + list, err := resolveAllowlist(cfg) |
| + if err != nil { |
| + return nil, err |
| + } |
| + |
| // Apply BootSchema, not Schema: BootSchema is everything this |
| - // app needs at boot (its own migrations plus any subsystem's), |
| + // app needs at boot (its own migrations plus sessions' and auth's), |
| // while Schema (migrations.go) stays just this app's own so the |
| // generator and its check never see a subsystem's tables and try |
| // to drop them. |
| @@ -43,20 +71,44 @@ func App(d *db.DB, origin string, logger *slog.Logger) (*http.ServeMux, error) { |
| logger.Info("migrate", "applied", migrated.Applied, |
| "skipped", migrated.Skipped, "adopted", migrated.Adopted) |
| - a := &app{db: d, logger: logger} |
| + authPlugin, err := newAuthPlugin(origin, instanceKey, d.Writer(), mailer, list, logger) |
| + if err != nil { |
| + return nil, err |
| + } |
| + |
| + a := &app{db: d, logger: logger, cfg: cfg} |
| r := chi.NewRouter() |
| // App-wide, from day one: every state-changing route added below |
| // is born covered. Origin-checking, not tokens — nothing to mint |
| // or forget in a form. |
| r.Use(csrf.Protect(origin)) |
| - r.Get("/", a.index) |
| + |
| + // Unauthenticated: the front door and rauth's four handlers. The |
| + // sign-in page has to be reachable signed out or nobody could ever |
| + // get in. |
| + r.Get(signinPath, a.signin) |
| + // gateBegin, not authPlugin.Begin directly — see its own doc |
| + // comment: the allowlist has to sit in FRONT of Begin, because |
| + // Begin is what sends the mail. |
| + r.Post(signinPath, gateBegin(authPlugin.Begin, list)) |
| + r.Get("/auth/callback", authPlugin.Callback) |
| + r.Get("/auth/verify", authPlugin.Verify) |
| + r.Post("/signout", authPlugin.Signout) |
| + |
| + // Everything else needs a real, signed-in person (SKILL.md §5). A |
| + // signed-out GET/HEAD redirects to /signin; anything else 403s. |
| + r.Group(func(r chi.Router) { |
| + r.Use(authPlugin.RequireSession) |
| + r.Get("/", a.index) |
| + }) |
| mux := http.NewServeMux() |
| // The app serves its own static files — the framework never does. |
| // They are embedded (render.go) and fingerprinted: templates link |
| // them via {{asset ...}}, which returns a content-hashed URL the |
| - // handler serves cacheable-forever. |
| + // handler serves cacheable-forever. Mounted outside the chi router |
| + // so the sign-in page can load its stylesheet signed out. |
| mux.Handle("GET /static/", assets.Handler()) |
| mux.Handle("/", r) |
| return mux, nil |
diff --git a/internal/slides/auth.go b/internal/slides/auth.go| new file mode 100644 |
| index 0000000..fc84364 |
| --- /dev/null |
| +++ b/internal/slides/auth.go |
| @@ -0,0 +1,306 @@ |
| +// auth.go is sign-in: rastrillo/auth's magic-link plugin, the |
| +// allowlist that gates it at both ends, and the mapping from a verified |
| +// address to this app's own users row. |
| +// |
| +// Per spec §6.1 a single instance signs people in itself — an emailed, |
| +// single-use link, no password, no credential enrolled. Deliberately |
| +// magic link only, never a passkey second factor: a passkey's relying |
| +// party id derives from this instance's own host, so a person on three |
| +// team instances would enrol three separate credentials under one app. |
| +// A magic link enrols nothing, so it creates no relying party and no |
| +// sprawl. That is a design ruling, not an omission to improve on later. |
| +// |
| +// This file is a port of oficina/docs' internal/docs/auth.go, adapted |
| +// to rastrillo v0.26.0 and to Config.AllowedAddresses being a slice |
| +// rather than a comma-separated string. Spec §6.1 names the second copy |
| +// as the extraction trigger: the next app to need this should get it |
| +// from rastrillo, not from a third copy. |
| +package slides |
| + |
| +import ( |
| + "context" |
| + "database/sql" |
| + "errors" |
| + "fmt" |
| + "log/slog" |
| + "net/http" |
| + "net/url" |
| + "strings" |
| + |
| + "gorm.io/gorm" |
| + "gorm.io/gorm/clause" |
| + |
| + "amadan.net/rastrillo/rastrillo" |
| + rauth "amadan.net/rastrillo/rastrillo/auth" |
| + "amadan.net/rastrillo/rastrillo/mail" |
| + "amadan.net/rastrillo/rastrillo/sessions" |
| +) |
| + |
| +// signinPath is where the sign-in page lives. It must keep matching |
| +// rauth.Config's own default SigninPath, because newAuthPlugin never |
| +// overrides it: rauth redirects every outcome back to its own copy of |
| +// this literal, and app.go mounts the page at this one. |
| +const signinPath = "/signin" |
| + |
| +// devInstanceKey is resolveInstanceKey's fallback when Env is |
| +// development and Config.InstanceKey is empty. It is not a secret: it |
| +// is a fixed, public string, exactly as dangerous — and exactly as |
| +// acceptable — as rastrillo/auth's own refused empty key would be, |
| +// except spelled out here instead of left implicit, and it never seals |
| +// anything outside a throwaway development database. |
| +const devInstanceKey = "slides-development-instance-key-not-a-secret" |
| + |
| +// resolveInstanceKey applies Config.InstanceKey's fail-closed rule: |
| +// non-empty always wins; empty is fine only in development; anywhere |
| +// else it is a startup refusal. |
| +func resolveInstanceKey(cfg Config) (string, error) { |
| + if key := strings.TrimSpace(cfg.InstanceKey); key != "" { |
| + return key, nil |
| + } |
| + if cfg.development() { |
| + return devInstanceKey, nil |
| + } |
| + return "", fmt.Errorf("slides: Config.InstanceKey is empty and Env is %q, not %q — refusing to start with no seal for the sign-in link's pending blob; set $SLIDES_INSTANCE_KEY", cfg.Env, EnvDevelopment) |
| +} |
| + |
| +// resolveMailer applies Config.Mailer's fail-closed rule: an explicit |
| +// Mailer always wins; nil falls back to rastrillo/mail's logging sender |
| +// in development, and is a startup refusal anywhere else. |
| +func resolveMailer(cfg Config, logger *slog.Logger) (mail.Sender, error) { |
| + if cfg.Mailer != nil { |
| + return cfg.Mailer, nil |
| + } |
| + if !cfg.development() { |
| + return nil, fmt.Errorf("slides: mail is not configured (set $SLIDES_SMTP_HOST and $SLIDES_SMTP_FROM) and Env is %q, not %q — refusing to start: a sign-in that cannot send its link is not sign-in", cfg.Env, EnvDevelopment) |
| + } |
| + // mail.Logged labels every send loudly itself — see its own doc |
| + // comment. Never reached outside development. |
| + return mail.Logged(logger), nil |
| +} |
| + |
| +// normalizeAddress puts address into the one form the allowlist |
| +// compares and the users table stores: trimmed, lowercased. Shared by |
| +// the allowlist and by ensureUser so the two can never drift into |
| +// comparing differently-normalized forms of the same address. |
| +func normalizeAddress(address string) string { |
| + return strings.ToLower(strings.TrimSpace(address)) |
| +} |
| + |
| +// allowlist is Config.AllowedAddresses, parsed and normalized. |
| +// |
| +// A nil addrs map means "admit every address" — resolveAllowlist's |
| +// development-only default, spelled out here rather than left as an |
| +// accidental nil. A non-nil one means "admit nothing but exactly this |
| +// set"; resolveAllowlist never returns a non-nil-but-empty map, since |
| +// an empty parse outside development is a refusal rather than a value. |
| +type allowlist struct { |
| + addrs map[string]struct{} |
| +} |
| + |
| +// allows reports whether address may sign in. |
| +func (a allowlist) allows(address string) bool { |
| + if a.addrs == nil { |
| + return true |
| + } |
| + _, ok := a.addrs[normalizeAddress(address)] |
| + return ok |
| +} |
| + |
| +// resolveAllowlist applies Config.AllowedAddresses' fail-closed rule. |
| +// See that field's own doc comment for why an empty list outside |
| +// development is a refusal rather than "admit nobody". |
| +func resolveAllowlist(cfg Config) (allowlist, error) { |
| + addrs := make(map[string]struct{}) |
| + for _, addr := range cfg.AllowedAddresses { |
| + if addr = normalizeAddress(addr); addr != "" { |
| + addrs[addr] = struct{}{} |
| + } |
| + } |
| + if len(addrs) > 0 { |
| + return allowlist{addrs: addrs}, nil |
| + } |
| + if cfg.development() { |
| + return allowlist{}, nil // addrs stays nil: admit everyone |
| + } |
| + return allowlist{}, fmt.Errorf("slides: Config.AllowedAddresses is empty and Env is %q, not %q — refusing to start with no allowlist: an instance admitting nobody is indistinguishable from one working correctly, since every POST /signin answers the same 303 either way; set $SLIDES_ALLOWED_ADDRESSES", cfg.Env, EnvDevelopment) |
| +} |
| + |
| +// gateBegin wraps rauth.Auth.Begin so a non-permitted address never |
| +// reaches it at all. Begin is what sends the mail; |
| +// rauth.Config.Authorize runs only afterwards, inside rauth's own |
| +// admit(), at the callback — by which point a link has already been |
| +// mailed and clicked. Gating only Authorize would leave this instance's |
| +// sending identity an open relay for any address a stranger types into |
| +// the form. |
| +// |
| +// A non-permitted address gets exactly the response the permitted path |
| +// sends: the same 303, to the same signinPath+"?sent=1". That is |
| +// deliberate. Any observable difference — a different status, a |
| +// different body, even a differently-cased query — would turn this |
| +// endpoint into a membership oracle, letting anyone learn which |
| +// addresses this instance admits by watching how its answers differ. |
| +// |
| +// force=1 is set unconditionally on the permitted branch, not left to |
| +// what the caller's form said. It selects rauth's plain-magic-link path |
| +// over keymail classification, which this app never wants (see this |
| +// file's package comment). Without it a permitted keymail-hosted |
| +// address would redirect out to keymail while a non-permitted one got |
| +// ?sent=1 — the same oracle, one level narrower. It also saves the |
| +// classification network call outright. |
| +// |
| +// r.ParseForm is documented idempotent, so reading the address here and |
| +// again inside Begin costs nothing and cannot disagree with itself. A |
| +// malformed body reads back as address == "", which is never on a |
| +// non-empty allowlist — the same answer Begin would eventually reach, |
| +// just reached before any mail could go out. |
| +func gateBegin(next http.HandlerFunc, list allowlist) http.HandlerFunc { |
| + return func(w http.ResponseWriter, r *http.Request) { |
| + _ = r.ParseForm() |
| + if !list.allows(r.FormValue("address")) { |
| + http.Redirect(w, r, signinPath+"?sent=1", http.StatusSeeOther) |
| + return |
| + } |
| + if r.Form == nil { |
| + r.Form = url.Values{} |
| + } |
| + r.Form.Set("force", "1") |
| + next(w, r) |
| + } |
| +} |
| + |
| +// newAuthPlugin wires rastrillo/auth per SKILL.md §5: exactly one *Auth |
| +// per process (the rate limiter's budget lives on the value, so a plugin |
| +// per request would silently have no rate limiting at all), Origin as |
| +// both the emailed link's base and the cookie decision, InstanceKey and |
| +// Mailer already resolved and fail-closed above. |
| +// |
| +// Authorize is list.allows as well as gateBegin's gate — belt and |
| +// braces, defending two different things: gateBegin stops the mail, |
| +// Authorize stops a session being minted from a link that was somehow |
| +// issued for a non-permitted address (mailed before the allowlist |
| +// changed, say). SecondFactor stays nil, per this file's package |
| +// comment. |
| +func newAuthPlugin(origin, instanceKey string, writer *sql.DB, mailer mail.Sender, list allowlist, logger *slog.Logger) (*rauth.Auth, error) { |
| + return rauth.New(rauth.Config{ |
| + DB: writer, |
| + Origin: origin, |
| + InstanceKey: instanceKey, |
| + Mailer: mailer, |
| + Authorize: list.allows, |
| + Logger: logger, |
| + }) |
| +} |
| + |
| +// presenceColours is the palette ensureUser assigns from — spec §3.2's |
| +// coloured cursor and block outline. Six, so a small team rarely |
| +// collides, and never the only carrier of who is who: the name rides |
| +// beside the colour everywhere it is drawn. |
| +var presenceColours = []string{ |
| + "#2f6f4f", "#8a4b2a", "#3a4f8a", "#7a2f5f", "#4a5a2a", "#2a5f7a", |
| +} |
| + |
| +// ensureUser maps a verified address to this app's own users row, |
| +// creating it on first sign-in. It is the mapping SKILL.md §5 insists |
| +// on: under rastrillo/auth the session Subject is the email, so |
| +// sessions.UserID answers (0, false) and scoping on it would file every |
| +// deck under user_id = 0. |
| +// |
| +// ON CONFLICT DO NOTHING on the unique Email index rather than a read |
| +// followed by a write: two requests arriving together for a person's |
| +// very first sign-in would otherwise both see no row and both insert |
| +// one, and only the unique index would stop them — as an error on the |
| +// loser's request rather than as a shared row. |
| +func ensureUser(ctx context.Context, g *gorm.DB, address string) (User, error) { |
| + email := normalizeAddress(address) |
| + if email == "" { |
| + return User{}, errors.New("slides: cannot map an empty address to a user") |
| + } |
| + u := User{Email: email, Colour: colourFor(email)} |
| + if err := g.WithContext(ctx).Clauses(clause.OnConflict{DoNothing: true}).Create(&u).Error; err != nil { |
| + return User{}, fmt.Errorf("slides: create user %q: %w", email, err) |
| + } |
| + // DoNothing leaves ID at zero when the row already existed, so read |
| + // it back either way rather than trusting the insert's return. |
| + var found User |
| + if err := g.WithContext(ctx).Where("email = ?", email).Take(&found).Error; err != nil { |
| + return User{}, fmt.Errorf("slides: load user %q: %w", email, err) |
| + } |
| + return found, nil |
| +} |
| + |
| +// colourFor picks a stable presence colour for an address: the same |
| +// person gets the same colour on every instance and after every |
| +// restart, which is what makes a colour worth recognising at all. |
| +func colourFor(email string) string { |
| + var sum uint32 |
| + for _, b := range []byte(email) { |
| + sum = sum*31 + uint32(b) |
| + } |
| + return presenceColours[int(sum%uint32(len(presenceColours)))] |
| +} |
| + |
| +// currentUser is the users row for whoever is signed in on r, created |
| +// on first sight. Past RequireSession the second return is always true; |
| +// it is false for a signed-out request, or when the row could not be |
| +// read (logged, never surfaced as a half-signed-in page). |
| +// |
| +// sessions.Current(r).Subject, never sessions.UserID — see ensureUser. |
| +func (a *app) currentUser(r *http.Request) (User, bool) { |
| + s, ok := sessions.Current(r) |
| + if !ok || s.Subject == "" { |
| + return User{}, false |
| + } |
| + u, err := ensureUser(r.Context(), a.db.G, s.Subject) |
| + if err != nil { |
| + a.logger.Error("slides: resolve current user", "err", err) |
| + return User{}, false |
| + } |
| + return u, true |
| +} |
| + |
| +// signinPage is GET /signin's view data. rauth's own handlers never |
| +// render a page (magic-links.md: "the sign-in page stays yours") — they |
| +// report every outcome by redirecting back here with a query this |
| +// handler reads. |
| +type signinPage struct { |
| + Title string |
| + Body string |
| + AddressLabel string |
| + SubmitLabel string |
| + Sent string // non-empty: a link was emailed — say so |
| + ErrorMessage string // non-empty: something went wrong — say what |
| +} |
| + |
| +// signin serves GET /signin: the form, and the landing spot for every |
| +// outcome rauth redirects back to (?sent=1, ?err=rate|address|expired| |
| +// keymail|1). rate, address and expired each get their own sentence; |
| +// keymail and a bare failure share the default, because every outcome |
| +// on this page means the same operational thing — sign-in did not |
| +// complete, ask for a new link — and rauth's Verify deliberately |
| +// refuses to be more specific than that, so there is no finer |
| +// distinction for this page to draw. |
| +func (a *app) signin(w http.ResponseWriter, r *http.Request) { |
| + q := r.URL.Query() |
| + data := signinPage{ |
| + Title: rastrillo.T(r, "slides.signin.title"), |
| + Body: rastrillo.T(r, "slides.signin.body"), |
| + AddressLabel: rastrillo.T(r, "slides.signin.address.label"), |
| + SubmitLabel: rastrillo.T(r, "slides.signin.submit"), |
| + } |
| + if q.Get("sent") == "1" { |
| + data.Sent = rastrillo.T(r, "slides.signin.sent") |
| + } |
| + switch q.Get("err") { |
| + case "": |
| + // nothing to report |
| + case "rate": |
| + data.ErrorMessage = rastrillo.T(r, "slides.signin.error.rate") |
| + case "address": |
| + data.ErrorMessage = rastrillo.T(r, "slides.signin.error.address") |
| + case "expired": |
| + data.ErrorMessage = rastrillo.T(r, "slides.signin.error.expired") |
| + default: |
| + data.ErrorMessage = rastrillo.T(r, "slides.signin.error.unknown") |
| + } |
| + render(w, "signin", data) |
| +} |
diff --git a/internal/slides/config.go b/internal/slides/config.go| new file mode 100644 |
| index 0000000..32f003a |
| --- /dev/null |
| +++ b/internal/slides/config.go |
| @@ -0,0 +1,165 @@ |
| +// config.go is everything cmd/slides/main.go reads out of the |
| +// environment and hands to New, plus the rules that decide whether a |
| +// given combination is safe to start. |
| +// |
| +// One rule runs through all of it: exactly one Env value — |
| +// "development" — may ever take a default, and cmd/slides/main.go's |
| +// chooseEnv makes sure only an explicit -env flag can select it. |
| +// Anywhere else, a setting that decides whether sign-in is safe is a |
| +// startup refusal when it is missing, never a silent fallback. A |
| +// misconfigured instance is down, which gets fixed the same day; an |
| +// instance that quietly did the wrong thing looks healthy for as long |
| +// as nobody checks. |
| +package slides |
| + |
| +import ( |
| + "fmt" |
| + "log/slog" |
| + "net/url" |
| + "strings" |
| + |
| + "amadan.net/rastrillo/rastrillo/blobs" |
| + "amadan.net/rastrillo/rastrillo/mail" |
| +) |
| + |
| +// EnvDevelopment is the one Env value that unlocks a default. Spelled |
| +// once, so the several places that compare against it cannot drift. |
| +const EnvDevelopment = "development" |
| + |
| +// EnvProduction is Env's default when nothing sets it — deliberately |
| +// the closed one, so forgetting to set $SLIDES_ENV never opens |
| +// anything. |
| +const EnvProduction = "production" |
| + |
| +// DefaultOrigin is Config.Origin's fallback, reachable only in |
| +// development. Exported so a caller that needs to know which origin a |
| +// development app is protecting itself against does not duplicate the |
| +// literal. |
| +const DefaultOrigin = "http://localhost:8091" |
| + |
| +// Config is what New needs to build the app. cmd/slides/main.go fills |
| +// it from the environment; the test harness fills it directly. |
| +type Config struct { |
| + // Origin is this instance's external origin, scheme included — |
| + // "https://slides.oficina.works". It is three things at once: |
| + // csrf.Protect's yardstick for the legacy Origin/Referer fallback |
| + // (a modern browser is decided by Sec-Fetch-Site before this |
| + // string is read), the base of every emailed magic link, and what |
| + // decides Secure/__Host- cookie names. |
| + // |
| + // Empty is allowed only in development, where it falls back to |
| + // DefaultOrigin. Anywhere else it is a startup refusal: on the |
| + // platform's socket/LISTEN_FDS activation path there is no -addr |
| + // to derive an origin from, so a silent placeholder would mean |
| + // mailing sign-in links to a hostname nobody can reach. |
| + Origin string |
| + |
| + // Env is EnvDevelopment or anything else (empty means |
| + // EnvProduction). It gates the defaults below and nothing else. |
| + // cmd/slides/main.go sources it from an explicit -env flag; |
| + // $SLIDES_ENV is consulted too, but never for "development" |
| + // itself — see chooseEnv there for why an env var must not be able |
| + // to select it. |
| + Env string |
| + |
| + // InstanceKey seals rastrillo/auth's pending-link blob (an HMAC |
| + // key). An empty one hashes to a single fixed, publicly computable |
| + // value, identical across every deployment that made the same |
| + // mistake — see auth.Config.InstanceKey's own doc comment. Empty |
| + // is allowed only in development, where it falls back to |
| + // devInstanceKey (auth.go); anywhere else it is a startup refusal. |
| + InstanceKey string |
| + |
| + // Mailer delivers the magic link. Nil is allowed only in |
| + // development, where mail.Logged puts the link in the log, clearly |
| + // marked. Outside development nil is a startup refusal: a sign-in |
| + // that cannot send its link is not sign-in, and quietly logging a |
| + // live credential in a real deployment's log is the mistake that |
| + // fallback exists to flag, not to absorb. |
| + Mailer mail.Sender |
| + |
| + // AllowedAddresses is the only set of addresses this instance ever |
| + // mails a link to or admits a session for. Compared |
| + // case-insensitively, each entry trimmed (auth.go's |
| + // normalizeAddress). |
| + // |
| + // It is wired to two places on purpose: rauth.Config.Authorize |
| + // gates admission, and gateBegin gates POST /signin ahead of |
| + // rauth's own Begin. The second is the one that matters — |
| + // Authorize fires only inside rauth's admit(), after the link has |
| + // already been mailed, so Authorize alone would leave this |
| + // instance's sending identity an open relay for any address a |
| + // stranger types into the form. |
| + // |
| + // Empty is allowed only in development, where it means "admit |
| + // everyone" so the dev script and the suite need no setup. |
| + // Anywhere else it is a startup refusal rather than "admit |
| + // nobody": an instance admitting nobody boots looking perfectly |
| + // healthy — /signin renders, POST /signin answers its ordinary 303 |
| + // for every address, because that identical answer is the whole |
| + // point of gateBegin — and the only symptom is that no link ever |
| + // arrives. |
| + AllowedAddresses []string |
| + |
| + // Blobs is where uploaded bytes live — the object store, never the |
| + // instance's own filesystem, which is ephemeral and hibernates. |
| + // cmd/slides/main.go binds the platform's bucket with |
| + // blobs.S3FromEnv and falls back to blobs.Dir in development. |
| + // Nothing in this task reads it yet; deck assets do. |
| + Blobs blobs.Store |
| + |
| + // Logger defaults to slog.Default(). |
| + Logger *slog.Logger |
| +} |
| + |
| +// development reports whether cfg is in the one Env that may default. |
| +func (cfg Config) development() bool { return cfg.Env == EnvDevelopment } |
| + |
| +// resolveOrigin applies Config.Origin's own rule: a non-empty value is |
| +// trimmed and validated, an empty one falls back to DefaultOrigin in |
| +// development and is refused anywhere else. |
| +// |
| +// Trimmed before the emptiness test on purpose: $SLIDES_ORIGIN=" " is |
| +// not empty by a bare == "" check, so it would sail past the guard and |
| +// only surface later, at request time, on whichever legacy client |
| +// still exercises csrf.Protect's Origin/Referer fallback. |
| +func resolveOrigin(cfg Config) (string, error) { |
| + origin := strings.TrimSpace(cfg.Origin) |
| + if origin == "" { |
| + if cfg.development() { |
| + return DefaultOrigin, nil |
| + } |
| + return "", fmt.Errorf("slides: Config.Origin is empty and Env is %q, not %q — refusing to start with an unset origin; set $SLIDES_ORIGIN to this instance's real origin", cfg.Env, EnvDevelopment) |
| + } |
| + if err := validateOrigin(origin); err != nil { |
| + return "", fmt.Errorf("slides: Config.Origin is invalid: %w", err) |
| + } |
| + return origin, nil |
| +} |
| + |
| +// validateOrigin reports whether origin is something csrf.Protect and |
| +// an emailed link can both actually use: an absolute http(s) URL naming |
| +// a scheme and a host and nothing else. |
| +// |
| +// "Nothing else" is checked deliberately. url.Parse happily accepts a |
| +// bare path, a value carrying a query string, or one with embedded |
| +// userinfo, none of which could ever equal a browser's Origin header, |
| +// and none of which belongs at the front of a magic link. Hostname() |
| +// rather than Host: "http://:8091" — what a bare -addr :8091 would |
| +// derive — has a non-empty Host and no host at all. |
| +func validateOrigin(origin string) error { |
| + u, err := url.Parse(origin) |
| + if err != nil { |
| + return fmt.Errorf("origin %q does not parse as a URL: %w", origin, err) |
| + } |
| + if u.Scheme != "http" && u.Scheme != "https" { |
| + return fmt.Errorf("origin %q must have an http or https scheme", origin) |
| + } |
| + if u.Hostname() == "" { |
| + return fmt.Errorf("origin %q has no host", origin) |
| + } |
| + if u.Path != "" || u.RawQuery != "" || u.Fragment != "" || u.User != nil { |
| + return fmt.Errorf("origin %q must be scheme://host[:port] only, with no path, query, fragment or userinfo", origin) |
| + } |
| + return nil |
| +} |
diff --git a/internal/slides/handlers.go b/internal/slides/handlers.go| index 0b3cbc6..cd7de25 100644 |
| --- a/internal/slides/handlers.go |
| +++ b/internal/slides/handlers.go |
| @@ -4,16 +4,57 @@ import ( |
| "log/slog" |
| "net/http" |
| + "amadan.net/rastrillo/rastrillo" |
| "amadan.net/rastrillo/rastrillo/db" |
| ) |
| -// app holds what every handler needs. When the app grows accounts, |
| -// this is where the sessions handle lands too. |
| +// app holds what every handler needs: the database, the logger, and |
| +// the resolved config — which is also where the object store arrives |
| +// (Config.Blobs) for the deck screens that come next. |
| type app struct { |
| db *db.DB |
| logger *slog.Logger |
| + cfg Config |
| } |
| +// indexPage is GET /'s view data — the deck list. Every string on it is |
| +// resolved here rather than written into the template, so the whole |
| +// screen is one catalog lookup away from another language. |
| +type indexPage struct { |
| + Title string |
| + EmptyTitle string |
| + EmptyBody string |
| + SignoutLabel string |
| + // Person is who this is, for the account menu. The name when there |
| + // is one, the address otherwise. |
| + Person string |
| +} |
| + |
| +// index serves GET /: this person's decks. Behind RequireSession, so |
| +// currentUser always finds somebody — the row is created here, on the |
| +// first request after a first sign-in. |
| +// |
| +// The list itself is empty for now: there is no deck to put in it until |
| +// decks exist. The empty state is the screen, not a placeholder for |
| +// one — it says what this screen is for and it is what a new person |
| +// meets on their very first visit either way. |
| func (a *app) index(w http.ResponseWriter, r *http.Request) { |
| - render(w, "index", nil) |
| + u, ok := a.currentUser(r) |
| + if !ok { |
| + // Past RequireSession this means the users row could not be |
| + // read (currentUser logged why), not that nobody is signed in. |
| + http.Error(w, rastrillo.T(r, "slides.error.generic"), http.StatusInternalServerError) |
| + return |
| + } |
| + person := u.Name |
| + if person == "" { |
| + person = u.Email |
| + } |
| + render(w, "index", indexPage{ |
| + Title: rastrillo.T(r, "slides.decks.title"), |
| + EmptyTitle: rastrillo.T(r, "slides.decks.empty.title"), |
| + EmptyBody: rastrillo.T(r, "slides.decks.empty.body"), |
| + SignoutLabel: rastrillo.T(r, "slides.account.signout"), |
| + Person: person, |
| + }) |
| } |
diff --git a/internal/slides/locales/en.toml b/internal/slides/locales/en.toml| new file mode 100644 |
| index 0000000..6492417 |
| --- /dev/null |
| +++ b/internal/slides/locales/en.toml |
| @@ -0,0 +1,45 @@ |
| +# Slides' English catalog. Every user-visible string this app writes is |
| +# a key here, never a literal in a template or a handler, resolved |
| +# through rastrillo.T at render time. |
| +# |
| +# Flat TOML, one key per line, dotted names grouped by screen — the |
| +# format rastrillo.Options.LocaleFS reads as locales/<code>.toml. |
| +# Adding a language means adding a file beside this one with the same |
| +# keys; nothing else in the app has to change. |
| + |
| +# slides.signin.* — GET /signin (auth.go's signin handler). The page |
| +# renders the form and, on the way back from rastrillo/auth, whichever |
| +# outcome it was redirected here with (?sent=1, or |
| +# ?err=rate|address|expired|keymail|1). |
| +# |
| +# error.unknown covers both the keymail outcome and a bare failure: |
| +# rastrillo/auth's Verify deliberately answers the same way for an |
| +# unknown, a used and an expired token, so there is no finer |
| +# distinction for this page to draw — and every outcome on this screen |
| +# means one operational thing, "ask for a new link". |
| +slides.signin.title = "Sign in" |
| +slides.signin.body = "Enter your email address and we'll send you a link to sign in." |
| +slides.signin.address.label = "Email address" |
| +slides.signin.submit = "Send link" |
| +slides.signin.sent = "Check your email for a link to sign in." |
| +slides.signin.error.rate = "Too many attempts. Wait a few minutes and try again." |
| +slides.signin.error.address = "That doesn't look like a valid email address." |
| +slides.signin.error.expired = "That link has expired or was already used. Enter your email below for a new one." |
| +slides.signin.error.unknown = "We couldn't sign you in. Enter your email below for a new link." |
| + |
| +# slides.decks.* — GET /, the deck list. Title is the screen's noun, |
| +# per page-header's own contract ("Decks", not "Home"). |
| +# |
| +# The empty state is a designed screen, not a blank page: it says what |
| +# this screen is for rather than only that there is nothing on it. |
| +slides.decks.title = "Decks" |
| +slides.decks.empty.title = "No decks yet" |
| +slides.decks.empty.body = "Decks you build will show up here, newest first." |
| + |
| +# slides.account.* — the shell's account menu. |
| +slides.account.signout = "Sign out" |
| + |
| +# slides.error.generic is this app's one fallback error string: a |
| +# failure with nowhere styled to land. Still a key, never a literal, |
| +# even though it is plain text today. |
| +slides.error.generic = "Something went wrong." |
diff --git a/internal/slides/migrations.go b/internal/slides/migrations.go| index e0f474d..870ba98 100644 |
| --- a/internal/slides/migrations.go |
| +++ b/internal/slides/migrations.go |
| @@ -3,7 +3,9 @@ package slides |
| import ( |
| "embed" |
| + rauth "amadan.net/rastrillo/rastrillo/auth" |
| "amadan.net/rastrillo/rastrillo/migrate" |
| + "amadan.net/rastrillo/rastrillo/sessions" |
| ) |
| //go:embed migrations/*.sql |
| @@ -24,19 +26,15 @@ var migrationFS embed.FS |
| var Schema = migrate.MustFromFS(migrationFS, "slides") |
| // BootSchema is everything applied at boot, in order — this app's own |
| -// Schema plus every framework subsystem's — and it is what App() below |
| +// Schema plus every framework subsystem's — and it is what New() (app.go) |
| // hands to migrate.Apply. It is a separate variable from Schema for |
| // the same reason Schema must stay narrow: generate and check would |
| // misread a subsystem's tables as ones Models forgot. |
| // |
| -// A fresh scaffold uses no subsystem yet, so today this is just |
| -// Schema. Adding one (say, sessions) means editing this line, not |
| -// Schema: |
| -// |
| -// var BootSchema = migrate.Merge(sessions.Schema, Schema) |
| -// |
| // Merge's argument order is apply order, so a subsystem whose |
| -// migrations another one reads (auth after sessions) goes first. |
| +// migrations another one reads goes first: rastrillo/auth's own |
| +// backfill migration reads the sessions table, so sessions.Schema |
| +// must apply before auth.Schema, and this app's own Schema last. |
| // Get this line wrong and either boot silently skips a subsystem's |
| // tables, or generate/check try to drop them. |
| -var BootSchema = migrate.Merge(Schema) |
| +var BootSchema = migrate.Merge(sessions.Schema, rauth.Schema, Schema) |
diff --git a/internal/slides/migrations/0001_init.sql b/internal/slides/migrations/0001_init.sql| index c63a80d..9e7d927 100644 |
| --- a/internal/slides/migrations/0001_init.sql |
| +++ b/internal/slides/migrations/0001_init.sql |
| @@ -1,2 +1,3 @@ |
| -CREATE TABLE `notes` (`id` integer PRIMARY KEY AUTOINCREMENT,`title` text,`body` text,`created_at` datetime,`updated_at` datetime); |
| +CREATE TABLE `users` (`id` integer PRIMARY KEY AUTOINCREMENT,`email` text,`name` text,`colour` text,`created_at` datetime); |
| +CREATE UNIQUE INDEX `idx_users_email` ON `users`(`email`); |
diff --git a/internal/slides/migrations/schema.sql b/internal/slides/migrations/schema.sql| index 239e296..2d6bcee 100644 |
| --- a/internal/slides/migrations/schema.sql |
| +++ b/internal/slides/migrations/schema.sql |
| @@ -1,5 +1,7 @@ |
| -- Generated by rastrillo migration generate; DO NOT EDIT. |
| -- The schema every migration in this directory adds up to. |
| -CREATE TABLE `notes` (`id` integer PRIMARY KEY AUTOINCREMENT,`title` text,`body` text,`created_at` datetime,`updated_at` datetime); |
| +CREATE TABLE `users` (`id` integer PRIMARY KEY AUTOINCREMENT,`email` text,`name` text,`colour` text,`created_at` datetime); |
| + |
| +CREATE UNIQUE INDEX `idx_users_email` ON `users`(`email`); |
diff --git a/internal/slides/models.go b/internal/slides/models.go| index 14f2c15..7315c62 100644 |
| --- a/internal/slides/models.go |
| +++ b/internal/slides/models.go |
| @@ -8,18 +8,37 @@ import "time" |
| // `rastrillo migration check` fails CI when it and the |
| // migrations disagree. |
| var Models = []any{ |
| - &Note{}, |
| + &User{}, |
| } |
| -// Note is a starting example model — rename it, replace it, add more |
| -// beside it. Whatever it becomes, the rule stays the same: every query |
| -// touching user-owned rows goes through scope.Owned (SKILL.md) — reads |
| -// AND writes — and a request body is never bound onto a model directly |
| -// (explicit map[string]any + .Select allowlist). |
| -type Note struct { |
| - ID int64 |
| - Title string |
| - Body string |
| +// User is a person who has signed in here — spec §6.1's |
| +// `users(id, email, name, colour)`, created on first sign-in by |
| +// auth.go's ensureUser. |
| +// |
| +// It exists because under rastrillo/auth the session Subject is the |
| +// verified email address, not a numeric id (SKILL.md §5's warning: |
| +// sessions.UserID answers (0, false) and would scope every owned query |
| +// to user_id = 0). Every later model's owner column points at User.ID, |
| +// so the address has to become a row before anything can be scoped. |
| +// |
| +// Email carries the unique index because the address is the identity: |
| +// two rows for one address would mean one person owning two disjoint |
| +// sets of decks depending on which row a request happened to find. |
| +// Stored normalized (auth.go's normalizeAddress) so "Paul@Example.test" |
| +// and "paul@example.test" are the same person, which is what the |
| +// allowlist already assumes. |
| +type User struct { |
| + ID int64 |
| + Email string `gorm:"uniqueIndex"` |
| + // Name is what other people see beside this person's cursor and in |
| + // a deck's activity list. Empty until there is a screen to set it; |
| + // callers fall back to Email. |
| + Name string |
| + // Colour is this person's presence colour (spec §3.2's coloured |
| + // outline and cursor label). Assigned at creation from a fixed |
| + // palette so two people in one deck are told apart by more than |
| + // their name — and never by colour alone, per the app's own WCAG |
| + // commitment. |
| + Colour string |
| CreatedAt time.Time |
| - UpdatedAt time.Time |
| } |
diff --git a/internal/slides/render.go b/internal/slides/render.go| index 6646461..b5c5d92 100644 |
| --- a/internal/slides/render.go |
| +++ b/internal/slides/render.go |
| @@ -15,9 +15,31 @@ import ( |
| //go:embed templates static |
| var appFS embed.FS |
| +// LocaleFS carries the catalogs as locales/<code>.toml — the shape |
| +// rastrillo.Options.LocaleFS (and rastrillo.NewLocales) expects. |
| +// Separate from appFS because that one is also the fingerprinted asset |
| +// tree, and a catalog is not an asset anybody fetches. |
| +// |
| +//go:embed locales |
| +var LocaleFS embed.FS |
| + |
| +// Locales and DefaultLocale declare this app's locale set. |
| +// cmd/slides/main.go wires all three of these into rastrillo.Options; |
| +// without them rastrillo installs no locale middleware and every |
| +// rastrillo.T call returns its key verbatim, so this is what makes the |
| +// catalog actually resolve on a real page. |
| +// |
| +// English only for now. Twelve locales are the suite's target and every |
| +// user-visible string is already a key, so adding one is adding a file. |
| +var Locales = []string{"en"} |
| + |
| +// DefaultLocale is the locale an unprefixed request with no stored |
| +// preference and no usable Accept-Language gets. |
| +const DefaultLocale = "en" |
| + |
| // assets fingerprints static/: {{asset "static/tokens.css"}} in a |
| // template returns a content-hashed URL, served immutable-cacheable by |
| -// the handler App mounts. |
| +// the handler New mounts (app.go). |
| var assets = rastrillo.NewAssets(appFS) |
| // pages is one *template.Template per page, each combining layout.html |
| @@ -26,7 +48,7 @@ var assets = rastrillo.NewAssets(appFS) |
| var pages = map[string]*template.Template{} |
| func init() { |
| - for _, name := range []string{"index", "errors"} { |
| + for _, name := range []string{"index", "signin", "errors"} { |
| pages[name] = template.Must( |
| // ui's partials and their helpers first, so every page can |
| // call one without registering anything. WithIcons points |
diff --git a/internal/slides/templates/index.html b/internal/slides/templates/index.html| index dd9abab..281e47c 100644 |
| --- a/internal/slides/templates/index.html |
| +++ b/internal/slides/templates/index.html |
| @@ -1,3 +1,52 @@ |
| +{{/* index — GET /, the deck list, behind the session gate. |
| + |
| + There are no decks to list yet; the deck model and its screens are |
| + the next task. What is here is not a placeholder for a list, it is |
| + the screen a new person meets on their very first visit either way: |
| + empty-state says what this screen is for rather than only that |
| + nothing is on it. Its call to action arrives with the create route |
| + that can answer it — an empty state whose button 404s is worse than |
| + one with no button. |
| + |
| + The list lives inside [rst-box] so that adding rows later is |
| + filling a container that already exists, rather than restyling the |
| + screen around them. |
| + |
| + Bare rst-* attribute grammar, no JavaScript: this page is a |
| + heading, a box and a sign-out form. */}} |
| +{{define "title"}}{{.Title}}{{end}} |
| +{{define "head"}}<style> |
| + /* The address the account menu is signed in as: a label above the |
| + one control, not a control itself. Muted and small so it reads as |
| + context rather than as a second thing to click, and padded to the |
| + same inset as the button below it so the two line up. */ |
| + [slides-account-who] { |
| + color: var(--rst-text-muted); |
| + font-size: var(--rst-fs-sm); |
| + margin: 0; |
| + overflow-wrap: anywhere; |
| + padding: 0.4rem 0.65rem; |
| + } |
| +</style>{{end}} |
| +{{define "account"}} |
| +<p slides-account-who>{{.Person}}</p> |
| +<hr> |
| +{{/* Sign-out is a POST, so it is a form and not a link: csrf.Protect |
| + guards state-changing methods, and a GET that ends a session is one |
| + a link prefetch can fire. A form also works with scripts off, which |
| + a fetch() would not — tokens.css already styles a bare button |
| + inside [rst-dropdown-menu] as a menu item. */}} |
| +<form method="post" action="/signout"> |
| + <button type="submit">{{.SignoutLabel}}</button> |
| +</form> |
| +{{end}} |
| {{define "content"}} |
| -<h1>Hello, World — this is a rastrillo app.</h1> |
| +<header rst-page-header> |
| + <div rst-page-header-titles> |
| + <h1>{{.Title}}</h1> |
| + </div> |
| +</header> |
| +<div rst-box> |
| + {{template "empty-state" dict "Title" .EmptyTitle "Body" .EmptyBody}} |
| +</div> |
| {{end}} |
diff --git a/internal/slides/templates/layout.html b/internal/slides/templates/layout.html| index 2535d1f..5f109e7 100644 |
| --- a/internal/slides/templates/layout.html |
| +++ b/internal/slides/templates/layout.html |
| @@ -3,7 +3,10 @@ |
| <head> |
| <meta charset="utf-8"> |
| <meta name="viewport" content="width=device-width"> |
| -<title>{{block "title" .}}Hello{{end}}</title> |
| +{{/* Slides, not a catalog key: the app's name is a proper noun and |
| + reads the same in every locale. Every real page overrides this |
| + block with its own screen name. */}} |
| +<title>{{block "title" .}}Slides{{end}}</title> |
| <link rel="stylesheet" href="{{asset "static/tokens.css"}}"> |
| <link rel="stylesheet" href="{{asset "static/theme.css"}}"> |
| <script defer src="{{asset "static/rastrillo.js"}}"></script> |
| @@ -21,7 +24,10 @@ |
| <div rst-shell-topbar> |
| <a rst-skip href="#main">{{T "rastrillo.ui.shell_skip"}}</a> |
| <header rst-shell-bar> |
| -{{block "brand" .}}<a rst-shell-brand href="/">{{T "rastrillo.ui.shell_home"}}</a>{{end}} |
| +{{/* The app's own name rather than the framework default "Home": a |
| + bar that says "Home" tells a person nothing about which of their |
| + tabs they are looking at. A proper noun, so not a catalog key. */}} |
| +{{block "brand" .}}<a rst-shell-brand href="/">Slides</a>{{end}} |
| {{/* The narrow layout. Below 800px the bar's tail — nav, account, |
| locale — hides behind this disclosure; above it, tokens.css hides |
| the summary and gives [rst-shell-tail] display: contents, so the |
diff --git a/internal/slides/templates/signin.html b/internal/slides/templates/signin.html| new file mode 100644 |
| index 0000000..e3264b0 |
| --- /dev/null |
| +++ b/internal/slides/templates/signin.html |
| @@ -0,0 +1,104 @@ |
| +{{/* signin — GET /signin (auth.go's signin handler). This is the page |
| + rastrillo/auth's own handlers never render themselves |
| + (magic-links.md: "the sign-in page stays yours"): Begin, Callback |
| + and Verify report every outcome by redirecting back here with a |
| + query the handler reads. It resolves those into already-translated |
| + Sent/ErrorMessage strings; this template only decides whether to |
| + show them. |
| + |
| + The form POSTs straight to rastrillo/auth's own Begin behind |
| + gateBegin (app.go) — no handler of this app's own sits in that |
| + path. The hidden force=1 field says "plain magic link": this app |
| + never offers a keymail or passkey ceremony (spec §8.1, auth.go's |
| + package comment), so there is nothing for a person to choose |
| + between, and force skips signin's classification network call |
| + outright. gateBegin sets it again server-side, so the guarantee |
| + does not depend on this field surviving. |
| + |
| + Bare rst-* attribute grammar throughout, never class="rst-*", and |
| + no JavaScript: a form and a submit button are the whole screen. */}} |
| +{{define "title"}}{{.Title}}{{end}} |
| +{{define "head"}}<style> |
| + /* No shell bar on the front door. The topbar shell writes |
| + [rst-shell-bar] on every page, outside any block a page could |
| + override, so this screen hides it in CSS — the only lever it has. |
| + |
| + It hides it because there is nothing true to put in it: the |
| + default brand is a link to "/", which the session gate bounces |
| + straight back here, and below 800px the hamburger would open an |
| + empty tail. An empty strip of chrome that looks like it should do |
| + something is worse than none. |
| + |
| + display: none, not visibility or opacity: this must leave the |
| + accessibility tree too, or a screen-reader user tabs into an |
| + "Account" disclosure nobody can see. The shell's skip link is a |
| + sibling of the bar rather than inside it, so this page keeps a |
| + working skip-to-content. |
| + |
| + Written as a descendant of [rst-shell-topbar] to match the |
| + framework's own selector, and that is load-bearing rather than |
| + stylistic: tokens.css lays the bar out with |
| + `[rst-shell-topbar] [rst-shell-bar]`, specificity (0,2,0), which a |
| + bare `[rst-shell-bar]` at (0,1,0) would lose to. */ |
| + [rst-shell-topbar] [rst-shell-bar] { |
| + display: none; |
| + } |
| + /* One measure for the whole screen, shared by the callouts and the |
| + box. Without it the box fills the shell's full column and a single |
| + email field stretches the width of a data table, with its submit |
| + button somewhere off to the right — the shape of markup nobody has |
| + laid out, on the one screen a person meets before they have any |
| + reason to extend this app credit. |
| + |
| + Left-aligned rather than centred, so the box hangs off the same |
| + axis as the heading above it and as every other screen. */ |
| + [slides-signin] { |
| + max-inline-size: 32rem; |
| + } |
| + /* The field and its button share a line, with real space between |
| + them. flex-basis 12rem with room to shrink, and min-inline-size: 0 |
| + because an <input>'s automatic minimum size is its content width — |
| + without the explicit 0 it refuses to shrink and the row overflows |
| + instead of reflowing at the 320px width WCAG 2.2 AA commits to. |
| + Below that the button wraps under the field rather than either one |
| + being squeezed unusable. */ |
| + [slides-signin-form] { |
| + display: flex; |
| + flex-wrap: wrap; |
| + align-items: center; |
| + gap: var(--rst-sp-3); |
| + } |
| + [slides-signin-form] [rst-input] { |
| + flex: 1 1 12rem; |
| + min-inline-size: 0; |
| + } |
| +</style>{{end}} |
| +{{define "content"}} |
| +<header rst-page-header> |
| + <div rst-page-header-titles> |
| + <h1>{{.Title}}</h1> |
| + </div> |
| +</header> |
| +<div slides-signin> |
| + {{/* The callouts live inside the same column as the box, not above |
| + it at the shell's full width: an answer to what was just |
| + submitted that runs twice the width of the thing it refers to |
| + reads as a banner about the app. */}} |
| + {{if .Sent}} |
| + {{template "callout" dict "Body" .Sent "Tone" "positive"}} |
| + {{end}} |
| + {{if .ErrorMessage}} |
| + {{template "callout" dict "Body" .ErrorMessage "Tone" "negative" "Alert" true}} |
| + {{end}} |
| + <div rst-box> |
| + <div rst-box-head> |
| + <p>{{.Body}}</p> |
| + </div> |
| + <form slides-signin-form method="post" action="/signin"> |
| + <input type="hidden" name="force" value="1"> |
| + <input rst-input type="email" name="address" placeholder="{{.AddressLabel}}" aria-label="{{.AddressLabel}}" autocomplete="email" required> |
| + <button rst-btn="primary" type="submit">{{.SubmitLabel}}</button> |
| + </form> |
| + </div> |
| +</div> |
| +{{end}} |
diff --git a/internal/slidestest/browser_test.go b/internal/slidestest/browser_test.go| index e1af105..a085bb6 100644 |
| --- a/internal/slidestest/browser_test.go |
| +++ b/internal/slidestest/browser_test.go |
| @@ -27,13 +27,14 @@ import ( |
| slides "amadan.net/oficina/slides/internal/slides" |
| ) |
| -// TestBrowserWalk is the minimal loud walk: the home screen arrives |
| -// with nothing wrong anywhere the rig watches. Grow it with the app — |
| -// each screen a flow reaches earns a Screen() line, and an expected |
| -// probe (a signed-out 401, say) earns a rig.Allow. |
| +// TestBrowserWalk is the minimal loud walk: the front door arrives with |
| +// nothing wrong anywhere the rig watches. It walks /signin rather than |
| +// "/" because "/" is behind the session gate now, and a redirect is not |
| +// a screen. Grow it with the app — each screen a flow reaches earns a |
| +// Screen() line, and an expected probe earns a rig.Allow. |
| func TestBrowserWalk(t *testing.T) { |
| rig := harness.New(t, func(origin string) http.Handler { |
| - // The scaffold's own wiring, origin included: the rig hands |
| + // The same wiring main.go does, origin included: the rig hands |
| // the app its localhost origin before serving — the same seam |
| // main.go fills from SLIDES_ORIGIN. |
| logger := slog.New(slog.NewTextHandler(io.Discard, nil)) |
| @@ -42,12 +43,18 @@ func TestBrowserWalk(t *testing.T) { |
| t.Fatalf("db.Open: %v", err) |
| } |
| t.Cleanup(func() { d.Close() }) |
| - mux, err := slides.App(d, origin, logger) |
| + mux, err := slides.New(d, slides.Config{ |
| + Origin: origin, |
| + InstanceKey: testInstanceKey, |
| + Mailer: &fakeMailer{}, |
| + AllowedAddresses: []string{allowedAddress}, |
| + Logger: logger, |
| + }) |
| if err != nil { |
| - t.Fatalf("App: %v", err) |
| + t.Fatalf("slides.New: %v", err) |
| } |
| - return mux |
| + return localised(t, mux) |
| }) |
| - rig.Run(chromedp.Navigate(rig.Origin + "/")) |
| - rig.Screen("body", "home") |
| + rig.Run(chromedp.Navigate(rig.Origin + "/signin")) |
| + rig.Screen("body", "signin") |
| } |
diff --git a/internal/slidestest/harness_test.go b/internal/slidestest/harness_test.go| index 47bccd2..b2721a3 100644 |
| --- a/internal/slidestest/harness_test.go |
| +++ b/internal/slidestest/harness_test.go |
| @@ -7,15 +7,19 @@ |
| package slidestest |
| import ( |
| + "context" |
| "io" |
| "log/slog" |
| "net/http" |
| "net/http/httptest" |
| "net/url" |
| "path/filepath" |
| + "regexp" |
| "strings" |
| + "sync" |
| "testing" |
| + "amadan.net/rastrillo/rastrillo" |
| "amadan.net/rastrillo/rastrillo/db" |
| slides "amadan.net/oficina/slides/internal/slides" |
| @@ -25,38 +29,188 @@ import ( |
| // same-origin evidence — the pair a browser form submission would be. |
| const testOrigin = "http://app.test" |
| +// allowedAddress is on newConfig's allowlist; deniedAddress is not. |
| +// Named constants rather than literals per test, so a test that means |
| +// "the allowlist let this through" cannot silently become a test about |
| +// a typo. |
| +const ( |
| + allowedAddress = "paul@example.test" |
| + deniedAddress = "someone@else.test" |
| +) |
| + |
| +// testInstanceKey seals rastrillo/auth's pending blob for a test app. |
| +// Explicit, so the suite exercises the same fully-configured path a |
| +// real deployment takes rather than the development fallbacks — those |
| +// get tests of their own (config_test's refusals). |
| +const testInstanceKey = "slidestest-instance-key-not-a-secret" |
| + |
| +// sentMail is one message the fake mailer captured. |
| +type sentMail struct{ To, Subject, Body string } |
| + |
| +// fakeMailer is the test suite's mail.Sender: it delivers nothing and |
| +// remembers everything, so a test can assert both that a link was sent |
| +// and — the case the allowlist exists for — that one was not. The app |
| +// itself carries no recorder of its own; this is wired in through |
| +// slides.Config.Mailer like any other sender. |
| +type fakeMailer struct { |
| + mu sync.Mutex |
| + sent []sentMail |
| +} |
| + |
| +func (m *fakeMailer) Send(_ context.Context, to, subject, body string) error { |
| + m.mu.Lock() |
| + defer m.mu.Unlock() |
| + m.sent = append(m.sent, sentMail{To: to, Subject: subject, Body: body}) |
| + return nil |
| +} |
| + |
| +// messages returns a copy of everything sent so far. |
| +func (m *fakeMailer) messages() []sentMail { |
| + m.mu.Lock() |
| + defer m.mu.Unlock() |
| + return append([]sentMail(nil), m.sent...) |
| +} |
| + |
| +// magicLinkPattern finds the emailed link inside the mail body |
| +// rastrillo/auth writes. Anchored to the app's own verify path so it |
| +// cannot mistake some other URL the body might someday carry for the |
| +// sign-in link itself. |
| +var magicLinkPattern = regexp.MustCompile(`https?://\S+/auth/verify\?token=\S+`) |
| + |
| +// testApp is the wired app plus the two seams a test needs to look |
| +// behind it: the mail the app tried to send, and the database it wrote. |
| +type testApp struct { |
| + http.Handler |
| + mailer *fakeMailer |
| + db *db.DB |
| +} |
| + |
| +// newConfig is the fully-configured Config every test starts from: |
| +// nothing left to a development fallback, so a test that means to |
| +// exercise one has to ask for it. Tweak the returned value and hand it |
| +// to newAppWith. |
| +func newConfig(t *testing.T) (slides.Config, *fakeMailer) { |
| + t.Helper() |
| + mailer := &fakeMailer{} |
| + return slides.Config{ |
| + Origin: testOrigin, |
| + InstanceKey: testInstanceKey, |
| + Mailer: mailer, |
| + AllowedAddresses: []string{allowedAddress}, |
| + Logger: slog.New(slog.NewTextHandler(io.Discard, nil)), |
| + }, mailer |
| +} |
| + |
| // newApp builds the whole app per test over a fresh temp database, |
| // exactly as main.go does. |
| -func newApp(t *testing.T) http.Handler { |
| +func newApp(t *testing.T) *testApp { |
| + t.Helper() |
| + cfg, mailer := newConfig(t) |
| + return newAppWith(t, cfg, mailer) |
| +} |
| + |
| +// newAppWith is newApp with the config spelled out — for the tests |
| +// whose subject is a config decision rather than a screen. |
| +func newAppWith(t *testing.T, cfg slides.Config, mailer *fakeMailer) *testApp { |
| + t.Helper() |
| + d := openTestDB(t, cfg) |
| + mux, err := slides.New(d, cfg) |
| + if err != nil { |
| + t.Fatalf("slides.New: %v", err) |
| + } |
| + return &testApp{Handler: localised(t, mux), mailer: mailer, db: d} |
| +} |
| + |
| +// openTestDB opens a fresh SQLite file for one test, closed on |
| +// cleanup. Separate from newAppWith so a test whose subject is |
| +// slides.New's own refusals can open a database without also expecting |
| +// New to succeed over it. |
| +func openTestDB(t *testing.T, cfg slides.Config) *db.DB { |
| t.Helper() |
| - logger := slog.New(slog.NewTextHandler(io.Discard, nil)) |
| + logger := cfg.Logger |
| + if logger == nil { |
| + logger = slog.New(slog.NewTextHandler(io.Discard, nil)) |
| + } |
| d, err := db.Open(filepath.Join(t.TempDir(), "app.db"), logger) |
| if err != nil { |
| t.Fatalf("db.Open: %v", err) |
| } |
| t.Cleanup(func() { d.Close() }) |
| - mux, err := slides.App(d, testOrigin, logger) |
| + return d |
| +} |
| + |
| +// localised wraps the app's mux in the same locale middleware |
| +// rastrillo.Serve mounts around it in production (cmd/slides/main.go |
| +// wires Options.Locales/LocaleFS; Serve builds the middleware from |
| +// them). Without it rastrillo.T returns the key verbatim, so every |
| +// assertion about a rendered string would be an assertion about a key. |
| +func localised(t *testing.T, mux http.Handler) http.Handler { |
| + t.Helper() |
| + loc, err := rastrillo.NewLocales(slides.Locales, slides.DefaultLocale, rastrillo.BaseCatalog(), slides.LocaleFS) |
| if err != nil { |
| - t.Fatalf("App: %v", err) |
| + t.Fatalf("rastrillo.NewLocales: %v", err) |
| } |
| - return mux |
| + return loc.Middleware(mux) |
| } |
| -func get(t *testing.T, h http.Handler, target string) *httptest.ResponseRecorder { |
| +func get(t *testing.T, h http.Handler, target string, cookies ...*http.Cookie) *httptest.ResponseRecorder { |
| t.Helper() |
| + req := httptest.NewRequest(http.MethodGet, target, nil) |
| + for _, c := range cookies { |
| + req.AddCookie(c) |
| + } |
| rec := httptest.NewRecorder() |
| - h.ServeHTTP(rec, httptest.NewRequest(http.MethodGet, target, nil)) |
| + h.ServeHTTP(rec, req) |
| return rec |
| } |
| -func post(t *testing.T, h http.Handler, target string, form url.Values) *httptest.ResponseRecorder { |
| +func post(t *testing.T, h http.Handler, target string, form url.Values, cookies ...*http.Cookie) *httptest.ResponseRecorder { |
| t.Helper() |
| req := httptest.NewRequest(http.MethodPost, target, strings.NewReader(form.Encode())) |
| req.Header.Set("Content-Type", "application/x-www-form-urlencoded") |
| // The same-origin evidence csrf.Protect requires — every current |
| // browser sends it on a form POST; a test client must too. |
| req.Header.Set("Origin", testOrigin) |
| + req.Header.Set("Sec-Fetch-Site", "same-origin") |
| + for _, c := range cookies { |
| + req.AddCookie(c) |
| + } |
| rec := httptest.NewRecorder() |
| h.ServeHTTP(rec, req) |
| return rec |
| } |
| + |
| +// signedIn runs the whole magic-link flow for address against the fake |
| +// mailer — POST /signin, read the link out of the captured message, |
| +// GET it — and returns the cookies the verify step set. Every later |
| +// test that needs a signed-in request builds on this rather than |
| +// minting a session by hand, so the suite always signs in the way a |
| +// person does. |
| +func signedIn(t *testing.T, a *testApp, address string) []*http.Cookie { |
| + t.Helper() |
| + |
| + before := len(a.mailer.messages()) |
| + rec := post(t, a, "/signin", url.Values{"address": {address}}) |
| + if rec.Code != http.StatusSeeOther { |
| + t.Fatalf("POST /signin as %q: status %d, want %d", address, rec.Code, http.StatusSeeOther) |
| + } |
| + msgs := a.mailer.messages() |
| + if len(msgs) != before+1 { |
| + t.Fatalf("POST /signin as %q: mailer captured %d messages, want %d", address, len(msgs), before+1) |
| + } |
| + link := magicLinkPattern.FindString(msgs[len(msgs)-1].Body) |
| + if link == "" { |
| + t.Fatalf("no magic link in the mailed body for %q:\n%s", address, msgs[len(msgs)-1].Body) |
| + } |
| + u, err := url.Parse(link) |
| + if err != nil { |
| + t.Fatalf("parse magic link %q: %v", link, err) |
| + } |
| + |
| + verify := get(t, a, u.RequestURI()) |
| + cookies := verify.Result().Cookies() |
| + if len(cookies) == 0 { |
| + t.Fatalf("following the magic link for %q set no session cookie (status %d)", address, verify.Code) |
| + } |
| + return cookies |
| +} |
diff --git a/internal/slidestest/index_test.go b/internal/slidestest/index_test.go| index c4789b9..bc0dda2 100644 |
| --- a/internal/slidestest/index_test.go |
| +++ b/internal/slidestest/index_test.go |
| @@ -10,43 +10,24 @@ import ( |
| slides "amadan.net/oficina/slides/internal/slides" |
| ) |
| -// hashedStylesheet is the fingerprinted URL shape the index page |
| -// links: /static/tokens.<16 hex>.css. |
| +// hashedStylesheet is the fingerprinted URL shape a page links: |
| +// /static/tokens.<16 hex>.css. |
| var hashedStylesheet = regexp.MustCompile(`/static/tokens\.[0-9a-f]{16}\.css`) |
| -// These two tests are about the PLACEHOLDER index page, and they are |
| -// yours to rewrite. They ship because a scaffold whose suite asserts |
| -// nothing teaches nothing, not because "/" answering 200 with those |
| -// words in it is a property of your app. |
| -// |
| -// The first feature that breaks them is sign-in, which is also the |
| -// first feature most apps add: put "/" in a Group behind |
| -// sessions.Require and a signed-out GET is a 303 to SigninPath, so |
| -// the status assertion fails and the redirect's body carries neither |
| -// the heading nor a stylesheet link. That is the app working. Point |
| -// them at a page that is public in your app, or delete them — the |
| -// rest of this file (the asset URLs, the error page) is about the |
| -// framework's behaviour rather than the placeholder's, and keeps |
| -// earning its place. |
| -// |
| -// Said in the failure messages too, and not only here. The reader who |
| -// needs it is looking at a red gate, and the gate prints messages |
| -// rather than source. |
| -func TestIndexRenders(t *testing.T) { |
| - rec := get(t, newApp(t), "/") |
| - if rec.Code != http.StatusOK { |
| - t.Fatalf("GET /: status %d, want 200. If you have put %q behind sign-in this is the placeholder test, not a fault: a signed-out GET redirects, and this test is yours to repoint or delete", rec.Code, "/") |
| - } |
| - if !strings.Contains(rec.Body.String(), "this is a rastrillo app") { |
| - t.Errorf("GET /: page is missing its heading. If you have replaced the placeholder index this test is yours to rewrite:\n%s", rec.Body.String()) |
| - } |
| -} |
| - |
| -func TestIndexLinksFingerprintedStylesheet(t *testing.T) { |
| +// The scaffold shipped two tests about the placeholder index page and |
| +// said in AGENTS.md that sign-in would break both, because it does: |
| +// "/" is behind the session gate now, so a signed-out GET is a 303 with |
| +// no <link> in its body. They are repointed at /signin, the one screen |
| +// that is still public — the subject was never the placeholder's |
| +// wording, it was the framework's asset delivery, and that still earns |
| +// its place. The placeholder's own "does this render" assertion is |
| +// covered by TestSigninPageIsPublic (signin_test.go), against a page |
| +// this app actually has. |
| +func TestSigninLinksFingerprintedStylesheet(t *testing.T) { |
| h := newApp(t) |
| - href := hashedStylesheet.FindString(get(t, h, "/").Body.String()) |
| + href := hashedStylesheet.FindString(get(t, h, "/signin").Body.String()) |
| if href == "" { |
| - t.Fatal("index page links no fingerprinted stylesheet. If you have put \"/\" behind sign-in, the redirect body has no <link> in it and this test wants pointing at a page that is still public") |
| + t.Fatal("the sign-in page links no fingerprinted stylesheet") |
| } |
| rec := get(t, h, href) |
| if rec.Code != http.StatusOK { |
| @@ -69,6 +50,16 @@ func TestBareAssetNameStaysFresh(t *testing.T) { |
| } |
| } |
| +// Static assets are mounted outside the session gate on purpose: the |
| +// sign-in page has to be able to load its own stylesheet before anybody |
| +// is signed in, or the front door renders unstyled. |
| +func TestStaticAssetsAreReachableSignedOut(t *testing.T) { |
| + rec := get(t, newApp(t), "/static/theme.css") |
| + if rec.Code != http.StatusOK { |
| + t.Fatalf("GET /static/theme.css signed out: status %d, want 200", rec.Code) |
| + } |
| +} |
| + |
| // ErrorPage is what main.go points Options.ErrorPage at (and the seam |
| // to wire into Ctx.ErrorPage as handlers grow); called directly, it |
| // pins that a panic's page is the framework's error-page partial, not |
diff --git a/internal/slidestest/signin_test.go b/internal/slidestest/signin_test.go| new file mode 100644 |
| index 0000000..fa3f144 |
| --- /dev/null |
| +++ b/internal/slidestest/signin_test.go |
| @@ -0,0 +1,187 @@ |
| +package slidestest |
| + |
| +import ( |
| + "net/http" |
| + "net/url" |
| + "strings" |
| + "testing" |
| + |
| + slides "amadan.net/oficina/slides/internal/slides" |
| +) |
| + |
| +// TestSignedOutIsRedirectedToSignin: "/" is behind the session gate, |
| +// and a signed-out page request lands on the sign-in page rather than |
| +// on an empty screen or a bare 403. |
| +func TestSignedOutIsRedirectedToSignin(t *testing.T) { |
| + rec := get(t, newApp(t), "/") |
| + if rec.Code != http.StatusSeeOther { |
| + t.Fatalf("GET / signed out: status %d, want %d", rec.Code, http.StatusSeeOther) |
| + } |
| + if loc := rec.Header().Get("Location"); !strings.HasPrefix(loc, "/signin") { |
| + t.Errorf("GET / signed out: Location = %q, want a redirect to /signin", loc) |
| + } |
| +} |
| + |
| +// TestSigninPageIsPublic: the front door has to be reachable signed |
| +// out, or nobody could ever get in. |
| +func TestSigninPageIsPublic(t *testing.T) { |
| + rec := get(t, newApp(t), "/signin") |
| + if rec.Code != http.StatusOK { |
| + t.Fatalf("GET /signin signed out: status %d, want 200", rec.Code) |
| + } |
| + body := rec.Body.String() |
| + if !strings.Contains(body, `name="address"`) { |
| + t.Errorf("GET /signin: no address field in the form:\n%s", body) |
| + } |
| + // The catalog resolved, rather than the key leaking onto the page. |
| + if !strings.Contains(body, "Sign in") { |
| + t.Errorf("GET /signin: page does not carry its translated title:\n%s", body) |
| + } |
| +} |
| + |
| +// TestSigninMailsAllowedAddressOnly is the allowlist's whole point: an |
| +// address nobody put on the list never causes this instance's sending |
| +// identity to mail anything at all — and it cannot tell that it did |
| +// not, because the two responses are identical (anything else would be |
| +// a membership oracle on an unauthenticated endpoint). |
| +func TestSigninMailsAllowedAddressOnly(t *testing.T) { |
| + app := newApp(t) |
| + |
| + allowed := post(t, app, "/signin", url.Values{"address": {allowedAddress}}) |
| + if allowed.Code != http.StatusSeeOther { |
| + t.Fatalf("POST /signin (allowed): status %d, want %d", allowed.Code, http.StatusSeeOther) |
| + } |
| + if loc := allowed.Header().Get("Location"); loc != "/signin?sent=1" { |
| + t.Errorf("POST /signin (allowed): Location = %q, want %q", loc, "/signin?sent=1") |
| + } |
| + if n := len(app.mailer.messages()); n != 1 { |
| + t.Fatalf("POST /signin (allowed): mailer captured %d messages, want 1", n) |
| + } |
| + if to := app.mailer.messages()[0].To; to != allowedAddress { |
| + t.Errorf("mailed to %q, want %q", to, allowedAddress) |
| + } |
| + |
| + denied := post(t, app, "/signin", url.Values{"address": {deniedAddress}}) |
| + if denied.Code != allowed.Code { |
| + t.Errorf("POST /signin (denied): status %d, want %d — identical to the permitted answer", denied.Code, allowed.Code) |
| + } |
| + if got, want := denied.Header().Get("Location"), allowed.Header().Get("Location"); got != want { |
| + t.Errorf("POST /signin (denied): Location = %q, want %q — identical to the permitted answer", got, want) |
| + } |
| + // Byte-for-byte, not merely "both are 303": any observable |
| + // difference at all would let someone enumerate the allowlist. |
| + if got, want := denied.Body.String(), allowed.Body.String(); got != want { |
| + t.Errorf("POST /signin (denied): body %q, want %q — identical to the permitted answer", got, want) |
| + } |
| + if n := len(app.mailer.messages()); n != 1 { |
| + t.Errorf("POST /signin (denied): mailer captured %d messages, want the 1 from the permitted address alone", n) |
| + } |
| +} |
| + |
| +// TestVerifyMintsSessionAndUserRow: following the emailed link both |
| +// establishes a usable session and creates this app's own person row — |
| +// the address-to-id mapping every later owner-scoped query needs, since |
| +// under rastrillo/auth the session Subject is the email, not a user id. |
| +func TestVerifyMintsSessionAndUserRow(t *testing.T) { |
| + app := newApp(t) |
| + cookies := signedIn(t, app, allowedAddress) |
| + |
| + rec := get(t, app, "/", cookies...) |
| + if rec.Code != http.StatusOK { |
| + t.Fatalf("GET / signed in: status %d, want 200", rec.Code) |
| + } |
| + |
| + var users []slides.User |
| + if err := app.db.G.Find(&users).Error; err != nil { |
| + t.Fatalf("read users: %v", err) |
| + } |
| + if len(users) != 1 { |
| + t.Fatalf("users table has %d rows, want 1: %+v", len(users), users) |
| + } |
| + if users[0].Email != allowedAddress { |
| + t.Errorf("users[0].Email = %q, want %q", users[0].Email, allowedAddress) |
| + } |
| + if users[0].ID == 0 { |
| + t.Error("users[0].ID is 0; the row was never assigned an id") |
| + } |
| +} |
| + |
| +// TestSecondSignInReusesTheUserRow: the row is created once, on first |
| +// sign-in, not on every request that reads it. |
| +func TestSecondSignInReusesTheUserRow(t *testing.T) { |
| + app := newApp(t) |
| + |
| + first := signedIn(t, app, allowedAddress) |
| + if rec := get(t, app, "/", first...); rec.Code != http.StatusOK { |
| + t.Fatalf("GET / after first sign-in: status %d, want 200", rec.Code) |
| + } |
| + second := signedIn(t, app, allowedAddress) |
| + if rec := get(t, app, "/", second...); rec.Code != http.StatusOK { |
| + t.Fatalf("GET / after second sign-in: status %d, want 200", rec.Code) |
| + } |
| + |
| + var users []slides.User |
| + if err := app.db.G.Find(&users).Error; err != nil { |
| + t.Fatalf("read users: %v", err) |
| + } |
| + if len(users) != 1 { |
| + t.Fatalf("users table has %d rows after two sign-ins, want 1: %+v", len(users), users) |
| + } |
| +} |
| + |
| +// TestSignoutEndsTheSession: sign-out is real revocation, so the |
| +// signed-in screen goes back to being a redirect. |
| +func TestSignoutEndsTheSession(t *testing.T) { |
| + app := newApp(t) |
| + cookies := signedIn(t, app, allowedAddress) |
| + |
| + if rec := post(t, app, "/signout", url.Values{}, cookies...); rec.Code != http.StatusSeeOther { |
| + t.Fatalf("POST /signout: status %d, want %d", rec.Code, http.StatusSeeOther) |
| + } |
| + rec := get(t, app, "/", cookies...) |
| + if rec.Code != http.StatusSeeOther { |
| + t.Errorf("GET / after sign-out: status %d, want %d", rec.Code, http.StatusSeeOther) |
| + } |
| +} |
| + |
| +// TestDevelopmentDefaultsRefusedInProduction: each of the four |
| +// settings that decide whether sign-in is safe refuses to start when it |
| +// is missing and Env is not "development". A missing one must never |
| +// silently take a development fallback in a deployment — the whole |
| +// fail-closed story, one subtest per setting so a regression names |
| +// which one. |
| +func TestDevelopmentDefaultsRefusedInProduction(t *testing.T) { |
| + for _, tc := range []struct { |
| + name string |
| + bend func(cfg *slides.Config) |
| + }{ |
| + {"InstanceKey", func(cfg *slides.Config) { cfg.InstanceKey = "" }}, |
| + {"Origin", func(cfg *slides.Config) { cfg.Origin = "" }}, |
| + {"Mailer", func(cfg *slides.Config) { cfg.Mailer = nil }}, |
| + {"AllowedAddresses", func(cfg *slides.Config) { cfg.AllowedAddresses = nil }}, |
| + } { |
| + t.Run(tc.name, func(t *testing.T) { |
| + cfg, _ := newConfig(t) |
| + cfg.Env = "production" |
| + tc.bend(&cfg) |
| + if _, err := slides.New(openTestDB(t, cfg), cfg); err == nil { |
| + t.Fatalf("slides.New with an empty %s and Env %q returned no error; want a startup refusal", tc.name, cfg.Env) |
| + } |
| + }) |
| + } |
| +} |
| + |
| +// TestDevelopmentDefaultsAllowedInDevelopment is the other half of the |
| +// same rule: the one Env that may default does, so scripts/run-dev.sh |
| +// needs no setup at all. |
| +func TestDevelopmentDefaultsAllowedInDevelopment(t *testing.T) { |
| + cfg, _ := newConfig(t) |
| + cfg.Env = "development" |
| + cfg.InstanceKey = "" |
| + cfg.Origin = "" |
| + cfg.Mailer = nil |
| + cfg.AllowedAddresses = nil |
| + if _, err := slides.New(openTestDB(t, cfg), cfg); err != nil { |
| + t.Fatalf("slides.New with an empty config and Env %q: %v", cfg.Env, err) |
| + } |
| +} |
diff --git a/scripts/run-dev.sh b/scripts/run-dev.sh| new file mode 100755 |
| index 0000000..d387c29 |
| --- /dev/null |
| +++ b/scripts/run-dev.sh |
| @@ -0,0 +1,46 @@ |
| +#!/usr/bin/env bash |
| +# run-dev.sh — build and run Slides locally, with no setup at all. |
| +# |
| +# scripts/run-dev.sh # http://localhost:8091 |
| +# SLIDES_DEV_PORT=9000 scripts/run-dev.sh |
| +# |
| +# It runs with -env development, which is the ONLY way that env can be |
| +# selected (cmd/slides/main.go's chooseEnv refuses to honour it from |
| +# $SLIDES_ENV, so a deployment's env-var block can never reach this |
| +# mode). In development, and nowhere else, four settings default: |
| +# |
| +# * the instance key becomes a fixed, public string; |
| +# * mail is written to this terminal instead of being sent, so the |
| +# magic link is the line marked "magic links will be logged"; |
| +# * the allowlist admits every address; |
| +# * the object store is a directory beside the scratch database. |
| +# |
| +# Sign in by entering any address at http://localhost:8091/signin, then |
| +# copying the /auth/verify?token=... link out of the log below into the |
| +# browser. |
| +# |
| +# The database is a scratch file under $TMPDIR, thrown away with it: |
| +# nothing this script runs should ever be pointed at data that matters. |
| +set -euo pipefail |
| + |
| +cd "$(dirname "$0")/.." |
| + |
| +port="${SLIDES_DEV_PORT:-8091}" |
| +tmp="${TMPDIR:-/tmp}" |
| +state="${tmp%/}/slides-dev" |
| +mkdir -p "$state" |
| + |
| +# CGO_ENABLED=0 here as everywhere: the stack is cgo-free by design, and |
| +# a local build that quietly used cgo would not be the binary CI checks. |
| +CGO_ENABLED=0 go build -o "$state/slides" ./cmd/slides |
| + |
| +echo "slides: http://localhost:$port (database: $state/slides.db)" |
| +echo "slides: sign in at http://localhost:$port/signin — the magic link is logged below" |
| + |
| +# -addr is what main.go derives the origin from when $SLIDES_ORIGIN is |
| +# unset, and it has to be the host a browser will actually send back as |
| +# its own Origin header — hence localhost, not a bare :port. |
| +exec "$state/slides" \ |
| + -env development \ |
| + -addr "localhost:$port" \ |
| + -db "$state/slides.db" |