🤖 The shape of an app
Before you change a new app, read these files in order.
cmd/<app>/main.go Resolve, db.Open, New, Serve
internal/<app>/app.go App, New and Router
internal/<app>/routes.go Routes: every path the app serves
internal/<app>/handlers.go the actions
internal/<app>/render.go the pages, contextFor and ErrorPage
internal/<app>/api.go the JSON API at /api/v1
internal/<app>/models.go plain GORM structs
internal/<app>/migrations.go Schema and BootSchema
An app made with --auth=magic also has auth.go, which signs people
in by emailed link and holds the owned seam.
main.go
started := time.Now()
logger := slog.New(slog.NewTextHandler(os.Stderr, nil))
options, err := rastrillo.Resolve(rastrillo.Options{
DBPath: "notes.db",
Logger: logger,
Locales: genlocales.Locales,
DefaultLocale: genlocales.DefaultLocale,
})
exitOnError(logger, "resolve activation", err)
database, err := db.Open(options.DBPath, logger)
exitOnError(logger, "open database", err)
defer database.Close()
origin := rastrillo.Origin("NOTES_ORIGIN", logger)
// ... the base path, from RASTRILLO_SLUG
mux, hub, err := notes.New(database, origin, logger)
exitOnError(logger, "build app", err)
notes.Configure(&options, mux, hub, started)
options.DBPath = ""
exitOnError(logger, "serve", rastrillo.Serve(options))
Keep these two lines: Configure sets options.Mux to the app, and
options.Live to its live pages hub, so shutdown ends
the open streams instead of waiting for them.
options.DBPath = "" stops Serve from opening the database file a
second time.
Use Resolve and Serve
Use Resolve and Serve because this app opens its database with
db.Open.
Run re-parses argv and fills in Options.DBPath again, so Serve
would open a second connection to the file opened by db.Open. Use
Resolve to do the activation-argv and $STATE_DIRECTORY work without
serving; it returns the resolved options.
What you get from Resolve and Serve
Use the framework's start-up support for activation argv in both shapes
the platform uses, LISTEN_FDS socket activation, $STATE_DIRECTORY
resolution for a relative database path, GET /healthz and
GET /api/version, the SIGTERM drain, and baseline security headers.
Your own Set wins.
Deploying covers what the platform does with all of that.
app.go
App holds what the actions need. Its fields are unexported.
type App struct {
database *db.DB
logger *slog.Logger
origin string
api http.Handler
mcp http.Handler
}
New builds the app and returns the *http.ServeMux that Configure
hands to Serve. It calls Router, which does the work:
migrated, err := migrate.Apply(context.Background(), database, BootSchema)
// ...
api, mcp, err := newDoors(database, logger)
// ...
app := &App{database: database, logger: logger, origin: origin,
api: api, mcp: mcp}
router := chi.NewRouter()
Routes(action.NewRoutes(router, app.contextFor), app)
migrate.Apply runs before anything reads a table.
Migrations explains BootSchema. The perf test
calls Router too, so build anything the app needs at boot there.
routes.go
const homePage action.Path = "/"
func Routes(routes *action.Routes, app *App) {
routes.Mount("/static", assets.Handler(), "static")
routes.Mount("/api/v1/mcp", app.mcp, "mcp")
routes.Mount("/api/v1", app.api, "api")
routes.Group(func(guarded *action.Routes) {
guarded.Guard("csrf", csrf.Protect(app.origin))
guarded.Guard("pages", pages.Middleware)
guarded.Get(homePage, app.home)
})
}
Routes registers everything the app serves. Put every page and form
in the guarded group: its csrf guard refuses a write from another
origin. Keep the static files, API and MCP endpoints outside it. A
bearer token is the only credential the API and MCP accept, so neither
gets a CSRF check.
Routes must only register routes. rastrillo routes calls it with an
empty App to list them, and make routes fails for an action that no
route mounts. See CLI.
In an --auth=magic app, put sign-in in the guarded group and pages
that need a session in their own group. A signed-out visitor who opens
one of those pages is sent to sign in.
guarded.Group(func(owned *action.Routes) {
owned.Guard("session", app.auth.RequireSession)
owned.Get(accountPage, app.account)
})
handlers.go
Each page is an action: a method on App that takes one
*action.Visit.
func (app *App) home(visit *action.Visit) {
visit.Render("index", http.StatusOK, nil)
}
An action reads its input, runs one operation, answers a refusal, and says what success looks like. action shows an action with a form.
In an --auth=magic app, every query on an owned row goes through
app.owned, in auth.go:
func (app *App) owned(request *http.Request) *gorm.DB {
return scope.OwnedBy(app.database.G, "owner", ownerOf(request))
}
Use this method to scope owned rows. Scoping explains what goes wrong without it.
render.go
render.go parses every page once, at boot. Add a new page's name to
the list in newPages.
contextFor builds each request's Ctx. Its Render draws through
that request, so visit.Render uses the request's language and base
path.
ErrorPage draws templates/errors.html for a refusal and, through
Options.ErrorPage, for a panic. A 500 from an action looks the same
as a 500 from a panic. Templates covers what a page
can use.
api.go
newDoors builds the JSON API at /api/v1 and MCP at /api/v1/mcp
from one config, so both apply the same checks. authorize is the one
policy for pages, the API and MCP. The API and MCP endpoints cannot see
page middleware, so add page rules to authorize too. JSON API
and MCP cover the rest.
Before you call it done
- Every action on an owned model goes through
app.owned. - Every update names its columns in
.Select(...). - Every page and form is in the guarded group in
Routes. - Signed-in pages are behind the session guard.
- One
migrate.Applyruns at boot, andmake cirunsrastrillo migration check. options.DBPathis blanked beforeServe.- Not-found and not-yours both answer 404.
make cipasses,make codeandmake routesincluded.
Read this page as markdown — exact, unstyled, and cheap for an agent to fetch.