| 1 | // Command docs runs the CARLOS Docs rastrillo app. |
| 2 | package main |
| 3 | |
| 4 | import ( |
| 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. |
| 22 | const 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). |
| 35 | func 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?). |
| 67 | func 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. |
| 103 | func 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. |
| 129 | func 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 | |
| 136 | func 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 | |