| 1 | package contra |
| 2 | |
| 3 | import ( |
| 4 | "context" |
| 5 | "log/slog" |
| 6 | "net/http" |
| 7 | |
| 8 | "github.com/go-chi/chi/v5" |
| 9 | |
| 10 | "github.com/carlosframework/rastrillo/csrf" |
| 11 | "github.com/carlosframework/rastrillo/db" |
| 12 | "github.com/carlosframework/rastrillo/migrate" |
| 13 | "github.com/carlosframework/rastrillo/sessions" |
| 14 | |
| 15 | "github.com/carlosframework/contra/internal/contra/auth" |
| 16 | "github.com/carlosframework/contra/internal/contra/mail" |
| 17 | "github.com/carlosframework/contra/internal/contra/vault" |
| 18 | ) |
| 19 | |
| 20 | // Config is what App needs from the outside. Pepper is separated from the |
| 21 | // rest because it is the one secret the instance holds, and it is worth |
| 22 | // seeing that it arrives from a caller rather than being read out of the |
| 23 | // environment somewhere in the middle of the wiring. |
| 24 | type Config struct { |
| 25 | // Origin is the app's external origin, scheme included. It decides the |
| 26 | // CSRF check's yardstick and the session cookie's Secure/__Host- |
| 27 | // attributes, so defaulting it silently in production is a real bug. |
| 28 | Origin string |
| 29 | |
| 30 | // Mailer sends the signup link. Nil boots with a loud warning rather than |
| 31 | // refusing, so a local run without SMTP still works — but nobody can sign |
| 32 | // up, which is worth saying at startup rather than at the first attempt. |
| 33 | Mailer mail.Sender |
| 34 | |
| 35 | // Pepper blinds the email index. It proves identity and decrypts |
| 36 | // nothing; PROTOCOL.md wants it moved off the instance to the routing |
| 37 | // layer or a KMS, so that a database-plus-bucket leak alone cannot |
| 38 | // unblind an address. |
| 39 | Pepper []byte |
| 40 | } |
| 41 | |
| 42 | // App wires the whole app: schema, sessions, auth, router, static files. It |
| 43 | // returns a *http.ServeMux because rastrillo.Options.Mux is typed that way — |
| 44 | // the chi router mounts inside it. |
| 45 | func App(d *db.DB, cfg Config, logger *slog.Logger) (*http.ServeMux, error) { |
| 46 | // Apply BootSchema, not Schema: BootSchema is everything this app needs |
| 47 | // at boot (its own migrations plus any subsystem's), while Schema stays |
| 48 | // just this app's own so the generator and its check never see a |
| 49 | // subsystem's tables and try to drop them. |
| 50 | migrated, err := migrate.Apply(context.Background(), d, BootSchema) |
| 51 | if err != nil { |
| 52 | return nil, err |
| 53 | } |
| 54 | logger.Info("migrate", "applied", migrated.Applied, |
| 55 | "skipped", migrated.Skipped, "adopted", migrated.Adopted) |
| 56 | |
| 57 | writer, err := d.G.DB() |
| 58 | if err != nil { |
| 59 | return nil, err |
| 60 | } |
| 61 | sess, err := sessions.New(sessions.Config{ |
| 62 | DB: writer, Origin: cfg.Origin, Logger: logger, SigninPath: "/signin", |
| 63 | }) |
| 64 | if err != nil { |
| 65 | return nil, err |
| 66 | } |
| 67 | |
| 68 | ident, err := auth.New(d.G, sess, cfg.Pepper, cfg.Mailer, cfg.Origin, logger) |
| 69 | if err != nil { |
| 70 | return nil, err |
| 71 | } |
| 72 | |
| 73 | a := &app{db: d, logger: logger, sess: sess, auth: ident} |
| 74 | |
| 75 | r := chi.NewRouter() |
| 76 | // App-wide, from day one: every state-changing route added below is born |
| 77 | // covered. Origin-checking, not tokens — nothing to mint or forget. |
| 78 | r.Use(csrf.Protect(cfg.Origin)) |
| 79 | // Resolves a session when there is one and blocks nothing, so the |
| 80 | // marketing page can tell a signed-in visitor from a stranger without |
| 81 | // any route becoming a gate by accident. |
| 82 | r.Use(sess.Middleware) |
| 83 | |
| 84 | r.Get("/", a.index) |
| 85 | r.Get("/signin", a.signinPage) |
| 86 | |
| 87 | // Signup, in steps. Each page is its own screen with its own errors, |
| 88 | // because the single screen this replaced did six seconds of work behind |
| 89 | // one button and reported whatever happened to throw. |
| 90 | r.Get("/signup", a.signupStart) // 1. the address |
| 91 | r.Get("/signup/sent", a.signupSent) // 1a. go and read your mail |
| 92 | r.Get("/signup/verify", ident.VerifyLink) // 1b. the link lands here |
| 93 | r.Get("/signup/passphrase", a.signupPassphrase) // 2. choose one |
| 94 | r.Get("/signup/passkey", a.signupPasskey) // 3. optional, and skippable |
| 95 | |
| 96 | // The ceremony. Every one of these carries public keys, sealed blobs or |
| 97 | // an opaque index — never a passphrase, never a key, never an address in |
| 98 | // a form the server keeps. |
| 99 | r.Post("/auth/salt", ident.Salt) |
| 100 | r.Post("/auth/begin", ident.Begin) |
| 101 | r.Post("/auth/complete", ident.Complete) |
| 102 | r.Post("/auth/challenge", ident.Challenge) |
| 103 | r.Post("/auth/verify", ident.Verify) |
| 104 | r.Post("/auth/signout", ident.SignOut) |
| 105 | |
| 106 | items := vault.New(d.G) |
| 107 | r.Group(func(r chi.Router) { |
| 108 | r.Use(sess.Require) |
| 109 | r.Get("/vault", a.vault) |
| 110 | |
| 111 | // The item API. Every one of these hands out or takes in ciphertext, |
| 112 | // scoped to the session's account inside the SQL rather than beside |
| 113 | // it, and answers 404 for a row that is not yours. |
| 114 | r.Get("/api/bootstrap", items.Bootstrap) |
| 115 | r.Post("/api/provision", items.Provision) |
| 116 | r.Get("/api/items", items.Items) |
| 117 | r.Post("/api/items", items.CreateItem) |
| 118 | r.Put("/api/items", items.UpdateItem) |
| 119 | r.Post("/api/items/delete", items.DeleteItem) |
| 120 | r.Post("/api/passkey", items.AddPasskey) |
| 121 | |
| 122 | // Sharing. AccountKey is the lookup that makes it possible: by |
| 123 | // account id, never by address, because an address-to-account |
| 124 | // endpoint would be the existence oracle the blind index exists to |
| 125 | // prevent. Removal has no route of its own — it is a rotation with a |
| 126 | // shorter member list, because in a seal-to-public-key model |
| 127 | // revocation IS rotation. |
| 128 | r.Get("/api/account-key", items.AccountKey) |
| 129 | r.Get("/api/members", items.Members) |
| 130 | r.Post("/api/members", items.AddMember) |
| 131 | r.Post("/api/vault/rotate", items.Rotate) |
| 132 | |
| 133 | // The printed kit: reprint the Account Key card exactly, or reissue |
| 134 | // the recovery code — which regenerates rather than reprints, because |
| 135 | // only its wrap is stored. PROTOCOL.md wants this obvious rather than |
| 136 | // buried: it is the mitigation for the one loss mode the Account Key |
| 137 | // introduced. |
| 138 | r.Get("/kit", a.kit) |
| 139 | r.Post("/api/recovery", items.RegenerateRecovery) |
| 140 | |
| 141 | // The device handoff, PROTOCOL.md's rung 1. Both ends are signed in as |
| 142 | // the same account — a fresh browser signs in with the passphrase |
| 143 | // alone and only then finds it has no Account Key — so every one of |
| 144 | // these is scoped to the session, which is what makes a code short |
| 145 | // enough to read aloud safe to use. |
| 146 | r.Get("/device", a.device) |
| 147 | r.Post("/api/link/start", items.StartLink) |
| 148 | r.Get("/api/link/pending", items.PendingLink) |
| 149 | r.Post("/api/link/approve", items.ApproveLink) |
| 150 | r.Get("/api/link/claim", items.ClaimLink) |
| 151 | r.Post("/api/link/cancel", items.CancelLink) |
| 152 | }) |
| 153 | |
| 154 | mux := http.NewServeMux() |
| 155 | // The app serves its own static files — the framework never does. They |
| 156 | // are embedded (render.go) and fingerprinted: templates link them via |
| 157 | // {{asset ...}}, which returns a content-hashed URL served |
| 158 | // cacheable-forever, while the bare name stays no-cache so the ES module |
| 159 | // graph can import by relative path. |
| 160 | mux.Handle("GET /static/", assets.Handler()) |
| 161 | mux.Handle("/", r) |
| 162 | return mux, nil |
| 163 | } |
| 164 | |