oficina / home Public

Add deployable skeleton: platform contract + one placeholder page

The home had zero Go files. This is the smallest honest deploy
target: go.mod, cmd/home/main.go on rastrillo v0.21.0's
Resolve->Serve contract (no database opened — this skeleton has
nothing to persist yet, and rastrillo.Handler skips OpenDB whenever
DBPath is unset), and internal/home serving one unstyled page at "/"
stating what the home is, that it isn't built yet, and where the
design spec lives. Makefile and .amadan/ci.d mirror sheets-core's,
minus the migrations and race targets neither apply to yet.

No sign-in, directory, assertions or provisioning — those are
docs/superpowers/specs/2026-08-30-home-design.md's substantial
project, deliberately out of scope here so this doesn't become a
half-built identity service.

🤖 Generated with Claude Opus 5, at the direction of Paul Campbell.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01APtNStXDZ5H2fAc1ZuGqtS
Paul Campbell pushed by paul@keymail.dev ef8a30f0dd7fbe9ff2184d40975fd47d4ada2fc1 parent da52b09
12 files changed, +339 −0
  • .amadan/ci.d/10-vet +8 −0
  • .amadan/ci.d/20-fmt +8 −0
  • .amadan/ci.d/30-build +8 −0
  • .amadan/ci.d/40-go-test +8 −0
  • .amadan/timeout +1 −0
  • Makefile +39 −0
  • cmd/home/main.go +60 −0
  • go.mod +21 −0
  • go.sum +55 −0
  • internal/home/app.go +51 −0
  • internal/home/app_test.go +38 −0
  • internal/home/page.go +42 −0

CI passed — run details

diff --git a/.amadan/ci.d/10-vet b/.amadan/ci.d/10-vet
new file mode 100755
index 0000000..698073f
--- /dev/null
+++ b/.amadan/ci.d/10-vet
@@ -0,0 +1,8 @@
+#!/bin/sh
+# One step of the home's CI. It runs a Makefile target rather than its
+# own copy of the commands, so CI and the local gate stay ONE
+# definition: `make test` runs these same targets in this same order,
+# and `make test-vet` is exactly what CI runs here. Debugging CI is
+# running it.
+set -eu
+exec make test-vet
diff --git a/.amadan/ci.d/20-fmt b/.amadan/ci.d/20-fmt
new file mode 100755
index 0000000..b802353
--- /dev/null
+++ b/.amadan/ci.d/20-fmt
@@ -0,0 +1,8 @@
+#!/bin/sh
+# One step of the home's CI. It runs a Makefile target rather than its
+# own copy of the commands, so CI and the local gate stay ONE
+# definition: `make test` runs these same targets in this same order,
+# and `make test-fmt` is exactly what CI runs here. Debugging CI is
+# running it.
+set -eu
+exec make test-fmt
diff --git a/.amadan/ci.d/30-build b/.amadan/ci.d/30-build
new file mode 100755
index 0000000..43a17da
--- /dev/null
+++ b/.amadan/ci.d/30-build
@@ -0,0 +1,8 @@
+#!/bin/sh
+# One step of the home's CI. It runs a Makefile target rather than its
+# own copy of the commands, so CI and the local gate stay ONE
+# definition: `make test` runs these same targets in this same order,
+# and `make test-build` is exactly what CI runs here. Debugging CI is
+# running it.
+set -eu
+exec make test-build
diff --git a/.amadan/ci.d/40-go-test b/.amadan/ci.d/40-go-test
new file mode 100755
index 0000000..94160ec
--- /dev/null
+++ b/.amadan/ci.d/40-go-test
@@ -0,0 +1,8 @@
+#!/bin/sh
+# One step of the home's CI. It runs a Makefile target rather than its
+# own copy of the commands, so CI and the local gate stay ONE
+# definition: `make test` runs these same targets in this same order,
+# and `make test-go` is exactly what CI runs here. Debugging CI is
+# running it.
+set -eu
+exec make test-go
diff --git a/.amadan/timeout b/.amadan/timeout
new file mode 100644
index 0000000..960e1d6
--- /dev/null
+++ b/.amadan/timeout
@@ -0,0 +1 @@
+15m
diff --git a/Makefile b/Makefile
new file mode 100644
index 0000000..0c08b0a
--- /dev/null
+++ b/Makefile
@@ -0,0 +1,39 @@
+.PHONY: build test test-vet test-fmt test-build test-go
+
+# One binary, CGO_ENABLED=0, cgo-free SQLite (AGENTS.md / Deploys). This
+# skeleton opens no database yet — see cmd/home/main.go — but the
+# constraint holds regardless: nothing here may need cgo to build.
+build:
+ CGO_ENABLED=0 go build ./...
+
+# The gate, split into named targets so CI can report them individually
+# (.amadan/ci.d calls these one at a time, each its own step). `make
+# test` runs exactly the same things in the same order, so the local
+# gate and CI stay ONE definition rather than two lists that drift
+# apart. Debugging CI is running `make test`.
+#
+# Order is cheapest-and-fastest first: vet and gofmt are near-instant,
+# build catches compile errors before time is spent on tests, plain
+# `go test` runs last. No test-migrations (no database yet) and no
+# test-race (no concurrency-bearing package yet) — see Sheets'
+# Makefile for what those look like once this app needs them.
+test: test-vet test-fmt test-build test-go
+
+test-vet:
+ CGO_ENABLED=0 go vet ./...
+
+# Fails on unformatted files rather than rewriting them. CI must never
+# silently "fix" a diff out from under a branch — a red test-fmt tells
+# the author to run `gofmt -w` themselves.
+test-fmt:
+ @if [ -n "$$(gofmt -l .)" ]; then \
+ echo "gofmt needs to be run on:"; \
+ gofmt -l .; \
+ exit 1; \
+ fi
+
+test-build:
+ CGO_ENABLED=0 go build ./...
+
+test-go:
+ CGO_ENABLED=0 go test -count=1 ./...
diff --git a/cmd/home/main.go b/cmd/home/main.go
new file mode 100644
index 0000000..93252d3
--- /dev/null
+++ b/cmd/home/main.go
@@ -0,0 +1,60 @@
+// Command home is the Oficina suite home binary.
+//
+// This is a deployable skeleton: it satisfies the platform activation
+// contract and serves internal/home's one placeholder page. None of
+// the home's real functionality exists yet — see
+// docs/superpowers/specs/2026-08-30-home-design.md and
+// internal/home/page.go.
+//
+// The platform activation contract has no $PORT: the platform (or a
+// developer) invokes this binary as
+//
+// home --socket <path>
+//
+// and rastrillo.Resolve/Serve own that contract end to end — activation
+// argv, LISTEN_FDS, $STATE_DIRECTORY, GET /healthz, GET /api/version,
+// SIGTERM drain, baseline security headers.
+//
+// Unlike Sheets (internal/sheets, one SQLite file per instance), this
+// skeleton opens no database: it has no models and nothing to persist
+// yet. rastrillo.Options.DBPath stays unset, so
+// rastrillo.Handler/Serve never call OpenDB at all (see serve.go:
+// "var db *sql.DB; ... if opts.DBPath != \"\" { db, err = OpenDB(...) }")
+// — there is no db.Open call to make and no handle to blank before
+// Serve. When the home gains real storage, this file grows the same
+// Resolve -> db.Open -> build mux -> blank DBPath -> Serve shape Sheets
+// uses.
+package main
+
+import (
+ "log/slog"
+ "net/http"
+ "os"
+
+ "github.com/carlosframework/rastrillo"
+
+ "amadan.net/oficina/home/internal/home"
+)
+
+func main() {
+ logger := slog.Default()
+
+ opts, err := rastrillo.Resolve(rastrillo.Options{Logger: logger})
+ if err != nil {
+ logger.Error("resolve", "err", err)
+ os.Exit(1)
+ }
+
+ app := home.New()
+
+ // rastrillo.Options.Mux wants a *http.ServeMux; the app's own
+ // router is a chi.Router (an http.Handler), so it mounts under one.
+ mux := http.NewServeMux()
+ mux.Handle("/", app.Router())
+ opts.Mux = mux
+
+ if err := rastrillo.Serve(opts); err != nil {
+ logger.Error("serve", "err", err)
+ os.Exit(1)
+ }
+}
diff --git a/go.mod b/go.mod
new file mode 100644
index 0000000..ede8ee7
--- /dev/null
+++ b/go.mod
@@ -0,0 +1,21 @@
+module amadan.net/oficina/home
+
+go 1.25.0
+
+require (
+ github.com/carlosframework/rastrillo v0.21.0
+ github.com/go-chi/chi/v5 v5.3.2
+)
+
+require (
+ github.com/dustin/go-humanize v1.0.1 // indirect
+ github.com/google/uuid v1.6.0 // 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
+ modernc.org/libc v1.74.1 // indirect
+ modernc.org/mathutil v1.7.1 // indirect
+ modernc.org/memory v1.11.0 // indirect
+ modernc.org/sqlite v1.55.0 // indirect
+)
diff --git a/go.sum b/go.sum
new file mode 100644
index 0000000..9d009d5
--- /dev/null
+++ b/go.sum
@@ -0,0 +1,55 @@
+github.com/carlosframework/rastrillo v0.21.0 h1:uqw/ZK+y/VwS+jDnIv2llAIug3AuFWesSp1xTEPS45w=
+github.com/carlosframework/rastrillo v0.21.0/go.mod h1:pZlrE5F5OhspvZFzef0DrfjHAjA8FXycb9J5/8vc8Tw=
+github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
+github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
+github.com/go-chi/chi/v5 v5.3.2 h1:5YQkICvTCSZ25hoRsyJazN0scjzKGiu4VAUc7H1o1nY=
+github.com/go-chi/chi/v5 v5.3.2/go.mod h1:R+tYY2hNuVUUjxoPtqUdgBqevM9s9njzkTLutVsOCto=
+github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e h1:ijClszYn+mADRFY17kjQEVQ1XRhq2/JR1M3sGqeJoxs=
+github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e/go.mod h1:boTsfXsheKC2y+lKOCMpSfarhxDeIzfZG1jqGcPl3cA=
+github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
+github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
+github.com/hashicorp/golang-lru/v2 v2.0.7 h1:a+bsQ5rvGLjzHuww6tVxozPZFVghXaHOwFs4luLUK2k=
+github.com/hashicorp/golang-lru/v2 v2.0.7/go.mod h1:QeFd9opnmA6QUJc5vARoKUSoFhyfM2/ZepoAG6RGpeM=
+github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
+github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
+github.com/ncruces/go-strftime v1.0.0 h1:HMFp8mLCTPp341M/ZnA4qaf7ZlsbTc+miZjCLOFAw7w=
+github.com/ncruces/go-strftime v1.0.0/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls=
+github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE=
+github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo=
+golang.org/x/mod v0.37.0 h1:vF1DjpVEshcIqoEaauuHebaLk1O1forxjxBaVn884JQ=
+golang.org/x/mod v0.37.0/go.mod h1:m8S8VeM9r4dzDwjrKO0a1sZP3YjeMamRRlD+fmR2Q/0=
+golang.org/x/sync v0.21.0 h1:HLII4xRRTtCRkxYp4HNFF0Js/Og6q2i++KXbg0gHCwM=
+golang.org/x/sync v0.21.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0=
+golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
+golang.org/x/sys v0.46.0 h1:noSf2Fq6F8DBgS+LysIkx7rIExoNHJsxOAtPp4rthXw=
+golang.org/x/sys v0.46.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
+golang.org/x/tools v0.47.0 h1:7Kn5x/d1svx/PzryTsqeoZN4TZwqeH5pGWjefhLi/1Q=
+golang.org/x/tools v0.47.0/go.mod h1:dFHnyTvFWY212G+h7ZY4Vsp/K3U4/7W9TyVaAul8uCA=
+modernc.org/cc/v4 v4.29.0 h1:CXgwL8cvxmyzBQZzbSl/6xFtMCryb6u8IOqDci39cgc=
+modernc.org/cc/v4 v4.29.0/go.mod h1:OnovgIhbbMXMu1aISnJ0wvVD1KnW+cAUJkIrAWh+kVI=
+modernc.org/ccgo/v4 v4.34.6 h1:sBgfIwyN0TQ9C5hwIeuqyeAKyMWnbvj2fvpF4L11uzU=
+modernc.org/ccgo/v4 v4.34.6/go.mod h1:SZ8YcN9NG7XVsQYdm6jYBvi8PQP1qi+kqB6OhjqI3Fk=
+modernc.org/fileutil v1.4.0 h1:j6ZzNTftVS054gi281TyLjHPp6CPHr2KCxEXjEbD6SM=
+modernc.org/fileutil v1.4.0/go.mod h1:EqdKFDxiByqxLk8ozOxObDSfcVOv/54xDs/DUHdvCUU=
+modernc.org/gc/v2 v2.6.5 h1:nyqdV8q46KvTpZlsw66kWqwXRHdjIlJOhG6kxiV/9xI=
+modernc.org/gc/v2 v2.6.5/go.mod h1:YgIahr1ypgfe7chRuJi2gD7DBQiKSLMPgBQe9oIiito=
+modernc.org/gc/v3 v3.1.4 h1:2g65LGVSmFQrXeITAw97x7hCRvZFcyE1uDP+7Vng7JI=
+modernc.org/gc/v3 v3.1.4/go.mod h1:HFK/6AGESC7Ex+EZJhJ2Gni6cTaYpSMmU/cT9RmlfYY=
+modernc.org/goabi0 v0.2.0 h1:HvEowk7LxcPd0eq6mVOAEMai46V+i7Jrj13t4AzuNks=
+modernc.org/goabi0 v0.2.0/go.mod h1:CEFRnnJhKvWT1c1JTI3Avm+tgOWbkOu5oPA8eH8LnMI=
+modernc.org/libc v1.74.1 h1:bdR4VTKFMC4966QSNZ05XLGI/VwzVa2kTUX51Dm0riQ=
+modernc.org/libc v1.74.1/go.mod h1:uH4t5bOx3G3g9Xcmj10YKlTcVISlRDwv8VoQJG9n8Os=
+modernc.org/mathutil v1.7.1 h1:GCZVGXdaN8gTqB1Mf/usp1Y/hSqgI2vAGGP4jZMCxOU=
+modernc.org/mathutil v1.7.1/go.mod h1:4p5IwJITfppl0G4sUEDtCr4DthTaT47/N3aT6MhfgJg=
+modernc.org/memory v1.11.0 h1:o4QC8aMQzmcwCK3t3Ux/ZHmwFPzE6hf2Y5LbkRs+hbI=
+modernc.org/memory v1.11.0/go.mod h1:/JP4VbVC+K5sU2wZi9bHoq2MAkCnrt2r98UGeSK7Mjw=
+modernc.org/opt v0.2.0 h1:tGyef5ApycA7FSEOMraay9SaTk5zmbx7Tu+cJs4QKZg=
+modernc.org/opt v0.2.0/go.mod h1:03fq9lsNfvkYSfxrfUhZCWPk1lm4cq4N+Bh//bEtgns=
+modernc.org/sortutil v1.2.1 h1:+xyoGf15mM3NMlPDnFqrteY07klSFxLElE2PVuWIJ7w=
+modernc.org/sortutil v1.2.1/go.mod h1:7ZI3a3REbai7gzCLcotuw9AC4VZVpYMjDzETGsSMqJE=
+modernc.org/sqlite v1.55.0 h1:hIFh0MCH0rGinQ/4KYb5/UbCkRkb+UP+OkLCVWa5MTM=
+modernc.org/sqlite v1.55.0/go.mod h1:4ntCLuNmnH8+GNqjka1wNg7KJd5/Hi5FYp8K+XQ7GZw=
+modernc.org/strutil v1.2.1 h1:UneZBkQA+DX2Rp35KcM69cSsNES9ly8mQWD71HKlOA0=
+modernc.org/strutil v1.2.1/go.mod h1:EHkiggD70koQxjVdSBM3JKM7k6L0FbGE5eymy9i3B9A=
+modernc.org/token v1.1.0 h1:Xl7Ap9dKaEs5kLoOQeQmPWevfnk/DM5qcLcYlA8ys6Y=
+modernc.org/token v1.1.0/go.mod h1:UGzOrNV1mAFSEB63lOFHIpNRUVMvYTc6yu1SMY/XTDM=
diff --git a/internal/home/app.go b/internal/home/app.go
new file mode 100644
index 0000000..d62bd2a
--- /dev/null
+++ b/internal/home/app.go
@@ -0,0 +1,51 @@
+// Package home is the Oficina suite home app: identity, directory,
+// provisioning glue shared by every instance in the suite. See
+// AGENTS.md (binding) and
+// docs/superpowers/specs/2026-08-30-home-design.md (the design) for
+// what this service is and, just as importantly, is not.
+//
+// This package is a deployable skeleton, not the home: it wires the
+// platform contract and serves one placeholder page. None of the
+// home's actual functionality — sign-in, directory, assertions,
+// provisioning — is implemented yet. See page.go.
+package home
+
+import (
+ "net/http"
+
+ "github.com/go-chi/chi/v5"
+)
+
+// App holds the wiring. One per process.
+//
+// It holds no database handle: the skeleton has no state to keep, and
+// AGENTS.md's "no address at rest" tests have nothing to guard until
+// real storage exists. Kept as a struct (rather than a bare package
+// func) so App/New/Router matches the Sheets app's shape at
+// internal/sheets/app.go — see New's comment.
+type App struct{}
+
+// New builds an App. It takes no arguments today because there is
+// nothing yet to wire in — no database, no session store, no identity
+// plugin. Kept as a constructor rather than a free function so this
+// package reads like Sheets' internal/sheets.New(db *gorm.DB) *App:
+// adding real wiring later is a one-line signature change, not a
+// reshape.
+func New() *App { return &App{} }
+
+// Router returns the mounted handler.
+func (a *App) Router() http.Handler {
+ r := chi.NewRouter()
+ r.Get("/", a.handleIndex)
+ // This /healthz is NOT what answers in a deployed instance:
+ // rastrillo.Serve registers its own GET /healthz on the outer mux
+ // (serve.go's buildHandler), and Go 1.22+ http.ServeMux prefers that
+ // exact pattern over this router's "/" mount, so the outer one wins
+ // every time in production. It exists here so App's own router is
+ // testable in isolation (see TestHealthz) and as the seam later
+ // routes mount alongside — mirrors Sheets' internal/sheets/app.go.
+ r.Get("/healthz", func(w http.ResponseWriter, _ *http.Request) {
+ w.WriteHeader(http.StatusOK)
+ })
+ return r
+}
diff --git a/internal/home/app_test.go b/internal/home/app_test.go
new file mode 100644
index 0000000..2c639aa
--- /dev/null
+++ b/internal/home/app_test.go
@@ -0,0 +1,38 @@
+package home
+
+import (
+ "net/http"
+ "net/http/httptest"
+ "testing"
+)
+
+// TestIndex asserts the one page this skeleton serves answers 200 with
+// HTML — the placeholder content itself is not asserted here so this
+// test does not need updating every time the wording changes.
+func TestIndex(t *testing.T) {
+ app := New()
+ req := httptest.NewRequest(http.MethodGet, "/", nil)
+ rec := httptest.NewRecorder()
+ app.Router().ServeHTTP(rec, req)
+ if rec.Code != http.StatusOK {
+ t.Fatalf("GET / = %d, want 200", rec.Code)
+ }
+ if ct := rec.Header().Get("Content-Type"); ct == "" {
+ t.Fatalf("GET / Content-Type is empty")
+ }
+}
+
+// TestHealthz exercises App.Router()'s own /healthz in isolation via
+// httptest — it does NOT prove what a deployed instance answers. In
+// production rastrillo.Serve's outer mux registers its own GET /healthz
+// ahead of this one and wins (see the comment on Router in app.go); this
+// test only proves the app's router itself is wired and reachable.
+func TestHealthz(t *testing.T) {
+ app := New()
+ req := httptest.NewRequest(http.MethodGet, "/healthz", nil)
+ rec := httptest.NewRecorder()
+ app.Router().ServeHTTP(rec, req)
+ if rec.Code != http.StatusOK {
+ t.Fatalf("healthz = %d, want 200", rec.Code)
+ }
+}
diff --git a/internal/home/page.go b/internal/home/page.go
new file mode 100644
index 0000000..43d8a7c
--- /dev/null
+++ b/internal/home/page.go
@@ -0,0 +1,42 @@
+package home
+
+import "net/http"
+
+// indexPage is the entire placeholder page: bare fact, not marketing.
+// It has no dynamic content and no user input to escape, so a plain
+// constant string stands in for html/template here rather than
+// pretending to need one.
+//
+// No stylesheet is linked. rastrillo's ui/tokens.css needs a theme
+// (day, plain or signal — see ui/themes/*.css) to paint anything;
+// picking one is a design decision, and design-system questions are
+// paused on this project (AGENTS.md's Frontend section assumes a
+// themed app, which this skeleton deliberately is not yet). Semantic,
+// unstyled HTML is the honest choice until that pause lifts.
+const indexPage = `<!doctype html>
+<html lang="en">
+<head>
+<meta charset="utf-8">
+<title>Oficina home</title>
+</head>
+<body>
+<main>
+<h1>Oficina home</h1>
+<p>This is the Oficina suite home: identity, a directory of your teams,
+and provisioning, shared by every app in the suite.</p>
+<p>It is not built yet. This page is a placeholder that proves the
+deploy path works. There is no sign-in, no directory, and no
+provisioning here.</p>
+<p>The design is at
+<code>docs/superpowers/specs/2026-08-30-home-design.md</code> in this
+repository.</p>
+</main>
+</body>
+</html>
+`
+
+// handleIndex serves the one page this skeleton has.
+func (a *App) handleIndex(w http.ResponseWriter, _ *http.Request) {
+ w.Header().Set("Content-Type", "text/html; charset=utf-8")
+ w.Write([]byte(indexPage))
+}