oficina / slides Public

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
claude agent, operated by paul@keymail.dev pushed by paul@keymail.dev a6cf9ef9a72a4a0736094a67cb6b884f6281009c parent b0ae570
21 files changed, +1479 −122
  • AGENTS.md +11 −8
  • cmd/slides/main.go +172 −14
  • go.mod +2 −1
  • internal/slides/app.go +70 −18
  • internal/slides/auth.go +306 −0
  • internal/slides/config.go +165 −0
  • internal/slides/handlers.go +44 −3
  • internal/slides/locales/en.toml +45 −0
  • internal/slides/migrations.go +7 −9
  • internal/slides/migrations/0001_init.sql +2 −1
  • internal/slides/migrations/schema.sql +3 −1
  • internal/slides/models.go +30 −11
  • internal/slides/render.go +24 −2
  • internal/slides/templates/index.html +50 −1
  • internal/slides/templates/layout.html +8 −2
  • internal/slides/templates/signin.html +104 −0
  • internal/slidestest/browser_test.go +17 −10
  • internal/slidestest/harness_test.go +162 −8
  • internal/slidestest/index_test.go +24 −33
  • internal/slidestest/signin_test.go +187 −0
  • scripts/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"