oficina / docs Public

Clone
git clone https://amadan.net/oficina/docs

Plain git — no account needed to clone.

Download

Download this file

1// Command docs runs the CARLOS Docs rastrillo app.
2package main
3
4import (
5 "context"
6 "fmt"
7 "log/slog"
8 "os"
9 "strings"
10
11 "amadan.net/rastrillo/rastrillo"
12 "amadan.net/rastrillo/rastrillo/mail"
13
14 "github.com/carlosframework/docs/internal/docs"
15)
16
17// devSeedAddress is docs.Seed's default owner when -seed is given
18// with no $DOCS_SEED_ADDRESS override: a fixed, non-secret address to
19// sign in with locally and see the sample documents land — the same
20// "one fixed literal, never a real credential" shape devInstanceKey
21// (internal/docs/auth.go) uses for the same reason.
22const devSeedAddress = "dev@example.com"
23
24// extractEnvFlag pulls "-env value"/"-env=value" (or the "--env"
25// spelling) out of args, returning the value (empty if absent) and
26// everything else unchanged, order preserved. rastrillo.Resolve
27// parses os.Args[1:] itself with its own flag.FlagSet, which knows
28// only -socket/-addr/-db and errors on anything else, so any flag
29// this binary defines on top of that has to be stripped out first —
30// the same reason the now-deleted extractDevAuthFlag existed (fix
31// round 1 on Task 1: -dev-auth's own identity had zero readers once
32// real sign-in landed, and was removed rather than left as a trap;
33// this flag replaces the one thing it was still secretly load-bearing
34// for — see Env's own doc comment on docs.Config).
35func extractEnvFlag(args []string) (env string, rest []string) {
36 for i := 0; i < len(args); i++ {
37 a := args[i]
38 switch {
39 case a == "-env" || a == "--env":
40 if i+1 < len(args) {
41 env = args[i+1]
42 i++
43 }
44 continue
45 case strings.HasPrefix(a, "-env="):
46 env = strings.TrimPrefix(a, "-env=")
47 continue
48 case strings.HasPrefix(a, "--env="):
49 env = strings.TrimPrefix(a, "--env=")
50 continue
51 }
52 rest = append(rest, a)
53 }
54 return env, rest
55}
56
57// extractSeedFlag pulls a bare "-seed"/"--seed" out of args, the same
58// strip-before-rastrillo.Resolve shape extractEnvFlag uses just above
59// and for the same reason (rastrillo.Resolve's own flag.FlagSet knows
60// nothing about this flag and errors on it otherwise). Deliberately
61// boolean, no "-seed=value" form: which address to seed is
62// $DOCS_SEED_ADDRESS's job (devSeedAddress's own doc comment), the
63// same env-var-for-configuration, flag-for-behaviour split Origin and
64// -env already use — a flag that sometimes eats the next token and
65// sometimes does not is exactly the ambiguity a bare boolean avoids
66// (what would "-seed -addr :8099" mean if -seed could take a value?).
67func extractSeedFlag(args []string) (seed bool, rest []string) {
68 for _, a := range args {
69 if a == "-seed" || a == "--seed" {
70 seed = true
71 continue
72 }
73 rest = append(rest, a)
74 }
75 return seed, rest
76}
77
78// chooseEnv decides docs.Config.Env from -env's own extracted value
79// (flagEnv, "" if the flag was absent or given with no value) and
80// $DOCS_ENV (osEnv, "" if unset). "development" is honoured ONLY from
81// flagEnv — fix round 2 on Task 1: the coordinator's own original
82// instruction added a $DOCS_ENV fallback for this, and that reinstated
83// exactly the trap the deleted -dev-auth flag existed to close.
84// devauth_flag.go's own words, before it was deleted: "no config
85// default, no env var, nothing a systemd unit's boilerplate could set
86// without someone typing this literal flag." Platform instance
87// configuration IS environment variables — $DOCS_ORIGIN,
88// $DOCS_INSTANCE_KEY, $DOCS_SMTP_* — so a bare $DOCS_ENV=development
89// sitting one line away from its neighbours in the same block is a
90// production-reachable path to the public constant
91// docs-development-instance-key-not-a-secret (internal/docs/auth.go)
92// standing in for the HMAC key that seals auth's pending blob, and to
93// every magic link — token included — landing in the instance's own
94// log instead of being mailed. Verified live by the reviewer:
95// $DOCS_ENV=development with a real origin booted a development app
96// under the round-1 code.
97//
98// osEnv is still consulted for any OTHER value (a future "staging",
99// say): every fail-closed check in docs.New reads as "== development"
100// or "!= development", so any non-"development" string already
101// behaves exactly like production, and there is nothing for $DOCS_ENV
102// to unlock there. Only "development" itself is special-cased out.
103func chooseEnv(flagEnv, osEnv string) string {
104 if flagEnv != "" {
105 return flagEnv
106 }
107 if osEnv != "" && osEnv != "development" {
108 return osEnv
109 }
110 return "production"
111}
112
113// checkSeedGate is -seed's whole safety story, extracted to a single
114// named predicate so main() and TestSeedGateRefusesWithoutDevelopment
115// (main_test.go) call the exact same code — not a copy of its logic
116// compared against itself. Fix round 1 on Task 3: the original test
117// re-implemented this expression inline, inverted operand order and
118// all, and so could not fail no matter how the real gate changed.
119//
120// chooseEnv above only ever returns "development" for the literal
121// -env flag on this exact invocation — never $DOCS_ENV, never any
122// default (chooseEnv's own doc comment, fix round 2's finding on Task
123// 1) — so this one check is genuinely the only thing standing between
124// -seed and a database that might hold real work: a systemd unit's
125// argv that carelessly kept a -seed flag around, or a copy-pasted
126// invocation run against a real deployment, must refuse here rather
127// than writing six sample documents into it. A nil error means
128// seeding may proceed.
129func checkSeedGate(seed bool, env string) error {
130 if !seed || env == "development" {
131 return nil
132 }
133 return fmt.Errorf("-seed requires -env development; refusing to seed a database that might not be a throwaway one (env=%q)", env)
134}
135
136func main() {
137 logger := slog.Default()
138 if len(os.Args) > 1 && os.Args[1] == "docs-seal" {
139 if err := runContentMaintenance(context.Background(), os.Args[2:], os.Getenv, logger); err != nil {
140 logger.Error("docs: new", "err", err)
141 os.Exit(1)
142 }
143 return
144 }
145
146 // -env (chooseEnv above; $DOCS_ENV is consulted too, but never for
147 // "development" — see chooseEnv's own doc comment, fix round 2)
148 // decides docs.Config.Env — deliberately the ONLY thing that
149 // does, independent of any identity flag. It used to be
150 // entangled with -dev-auth (whichever call set Env also turned
151 // the now-deleted development-only identity on); fix round 1 on
152 // Task 1 split them apart, because "log mail instead of sending
153 // it" and "who can sign in" were never the same decision, and a
154 // future deletion of an identity flag should never silently take
155 // the mail-logging fallback (and scripts/run-dev.sh) down with
156 // it.
157 envFlag, rest := extractEnvFlag(os.Args[1:])
158 // -seed: extracted the same way, and for the same reason —
159 // see docs.Seed's own doc comment for what it does and this
160 // flag's gate on Env, just below, for why it can never reach a
161 // database that might be real.
162 seedFlag, rest := extractSeedFlag(rest)
163 os.Args = append(os.Args[:1], rest...)
164
165 // Resolve applies the platform's activation argv (-socket/-addr/-db,
166 // or LISTEN_FDS/$STATE_DIRECTORY under the platform) — never
167 // hand-rolled, per SKILL.md §1.
168 opts, err := rastrillo.Resolve(rastrillo.Options{
169 DBPath: "docs.db",
170 Logger: logger,
171 })
172 if err != nil {
173 logger.Error("docs: resolve", "err", err)
174 os.Exit(1)
175 }
176
177 // Origin feeds docs.Config.Origin — see its doc comment for what
178 // csrf.Protect actually does with it (fix round 3: Sec-Fetch-Site
179 // decides a modern browser's own traffic before this string is
180 // ever read; Origin only matters to the legacy Origin/Referer
181 // fallback path, and a wrong value there fails silently rather
182 // than visibly). An empty value here is not "no opinion", it is
183 // "New must refuse to start" once Env is anything but development.
184 //
185 // $DOCS_ORIGIN is the explicit, deploy-time override — the
186 // "<APP>_ORIGIN" convention rastrillo's own `new` scaffold uses
187 // (cmd/rastrillo/new.go's mainTemplate) — and always wins when set;
188 // that is where the platform's real per-instance origin (spec
189 // §9.2's <app>.<sqid>.oncarlos.com) belongs. Falling back to a TCP
190 // -addr only helps the local/dev path (scripts/run-dev.sh, or a
191 // person passing -addr by hand): that is exactly the host a
192 // browser loading this instance sends back as its own Origin
193 // header. The platform's real activation path is a unix socket or
194 // LISTEN_FDS — no -addr to derive anything from — so without
195 // $DOCS_ORIGIN set there, origin stays empty here and New refuses
196 // to start rather than silently falling back to docs.DefaultOrigin,
197 // a placeholder with no relationship to where this instance is
198 // actually reachable — that is exactly the trap this task closes,
199 // whether or not a given request would ever notice.
200 origin := os.Getenv("DOCS_ORIGIN")
201 if origin == "" && opts.Addr != "" {
202 origin = "http://" + opts.Addr
203 }
204
205 // $DOCS_INSTANCE_KEY seals rastrillo/auth's pending-link blob —
206 // docs.Config.InstanceKey's own doc comment. Left empty here when
207 // unset: docs.New refuses to start on an empty one outside
208 // development, the same fail-closed shape as an empty Origin.
209 instanceKey := os.Getenv("DOCS_INSTANCE_KEY")
210
211 // $DOCS_SMTP_HOST/$DOCS_SMTP_FROM (and the sibling
212 // $DOCS_SMTP_PORT/$DOCS_SMTP_USER/$DOCS_SMTP_PASS
213 // rastrillo/mail.FromEnv also reads) configure real magic-link
214 // delivery. Only built when both of the two values with no sane
215 // default are actually set — mail.FromEnv itself falls back to a
216 // silent warning-and-log Sender when they're missing, which is
217 // the wrong answer outside development, so that decision is left
218 // to docs.New (Config.Mailer's own doc comment): a nil Mailer
219 // here is what lets it refuse to start rather than silently
220 // logging a live credential in a real deployment's log.
221 var mailer mail.Sender
222 if host := strings.TrimSpace(os.Getenv("DOCS_SMTP_HOST")); host != "" {
223 if from := strings.TrimSpace(os.Getenv("DOCS_SMTP_FROM")); from != "" {
224 mailer = mail.FromEnv("DOCS", logger)
225 }
226 }
227
228 // $DOCS_ALLOWED_ADDRESSES is the comma-separated allowlist
229 // docs.Config.AllowedAddresses' own doc comment describes — the
230 // only addresses this instance ever mails a sign-in link to or
231 // admits a session for. Left empty here when unset: docs.New
232 // (via resolveAllowlist, internal/docs/auth.go) refuses to start
233 // on an empty one outside development, the same fail-closed shape
234 // as InstanceKey and Mailer just above — never "admit everyone",
235 // which is what an unset allowlist reaching a public instance
236 // would otherwise silently mean.
237 allowedAddresses := os.Getenv("DOCS_ALLOWED_ADDRESSES")
238
239 // $DOCS_SAMPLE_DOCUMENTS opts a deployed instance into
240 // docs.Config.SampleDocuments — see its own doc comment for the
241 // full reasoning (parsed permissively, default off, deliberately
242 // not fail-closed like the settings just above). This is the
243 // platform-reachable fix for the bug this branch exists to close:
244 // -seed below never runs on a deployed instance (gated to -env
245 // development, which activation argv never sets), so without this
246 // env var a real deployment's sample content stays exactly as
247 // unreachable as it was before.
248 sampleDocuments := os.Getenv("DOCS_SAMPLE_DOCUMENTS")
249
250 // chooseEnv: -env wins; $DOCS_ENV wins next EXCEPT for
251 // "development" itself, which chooseEnv never honours from an env
252 // var (fix round 2 — see its own doc comment); the default with
253 // neither set, or with $DOCS_ENV=development and no flag, is
254 // "production". Nothing here ever defaults to "development" on its
255 // own; that always takes an explicit -env flag, on this binary or
256 // in run-dev.sh's own invocation.
257 env := chooseEnv(envFlag, os.Getenv("DOCS_ENV"))
258
259 // checkSeedGate's own doc comment has the full reasoning; this
260 // call runs before docs.New even opens the database, so there is
261 // no window where a wrongly-seeded production process is already
262 // serving requests by the time this fires.
263 if err := checkSeedGate(seedFlag, env); err != nil {
264 logger.Error("docs: " + err.Error())
265 os.Exit(1)
266 }
267
268 // New opens its own database handle (SKILL.md §1's "app that opens
269 // its own database"), so opts.DBPath is blanked below before Serve
270 // — otherwise Serve would open a second connection to the file New
271 // already owns.
272 cfg := docs.Config{
273 DBPath: opts.DBPath,
274 ContentKey: os.Getenv("DOCS_CONTENT_KEY"),
275 Env: env,
276 Origin: origin,
277 InstanceKey: instanceKey,
278 Mailer: mailer,
279 AllowedAddresses: allowedAddresses,
280 SampleDocuments: sampleDocuments,
281 Logger: logger,
282 // The hub directory the app switcher's "All apps" goes to. The
283 // same variable Home itself reads (home origins.go).
284 Directory: os.Getenv("HOME_DIRECTORY_ORIGIN"),
285 }
286 home := docs.HomeConfig{Issuer: os.Getenv("DOCS_HOME_ISSUER"), ConsumerID: os.Getenv("DOCS_HOME_CONSUMER_ID"), KeysBase64: os.Getenv("DOCS_HOME_KEYS_BASE64"), DEKBase64: os.Getenv("DOCS_HOME_DEK_BASE64")}
287 if home.Issuer != "" || home.ConsumerID != "" || home.KeysBase64 != "" || home.DEKBase64 != "" {
288 cfg.Home = &home
289 }
290
291 team := docs.TeamConfig{TeamID: os.Getenv("DOCS_TEAM_ID"), DirectoryOrigin: os.Getenv("DOCS_TEAM_DIRECTORY_ORIGIN"), ProvisioningBase64: os.Getenv("DOCS_TEAM_PROVISIONING_BASE64"), OperatorKeysBase64: os.Getenv("DOCS_TEAM_OPERATOR_KEYS_BASE64"), RosterToken: os.Getenv("DOCS_TEAM_ROSTER_TOKEN")}
292 if team.TeamID != "" || team.DirectoryOrigin != "" || team.ProvisioningBase64 != "" || team.OperatorKeysBase64 != "" || team.RosterToken != "" {
293 cfg.Team = &team
294 if seedFlag {
295 logger.Error("docs: seed unavailable in TeamMode")
296 os.Exit(1)
297 }
298 }
299 app, err := docs.New(cfg)
300 if err != nil {
301 logger.Error("docs: new", "err", err)
302 os.Exit(1)
303 }
304 defer app.Close()
305
306 // Only reachable with both -seed and -env development (the check
307 // above) — after New, since both EnsurePerson and docs.Seed need a
308 // real app.Store() over an already-migrated database, and after
309 // the InstanceKey/Mailer checks above, so a database that cannot
310 // even send a real sign-in link never gets seeded either.
311 if seedFlag {
312 seedAddress := strings.TrimSpace(os.Getenv("DOCS_SEED_ADDRESS"))
313 if seedAddress == "" {
314 seedAddress = devSeedAddress
315 }
316 // EnsurePerson here runs with cfg.SampleDocuments as given on
317 // this invocation: if it's also truthy, EnsurePerson's own
318 // per-person seeding (store.go) already seeds seedAddress the
319 // moment its Person row is first created, before this call
320 // even returns — see its doc comment. The explicit docs.Seed
321 // call just below runs unconditionally regardless, exactly as
322 // it always has: -seed's whole point is "seed this address
323 // right now, guaranteed", not "seed it only if some other
324 // setting happens to agree."
325 owner, err := app.Store().EnsurePerson(context.Background(), seedAddress)
326 if err != nil {
327 logger.Error("docs: seed: resolve person", "address", seedAddress, "err", err)
328 os.Exit(1)
329 }
330 if err := docs.Seed(context.Background(), app.Store(), docs.Actor{PersonID: owner, VerifiedAddress: &seedAddress}); err != nil {
331 logger.Error("docs: seed", "address", seedAddress, "err", err)
332 os.Exit(1)
333 }
334 logger.Info("docs: seeded sample documents", "address", seedAddress)
335 }
336
337 opts = docs.ServeOptions(opts, app)
338
339 if err := rastrillo.Serve(opts); err != nil {
340 logger.Error("docs: serve", "err", err)
341 os.Exit(1)
342 }
343}
344