Sign-in, document ownership, and sample content — live
Magic-link sign-in via rastrillo/auth, documents owned by people, six sample documents, and the app deployed at https://docs.bds.oncarlos.com. Spec §8.1 is reframed: an instance signing people in itself is REQUIRED by factor VII, not merely acceptable. Self-hosting is a right, so the standalone path cannot be a degraded tier. Document text still exists only as replayed Yjs updates through WebAssembly. There is no content column; the seed updates are honest output of real yjs with a pinned client id, byte-identical across runs. Two defects found late are worth recording, because both were certified correct by something green before they were found. ApplyUpdate scoped the wrong write. The updated_at bump carried an owner predicate; the doc_updates INSERT never did. A gorm UPDATE matching zero rows is not an error, so the method returned nil while a forged OwnerID wrote a whole document into someone else's. Three things agreed and all three were wrong: a comment claiming it had closed "the one write in this file that skipped the seam" (there were two), a test NAMED as proving the write was refused that asserted only a timestamp, and a verification note that read the line it was pointed at rather than the operation. Ownership is now re-derived from the database before anything is replayed or written — a *Document is a value a caller can construct freely, a row is not. Latent, not exploited: only Seed called it, with a correct owner. Sign-in was an open relay. rastrillo/auth consults Config.Authorize at admission (handlers.go:131, after the link is clicked) and never in Begin. Ours was nil, and nil admits everybody, so any stranger could have had this instance's SES identity mail them a link that worked — a session and a document space, on an app whose premise is one instance per team. Found by the CARLOS Sheets session on their own deployment; their diagnosis generalises: every unit test passed because they all tested admission and none tested delivery. Now gated before Begin, with byte-identical refusals so the endpoint cannot be used to probe which addresses exist, and verified live against SES's own send counter rather than our test suite — the counter is an oracle outside our code, which is exactly what the blind spot required. An empty allowlist refuses at startup outside development rather than admitting nobody: the security fix and the silent-misconfiguration mode are the same mechanism, since an instance admitting nobody boots looking healthy, answering its ordinary 303 ?sent=1 on every submission because the byte-identical refusal is working as designed. The recurring shape across this branch was a check that reports success while measuring something adjacent to the question: a gate test that re-implemented the gate and compared it to itself; carlos ship --target, which declares an architecture without verifying it, shipping an amd64 ELF labelled linux-arm64 that promoted cleanly and then failed to start thirty times; an allowlist tested at admission while the hole was at send. rastrillo.BuildVersion defaults to the literal "dev" unless stamped via ldflags, which would have made the process-level deploy check pass while distinguishing nothing. Reviewed as a single diff by the highest-capability model per AGENTS.md. Discussion: https://amadan.net/oficina/docs/discussions/7
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_011F5CivVm4CmL9ByfSSAaXA
46 files changed,
+3696
−488
.amadan/README.md+20 −9.amadan/ci.d/10-gate+21 −47AGENTS.md+75 −29cmd/docs/devauth_flag.go+0 −32cmd/docs/devauth_flag_release.go+0 −55cmd/docs/main.go+222 −23cmd/docs/main_test.go+265 −0docs/superpowers/plans/2026-08-31-signin-and-samples.md+340 −0docs/superpowers/specs/2026-08-30-docs-design.md+50 −1fixtures/generate-seed.mjs+46 −0go.mod+1 −0internal/docs/app.go+161 −50internal/docs/app_test.go+4 −0internal/docs/auth.go+376 −0internal/docs/auth_allowlist_test.go+276 −0internal/docs/auth_test.go+282 −0internal/docs/devauth.go+0 −81internal/docs/devauth_test.go+0 −80internal/docs/handlers.go+73 −19internal/docs/list_test.go+52 −2internal/docs/migrations/0002_owners.sql+21 −0internal/docs/models.go+27 −0internal/docs/render.go+32 −2internal/docs/scope_test.go+216 −0internal/docs/seed.go+107 −0internal/docs/seed_test.go+131 −0internal/docs/seeddata/documentation.txt+12 −0internal/docs/seeddata/documentation.update.binbinaryinternal/docs/seeddata/history.txt+29 −0internal/docs/seeddata/history.update.binbinaryinternal/docs/seeddata/letter.txt+13 −0internal/docs/seeddata/letter.update.binbinaryinternal/docs/seeddata/meeting-notes.txt+30 −0internal/docs/seeddata/meeting-notes.update.binbinaryinternal/docs/seeddata/packing-list.txt+9 −0internal/docs/seeddata/packing-list.update.binbinaryinternal/docs/seeddata/proposal.txt+19 −0internal/docs/seeddata/proposal.update.binbinaryinternal/docs/store.go+176 −18internal/docs/store_test.go+159 −8internal/docs/testing.go+139 −8internal/docs/views/list.html+36 −14internal/docs/views/show.html+9 −0internal/docs/views/signin.html+51 −0internal/lint/no_sessions_userid_test.go+185 −0scripts/run-dev.sh+31 −10
CI passed — run details
diff --git a/.amadan/README.md b/.amadan/README.md| index d63de43..4e8ba82 100644 |
| --- a/.amadan/README.md |
| +++ b/.amadan/README.md |
| @@ -53,12 +53,23 @@ in the same commit as the change. |
| `go vet ./...` and `go test ./...` already cover `cmd/docs` and |
| `internal/docs` — the rastrillo app built on top of `internal/core` — but |
| neither one proves the app actually links into a runnable binary. The gate |
| -also runs `CGO_ENABLED=0 go build ./...` for that, and separately builds |
| -`cmd/docs` with `-tags release` and runs the result with `-dev-auth` on its |
| -command line, asserting it refuses to start: `devauth_flag_release.go` |
| -replaces the flag-parsing function with one that can never return true, so |
| -a release binary has no code path that can turn the development-only |
| -identity (`internal/docs/devauth.go`) on at all — a build-time closure of |
| -that hole, not just `guardDevAuth`'s runtime check. `scripts/run-dev.sh` |
| -builds and runs the ordinary (non-release) binary with `-dev-auth`, a fixed |
| -port, and a scratch database — one command to see the app in a browser. |
| +also runs `CGO_ENABLED=0 go build ./...` for that. |
| + |
| +There used to be a further step here: a separate `-tags release` build, |
| +run with `-dev-auth` on its command line, asserting it refused to start — |
| +proving a release binary had no code path that could turn a |
| +development-only stand-in identity on. That identity |
| +(`internal/docs/devauth.go`) had zero readers once rastrillo/auth's |
| +`RequireSession` started guarding every document route unconditionally |
| +(a fix round on Task 1, once real per-person sign-in existed), so it was |
| +deleted rather than kept as dead code — and the `release` build tag it |
| +was the only user of went with it. There is no release/non-release |
| +distinction left for this gate to hold apart; `go test` covers the |
| +fail-closed properties that matter now |
| +(`TestNewRefusesMissingInstanceKeyOutsideDevelopment`, |
| +`TestNewRefusesMissingMailOutsideDevelopment`, |
| +`TestSignInRequiredForEverything` — all `internal/docs/auth_test.go`). |
| +`scripts/run-dev.sh` builds and runs the ordinary binary with `-env |
| +development`, a fixed port, and a scratch database — one command to see |
| +the app in a browser, signed in for real via a magic link logged to the |
| +terminal instead of mailed. |
diff --git a/.amadan/ci.d/10-gate b/.amadan/ci.d/10-gate| index 2f8d83d..220c1ec 100755 |
| --- a/.amadan/ci.d/10-gate |
| +++ b/.amadan/ci.d/10-gate |
| @@ -79,52 +79,26 @@ echo "=== go: build ./... (the app, not just the packages tests import)" |
| # correctly on its own. |
| CGO_ENABLED=0 go build ./... |
| -echo "=== go: a release build cannot start with dev auth" |
| -# cmd/docs/devauth_flag_release.go (-tags release) replaces |
| -# extractDevAuthFlag with a version that never returns true — the |
| -# strongest closure available for "a -dev-auth flag left in a deploy |
| -# script": the release binary has no code path that can turn it on, |
| -# not merely a runtime check that refuses once it is. This step |
| -# proves both halves: the tagged build compiles at all (a file that |
| -# only exists under a tag most local runs never pass is otherwise |
| -# untested for as long as nobody happens to build it), and running |
| -# that binary with -dev-auth on its command line actually refuses to |
| -# start rather than silently doing nothing. |
| -gate_tmp="$(mktemp -d)" |
| -trap 'rm -rf "$gate_tmp"' EXIT |
| -CGO_ENABLED=0 go build -tags release -o "$gate_tmp/docs-release" ./cmd/docs |
| - |
| -# A release binary that (wrongly) accepted -dev-auth would go on to call |
| -# rastrillo.Serve and block forever, listening — a hang, not a failure. |
| -# "if cmd; then" alone cannot tell "refused in 5ms" apart from "still |
| -# running after 5 seconds": both only reach a branch once something |
| -# finally kills the process, and a bare invocation with no timeout never |
| -# would. This check exists specifically to catch that regression, so it |
| -# must fail loudly the day it fires rather than hang the gate itself — a |
| -# hang gets retried, a failure gets read. `timeout` turns "still running" |
| -# into its own distinguishable exit code (124) instead. -db and -addr |
| -# point at a scratch path and an ephemeral port so a wrongly-accepted |
| -# -dev-auth run cannot litter the repo with a stray docs.db (it did, |
| -# once, from the default relative DBPath) or fight over a real port. |
| -set +e |
| -timeout 5 "$gate_tmp/docs-release" -dev-auth -db "$gate_tmp/scratch.db" -addr 127.0.0.1:0 \ |
| - >"$gate_tmp/out" 2>&1 |
| -status=$? |
| -set -e |
| -if [ "$status" -eq 124 ]; then |
| - echo "gate: a release build accepted -dev-auth and kept running until killed by timeout — see cmd/docs/devauth_flag_release.go" >&2 |
| - cat "$gate_tmp/out" >&2 |
| - exit 1 |
| -fi |
| -if [ "$status" -eq 0 ]; then |
| - echo "gate: a release build accepted -dev-auth and exited 0 — see cmd/docs/devauth_flag_release.go" >&2 |
| - cat "$gate_tmp/out" >&2 |
| - exit 1 |
| -fi |
| -grep -qi 'dev-auth' "$gate_tmp/out" || { |
| - echo "gate: release build refused to start, but not because of -dev-auth — investigate:" >&2 |
| - cat "$gate_tmp/out" >&2 |
| - exit 1 |
| -} |
| +# There used to be a further step here: build cmd/docs with -tags |
| +# release and assert the result refuses to start with -dev-auth on |
| +# its command line, proving a release binary had no code path that |
| +# could ever turn the development-only stand-in identity on |
| +# (cmd/docs/devauth_flag_release.go). Fix round 1 on Task 1 deleted |
| +# that identity outright — internal/docs/devauth.go had zero readers |
| +# once rastrillo/auth's RequireSession started guarding every |
| +# document route unconditionally, with no flag anywhere capable of |
| +# disabling it — so the `release` build tag it was the only user of |
| +# is gone too, and there is no longer a release/non-release |
| +# distinction for this gate to hold apart. The property that check |
| +# guarded (an unauthenticated binary reachable at a real URL) is now |
| +# covered a different way: go test above runs |
| +# TestNewRefusesMissingInstanceKeyOutsideDevelopment and |
| +# TestNewRefusesMissingMailOutsideDevelopment (internal/docs/auth_test.go), |
| +# proving Env other than "development" still fails closed on its own |
| +# fail-closed configuration, and TestSignInRequiredForEverything |
| +# proves every document route — GET and POST alike — refuses a |
| +# signed-out request. A stray "-dev-auth" on any build's command line |
| +# today reaches rastrillo.Resolve's own flag.FlagSet undefined, and is |
| +# refused exactly like any other unrecognized flag. |
| echo "gate: ok" |
diff --git a/AGENTS.md b/AGENTS.md| index cb18438..f4f295f 100644 |
| --- a/AGENTS.md |
| +++ b/AGENTS.md |
| @@ -20,38 +20,84 @@ Read `building-carlos-apps` (the skill) before writing app code, and |
| `rastrillo`'s own `SKILL.md` before writing handlers or models. Assume |
| nothing about either is in your training data. |
| -## This app has no authentication. Do not deploy it anywhere reachable. |
| - |
| -Every route — `GET /`, `POST /documents`, `GET /d/{sqid}` — answers anyone |
| -who can reach the process. `internal/docs/devauth.go`'s development-only |
| -identity is real code, guarded so it cannot start outside |
| -`Env=development` and compiled out of a release build entirely (`-tags |
| -release`), but **nothing in this app consumes the identity it grants**: no |
| -handler calls `CurrentDevIdentity`, no route requires sign-in, no document |
| -is scoped to anyone. All that hardening guards an identity nothing checks |
| -— it is not evidence this app is safe to expose. A release binary is a |
| -wide-open, unauthenticated, everyone-sees-every-document app, on purpose, |
| -because real identity does not exist yet: spec §8.1's home issues it, and |
| -that is explicitly deferred, not forgotten (`devauth.go`'s own doc comment |
| -says so). Until that lands, this app belongs on a laptop |
| -(`scripts/run-dev.sh`) or behind something else's access control — never |
| -on a network anyone but its operator can reach. |
| +## Real per-person sign-in (Task 1, extended in a later fix round) |
| + |
| +Every document route — `GET /`, `POST /documents`, `GET /d/{sqid}` — sits |
| +behind rastrillo/auth's `RequireSession` and is unreachable without a real |
| +session (`internal/docs/auth.go`). Sign-in is a magic link: `POST /signin` |
| +emails (or, in development, logs) a single-use link; following it |
| +(`GET /auth/verify`) sets a real session cookie; `POST /signout` (linked |
| +from both `list.html` and `show.html`'s page headers) really revokes it, |
| +not just clearing the cookie client-side. No passkey second factor is |
| +wired, on purpose — spec §8.1's ruling: `passkey.Config.Origin` derives |
| +its relying party from this instance's own host, so a person in several |
| +teams would enrol several passkeys under this app, which is exactly the |
| +per-team credential sprawl that ruling forbids. A magic link enrols no |
| +credential, so it violates nothing. |
| + |
| +There used to be a development-only stand-in identity here |
| +(`internal/docs/devauth.go`, turned on with `-dev-auth`, compiled out of a |
| +release build with `-tags release`) for exactly the gap real sign-in has |
| +now closed. It had zero readers the moment `RequireSession` started |
| +guarding every route unconditionally — no handler ever consulted it — and |
| +was deleted in a fix round on Task 1 rather than left as dead code and an |
| +unreachable trap for whoever next assumed `-dev-auth` still did something. |
| +The `release` build tag it was the only user of is gone with it; there is |
| +no longer a release/non-release distinction anywhere in this app. |
| + |
| +`Config.Env` still exists (`"development"` or `"production"`), but it now |
| +governs exactly one thing, independent of any identity flag: whether |
| +`InstanceKey` and `Mailer` (`internal/docs/auth.go`) may default — an |
| +empty `$DOCS_INSTANCE_KEY` or unconfigured mail is fine only in |
| +development (a fixed dev key, and magic links logged instead of mailed, |
| +clearly marked); anywhere else it is a startup refusal, the same |
| +fail-closed shape `Origin` already used. `cmd/docs/main.go`'s `-env` flag |
| +sets it; `$DOCS_ENV` is consulted too but can never select |
| +`"development"` on its own (fix round 2 on Task 1 — platform instance |
| +config *is* environment variables, so a bare `$DOCS_ENV=development` |
| +sitting beside `$DOCS_ORIGIN`/`$DOCS_INSTANCE_KEY`/`$DOCS_SMTP_*` would |
| +be a production-reachable path to the public dev `InstanceKey` and to |
| +every magic link, token included, landing in the instance's own log — |
| +see `chooseEnv`'s own doc comment). The default, with no flag and no |
| +env var (or with `$DOCS_ENV=development` and no flag), is |
| +`"production"` — `"development"` always takes an explicit `-env` |
| +flag, never a silent fallback. |
| + |
| +### Sign-in allowlist (Task 4 security fix, pre-launch) |
| + |
| +Real sign-in above closed "who can get a session" but left a separate |
| +hole open: `rastrillo/auth`'s `Config.Authorize` — the admission gate — |
| +is consulted only in its own callback handler, after `POST /signin` |
| +(`Begin`) has already emailed the link. With `Authorize` left `nil` (as |
| +it was through Task 3), that meant anyone who could reach the instance |
| +could POST any address and get a working sign-in link mailed to it — |
| +an open relay on our own SES sending identity, and unrestricted account |
| +creation on an app whose whole premise is instance-per-team. Found by |
| +the CARLOS Sheets session on their own deployed instance; every existing |
| +test passed because they all tested admission, none tested delivery. |
| + |
| +The fix is `$DOCS_ALLOWED_ADDRESSES` (`docs.Config.AllowedAddresses`, |
| +`internal/docs/auth.go`): a comma-separated, case/whitespace-insensitive |
| +allowlist, wired to *two* places — `rauth.Config.Authorize` (admission, |
| +belt-and-suspenders) and `gateBegin`, a wrapper mounted in front of |
| +`rauth.Auth.Begin` on `POST /signin` itself, which is the part that |
| +actually matters, since it is what stops the mail. A non-permitted |
| +address gets exactly the same `303 ?sent=1` response a permitted one |
| +gets — anything else would turn the endpoint into a membership oracle. |
| +Empty is allowed only in development (admits everyone, so `TestSignIn` |
| +and `run-dev.sh` need no setup); anywhere else it is a startup refusal, |
| +the same fail-closed shape `Origin`/`InstanceKey`/`Mailer` already use, |
| +chosen over silently admitting nobody because a silently-empty allowlist |
| +would boot looking identical to a working instance. |
| ## Running it locally |
| -`./scripts/run-dev.sh` builds and runs the app in one command: the |
| -development-only identity on (`-dev-auth`), a fixed port |
| -(`localhost:8099`, override with `$DOCS_DEV_PORT`), and a scratch SQLite |
| -database that starts empty every run. It prints the URL to open. |
| - |
| -`-dev-auth` only exists in an ordinary build. `cmd/docs/devauth_flag_release.go` |
| -(`-tags release`) replaces the flag-parsing function with one that can never |
| -observe it — a release binary has no code path that can turn the |
| -development-only identity on at all, not just a runtime check that refuses |
| -once it is (`internal/docs/devauth.go`'s `guardDevAuth`, which still runs |
| -either way, for anything else that might one day set `Config.DevAuth`). |
| -`.amadan/ci.d/10-gate` builds both configurations and asserts the release |
| -one actually refuses `-dev-auth`. |
| +`./scripts/run-dev.sh` builds and runs the app in one command: `-env |
| +development`, a fixed port (`localhost:8099`, override with |
| +`$DOCS_DEV_PORT`), and a scratch SQLite database that starts empty every |
| +run. It prints the URL to open, and signing in there gets a real session |
| +via a magic link logged to the terminal instead of mailed — the same flow |
| +a real deployment uses, just not actually delivered anywhere. |
| **Origin is not optional in anything but development — but know what it |
| actually guards.** rastrillo v0.21.0's `csrf.Protect` checks |
diff --git a/cmd/docs/devauth_flag.go b/cmd/docs/devauth_flag.go| deleted file mode 100644 |
| index 16ea4ca..0000000 |
| --- a/cmd/docs/devauth_flag.go |
| +++ /dev/null |
| @@ -1,32 +0,0 @@ |
| -//go:build !release |
| - |
| -// This file exists so a normal build (`go build ./...`, `go run`, |
| -// scripts/run-dev.sh — no build tags) can still reach the |
| -// development-only identity by spelling out -dev-auth on the command |
| -// line. devauth_flag_release.go is this file's mirror image, built |
| -// only with -tags release, where the function below does not exist at |
| -// all — see that file's doc comment for why a build tag is the |
| -// strongest closure available for this particular hole. |
| -package main |
| - |
| -// extractDevAuthFlag pulls a bare "-dev-auth"/"--dev-auth" token out |
| -// of args, returning whether it was present and everything else |
| -// unchanged (order preserved). rastrillo.Resolve parses os.Args[1:] |
| -// itself, with its own flag.FlagSet that knows only -socket/-addr/-db |
| -// and errors on anything else — so -dev-auth must be stripped out of |
| -// os.Args before Resolve ever sees it, or Resolve fails to start |
| -// outright rather than merely refusing dev auth. This is presence-only |
| -// (no "-dev-auth=false"): the deliberately narrow, explicit shape the |
| -// brief asks for — no config default, no env var, nothing a systemd |
| -// unit's boilerplate could set without someone typing this literal |
| -// flag. |
| -func extractDevAuthFlag(args []string) (enabled bool, rest []string) { |
| - for _, a := range args { |
| - if a == "-dev-auth" || a == "--dev-auth" { |
| - enabled = true |
| - continue |
| - } |
| - rest = append(rest, a) |
| - } |
| - return enabled, rest |
| -} |
diff --git a/cmd/docs/devauth_flag_release.go b/cmd/docs/devauth_flag_release.go| deleted file mode 100644 |
| index eb1912f..0000000 |
| --- a/cmd/docs/devauth_flag_release.go |
| +++ /dev/null |
| @@ -1,55 +0,0 @@ |
| -//go:build release |
| - |
| -// A `-dev-auth` flag left in a deploy script or a systemd unit is a |
| -// residual security hole no in-process check can fully close — by the |
| -// time guardDevAuth (internal/docs/devauth.go) runs, the flag has |
| -// already been read and turned into Config.DevAuth true; a Env check |
| -// at that point catches "development" spelled wrong, not "someone |
| -// pasted the wrong invocation into a unit file that happens to set |
| -// Env=production correctly." Moving the closure to build time removes |
| -// the capability from the binary itself: a release build has no |
| -// extractDevAuthFlag that can ever observe -dev-auth and return true, |
| -// so there is no argv, env var, or config file anyone could hand this |
| -// specific binary that turns dev auth on. `go build -tags release |
| -// ./cmd/docs` is what the gate (.amadan/ci.d/10-gate) builds and |
| -// exercises; any real deploy pipeline must build the same way. |
| -// |
| -// What this still misses, honestly: |
| -// |
| -// 1. It only closes the argv path in *this* binary. Nothing enforces |
| -// that a deploy actually passes -tags release — a build run |
| -// without it still gets devauth_flag.go's real implementation. |
| -// The gate asserts the tagged build behaves correctly; it cannot |
| -// force a deploy script elsewhere to use it. That is a process |
| -// control, not a code one. |
| -// 2. internal/docs' devauth.go (NewWithDevAuth, guardDevAuth, |
| -// devAuthMiddleware, DevIdentity) is still compiled into the |
| -// binary either way — this file only removes the one path in |
| -// main() that could ever set Config.DevAuth true from outside the |
| -// process. guardDevAuth's runtime Env check (internal/docs/devauth.go) |
| -// is what still catches any *other* future path into DevAuth true |
| -// (a new flag added carelessly, a config field set by hand) — the |
| -// two guards are complementary, not substitutes for each other. |
| -// 3. The failure mode when someone does pass -dev-auth to a release |
| -// binary is a generic rastrillo.Resolve flag-parse error ("flag |
| -// provided but not defined: -dev-auth"), not a custom, friendlier |
| -// security message — deliberately: keeping this function's shape |
| -// identical to the non-release build (same signature, same |
| -// "always return false" contract when there is nothing to enable) |
| -// is what makes this file safe to review as "does nothing" rather |
| -// than as new logic that could itself have a bug. The refusal is |
| -// still loud (nonzero exit, a message naming the flag) — see |
| -// main.go's own os.Exit(1) on a Resolve error. |
| -package main |
| - |
| -// extractDevAuthFlag is the release build's version: it does not |
| -// scan args for -dev-auth at all, so `enabled` can never be anything |
| -// but false and `rest` is always the untouched input — a release |
| -// binary given "-dev-auth" on its command line has no code path that |
| -// turns it into Config.DevAuth true; the token simply reaches |
| -// rastrillo.Resolve's flag.FlagSet, which does not define it, and |
| -// Resolve fails to start (main.go's os.Exit(1)) exactly like any |
| -// other unrecognized flag. |
| -func extractDevAuthFlag(args []string) (enabled bool, rest []string) { |
| - return false, args |
| -} |
diff --git a/cmd/docs/main.go b/cmd/docs/main.go| index 3809558..3780808 100644 |
| --- a/cmd/docs/main.go |
| +++ b/cmd/docs/main.go |
| @@ -2,18 +2,157 @@ |
| package main |
| import ( |
| + "context" |
| + "fmt" |
| "log/slog" |
| "os" |
| + "strings" |
| "github.com/carlosframework/rastrillo" |
| + "github.com/carlosframework/rastrillo/mail" |
| "github.com/carlosframework/docs/internal/docs" |
| ) |
| +// devSeedAddress is docs.Seed's default owner when -seed is given |
| +// with no $DOCS_SEED_ADDRESS override: a fixed, non-secret address to |
| +// sign in with locally and see the sample documents land — the same |
| +// "one fixed literal, never a real credential" shape devInstanceKey |
| +// (internal/docs/auth.go) uses for the same reason. |
| +const devSeedAddress = "dev@example.com" |
| + |
| +// extractEnvFlag pulls "-env value"/"-env=value" (or the "--env" |
| +// spelling) out of args, returning the value (empty if absent) and |
| +// everything else unchanged, order preserved. rastrillo.Resolve |
| +// parses os.Args[1:] itself with its own flag.FlagSet, which knows |
| +// only -socket/-addr/-db and errors on anything else, so any flag |
| +// this binary defines on top of that has to be stripped out first — |
| +// the same reason the now-deleted extractDevAuthFlag existed (fix |
| +// round 1 on Task 1: -dev-auth's own identity had zero readers once |
| +// real sign-in landed, and was removed rather than left as a trap; |
| +// this flag replaces the one thing it was still secretly load-bearing |
| +// for — see Env's own doc comment on docs.Config). |
| +func extractEnvFlag(args []string) (env string, rest []string) { |
| + for i := 0; i < len(args); i++ { |
| + a := args[i] |
| + switch { |
| + case a == "-env" || a == "--env": |
| + if i+1 < len(args) { |
| + env = args[i+1] |
| + i++ |
| + } |
| + continue |
| + case strings.HasPrefix(a, "-env="): |
| + env = strings.TrimPrefix(a, "-env=") |
| + continue |
| + case strings.HasPrefix(a, "--env="): |
| + env = strings.TrimPrefix(a, "--env=") |
| + continue |
| + } |
| + rest = append(rest, a) |
| + } |
| + return env, rest |
| +} |
| + |
| +// extractSeedFlag pulls a bare "-seed"/"--seed" out of args, the same |
| +// strip-before-rastrillo.Resolve shape extractEnvFlag uses just above |
| +// and for the same reason (rastrillo.Resolve's own flag.FlagSet knows |
| +// nothing about this flag and errors on it otherwise). Deliberately |
| +// boolean, no "-seed=value" form: which address to seed is |
| +// $DOCS_SEED_ADDRESS's job (devSeedAddress's own doc comment), the |
| +// same env-var-for-configuration, flag-for-behaviour split Origin and |
| +// -env already use — a flag that sometimes eats the next token and |
| +// sometimes does not is exactly the ambiguity a bare boolean avoids |
| +// (what would "-seed -addr :8099" mean if -seed could take a value?). |
| +func extractSeedFlag(args []string) (seed bool, rest []string) { |
| + for _, a := range args { |
| + if a == "-seed" || a == "--seed" { |
| + seed = true |
| + continue |
| + } |
| + rest = append(rest, a) |
| + } |
| + return seed, rest |
| +} |
| + |
| +// chooseEnv decides docs.Config.Env from -env's own extracted value |
| +// (flagEnv, "" if the flag was absent or given with no value) and |
| +// $DOCS_ENV (osEnv, "" if unset). "development" is honoured ONLY from |
| +// flagEnv — fix round 2 on Task 1: the coordinator's own original |
| +// instruction added a $DOCS_ENV fallback for this, and that reinstated |
| +// exactly the trap the deleted -dev-auth flag existed to close. |
| +// devauth_flag.go's own words, before it was deleted: "no config |
| +// default, no env var, nothing a systemd unit's boilerplate could set |
| +// without someone typing this literal flag." Platform instance |
| +// configuration IS environment variables — $DOCS_ORIGIN, |
| +// $DOCS_INSTANCE_KEY, $DOCS_SMTP_* — so a bare $DOCS_ENV=development |
| +// sitting one line away from its neighbours in the same block is a |
| +// production-reachable path to the public constant |
| +// docs-development-instance-key-not-a-secret (internal/docs/auth.go) |
| +// standing in for the HMAC key that seals auth's pending blob, and to |
| +// every magic link — token included — landing in the instance's own |
| +// log instead of being mailed. Verified live by the reviewer: |
| +// $DOCS_ENV=development with a real origin booted a development app |
| +// under the round-1 code. |
| +// |
| +// osEnv is still consulted for any OTHER value (a future "staging", |
| +// say): every fail-closed check in docs.New reads as "== development" |
| +// or "!= development", so any non-"development" string already |
| +// behaves exactly like production, and there is nothing for $DOCS_ENV |
| +// to unlock there. Only "development" itself is special-cased out. |
| +func chooseEnv(flagEnv, osEnv string) string { |
| + if flagEnv != "" { |
| + return flagEnv |
| + } |
| + if osEnv != "" && osEnv != "development" { |
| + return osEnv |
| + } |
| + return "production" |
| +} |
| + |
| +// checkSeedGate is -seed's whole safety story, extracted to a single |
| +// named predicate so main() and TestSeedGateRefusesWithoutDevelopment |
| +// (main_test.go) call the exact same code — not a copy of its logic |
| +// compared against itself. Fix round 1 on Task 3: the original test |
| +// re-implemented this expression inline, inverted operand order and |
| +// all, and so could not fail no matter how the real gate changed. |
| +// |
| +// chooseEnv above only ever returns "development" for the literal |
| +// -env flag on this exact invocation — never $DOCS_ENV, never any |
| +// default (chooseEnv's own doc comment, fix round 2's finding on Task |
| +// 1) — so this one check is genuinely the only thing standing between |
| +// -seed and a database that might hold real work: a systemd unit's |
| +// argv that carelessly kept a -seed flag around, or a copy-pasted |
| +// invocation run against a real deployment, must refuse here rather |
| +// than writing six sample documents into it. A nil error means |
| +// seeding may proceed. |
| +func checkSeedGate(seed bool, env string) error { |
| + if !seed || env == "development" { |
| + return nil |
| + } |
| + return fmt.Errorf("-seed requires -env development; refusing to seed a database that might not be a throwaway one (env=%q)", env) |
| +} |
| + |
| func main() { |
| logger := slog.Default() |
| - devAuth, rest := extractDevAuthFlag(os.Args[1:]) |
| + // -env (chooseEnv above; $DOCS_ENV is consulted too, but never for |
| + // "development" — see chooseEnv's own doc comment, fix round 2) |
| + // decides docs.Config.Env — deliberately the ONLY thing that |
| + // does, independent of any identity flag. It used to be |
| + // entangled with -dev-auth (whichever call set Env also turned |
| + // the now-deleted development-only identity on); fix round 1 on |
| + // Task 1 split them apart, because "log mail instead of sending |
| + // it" and "who can sign in" were never the same decision, and a |
| + // future deletion of an identity flag should never silently take |
| + // the mail-logging fallback (and scripts/run-dev.sh) down with |
| + // it. |
| + envFlag, rest := extractEnvFlag(os.Args[1:]) |
| + // -seed: extracted the same way, and for the same reason — |
| + // see docs.Seed's own doc comment for what it does and this |
| + // flag's gate on Env, just below, for why it can never reach a |
| + // database that might be real. |
| + seedFlag, rest := extractSeedFlag(rest) |
| os.Args = append(os.Args[:1], rest...) |
| // Resolve applies the platform's activation argv (-socket/-addr/-db, |
| @@ -56,40 +195,100 @@ func main() { |
| origin = "http://" + opts.Addr |
| } |
| + // $DOCS_INSTANCE_KEY seals rastrillo/auth's pending-link blob — |
| + // docs.Config.InstanceKey's own doc comment. Left empty here when |
| + // unset: docs.New refuses to start on an empty one outside |
| + // development, the same fail-closed shape as an empty Origin. |
| + instanceKey := os.Getenv("DOCS_INSTANCE_KEY") |
| + |
| + // $DOCS_SMTP_HOST/$DOCS_SMTP_FROM (and the sibling |
| + // $DOCS_SMTP_PORT/$DOCS_SMTP_USER/$DOCS_SMTP_PASS |
| + // rastrillo/mail.FromEnv also reads) configure real magic-link |
| + // delivery. Only built when both of the two values with no sane |
| + // default are actually set — mail.FromEnv itself falls back to a |
| + // silent warning-and-log Sender when they're missing, which is |
| + // the wrong answer outside development, so that decision is left |
| + // to docs.New (Config.Mailer's own doc comment): a nil Mailer |
| + // here is what lets it refuse to start rather than silently |
| + // logging a live credential in a real deployment's log. |
| + var mailer mail.Sender |
| + if host := strings.TrimSpace(os.Getenv("DOCS_SMTP_HOST")); host != "" { |
| + if from := strings.TrimSpace(os.Getenv("DOCS_SMTP_FROM")); from != "" { |
| + mailer = mail.FromEnv("DOCS", logger) |
| + } |
| + } |
| + |
| + // $DOCS_ALLOWED_ADDRESSES is the comma-separated allowlist |
| + // docs.Config.AllowedAddresses' own doc comment describes — the |
| + // only addresses this instance ever mails a sign-in link to or |
| + // admits a session for. Left empty here when unset: docs.New |
| + // (via resolveAllowlist, internal/docs/auth.go) refuses to start |
| + // on an empty one outside development, the same fail-closed shape |
| + // as InstanceKey and Mailer just above — never "admit everyone", |
| + // which is what an unset allowlist reaching a public instance |
| + // would otherwise silently mean. |
| + allowedAddresses := os.Getenv("DOCS_ALLOWED_ADDRESSES") |
| + |
| + // chooseEnv: -env wins; $DOCS_ENV wins next EXCEPT for |
| + // "development" itself, which chooseEnv never honours from an env |
| + // var (fix round 2 — see its own doc comment); the default with |
| + // neither set, or with $DOCS_ENV=development and no flag, is |
| + // "production". Nothing here ever defaults to "development" on its |
| + // own; that always takes an explicit -env flag, on this binary or |
| + // in run-dev.sh's own invocation. |
| + env := chooseEnv(envFlag, os.Getenv("DOCS_ENV")) |
| + |
| + // checkSeedGate's own doc comment has the full reasoning; this |
| + // call runs before docs.New even opens the database, so there is |
| + // no window where a wrongly-seeded production process is already |
| + // serving requests by the time this fires. |
| + if err := checkSeedGate(seedFlag, env); err != nil { |
| + logger.Error("docs: " + err.Error()) |
| + os.Exit(1) |
| + } |
| + |
| // New opens its own database handle (SKILL.md §1's "app that opens |
| // its own database"), so opts.DBPath is blanked below before Serve |
| // — otherwise Serve would open a second connection to the file New |
| // already owns. |
| - // |
| - // Env is "production" unless -dev-auth was given on the command |
| - // line, in which case NewWithDevAuth both sets Env to |
| - // "development" and asks for the development-only identity — the |
| - // only way any of this ever turns on is someone spelling that flag |
| - // out explicitly; running this binary with no arguments always |
| - // gets "production", DevAuth false. |
| cfg := docs.Config{ |
| - DBPath: opts.DBPath, |
| - Env: "production", |
| - Origin: origin, |
| - Logger: logger, |
| - } |
| - var app *docs.App |
| - if devAuth { |
| - // NewWithDevAuth itself forces DevAuth true; Env must be set |
| - // to "development" here too — its guard checks both, and |
| - // leaving Env at "production" would make -dev-auth refuse to |
| - // start rather than silently doing nothing. |
| - cfg.Env = "development" |
| - app, err = docs.NewWithDevAuth(cfg) |
| - } else { |
| - app, err = docs.New(cfg) |
| + DBPath: opts.DBPath, |
| + Env: env, |
| + Origin: origin, |
| + InstanceKey: instanceKey, |
| + Mailer: mailer, |
| + AllowedAddresses: allowedAddresses, |
| + Logger: logger, |
| } |
| + app, err := docs.New(cfg) |
| if err != nil { |
| logger.Error("docs: new", "err", err) |
| os.Exit(1) |
| } |
| defer app.Close() |
| + // Only reachable with both -seed and -env development (the check |
| + // above) — after New, since docs.Seed needs a real *docs.App with |
| + // its store and migrated database already in place, and after the |
| + // InstanceKey/Mailer checks above, so a database that cannot even |
| + // send a real sign-in link never gets seeded either. |
| + if seedFlag { |
| + seedAddress := strings.TrimSpace(os.Getenv("DOCS_SEED_ADDRESS")) |
| + if seedAddress == "" { |
| + seedAddress = devSeedAddress |
| + } |
| + owner, err := app.Store().EnsurePerson(context.Background(), seedAddress) |
| + if err != nil { |
| + logger.Error("docs: seed: resolve person", "address", seedAddress, "err", err) |
| + os.Exit(1) |
| + } |
| + if err := docs.Seed(context.Background(), app, owner); err != nil { |
| + logger.Error("docs: seed", "address", seedAddress, "err", err) |
| + os.Exit(1) |
| + } |
| + logger.Info("docs: seeded sample documents", "address", seedAddress) |
| + } |
| + |
| opts.Mux = app.Handler() |
| opts.DBPath = "" |
| opts.Locales = docs.Locales |
diff --git a/cmd/docs/main_test.go b/cmd/docs/main_test.go| new file mode 100644 |
| index 0000000..fac51a8 |
| --- /dev/null |
| +++ b/cmd/docs/main_test.go |
| @@ -0,0 +1,265 @@ |
| +package main |
| + |
| +import ( |
| + "reflect" |
| + "testing" |
| +) |
| + |
| +// TestExtractEnvFlag covers the argv parser that alone decides the |
| +// security-relevant Env value (fix round 2, finding 2: cmd/docs had |
| +// no tests at all, so this parser was covered by neither go test nor |
| +// the gate). rastrillo.Resolve owns os.Args and errors on anything it |
| +// does not define, so getting the strip-before-Resolve behavior right |
| +// matters as much as getting the value right. |
| +func TestExtractEnvFlag(t *testing.T) { |
| + cases := []struct { |
| + name string |
| + args []string |
| + wantEnv string |
| + wantRest []string |
| + }{ |
| + { |
| + name: "flag present, set to development", |
| + args: []string{"-env", "development", "-addr", ":8080"}, |
| + wantEnv: "development", |
| + wantRest: []string{"-addr", ":8080"}, |
| + }, |
| + { |
| + name: "flag absent", |
| + args: []string{"-addr", ":8080", "-db", "docs.db"}, |
| + wantEnv: "", |
| + wantRest: []string{"-addr", ":8080", "-db", "docs.db"}, |
| + }, |
| + { |
| + name: "equals form", |
| + args: []string{"-env=development"}, |
| + wantEnv: "development", |
| + wantRest: nil, |
| + }, |
| + { |
| + name: "double-dash equals form", |
| + args: []string{"--env=production"}, |
| + wantEnv: "production", |
| + wantRest: nil, |
| + }, |
| + { |
| + // A trailing bare -env with no value: extractEnvFlag must |
| + // not treat the flag as if it had been given a value (and |
| + // must not panic reading past the end of args). The |
| + // reviewer confirmed this lands on non-development |
| + // downstream (via chooseEnv, tested below) — this case |
| + // pins down why: an empty env here, not a crash or a |
| + // swallowed neighbor. |
| + name: "trailing bare -env, no value", |
| + args: []string{"-addr", ":8080", "-env"}, |
| + wantEnv: "", |
| + wantRest: []string{"-addr", ":8080"}, |
| + }, |
| + { |
| + // -env greedily takes the very next token as its value, |
| + // so "-env -addr :8080" swallows "-addr" as the env |
| + // string rather than leaving it for rastrillo.Resolve. |
| + // This is the other case the reviewer flagged as |
| + // "currently lands on non-development... but untested": |
| + // pinned down here at the parser level (env == "-addr", |
| + // definitely not "development"), and again below via |
| + // chooseEnv, which is what actually decides |
| + // docs.Config.Env from it. |
| + name: "-env with no value, next token is another flag", |
| + args: []string{"-env", "-addr", ":8080"}, |
| + wantEnv: "-addr", |
| + wantRest: []string{":8080"}, |
| + }, |
| + } |
| + for _, c := range cases { |
| + t.Run(c.name, func(t *testing.T) { |
| + env, rest := extractEnvFlag(c.args) |
| + if env != c.wantEnv { |
| + t.Errorf("extractEnvFlag(%v) env = %q, want %q", c.args, env, c.wantEnv) |
| + } |
| + if !reflect.DeepEqual(rest, c.wantRest) { |
| + t.Errorf("extractEnvFlag(%v) rest = %v, want %v", c.args, rest, c.wantRest) |
| + } |
| + }) |
| + } |
| +} |
| + |
| +// TestExtractSeedFlag covers -seed's own argv parser, the same |
| +// strip-before-Resolve reasoning as TestExtractEnvFlag above. Unlike |
| +// -env, -seed is a bare boolean with no value form at all (its own |
| +// doc comment explains why: which address to seed is |
| +// $DOCS_SEED_ADDRESS's job, not this flag's), so there is no |
| +// swallow-the-next-token case to pin down here. |
| +func TestExtractSeedFlag(t *testing.T) { |
| + cases := []struct { |
| + name string |
| + args []string |
| + wantSeed bool |
| + wantRest []string |
| + }{ |
| + { |
| + name: "flag present", |
| + args: []string{"-seed", "-env", "development"}, |
| + wantSeed: true, |
| + wantRest: []string{"-env", "development"}, |
| + }, |
| + { |
| + name: "double-dash form", |
| + args: []string{"--seed"}, |
| + wantSeed: true, |
| + wantRest: nil, |
| + }, |
| + { |
| + name: "flag absent", |
| + args: []string{"-env", "development", "-addr", ":8080"}, |
| + wantSeed: false, |
| + wantRest: []string{"-env", "development", "-addr", ":8080"}, |
| + }, |
| + { |
| + name: "flag in the middle of other args", |
| + args: []string{"-env", "development", "-seed", "-addr", ":8080"}, |
| + wantSeed: true, |
| + wantRest: []string{"-env", "development", "-addr", ":8080"}, |
| + }, |
| + } |
| + for _, c := range cases { |
| + t.Run(c.name, func(t *testing.T) { |
| + seed, rest := extractSeedFlag(c.args) |
| + if seed != c.wantSeed { |
| + t.Errorf("extractSeedFlag(%v) seed = %v, want %v", c.args, seed, c.wantSeed) |
| + } |
| + if !reflect.DeepEqual(rest, c.wantRest) { |
| + t.Errorf("extractSeedFlag(%v) rest = %v, want %v", c.args, rest, c.wantRest) |
| + } |
| + }) |
| + } |
| +} |
| + |
| +// TestSeedGateRefusesWithoutDevelopment calls checkSeedGate itself — |
| +// main.go's own predicate, not a copy of its expression — so this |
| +// test fails if the real gate ever changes. Fix round 1 on Task 3: |
| +// the version this replaces re-implemented "seedFlag && env != |
| +// development" inline and compared it to itself; inverting the real |
| +// if, reordering its operands or dropping a clause left it green. |
| +// This calls checkSeedGate directly, so none of that is possible — |
| +// see main.go's own doc comment on checkSeedGate for why this one |
| +// function is the entirety of -seed's safety story. |
| +func TestSeedGateRefusesWithoutDevelopment(t *testing.T) { |
| + cases := []struct { |
| + name string |
| + seed bool |
| + env string |
| + wantErr bool |
| + }{ |
| + {name: "seed with development env runs", seed: true, env: "development", wantErr: false}, |
| + {name: "seed with production env is refused", seed: true, env: "production", wantErr: true}, |
| + {name: "seed with empty env is refused", seed: true, env: "", wantErr: true}, |
| + {name: "seed with staging env is refused", seed: true, env: "staging", wantErr: true}, |
| + {name: "no seed flag never refuses regardless of env", seed: false, env: "production", wantErr: false}, |
| + } |
| + for _, c := range cases { |
| + t.Run(c.name, func(t *testing.T) { |
| + err := checkSeedGate(c.seed, c.env) |
| + if (err != nil) != c.wantErr { |
| + t.Errorf("checkSeedGate(%v, %q) = %v, wantErr %v", c.seed, c.env, err, c.wantErr) |
| + } |
| + }) |
| + } |
| +} |
| + |
| +// TestChooseEnvHonoursDevelopmentOnlyFromTheFlag is fix round 2's own |
| +// regression test for finding 1: the coordinator's original |
| +// instruction added a $DOCS_ENV fallback that could set |
| +// Env=development, reinstating exactly the trap the deleted |
| +// -dev-auth flag existed to close (no env var may ever grant |
| +// development — only the literal flag). $DOCS_ENV=development with |
| +// no flag is the case that must NOT produce a development app. |
| +func TestChooseEnvHonoursDevelopmentOnlyFromTheFlag(t *testing.T) { |
| + cases := []struct { |
| + name string |
| + flagEnv string |
| + osEnv string |
| + want string |
| + }{ |
| + { |
| + name: "flag present and set to development", |
| + flagEnv: "development", |
| + osEnv: "", |
| + want: "development", |
| + }, |
| + { |
| + name: "absent: no flag, no env var", |
| + flagEnv: "", |
| + osEnv: "", |
| + want: "production", |
| + }, |
| + { |
| + // The regression this whole fix round exists for: |
| + // $DOCS_ENV=development with no -env flag must not select |
| + // development. Platform instance config IS environment |
| + // variables (DOCS_ORIGIN, DOCS_INSTANCE_KEY, DOCS_SMTP_*), |
| + // so this is the one value $DOCS_ENV must never be |
| + // trusted with. |
| + name: "$DOCS_ENV=development with no flag", |
| + flagEnv: "", |
| + osEnv: "development", |
| + want: "production", |
| + }, |
| + { |
| + // $DOCS_ENV is still useful for any OTHER value: nothing |
| + // in docs.New treats a non-"development" Env specially, |
| + // so there is no risk here to guard against. |
| + name: "$DOCS_ENV set to something other than development", |
| + flagEnv: "", |
| + osEnv: "staging", |
| + want: "staging", |
| + }, |
| + { |
| + name: "flag always wins over $DOCS_ENV, even the other way", |
| + flagEnv: "development", |
| + osEnv: "staging", |
| + want: "development", |
| + }, |
| + { |
| + name: "explicit -env production overrides a stray $DOCS_ENV=development", |
| + flagEnv: "production", |
| + osEnv: "development", |
| + want: "production", |
| + }, |
| + } |
| + for _, c := range cases { |
| + t.Run(c.name, func(t *testing.T) { |
| + got := chooseEnv(c.flagEnv, c.osEnv) |
| + if got != c.want { |
| + t.Errorf("chooseEnv(%q, %q) = %q, want %q", c.flagEnv, c.osEnv, got, c.want) |
| + } |
| + }) |
| + } |
| +} |
| + |
| +// TestArgvEdgeCasesFailTowardProduction composes extractEnvFlag and |
| +// chooseEnv exactly as main() does, for the two argv shapes the |
| +// reviewer named as "currently land on non-development... but |
| +// untested": a trailing bare -env, and -env immediately followed by |
| +// another flag (which extractEnvFlag swallows as -env's own value). |
| +// Both must resolve to something other than "development" — "it |
| +// happens to fail safe" is not a property until something asserts |
| +// it. |
| +func TestArgvEdgeCasesFailTowardProduction(t *testing.T) { |
| + cases := []struct { |
| + name string |
| + args []string |
| + }{ |
| + {name: "trailing bare -env", args: []string{"-addr", ":8080", "-env"}}, |
| + {name: "-env swallows the next flag as its value", args: []string{"-env", "-addr", ":8080"}}, |
| + } |
| + for _, c := range cases { |
| + t.Run(c.name, func(t *testing.T) { |
| + flagEnv, _ := extractEnvFlag(c.args) |
| + got := chooseEnv(flagEnv, "") |
| + if got == "development" { |
| + t.Errorf("args %v resolved to Env=development, want anything else (fails toward production)", c.args) |
| + } |
| + }) |
| + } |
| +} |
diff --git a/docs/superpowers/plans/2026-08-31-signin-and-samples.md b/docs/superpowers/plans/2026-08-31-signin-and-samples.md| new file mode 100644 |
| index 0000000..4ac7c19 |
| --- /dev/null |
| +++ b/docs/superpowers/plans/2026-08-31-signin-and-samples.md |
| @@ -0,0 +1,340 @@ |
| +# Sign-in, and documents worth looking at — Implementation Plan |
| + |
| +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. |
| + |
| +**Goal:** Real per-person sign-in at the instance, so Docs can go live on a public URL — and sample documents with enough content that the reading view is worth looking at. |
| + |
| +**Architecture:** rastrillo's `auth` plugin: magic link, auto-upgrading to keymail when an address resolves to a claimed inbox. Documents become owned, and every route requires a signed-in person. Seed content is real prose applied as Yjs updates through the CRDT, not rows in a table. |
| + |
| +**Tech Stack:** Go 1.26, rastrillo `v0.22.0` (`auth`, `sessions`, `csrf`, `scope`), `internal/core` (wazero + Rust CRDT), SQLite. |
| + |
| +**Spec:** `docs/superpowers/specs/2026-08-30-docs-design.md` — §8.1 (the two deployment shapes), §8.2 (sharing), §4.2 (the 404-not-403 contract), §6.2 (where zero-JS stops). |
| + |
| +## Why this plan exists |
| + |
| +Paul asked for Docs live at `console.carloku.com/a/bds/apps/docs`. It cannot go on a public URL as it stands: no route requires sign-in, so it is a public read-and-write documents endpoint. |
| + |
| +The home would solve it and is weeks away — verified, not assumed: `oficina/home` is a skeleton that boots and serves one page saying it is not built. Nothing about assertions exists anywhere. |
| + |
| +**But §8.1 was reframed on 2026-08-31 and this is no longer a stopgap.** A single instance signs people in itself; the home is what a *suite* adds. So this plan builds the single-instance path, which is a real deployment shape with a permanent answer, and the suite path arrives later as an addition rather than a replacement. |
| + |
| +## Global Constraints |
| + |
| +- **`CGO_ENABLED=0` everywhere.** No cgo. |
| +- **rastrillo pinned to `v0.22.0`.** |
| +- **Read `SKILL.md` from the module before writing app code.** It carries the identity plugin's contract, the scoping rules, and the mass-assignment rule. Where it contradicts this plan, **SKILL.md wins** — say so in the report. |
| +- **Bare `rst-*` attribute grammar.** A test walks our view FS and enforces it. |
| +- **Every user-visible string is a catalog key.** |
| +- **`scope.Owned` must NOT be used for documents.** Verified in plan 1: it is `Where("user_id = ?")`, and applied to a shared table it returns a short list rather than an error — a library that looks empty rather than broken. Docs scopes over its own ownership join, preserving 404-not-403. |
| +- **No secret, token or address is ever logged.** Magic-link tokens are 256-bit random, stored only as SHA-256. |
| +- Work on a branch; push regular commits; squash-merge when done, approved, and CI green. |
| + |
| +--- |
| + |
| +### Task 1: Sign-in with a magic link |
| + |
| +**Files:** |
| +- Create: `internal/docs/auth.go`, `internal/docs/auth_test.go` |
| +- Modify: `internal/docs/app.go`, `cmd/docs/main.go`, `internal/docs/migrations/` |
| + |
| +**Interfaces:** |
| +- Produces: `GET /signin` (the form), `POST /signin` (sends the link), `GET /signin/{token}` (completes), `POST /signout`. A signed-in person is readable by handlers. |
| + |
| +- [ ] **Step 1: Read the plugin's contract first** |
| + |
| +```bash |
| +cat "$(CGO_ENABLED=0 go env GOMODCACHE)/github.com/carlosframework/rastrillo@v0.22.0/SKILL.md" |
| +``` |
| + |
| +Find what `auth` requires: how it is mounted, what it needs from `sessions`, how it delivers mail, and what it expects of the app. **Quote the relevant part in your report** — later tasks depend on you having read it rather than inferred it. |
| + |
| +Note especially: read the viewer with `auth.From(r)` or `sessions.Current(r)`, **never `sessions.UserID`** — it returns `(0, false)` for an email subject, and dropping that `ok` scopes every query to uid 0. |
| + |
| +- [ ] **Step 2: Write the failing test** |
| + |
| +```go |
| +func TestSignInRequiredForEverything(t *testing.T) { |
| + app, err := docs.NewForTest(t) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + for _, path := range []string{"/", "/d/anything"} { |
| + req := httptest.NewRequest(http.MethodGet, path, nil) |
| + rec := httptest.NewRecorder() |
| + app.Handler().ServeHTTP(rec, req) |
| + if rec.Code != http.StatusSeeOther && rec.Code != http.StatusFound { |
| + t.Errorf("GET %s signed out: status %d, want a redirect to sign-in", path, rec.Code) |
| + } |
| + } |
| +} |
| + |
| +func TestMagicLinkSignsSomebodyIn(t *testing.T) { |
| + app, err := docs.NewForTest(t) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + link, err := app.TestSignIn(t, "reader@example.com") |
| + if err != nil { |
| + t.Fatalf("TestSignIn: %v", err) |
| + } |
| + // Following the link must establish a session. |
| + req := httptest.NewRequest(http.MethodGet, link, nil) |
| + rec := httptest.NewRecorder() |
| + app.Handler().ServeHTTP(rec, req) |
| + if len(rec.Result().Cookies()) == 0 { |
| + t.Fatal("following the magic link set no session cookie") |
| + } |
| +} |
| +``` |
| + |
| +`TestSignIn` mints a link without sending mail and returns its path — put it in `internal/docs/testing.go`. It is test scaffolding for later tasks, which all need a signed-in request. |
| + |
| +- [ ] **Step 3: Run it and watch it fail.** `CGO_ENABLED=0 go test ./internal/docs/ -run 'TestSignIn|TestMagicLink'` |
| + |
| +- [ ] **Step 4: Implement** |
| + |
| +Mount rastrillo's `auth` plugin per SKILL.md. **Magic link only — do not enable a passkey second factor.** §8.1 explains why: a passkey enrols a credential whose relying party is this instance's host, and a person in several teams would enrol several. Magic link enrols nothing. |
| + |
| +Mail delivery in development must not require a mail server: write the link to the log, clearly marked, so `run-dev.sh` remains one command. In any other environment, real delivery is required and its absence is a startup refusal — the same fail-closed shape as the origin check. |
| + |
| +- [ ] **Step 5: Run and watch pass.** |
| + |
| +- [ ] **Step 6: Commit** |
| + |
| +```bash |
| +git add internal/docs cmd |
| +git commit -m "app: sign in with a magic link |
| + |
| +rastrillo's auth plugin, magic link only and no passkey second factor -- |
| +a passkey would enrol a credential whose relying party is this instance's |
| +host, and a person in several teams would enrol several. Magic link |
| +enrols nothing, so the one-relying-party ruling is untouched. |
| + |
| +Per spec §8.1 this is the single-instance shape's real answer, not a |
| +placeholder: a lone instance has no one-person-many-teams problem, which |
| +is the problem the home exists to solve." |
| +``` |
| + |
| +--- |
| + |
| +### Task 2: Documents belong to people |
| + |
| +**Files:** |
| +- Create: `internal/docs/migrations/0002_owners.sql` |
| +- Modify: `internal/docs/store.go`, `internal/docs/models.go`, `internal/docs/handlers.go`, `internal/docs/store_test.go` |
| + |
| +**Interfaces:** |
| +- Consumes: the signed-in person from Task 1. |
| +- Produces: `Store.Create/List/Get` all taking an owner; a document not yours 404s. |
| + |
| +- [ ] **Step 1: Write the failing test** |
| + |
| +```go |
| +func TestADocumentYouDoNotOwnIsNotThere(t *testing.T) { |
| + app, _ := docs.NewForTest(t) |
| + ctx := context.Background() |
| + |
| + alice := app.TestPerson(t, "alice@example.com") |
| + bob := app.TestPerson(t, "bob@example.com") |
| + |
| + doc, err := app.Store().Create(ctx, alice, "Alice's private notes") |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + |
| + // Bob must not be able to fetch it, and must not be told it exists. |
| + if _, err := app.Store().Get(ctx, bob, doc.Sqid); err == nil { |
| + t.Fatal("Bob fetched Alice's document") |
| + } |
| + |
| + list, err := app.Store().List(ctx, bob) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + if len(list) != 0 { |
| + t.Errorf("Bob's list has %d documents, want 0", len(list)) |
| + } |
| +} |
| + |
| +func TestUnownedDocumentOverHTTPIs404Not403(t *testing.T) { |
| + app, _ := docs.NewForTest(t) |
| + ctx := context.Background() |
| + alice := app.TestPerson(t, "alice@example.com") |
| + doc, _ := app.Store().Create(ctx, alice, "Alice's private notes") |
| + |
| + req := app.TestRequest(t, "bob@example.com", http.MethodGet, "/d/"+doc.Sqid, nil) |
| + rec := httptest.NewRecorder() |
| + app.Handler().ServeHTTP(rec, req) |
| + |
| + if rec.Code != http.StatusNotFound { |
| + t.Errorf("status %d, want 404 — a document you cannot see does not exist (spec §4.2)", rec.Code) |
| + } |
| +} |
| +``` |
| + |
| +`TestPerson` and `TestRequest` are scaffolding in `testing.go`: one creates a person, the other builds a request already carrying that person's session. |
| + |
| +- [ ] **Step 2: Run and watch fail.** |
| + |
| +- [ ] **Step 3: Implement** |
| + |
| +Add an owner to `documents` and scope every read and write by it. **Do not use `scope.Owned`** — see Global Constraints. Write the scoping as one function that every query goes through, and test that function directly. |
| + |
| +**403 must never appear.** A document you cannot see returns 404, so a stranger cannot learn a sqid exists by probing. |
| + |
| +- [ ] **Step 4: Run and watch pass.** |
| + |
| +- [ ] **Step 5: Commit** |
| + |
| +```bash |
| +git add internal/docs |
| +git commit -m "app: documents belong to people |
| + |
| +Every read and write scoped by owner, through one function rather than |
| +scattered WHERE clauses. Not scope.Owned: verified in plan 1 that it is |
| +Where(\"user_id = ?\") and returns a short list rather than an error on a |
| +shared table, which looks like an empty library rather than a bug. |
| + |
| +A document you cannot see 404s and never 403s, so a stranger cannot |
| +learn a sqid exists by probing it." |
| +``` |
| + |
| +--- |
| + |
| +### Task 3: Sample documents worth reading |
| + |
| +**Files:** |
| +- Create: `internal/docs/seed.go`, `internal/docs/seed_test.go`, `internal/docs/seeddata/` |
| +- Modify: `cmd/docs/main.go`, `scripts/run-dev.sh` |
| + |
| +**Interfaces:** |
| +- Produces: `docs.Seed(ctx, app, owner) error`, and a `-seed` flag that populates a fresh database. |
| + |
| +The reading view currently shows two words. Sample content is what makes the canvas, the typography and the empty-versus-full states worth looking at. |
| + |
| +- [ ] **Step 1: Write the failing test** |
| + |
| +```go |
| +func TestSeedProducesReadableDocuments(t *testing.T) { |
| + app, _ := docs.NewForTest(t) |
| + ctx := context.Background() |
| + owner := app.TestPerson(t, "seed@example.com") |
| + |
| + if err := docs.Seed(ctx, app, owner); err != nil { |
| + t.Fatalf("Seed: %v", err) |
| + } |
| + |
| + list, err := app.Store().List(ctx, owner) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + if len(list) < 4 { |
| + t.Fatalf("seeded %d documents, want at least 4", len(list)) |
| + } |
| + |
| + // Every seeded document must have real content, through the CRDT. |
| + for _, d := range list { |
| + text, err := app.Store().Text(ctx, d) |
| + if err != nil { |
| + t.Fatalf("Text(%q): %v", d.Title, err) |
| + } |
| + if len(text) < 200 { |
| + t.Errorf("%q has %d characters — seed content should be substantial", d.Title, len(text)) |
| + } |
| + } |
| +} |
| +``` |
| + |
| +- [ ] **Step 2: Run and watch fail.** |
| + |
| +- [ ] **Step 3: Write the content** |
| + |
| +Four to six documents of genuinely plausible prose — several paragraphs each, varied lengths so the list shows range. **Write real sentences, not lorem ipsum**: the point is to see how the canvas reads, and Latin filler hides line-length and rhythm problems that English exposes. |
| + |
| +Suggested shapes, all fictional: a project proposal, meeting notes with attendees and actions, a short piece of documentation, a letter, and one deliberately long document so the canvas can be judged at length. |
| + |
| +Content lives in `seeddata/` as plain `.txt` files embedded with `go:embed` — editable without touching Go. |
| + |
| +- [ ] **Step 4: Apply it through the CRDT, not the database** |
| + |
| +Each document's text becomes a **Yjs update applied via `Store.ApplyUpdate`**. It must arrive the same way a person's typing will. |
| + |
| +There is no Go-side Yjs *encoder* — `internal/core` applies updates and reads text, and the ABI exposes no insert. **Resolve this and report which route you took:** |
| +1. Add an ABI function that inserts text into a document and returns the resulting update — a real ABI change, and the honest one if editing needs it anyway (it does; the next plan writes it). |
| +2. Generate the updates offline with the Node fixture generator (`fixtures/generate.mjs` already uses real `yjs`), commit the `.bin` files as seed fixtures, and have the seeder apply them. |
| + |
| +**Route 2 is likely right for this plan** — it reuses machinery that exists, keeps the ABI stable, and the fixtures are honest artefacts of real `yjs`. Route 1 is a bigger change belonging to the editing plan. If you choose route 1 anyway, say why. |
| + |
| +- [ ] **Step 5: Run and watch pass.** |
| + |
| +- [ ] **Step 6: Wire `-seed` and check it looks right** |
| + |
| +Add `-seed` to `cmd/docs/main.go` and use it in `run-dev.sh` so one command gives a populated app. Then run it, curl the list and two documents, and **paste the rendered HTML**. Judge it: does the canvas read well at these lengths? Is the list's grouping doing anything visible with varied dates? |
| + |
| +- [ ] **Step 7: Commit** |
| + |
| +```bash |
| +git add internal/docs cmd scripts |
| +git commit -m "app: sample documents with real content |
| + |
| +Four to six documents of plausible English prose rather than lorem |
| +ipsum, because Latin filler hides the line-length and rhythm problems |
| +the canvas exists to get right. |
| + |
| +Applied as Yjs updates through the CRDT, the same route a person's |
| +typing will take -- not rows written into a table." |
| +``` |
| + |
| +--- |
| + |
| +### Task 4: Take it live |
| + |
| +**Files:** |
| +- Modify: `.amadan/README.md`, `AGENTS.md` |
| + |
| +Docs can now go on a public URL, because every route requires a signed-in person and documents are scoped to their owner. |
| + |
| +- [ ] **Step 1: Check what deployment needs** |
| + |
| +`carlos email enable` for account `bds`, since magic links need outbound mail. **If email is not available, stop and report** — a sign-in that cannot send its link is not sign-in, and a preview with mail stubbed out is a public app with extra steps. |
| + |
| +- [ ] **Step 2: Verify the guards hold** |
| + |
| +Corrected in a fix round on Task 1: there is no `-tags release` build and no dev-auth flag to exclude any more — the development-only stand-in identity that check used to verify the *absence* of was deleted outright once real sign-in made it dead code (`internal/docs/devauth.go` and both `cmd/docs/devauth_flag*.go` are gone; `release` is now an undefined build tag, so building with it would silently compile the ordinary binary and verify nothing). Verify instead, against a plain build (`CGO_ENABLED=0 go build ./cmd/docs`): |
| + |
| +- an unset `$DOCS_ORIGIN` refuses to start; |
| +- unset mail configuration (`$DOCS_SMTP_HOST`/`$DOCS_SMTP_FROM`) refuses to start; |
| +- `$DOCS_ENV=development` with **no** `-env` flag does **not** produce a development app — it must still refuse on the two points above, since `chooseEnv` (`cmd/docs/main.go`) never honours `"development"` from an environment variable, only from the literal `-env` flag. This is the property fix round 2 added: platform instance configuration is environment variables, so `$DOCS_ENV=development` sitting beside `$DOCS_ORIGIN`/`$DOCS_INSTANCE_KEY`/`$DOCS_SMTP_*` would otherwise be a production-reachable path to the public dev `InstanceKey` and to every magic link, token included, landing in the instance's own log. |
| + |
| +Paste the refusals — all three. |
| + |
| +- [ ] **Step 3: Ship** |
| + |
| +```bash |
| +CGO_ENABLED=0 GOOS=linux GOARCH=arm64 go build -ldflags "-s -w" -o docs-linux-arm64 ./cmd/docs |
| +carlos ship --account bds --app docs --kind binary --target linux-arm64 --label "first live" docs-linux-arm64 |
| +``` |
| + |
| +No `-tags release`: that tag has no meaning left in this codebase (see Step 2). |
| + |
| +Then promote and create an instance per the platform's own flow — read `carlos deploy -h` and `carlos instances -h` rather than guessing, and **report each command's real output**. |
| + |
| +- [ ] **Step 4: Prove it from outside** |
| + |
| +Fetch the public URL and confirm: signed out redirects to sign-in; the sign-in form renders; `/api/version` reports the shipped sha. **Do not paste a working magic link into the report** — it is a credential. |
| + |
| +- [ ] **Step 5: Update the warnings** |
| + |
| +`AGENTS.md` currently says this app has no authentication and must not be deployed anywhere reachable. **That statement is now false and must change** — say what is true instead: every route requires sign-in, documents are owner-scoped, and the suite path (§8.1.1) is still to come. |
| + |
| +--- |
| + |
| +## Self-Review |
| + |
| +**Spec coverage.** Implements §8.1's single-instance shape, §8.2's ownership, §4.2's 404-not-403. Does **not** implement: sharing with guests or links (§8.2's later half), the suite assertion path (§8.1.1), editing, the layout engine, sealing. |
| + |
| +**Placeholder scan:** none. Task 3 step 4 names an unresolved choice with a recommendation and requires the implementer to report which route they took — that is a decision point, not a placeholder. |
| + |
| +**Type consistency:** `TestSignIn`, `TestPerson` and `TestRequest` are introduced in Tasks 1–2 and used throughout; `Store.Create/List/Get` gain an owner parameter in Task 2 and Task 3's seeder uses the new signature. |
| + |
| +**Known risk carried forward:** `internal/core` caps its update log at 4096 with no state export. Seed documents are single updates so this plan will not approach it, but the editing plan will and must not paper over it. |
diff --git a/docs/superpowers/specs/2026-08-30-docs-design.md b/docs/superpowers/specs/2026-08-30-docs-design.md| index 43be865..4bbe01e 100644 |
| --- a/docs/superpowers/specs/2026-08-30-docs-design.md |
| +++ b/docs/superpowers/specs/2026-08-30-docs-design.md |
| @@ -434,7 +434,56 @@ is destroyed — so the primitive serves both. |
| ## 8. Identity and sharing |
| -### 8.1 Sign-in — one relying party |
| +### 8.1 Sign-in — two shapes, and which one a deployment is |
| + |
| +**Ruled by Paul, 2026-08-31, and it reframes what follows.** An earlier draft |
| +treated the home as the only way in, which made instance-local sign-in a |
| +stopgap. It is not. There are two deployment shapes and they have different |
| +correct answers: |
| + |
| +- **A single instance** — one team, self-hosted, no suite. It has no |
| + "one person, many teams" problem, because there is one instance. So it |
| + signs people in **itself**, with rastrillo's `auth` plugin: magic link, |
| + auto-upgrading to keymail when the address resolves to a claimed inbox. |
| + This is the right answer for that shape, not a placeholder for one. |
| + |
| + **And it is required rather than merely correct.** Factor VII holds that |
| + self-hosting is a right and first-class, not a degraded tier. An earlier |
| + draft of this section made the home the only way in — which would have |
| + obliged a person self-hosting one instance to stand up the entire suite |
| + home, split origins, blind-index directory, assertion minting and key |
| + rotation included, in order to sign in to their own documents. That is not |
| + a degraded tier; it is a prohibitive one, and it would have made |
| + self-hosting nominal. *(Argument owed to the Sheets session, 2026-08-31, |
| + which is a better justification than the one this spec first gave.)* |
| + |
| + It also sharpens what the home **is**: not "how you sign in", but *how one |
| + person reaches many instances without many credentials*. Split origins, the |
| + blind index, the rekey journal, the pepper living in exactly one place — |
| + every part of it serves that and nothing else. A lone instance has none of |
| + that problem and therefore needs none of that machinery. |
| +- **A suite deployment** — Docs beside Sheets and others, one person |
| + spanning several teams. Here three instances must not mean three |
| + credentials, which is the problem the home exists to solve, and sign-in |
| + moves to its credential origin with instances verifying signed assertions. |
| + |
| +So an instance is built to accept identity from **either**: its own |
| +magic-link flow, or a verified assertion. The second is not a replacement for |
| +the first; it is what a suite adds. |
| + |
| +**Why magic-link-at-the-instance does not violate the one-relying-party |
| +ruling.** That ruling is about **passkeys** — a person in three teams must not |
| +enrol three credentials, and `passkey.Config.Origin` derives the RPID from the |
| +app's own host. Magic link enrols **no credential at all**, so it creates no |
| +relying party and strands nothing. An instance running it and later accepting |
| +assertions is not a migration; people simply sign in by a different route. |
| + |
| +**And it is consistent with the home's own blindness rule.** The home spec |
| +rules that **instances hold readable addresses and the home never does**. An |
| +app instance storing an address is the design, not a deviation — which is |
| +exactly why `rastrillo/auth` is wrong for the *home* and right for an *app*. |
| + |
| +### 8.1.1 The suite path — one relying party |
| Revision 1 held three positions at once: it specced instance-local |
| `rastrillo/auth` magic links, then said sign-in was unresolved, while §9 call |
diff --git a/fixtures/generate-seed.mjs b/fixtures/generate-seed.mjs| new file mode 100644 |
| index 0000000..47054fe |
| --- /dev/null |
| +++ b/fixtures/generate-seed.mjs |
| @@ -0,0 +1,46 @@ |
| +// Generates the Yjs updates behind CARLOS Docs' sample documents |
| +// (internal/docs/seed.go). Same reasoning as generate.mjs: there is no |
| +// Go-side Yjs encoder, so seed content has to arrive as a real update |
| +// produced by real yjs, exactly like a person's own typing would |
| +// produce one — never a text column written directly. |
| +// |
| +// The .txt files under internal/docs/seeddata/ are the editable |
| +// source; this script is what turns an edited .txt back into the |
| +// matching .update.bin the app actually applies. Run it after editing |
| +// any seed .txt file, and commit both together — the .bin is the |
| +// artefact, the .txt is the reason it looks the way it does. |
| +import * as Y from 'yjs' |
| +import { readFileSync, writeFileSync, readdirSync } from 'node:fs' |
| +import { join, dirname, basename } from 'node:path' |
| +import { fileURLToPath } from 'node:url' |
| + |
| +const seedDir = join( |
| + dirname(fileURLToPath(import.meta.url)), |
| + '..', |
| + 'internal', |
| + 'docs', |
| + 'seeddata', |
| +) |
| + |
| +const names = readdirSync(seedDir) |
| + .filter((f) => f.endsWith('.txt')) |
| + .map((f) => basename(f, '.txt')) |
| + .sort() |
| + |
| +for (const name of names) { |
| + const text = readFileSync(join(seedDir, `${name}.txt`), 'utf8') |
| + |
| + const doc = new Y.Doc() |
| + // Fixed clientID, same reasoning as generate.mjs: running this twice |
| + // must produce byte-identical output. It only needs to differ from |
| + // the *other* clients that ever touch this specific document, and a |
| + // freshly seeded document has none yet — the app's own random |
| + // per-document ClientID (internal/docs/store.go's Store.Create) is |
| + // what a real edit would be attributed to. |
| + doc.clientID = 1 |
| + doc.getText('content').insert(0, text) |
| + |
| + const update = Y.encodeStateAsUpdate(doc) |
| + writeFileSync(join(seedDir, `${name}.update.bin`), Buffer.from(update)) |
| + console.log(`${name}: ${text.length} chars -> ${update.length} bytes`) |
| +} |
diff --git a/go.mod b/go.mod| index f0856ab..5c58304 100644 |
| --- a/go.mod |
| +++ b/go.mod |
| @@ -14,6 +14,7 @@ require ( |
| github.com/dustin/go-humanize v1.0.1 // indirect |
| github.com/jinzhu/inflection v1.0.0 // indirect |
| github.com/jinzhu/now v1.1.5 // indirect |
| + github.com/keymaildev/signin v0.1.1 // 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 |
diff --git a/internal/docs/app.go b/internal/docs/app.go| index 57cec24..5be3dfe 100644 |
| --- a/internal/docs/app.go |
| +++ b/internal/docs/app.go |
| @@ -21,7 +21,9 @@ import ( |
| "github.com/carlosframework/rastrillo/csrf" |
| "github.com/carlosframework/rastrillo/db" |
| + "github.com/carlosframework/rastrillo/mail" |
| "github.com/carlosframework/rastrillo/migrate" |
| + "github.com/carlosframework/rastrillo/sessions" |
| "github.com/go-chi/chi/v5" |
| "github.com/carlosframework/docs/internal/core" |
| @@ -33,19 +35,24 @@ type Config struct { |
| DBPath string |
| // Env is "development" or "production" ("" defaults to |
| - // "production"). devauth.go's development-only identity refuses to |
| - // start unless Env is exactly "development" — see DevAuth. |
| + // "production"). It gates two fail-closed defaults below — Origin |
| + // and (auth.go) InstanceKey/Mailer — and nothing else: the |
| + // development-only stand-in identity that used to live in |
| + // devauth.go is gone (fix round 1 on Task 1 — real sign-in made |
| + // it dead code the moment RequireSession started guarding every |
| + // route, and it was deleted rather than left as an unreachable |
| + // trap for whoever next read "DevAuth" and assumed it still did |
| + // something). cmd/docs/main.go sources this from an explicit |
| + // -env flag, deliberately independent of any identity flag — Env |
| + // choosing "log mail instead of sending it" is not the same |
| + // decision as "who can sign in", and conflating the two is |
| + // exactly what fix round 1 un-did. $DOCS_ENV is consulted too, |
| + // but — fix round 2, after that round's own $DOCS_ENV fallback |
| + // turned out to reopen the same hole — never for "development" |
| + // itself: only the literal flag may ever select it |
| + // (cmd/docs/main.go's chooseEnv has the full reasoning). |
| Env string |
| - // DevAuth enables devauth.go's fixed development identity — |
| - // New's guardDevAuth refuses to start unless Env is exactly |
| - // "development" too. Prefer NewWithDevAuth (devauth.go) over |
| - // setting this by hand; it exists as its own Config field, rather |
| - // than being threaded through only that constructor, so |
| - // NewForTest — which four later tasks' tests depend on — never |
| - // needed a signature change once devauth.go landed. |
| - DevAuth bool |
| - |
| // Origin is this app's own origin (scheme://host[:port], no path), |
| // what csrf.Protect (rastrillo SKILL.md §5) uses as its fallback |
| // same-origin evidence. Fix round 3 corrected a wrong model of |
| @@ -92,6 +99,67 @@ type Config struct { |
| // tried, and failed, to match it against anything real. |
| Origin string |
| + // InstanceKey seals rastrillo/auth's pending-link blob (an HMAC |
| + // key — auth.Config.InstanceKey's own doc comment explains why an |
| + // empty one is dangerous, not just inconvenient: it hashes to one |
| + // fixed, publicly computable value, identical across every |
| + // deployment that made the same mistake). Sourced from |
| + // $DOCS_INSTANCE_KEY (cmd/docs/main.go), the same "read in main, |
| + // validated in New" split Origin above uses. |
| + // |
| + // Empty is allowed only when Env is "development" (New falls back |
| + // to a fixed, non-secret development value — see devInstanceKey in |
| + // auth.go); with any other Env, empty is a startup refusal, the |
| + // same fail-closed shape as Origin. |
| + InstanceKey string |
| + |
| + // Mailer delivers the magic-link email. Nil is fine only in |
| + // development: New wraps rastrillo/mail's logging fallback so the |
| + // link lands in the log, clearly marked, and TestSignIn (testing.go) |
| + // can read it back — no mail server required for |
| + // scripts/run-dev.sh or for tests. Outside development, nil is a |
| + // startup refusal: a sign-in that cannot send its link is not |
| + // sign-in, and silently falling back to logging a live credential |
| + // in a real deployment's log is exactly the mistake that fallback |
| + // exists to flag, not absorb. cmd/docs/main.go builds this from |
| + // $DOCS_SMTP_HOST/$DOCS_SMTP_FROM (and the other |
| + // $DOCS_SMTP_* vars rastrillo/mail.FromEnv reads) when both are |
| + // set, and leaves it nil otherwise — New.buildMailer (auth.go) is |
| + // what turns "nil outside development" into the refusal, the same |
| + // split Origin/InstanceKey use. |
| + Mailer mail.Sender |
| + |
| + // AllowedAddresses is the comma-separated allowlist of the only |
| + // addresses this instance admits — $DOCS_ALLOWED_ADDRESSES |
| + // (cmd/docs/main.go), the same "<APP>_..." env-var convention |
| + // Origin and the SMTP vars above use. Compared case-insensitively, |
| + // each entry trimmed of surrounding whitespace (auth.go's |
| + // normalizeAddress) — a real config file has spaces after its |
| + // commas, and people type capitals into an email field. |
| + // |
| + // This is wired to TWO places, deliberately, not one: |
| + // rauth.Config.Authorize (newAuthPlugin, auth.go) gates admission, |
| + // and gateBegin (auth.go) gates POST /signin itself, ahead of |
| + // rauth.Auth.Begin. The second wiring is the one that actually |
| + // matters: rastrillo/auth's Authorize hook fires only in its own |
| + // admit() (rastrillo/auth's handlers.go), which runs after Begin |
| + // has already mailed the link, so Authorize alone defends the |
| + // session but not the mail — an Authorize-only fix would leave |
| + // this app's own SES sending identity an open relay for any |
| + // address a stranger cares to type into the sign-in form (the |
| + // defect Task 4 exists to close; credited to the CARLOS Sheets |
| + // session's finding on their own deployed instance). gateBegin's |
| + // short-circuit is what actually stops that: a non-permitted |
| + // address never reaches Begin, so no mail is ever sent for it. |
| + // |
| + // Empty is allowed only when Env is "development" |
| + // (resolveAllowlist below defaults that to "admit everyone", so |
| + // TestSignIn and scripts/run-dev.sh keep working with no setup of |
| + // their own) — anywhere else, empty is a startup refusal; see |
| + // resolveAllowlist's own doc comment for why that beats the |
| + // alternative of silently admitting nobody. |
| + AllowedAddresses string |
| + |
| // Logger defaults to slog.Default(). |
| Logger *slog.Logger |
| } |
| @@ -145,28 +213,28 @@ func validateOrigin(origin string) error { |
| // App is the CARLOS Docs rastrillo app. |
| type App struct { |
| - cfg Config |
| - db *db.DB |
| - mux *http.ServeMux |
| - logger *slog.Logger |
| - core *core.Runtime |
| - store *Store |
| + cfg Config |
| + db *db.DB |
| + mux *http.ServeMux |
| + logger *slog.Logger |
| + core *core.Runtime |
| + store *Store |
| + mailRecorder *recordingMailer // nil outside development; see auth.go |
| } |
| // BootSchema is everything New applies at boot, in apply order. |
| -// migrate.Merge(sessions.Schema, ...) adds another set here once auth |
| -// does; for now it's just this app's own Schema (models.go). |
| -var BootSchema = migrate.Merge(Schema) |
| +// migrate.Merge's argument order is apply order (rastrillo SKILL.md |
| +// §2): sessions.Schema before auth.Schema — auth's own backfill |
| +// migration reads the sessions table — before this app's own Schema |
| +// (models.go). |
| +var BootSchema = migrate.Merge(sessions.Schema, authSchema, Schema) |
| // New opens the database, applies migrations and wires the router. It |
| // is rastrillo SKILL.md's app.go shape: db.Open for the SQLite handle, |
| // migrate.Apply once at boot, a chi router wrapped as an |
| // *http.ServeMux so httptest exercises the real router, csrf.Protect |
| -// mounted app-wide over POST /documents (SKILL.md §5). It does not |
| -// wire rastrillo's sessions package or a real identity plugin — there |
| -// is no signed-in route yet; spec §8.1's home issues the real identity |
| -// once it exists. devauth.go's guardDevAuth runs first, so a |
| -// misconfigured Config.DevAuth refuses before any of the above opens. |
| +// mounted app-wide (SKILL.md §5), rastrillo/auth (auth.go) guarding |
| +// every document route behind a real, per-person magic-link sign-in. |
| func New(cfg Config) (*App, error) { |
| logger := cfg.Logger |
| if logger == nil { |
| @@ -184,9 +252,9 @@ func New(cfg Config) (*App, error) { |
| // effect is on csrf.Protect's legacy Origin/Referer fallback path |
| // (Config.Origin's own doc comment explains why that path, not |
| // "every request", is what a wrong string actually touches). This |
| - // is the same fail-closed shape as guardDevAuth below: exactly one |
| - // string ("development") is ever treated as "safe to default", |
| - // checked before anything else opens. |
| + // is the same fail-closed shape InstanceKey and Mailer use below |
| + // (auth.go): exactly one string ("development") is ever treated |
| + // as "safe to default", checked before anything else opens. |
| // |
| // cfg.Origin == "" alone is not the right test: a whitespace value |
| // like $DOCS_ORIGIN=" " is non-empty, so it would sail past that |
| @@ -214,20 +282,27 @@ func New(cfg Config) (*App, error) { |
| cfg.Origin = trimmedOrigin |
| } |
| - // Checked before anything else opens — including the database — |
| - // so a misconfigured DevAuth refuses before New has any side |
| - // effect to unwind. See devauth.go: this is the single choke |
| - // point every path to the development-only identity passes |
| - // through, NewWithDevAuth and NewForTest included. |
| - if err := guardDevAuth(cfg); err != nil { |
| + // Resolved before anything else opens — same fail-closed shape as |
| + // Origin above: a deployment that cannot seal a |
| + // pending link, or cannot send one, refuses to start rather than |
| + // booting into a broken or (worse) silently-logging-credentials |
| + // sign-in. See auth.go. |
| + instanceKey, err := resolveInstanceKey(cfg) |
| + if err != nil { |
| return nil, err |
| } |
| - if cfg.DevAuth { |
| - // Loud on purpose: an operator watching boot logs should |
| - // never be able to miss this. It does not make the guard |
| - // above any less necessary — logging is a tripwire, not a |
| - // control. |
| - logger.Warn("docs: development-only identity ENABLED — refuses to start outside Env=development", "env", cfg.Env) |
| + mailer, mailRecorder, err := buildMailer(cfg, logger) |
| + if err != nil { |
| + return nil, err |
| + } |
| + // Same fail-closed shape again: an instance with nobody configured |
| + // to sign in either refuses to start (outside development) or, in |
| + // development, admits everyone — never a silent "admits nobody" |
| + // that would boot looking healthy. See resolveAllowlist's own doc |
| + // comment for the full reasoning. |
| + list, err := resolveAllowlist(cfg) |
| + if err != nil { |
| + return nil, err |
| } |
| d, err := db.Open(cfg.DBPath, logger) |
| @@ -257,28 +332,64 @@ func New(cfg Config) (*App, error) { |
| return nil, fmt.Errorf("docs: build renderer: %w", err) |
| } |
| + // auth.go: rastrillo's magic-link plugin, mounted per its own |
| + // SKILL.md contract (POST /signin, GET /auth/callback, GET |
| + // /auth/verify, POST /signout) — never a passkey second factor; |
| + // see spec §8.1 and auth.go's own doc comment for why. list also |
| + // becomes rauth.Config.Authorize here (admission), and — separately, |
| + // below — gateBegin's argument (delivery); see AllowedAddresses' |
| + // own doc comment on Config for why both wirings exist. |
| + authPlugin, err := newAuthPlugin(cfg, d.Writer(), instanceKey, mailer, list, logger) |
| + if err != nil { |
| + rt.Close(context.Background()) |
| + d.Close() |
| + return nil, fmt.Errorf("docs: build auth: %w", err) |
| + } |
| + |
| h := &handlers{render: render, logger: logger, store: store} |
| r := chi.NewRouter() |
| // Mounted above every route, per rastrillo SKILL.md §5: refuses a |
| // cross-origin POST/PUT/PATCH/DELETE by Sec-Fetch-Site/Origin/ |
| // Referer evidence; GET/HEAD/OPTIONS pass untouched, so this does |
| - // nothing to "/" or "/d/{sqid}" and everything to "/documents". |
| + // nothing to "/" or "/d/{sqid}" and everything to "/documents", |
| + // "/signin" and "/signout". |
| r.Use(csrf.Protect(cfg.Origin)) |
| - if cfg.DevAuth { |
| - // Guarded above: this only ever mounts when guardDevAuth has |
| - // already confirmed Env == "development". |
| - r.Use(devAuthMiddleware) |
| - } |
| - r.Get("/", h.list) |
| - r.Post("/documents", h.create) |
| - r.Get("/d/{sqid}", h.show) |
| + // Unauthenticated: the sign-in page and its four handlers, and the |
| + // static assets that page (and the shell around it) needs to |
| + // render at all. |
| + r.Get("/signin", h.signin) |
| + // gateBegin, not authPlugin.Begin directly: see AllowedAddresses' |
| + // own doc comment on Config, and gateBegin's own doc comment |
| + // (auth.go), for why the allowlist has to sit in front of Begin |
| + // itself rather than only behind it as rauth.Config.Authorize. |
| + // "/signin" here must keep matching rauth's own default |
| + // SigninPath (auth.New's own zero-value default) — newAuthPlugin |
| + // never overrides Config.SigninPath, and nothing else in this file |
| + // does either, so the two literals staying equal is not a |
| + // coincidence needing its own sync mechanism, only something a |
| + // future SigninPath override would have to touch in both places. |
| + r.Post("/signin", gateBegin(authPlugin.Begin, list, "/signin")) |
| + r.Get("/auth/callback", authPlugin.Callback) |
| + r.Get("/auth/verify", authPlugin.Verify) |
| + r.Post("/signout", authPlugin.Signout) |
| r.Handle("/static/*", render.assets.Handler()) |
| + // Everything else needs a real, signed-in person — rastrillo |
| + // SKILL.md §5/§1's "signed-in routes... in a Group with |
| + // sess.Require", here auth's own RequireSession. A signed-out |
| + // GET/HEAD redirects to /signin; anything else 403s. |
| + r.Group(func(r chi.Router) { |
| + r.Use(authPlugin.RequireSession) |
| + r.Get("/", h.list) |
| + r.Post("/documents", h.create) |
| + r.Get("/d/{sqid}", h.show) |
| + }) |
| + |
| mux := http.NewServeMux() |
| mux.Handle("/", r) |
| - return &App{cfg: cfg, db: d, mux: mux, logger: logger, core: rt, store: store}, nil |
| + return &App{cfg: cfg, db: d, mux: mux, logger: logger, core: rt, store: store, mailRecorder: mailRecorder}, nil |
| } |
| // Store returns the app's document store — models.go's Document, |
diff --git a/internal/docs/app_test.go b/internal/docs/app_test.go| index 9042c61..71b2a16 100644 |
| --- a/internal/docs/app_test.go |
| +++ b/internal/docs/app_test.go |
| @@ -15,7 +15,11 @@ func TestRootRendersAPage(t *testing.T) { |
| if err != nil { |
| t.Fatalf("NewForTest: %v", err) |
| } |
| + cookies := signInCookies(t, app, "reader@example.com") |
| req := httptest.NewRequest(http.MethodGet, "/", nil) |
| + for _, c := range cookies { |
| + req.AddCookie(c) |
| + } |
| rec := httptest.NewRecorder() |
| app.Handler().ServeHTTP(rec, req) |
diff --git a/internal/docs/auth.go b/internal/docs/auth.go| new file mode 100644 |
| index 0000000..7fff498 |
| --- /dev/null |
| +++ b/internal/docs/auth.go |
| @@ -0,0 +1,376 @@ |
| +// Per spec §8.1 (reframed the day this task started): a single |
| +// instance signs people in itself, permanently, not as a stopgap for |
| +// a shared home that does not exist yet — a lone instance has no |
| +// "one person, many teams" problem, which is the problem a shared |
| +// home would exist to solve. It does so with rastrillo/auth's |
| +// turnkey magic-link plugin (SKILL.md §5, docs/site/magic-links.md |
| +// in the rastrillo module): an emailed, single-use link, no password, |
| +// no credential enrolled. |
| +// |
| +// Deliberately magic link only, never a passkey second factor: |
| +// passkey.Config.Origin derives its relying party ID from this |
| +// instance's own host, so a person signed into three team instances |
| +// would enrol three separate passkeys under this app — exactly the |
| +// per-team credential sprawl §8.1's ruling forbids. A magic link |
| +// enrols no credential at all, so it creates no relying party and |
| +// violates nothing. Task 1's brief is explicit that this is a design |
| +// ruling, not an oversight to "improve" on later. |
| +package docs |
| + |
| +import ( |
| + "context" |
| + "database/sql" |
| + "fmt" |
| + "log/slog" |
| + "net/http" |
| + "net/url" |
| + "regexp" |
| + "strings" |
| + "sync" |
| + |
| + rauth "github.com/carlosframework/rastrillo/auth" |
| + "github.com/carlosframework/rastrillo/mail" |
| + |
| + "github.com/carlosframework/rastrillo" |
| +) |
| + |
| +// authSchema aliases rauth.Schema — auth's own migrations, merged into |
| +// BootSchema (app.go) ahead of this app's own Schema and after |
| +// sessions.Schema, per rauth's own doc comment: its backfill migration |
| +// reads the sessions table, so sessions.Schema must apply first. |
| +var authSchema = rauth.Schema |
| + |
| +// devInstanceKey is resolveInstanceKey's fallback, used only when Env |
| +// is "development" and Config.InstanceKey is empty (NewForTest's |
| +// case). It is not a secret: it is a fixed, public string, exactly as |
| +// dangerous — and exactly as acceptable — as rastrillo/auth's own |
| +// empty-key value would be, except spelled out here instead of left |
| +// implicit, and it never seals anything outside a throwaway |
| +// development database. resolveInstanceKey refuses to fall back to it |
| +// for any other Env. |
| +const devInstanceKey = "docs-development-instance-key-not-a-secret" |
| + |
| +// resolveInstanceKey applies Config.InstanceKey's own fail-closed |
| +// rule (see its doc comment on Config): non-empty always wins; empty |
| +// is fine only in development, where it defaults to devInstanceKey; |
| +// anywhere else it is a startup refusal, the same shape New already |
| +// uses for Origin. |
| +func resolveInstanceKey(cfg Config) (string, error) { |
| + key := strings.TrimSpace(cfg.InstanceKey) |
| + if key != "" { |
| + return key, nil |
| + } |
| + if cfg.Env == "development" { |
| + return devInstanceKey, nil |
| + } |
| + return "", fmt.Errorf("docs: Config.InstanceKey is empty and Env is %q, not %q — refusing to start with no seal for the sign-in link's pending blob; set $DOCS_INSTANCE_KEY", cfg.Env, "development") |
| +} |
| + |
| +// buildMailer applies Config.Mailer's fail-closed rule (see its own |
| +// doc comment): an explicit Mailer always wins; nil is fine only in |
| +// development, where it falls back to rastrillo/mail's logging |
| +// sender; anywhere else nil is a startup refusal — a sign-in that |
| +// cannot send its link is not sign-in, so this is the same fail-closed |
| +// shape New already uses for Origin, not merely a warning. |
| +// |
| +// In development the chosen sender is always wrapped in a |
| +// *recordingMailer, whether or not Config.Mailer was already set: |
| +// that is what lets TestSignIn (testing.go) read back the last |
| +// mailed link without a real mail server, keeping |
| +// scripts/run-dev.sh a one-command affair. The returned |
| +// *recordingMailer is nil outside development — nothing there needs |
| +// it, and it holds the plaintext of the last link sent in memory, |
| +// which a real deployment has no reason to carry. |
| +func buildMailer(cfg Config, logger *slog.Logger) (mail.Sender, *recordingMailer, error) { |
| + sender := cfg.Mailer |
| + if sender == nil { |
| + if cfg.Env != "development" { |
| + return nil, nil, fmt.Errorf("docs: mail is not configured (set $DOCS_SMTP_HOST and $DOCS_SMTP_FROM) and Env is %q, not %q — refusing to start: a sign-in that cannot send its link is not sign-in", cfg.Env, "development") |
| + } |
| + // Loudly labelled by mail.Logged itself on every send — see |
| + // its own doc comment; this is the dev/test-only fallback, |
| + // never reached outside Env == "development". |
| + sender = mail.Logged(logger) |
| + } |
| + if cfg.Env != "development" { |
| + return sender, nil, nil |
| + } |
| + rec := &recordingMailer{next: sender} |
| + return rec, rec, nil |
| +} |
| + |
| +// recordingMailer wraps a mail.Sender, remembering the most recently |
| +// sent message's body so TestSignIn (testing.go) can read the magic |
| +// link back out of it — the development/test convenience buildMailer |
| +// installs, never wired outside Env == "development". It still |
| +// forwards every Send to the wrapped sender, so the ordinary |
| +// dev-mode behavior (the link lands in the log, clearly marked) is |
| +// unchanged; this only adds a second, in-memory place to find it. |
| +type recordingMailer struct { |
| + mu sync.Mutex |
| + next mail.Sender |
| + lastBody string |
| +} |
| + |
| +func (m *recordingMailer) Send(ctx context.Context, to, subject, body string) error { |
| + m.mu.Lock() |
| + m.lastBody = body |
| + m.mu.Unlock() |
| + return m.next.Send(ctx, to, subject, body) |
| +} |
| + |
| +// magicLinkPattern finds the emailed link inside signin's default mail |
| +// body ("Open this link to sign in:\n\n<link>\n\n..." — |
| +// keymaildev/signin's flow.go). Anchored to the app's own callback |
| +// path so it cannot mistake some other URL the body might someday |
| +// carry for the sign-in link itself. |
| +var magicLinkPattern = regexp.MustCompile(`https?://\S+/auth/verify\?token=\S+`) |
| + |
| +// normalizeAddress puts address into the one form allowlist compares |
| +// and stores: trimmed of surrounding whitespace, lowercased. Shared |
| +// by resolveAllowlist (building the set) and allowlist.allows |
| +// (checking against it) so the two can never drift into comparing |
| +// differently-normalized forms of the same address. |
| +func normalizeAddress(address string) string { |
| + return strings.ToLower(strings.TrimSpace(address)) |
| +} |
| + |
| +// allowlist is Config.AllowedAddresses ($DOCS_ALLOWED_ADDRESSES), |
| +// parsed and normalized — see that field's own doc comment on Config |
| +// for what it gates and why it is wired into two different places. |
| +// |
| +// addrs == nil means "admit every address" — resolveAllowlist's |
| +// development-only default (the same shape devInstanceKey uses for |
| +// InstanceKey: spelled out here rather than left as an accidental |
| +// nil, and refused everywhere else). addrs != nil means "admit |
| +// nothing but exactly this set"; resolveAllowlist never returns a |
| +// non-nil-but-empty map outside development, since an empty parse |
| +// there is a startup refusal, not a value this type carries. |
| +type allowlist struct { |
| + addrs map[string]struct{} |
| +} |
| + |
| +// allows reports whether address may sign in. |
| +func (a allowlist) allows(address string) bool { |
| + if a.addrs == nil { |
| + return true |
| + } |
| + _, ok := a.addrs[normalizeAddress(address)] |
| + return ok |
| +} |
| + |
| +// resolveAllowlist applies Config.AllowedAddresses' own fail-closed |
| +// rule (see its doc comment on Config): a non-empty, comma-separated |
| +// list always wins, parsed and normalized; an empty one is fine only |
| +// in development, where it defaults to "admit everyone" so TestSignIn |
| +// and scripts/run-dev.sh need no setup of their own; anywhere else it |
| +// is a startup refusal. |
| +// |
| +// Refusal, not "admit nobody" — a real choice, made deliberately, not |
| +// the shape everything else in this file happens to use. An instance |
| +// that silently admitted nobody would boot looking entirely healthy: |
| +// /signin renders, POST /signin answers its ordinary 303 ?sent=1 on |
| +// every submission (gateBegin's whole point is that a refused |
| +// submission and a genuine one are byte-identical), and nothing would |
| +// ever surface the mistake short of a person noticing that no sign-in |
| +// link ever arrives — on an app whose entire premise is one |
| +// instance, one team, where "nobody configured" is exactly the state |
| +// a fresh, not-yet-configured deployment starts in. A startup refusal |
| +// is loud: the process does not come up, whoever is deploying it sees |
| +// that immediately, and cannot ship it half-configured by accident. |
| +// The honest cost is that a misconfigured instance is down rather |
| +// than merely closed — weighed and accepted here because "down" gets |
| +// fixed the same day, while "quietly admits nobody" on a public |
| +// hostname could sit unnoticed indefinitely. This is the same |
| +// tradeoff Origin, InstanceKey and Mailer already make in this file |
| +// and app.go: a misconfigured instance refuses to start, never |
| +// quietly does the wrong thing. |
| +func resolveAllowlist(cfg Config) (allowlist, error) { |
| + addrs := make(map[string]struct{}) |
| + for _, addr := range strings.Split(cfg.AllowedAddresses, ",") { |
| + addr = normalizeAddress(addr) |
| + if addr == "" { |
| + continue |
| + } |
| + addrs[addr] = struct{}{} |
| + } |
| + if len(addrs) > 0 { |
| + return allowlist{addrs: addrs}, nil |
| + } |
| + if cfg.Env == "development" { |
| + return allowlist{}, nil // addrs stays nil: admit everyone |
| + } |
| + return allowlist{}, fmt.Errorf("docs: $DOCS_ALLOWED_ADDRESSES is empty and Env is %q, not %q — refusing to start with no allowlist configured: an instance-per-team app admitting nobody would look identical to one working correctly (see resolveAllowlist's own doc comment), so this refuses rather than boots quietly wrong; set $DOCS_ALLOWED_ADDRESSES to this team's address(es)", cfg.Env, "development") |
| +} |
| + |
| +// gateBegin wraps rauth.Auth.Begin so a non-permitted address never |
| +// reaches it at all — the actual fix for the defect Config. |
| +// AllowedAddresses' own doc comment describes: Begin is what sends |
| +// the mail, and rauth.Config.Authorize (wired in newAuthPlugin below, |
| +// too) is checked only afterward, inside rauth's own admit(), which |
| +// runs at the callback once a link has already been mailed and |
| +// clicked. Gating only Authorize would leave this app's own mail |
| +// sending identity an open relay for any address a stranger types |
| +// into the sign-in form; gating here means a non-permitted address |
| +// causes rauth's Begin to never run, so no mail is ever sent for it. |
| +// |
| +// It reads the submitted address with r.FormValue exactly the way |
| +// rauth's own Begin does (rastrillo/auth's handlers.go), so by the |
| +// time next (Begin, for a permitted address) reads it again, |
| +// net/http's request.ParseForm has already cached the parse — |
| +// ParseForm is documented idempotent, so parsing twice costs nothing |
| +// and cannot disagree with itself. |
| +// |
| +// A non-permitted address gets exactly the response Begin's own |
| +// success path sends: the same 303, to the same signinPath+"?sent=1", |
| +// built the same way (http.Redirect). That is deliberate, not merely |
| +// convenient: any observable difference — a different status, a |
| +// different body, even a differently-cased query string — would turn |
| +// this endpoint into a membership oracle, letting an attacker learn |
| +// which addresses this instance's allowlist contains by watching how |
| +// its response to each one differs. See |
| +// TestSigninResponseIdenticalForNonPermittedAddress (auth_test.go), |
| +// which asserts the two responses byte-for-byte, not merely "both are |
| +// 303". |
| +// |
| +// That guarantee only held for a non-keymail address: on the |
| +// permitted branch, this used to hand the request to Begin exactly as |
| +// received, and Begin auto-upgrades a permitted address that resolves |
| +// to a claimed keymail inbox to the keymail ceremony — a 303 to the |
| +// keymail authorize URL, not signinPath+"?sent=1". A non-permitted |
| +// address never reaches Begin at all, so it always got ?sent=1. That |
| +// reopened the same oracle one level narrower: an attacker who knows |
| +// (or suspects) an address is keymail-hosted could tell "on the |
| +// allowlist" from "not" by whether the response redirected to keymail |
| +// or to ?sent=1. |
| +// |
| +// force=1 is what selects the plain-magic-link path over that |
| +// classification (rastrillo/auth's handlers.go), and this app never |
| +// wants the keymail ceremony in the first place — spec §8.1's ruling, |
| +// this file's own package comment, and signin.html's form, which |
| +// already hardcodes a hidden force=1 field so no request built by |
| +// this app's own UI ever takes the keymail branch. Setting it here |
| +// too, unconditionally, on every permitted request — regardless of |
| +// what the caller's own form said — makes that the actual guarantee |
| +// for any request this handler ever forwards, not just the ones sent |
| +// through this app's form: every permitted address takes the identical |
| +// plain-magic-link path Begin would take for force=1, so the keymail |
| +// branch is never reached and never has a distinct response to leak |
| +// through. This costs nothing a legitimate caller wanted: it matches |
| +// what the UI already always sends, and it is not a behavior change |
| +// this app would ever notice. |
| +func gateBegin(next http.HandlerFunc, list allowlist, signinPath string) http.HandlerFunc { |
| + return func(w http.ResponseWriter, r *http.Request) { |
| + // A malformed body (ParseForm's error) reads back as address |
| + // == "" below, which is never on a non-empty allowlist and is |
| + // on an admit-everyone one only in development — the same |
| + // answer Begin itself would eventually reach for the same |
| + // malformed request, just reached here instead, before any |
| + // mail could be sent over it. |
| + _ = r.ParseForm() |
| + if list.allows(r.FormValue("address")) { |
| + if r.Form == nil { |
| + r.Form = url.Values{} |
| + } |
| + r.Form.Set("force", "1") |
| + next(w, r) |
| + return |
| + } |
| + http.Redirect(w, r, signinPath+"?sent=1", http.StatusSeeOther) |
| + } |
| +} |
| + |
| +// newAuthPlugin wires rastrillo/auth per its SKILL.md contract: one |
| +// *Auth built at boot (New — SKILL.md's own warning against building |
| +// a Flow per request, since the rate limiter's budget lives on the |
| +// value), Origin as both the emailed link's base and the cookie |
| +// decision, InstanceKey and Mailer already resolved and fail-closed |
| +// by resolveInstanceKey/buildMailer above. Authorize is list.allows — |
| +// belt-and-suspenders alongside gateBegin's own gate on POST /signin: |
| +// this defends admission itself (a link somehow minted for a |
| +// non-permitted address — a future code path, or a link mailed before |
| +// the allowlist changed — still cannot mint a session), even though |
| +// gateBegin's own gate is what actually stops the mail from going out |
| +// in the first place; see Config.AllowedAddresses' own doc comment |
| +// for why both wirings exist rather than either alone. SecondFactor |
| +// is left nil on purpose: no passkey step-up, ever, for the reason |
| +// this file's own package comment gives. |
| +func newAuthPlugin(cfg Config, writer *sql.DB, instanceKey string, mailer mail.Sender, list allowlist, logger *slog.Logger) (*rauth.Auth, error) { |
| + return rauth.New(rauth.Config{ |
| + DB: writer, |
| + Origin: cfg.Origin, |
| + InstanceKey: instanceKey, |
| + Mailer: mailer, |
| + Authorize: list.allows, |
| + Logger: logger, |
| + }) |
| +} |
| + |
| +// signinPage is GET /signin's view data. rauth.Auth's own handlers |
| +// (Begin, Callback, Verify) never render a page themselves — SKILL.md |
| +// §5/magic-links.md: "the sign-in page stays yours" — they report |
| +// outcomes by redirecting back here with a query this handler reads. |
| +type signinPage struct { |
| + Title string |
| + Body string |
| + AddressLabel string |
| + SubmitLabel string |
| + Sent string // non-empty: a link was emailed — show it |
| + ErrorMessage string // non-empty: something went wrong — show it |
| +} |
| + |
| +// signin serves GET /signin — the form itself, and the landing spot |
| +// for every outcome rauth.Auth's handlers redirect back to |
| +// (?sent=1, ?err=rate|address|expired|keymail|1, ?force=1). rate, |
| +// address and expired each get their own copy below; keymail and 1 |
| +// (a raw Begin failure logged server-side, and Verify's own "unknown, |
| +// used and expired alike" — its own doc comment says rauth.Auth |
| +// refuses to be more specific than that, so there is nothing this |
| +// page could add by drawing a finer distinction of its own) share the |
| +// switch's default branch, docs.signin.error.unknown — never |
| +// docs.error.generic (fix round 1 on Task 3): every outcome on this |
| +// one page means the same operational thing, "sign-in did not |
| +// complete, ask for a new link," and docs.error.generic is authored |
| +// for a render failure with nowhere styled to land, not for this |
| +// page's own ordinary, recoverable outcomes. |
| +func (h *handlers) signin(w http.ResponseWriter, r *http.Request) { |
| + q := r.URL.Query() |
| + data := signinPage{ |
| + Title: rastrillo.T(r, "docs.signin.title"), |
| + Body: rastrillo.T(r, "docs.signin.body"), |
| + AddressLabel: rastrillo.T(r, "docs.signin.address.label"), |
| + SubmitLabel: rastrillo.T(r, "docs.signin.submit"), |
| + } |
| + if q.Get("sent") == "1" { |
| + data.Sent = rastrillo.T(r, "docs.signin.sent") |
| + } |
| + switch q.Get("err") { |
| + case "": |
| + // nothing to report |
| + case "rate": |
| + data.ErrorMessage = rastrillo.T(r, "docs.signin.error.rate") |
| + case "address": |
| + data.ErrorMessage = rastrillo.T(r, "docs.signin.error.address") |
| + case "expired": |
| + data.ErrorMessage = rastrillo.T(r, "docs.signin.error.expired") |
| + default: |
| + data.ErrorMessage = rastrillo.T(r, "docs.signin.error.unknown") |
| + } |
| + if err := h.render.page(w, r, "signin", data); err != nil { |
| + h.logger.Error("docs: render signin", "err", err) |
| + http.Error(w, rastrillo.T(r, "docs.error.generic"), http.StatusInternalServerError) |
| + } |
| +} |
| + |
| +// linkPath extracts the request-target (path plus query, no |
| +// scheme/host) from an emailed magic link — what |
| +// httptest.NewRequest, and a real browser following a same-origin |
| +// link, both just need. It exists so TestSignIn (testing.go) does not |
| +// have to know the emailed link is absolute. |
| +func linkPath(link string) (string, error) { |
| + u, err := url.Parse(link) |
| + if err != nil { |
| + return "", fmt.Errorf("docs: parse magic link %q: %w", link, err) |
| + } |
| + return u.RequestURI(), nil |
| +} |
diff --git a/internal/docs/auth_allowlist_test.go b/internal/docs/auth_allowlist_test.go| new file mode 100644 |
| index 0000000..215e535 |
| --- /dev/null |
| +++ b/internal/docs/auth_allowlist_test.go |
| @@ -0,0 +1,276 @@ |
| +package docs |
| + |
| +import ( |
| + "bytes" |
| + "context" |
| + "net/http" |
| + "net/http/httptest" |
| + "net/url" |
| + "path/filepath" |
| + "strings" |
| + "testing" |
| +) |
| + |
| +// loggingMailerStub is a minimal mail.Sender good enough to satisfy |
| +// Config.Mailer's own "non-nil outside development" fail-closed |
| +// check (buildMailer, auth.go), so |
| +// TestNewRefusesMissingAllowlistOutsideDevelopment reaches the |
| +// allowlist check it actually means to pin rather than failing |
| +// earlier on an unrelated one. It never actually sends anything — |
| +// nothing in that test signs in. |
| +type loggingMailerStub struct{} |
| + |
| +func (loggingMailerStub) Send(ctx context.Context, to, subject, body string) error { return nil } |
| + |
| +// newAllowlistedTestApp builds a development App exactly like |
| +// NewForTest (testing.go), except with AllowedAddresses set |
| +// explicitly. NewForTest itself cannot exercise the allowlist at all: |
| +// an empty AllowedAddresses in development means "admit everyone" |
| +// (resolveAllowlist's own doc comment, auth.go) — precisely so |
| +// TestSignIn and every other test built on NewForTest need no |
| +// allowlist setup of their own. These tests are the ones that |
| +// actually need a non-default allowlist configured, so they build |
| +// their own App via New directly, package-internal (like |
| +// origin_test.go and render_test.go) so they can also reach |
| +// app.mailRecorder — unexported, and deliberately not something |
| +// testing.go exposes outside this package: nothing outside auth.go's |
| +// own tests should need to know whether mail was sent versus merely |
| +// that a link came back, and TestSignIn already covers the latter. |
| +func newAllowlistedTestApp(t *testing.T, allowed string) *App { |
| + t.Helper() |
| + app, err := New(Config{ |
| + DBPath: filepath.Join(t.TempDir(), "docs.db"), |
| + Env: "development", |
| + AllowedAddresses: allowed, |
| + }) |
| + if err != nil { |
| + t.Fatalf("New: %v", err) |
| + } |
| + t.Cleanup(func() { |
| + if cerr := app.Close(); cerr != nil { |
| + t.Errorf("App.Close: %v", cerr) |
| + } |
| + }) |
| + return app |
| +} |
| + |
| +// postSignin POSTs address to /signin as a same-origin browser form |
| +// would, with force=1 — the same escape hatch TestSignIn (testing.go) |
| +// uses to skip signin's own keymail classification (which would |
| +// otherwise make a real outbound DNS/HTTPS call per address, per |
| +// signin's own README, entirely beside the point of what these tests |
| +// check). force=1 does not, and must not, change gateBegin's own |
| +// decision: gateBegin looks only at the address field, not force — |
| +// see gateBegin's own doc comment (auth.go). |
| +func postSignin(app *App, address string) *httptest.ResponseRecorder { |
| + form := url.Values{"address": {address}, "force": {"1"}} |
| + req := httptest.NewRequest(http.MethodPost, "/signin", strings.NewReader(form.Encode())) |
| + req.Header.Set("Content-Type", "application/x-www-form-urlencoded") |
| + req.Header.Set("Sec-Fetch-Site", "same-origin") |
| + rec := httptest.NewRecorder() |
| + app.Handler().ServeHTTP(rec, req) |
| + return rec |
| +} |
| + |
| +// lastMailBody reads back app.mailRecorder's most recently recorded |
| +// send, "" if nothing has been sent yet (or since the caller last |
| +// cleared it) — the direct way to observe whether Begin actually ran, |
| +// as opposed to TestSignIn's own success-shaped check (a link came |
| +// back), which a test proving the ABSENCE of a send cannot use: there |
| +// is no link to find in an empty body. |
| +func lastMailBody(app *App) string { |
| + app.mailRecorder.mu.Lock() |
| + defer app.mailRecorder.mu.Unlock() |
| + return app.mailRecorder.lastBody |
| +} |
| + |
| +// TestNonPermittedAddressSendsNoMail is this task's whole point, |
| +// stated as literally as possible: the defect (Config.AllowedAddresses' |
| +// own doc comment on Config has the full story — credited to the |
| +// CARLOS Sheets session's finding on their own deployed instance) was |
| +// never that a non-permitted address could sign in; rauth.Config. |
| +// Authorize already stopped that, once wired. It was that Begin sends |
| +// mail to any address at all, regardless of Authorize, because |
| +// Authorize is only ever consulted afterward, at the callback. This |
| +// is the test whose absence let that ship: every existing test in |
| +// this package (before this task) proved something about admission, |
| +// and none of them ever looked at whether a Send happened. Before |
| +// gateBegin existed, this test failed exactly this way — see this |
| +// file's own commit message for how that was confirmed live, not |
| +// assumed. |
| +func TestNonPermittedAddressSendsNoMail(t *testing.T) { |
| + app := newAllowlistedTestApp(t, "permitted@example.com") |
| + |
| + rec := postSignin(app, "stranger@evil.example") |
| + |
| + if rec.Code != http.StatusSeeOther { |
| + t.Fatalf("POST /signin for a non-permitted address: status %d, want %d", rec.Code, http.StatusSeeOther) |
| + } |
| + if loc := rec.Header().Get("Location"); !strings.Contains(loc, "sent=1") { |
| + t.Fatalf("POST /signin for a non-permitted address redirected to %q, want a %q outcome — the refusal must be invisible, not merely non-fatal", loc, "?sent=1") |
| + } |
| + if body := lastMailBody(app); body != "" { |
| + t.Fatalf("POST /signin for a non-permitted address sent mail (recorded body: %q) — Begin must never run for an address the allowlist refuses", body) |
| + } |
| +} |
| + |
| +// TestSigninResponseIdenticalForNonPermittedAddress is fix |
| +// requirement 3, checked the way its own reasoning demands: not |
| +// "both responses are a 303" but the actual bytes — status, Location |
| +// and body — compared directly. Any observable difference here would |
| +// turn POST /signin into a membership oracle: an attacker who can |
| +// tell "sent" from "not sent" has learned whether a given address is |
| +// on this instance's allowlist, which is exactly the kind of leak an |
| +// admission check alone would not create (it would 403 visibly) but |
| +// a badly-built send-time gate could. |
| +func TestSigninResponseIdenticalForNonPermittedAddress(t *testing.T) { |
| + app := newAllowlistedTestApp(t, "permitted@example.com") |
| + |
| + permitted := postSignin(app, "permitted@example.com") |
| + nonPermitted := postSignin(app, "stranger@evil.example") |
| + |
| + if permitted.Code != http.StatusSeeOther { |
| + t.Fatalf("POST /signin for the permitted address: status %d, want %d", permitted.Code, http.StatusSeeOther) |
| + } |
| + if got, want := nonPermitted.Code, permitted.Code; got != want { |
| + t.Errorf("status differs: non-permitted=%d permitted=%d", got, want) |
| + } |
| + if got, want := nonPermitted.Header().Get("Location"), permitted.Header().Get("Location"); got != want { |
| + t.Errorf("Location differs: non-permitted=%q permitted=%q", got, want) |
| + } |
| + if got, want := nonPermitted.Header().Get("Content-Type"), permitted.Header().Get("Content-Type"); got != want { |
| + t.Errorf("Content-Type differs: non-permitted=%q permitted=%q", got, want) |
| + } |
| + if got, want := nonPermitted.Body.Bytes(), permitted.Body.Bytes(); !bytes.Equal(got, want) { |
| + t.Errorf("body differs: non-permitted=%q permitted=%q", got, want) |
| + } |
| +} |
| + |
| +// TestPermittedAddressStillSignsIn proves the fix does not break the |
| +// working path: a permitted address must still complete the full |
| +// magic-link ceremony end to end, mirroring |
| +// TestMagicLinkThenSignedInRouteWorks (auth_test.go) but against an |
| +// App with a real, non-empty allowlist instead of NewForTest's |
| +// admit-everyone default. |
| +func TestPermittedAddressStillSignsIn(t *testing.T) { |
| + app := newAllowlistedTestApp(t, "permitted@example.com") |
| + |
| + link, err := app.TestSignIn(t, "permitted@example.com") |
| + if err != nil { |
| + t.Fatalf("TestSignIn: %v", err) |
| + } |
| + |
| + verifyReq := httptest.NewRequest(http.MethodGet, link, nil) |
| + verifyRec := httptest.NewRecorder() |
| + app.Handler().ServeHTTP(verifyRec, verifyReq) |
| + cookies := verifyRec.Result().Cookies() |
| + if len(cookies) == 0 { |
| + t.Fatal("following the magic link set no session cookie") |
| + } |
| + |
| + req := httptest.NewRequest(http.MethodGet, "/", nil) |
| + for _, c := range cookies { |
| + req.AddCookie(c) |
| + } |
| + rec := httptest.NewRecorder() |
| + app.Handler().ServeHTTP(rec, req) |
| + if rec.Code != http.StatusOK { |
| + t.Fatalf("GET / signed in as a permitted address: status %d, want 200", rec.Code) |
| + } |
| +} |
| + |
| +// TestAllowlistIsCaseAndWhitespaceInsensitive is fix requirement 1's |
| +// own explicit demand: real config files have spaces after their |
| +// commas, and people type capitals — $DOCS_ALLOWED_ADDRESSES= |
| +// "Permitted@Example.com" must still admit |
| +// "permitted@example.com" and vice versa, in both directions |
| +// (the configured entry's own case/whitespace, and the submitted |
| +// address's). |
| +func TestAllowlistIsCaseAndWhitespaceInsensitive(t *testing.T) { |
| + app := newAllowlistedTestApp(t, " Permitted@Example.com , other@example.com") |
| + |
| + for _, variant := range []string{ |
| + "permitted@example.com", |
| + "PERMITTED@EXAMPLE.COM", |
| + " permitted@example.com ", |
| + "Permitted@Example.com", |
| + } { |
| + if _, err := app.TestSignIn(t, variant); err != nil { |
| + t.Errorf("TestSignIn(%q): %v, want the allowlisted address to be admitted regardless of case or surrounding whitespace", variant, err) |
| + } |
| + } |
| +} |
| + |
| +// TestResolveAllowlistParsesCommaSeparatedList pins |
| +// resolveAllowlist's own parsing directly: comma-separated, each |
| +// entry trimmed and lowercased, blank entries (a trailing comma, or |
| +// stray whitespace between two commas) dropped rather than becoming a |
| +// spurious "" that could never match a real address anyway but would |
| +// otherwise clutter the set. |
| +func TestResolveAllowlistParsesCommaSeparatedList(t *testing.T) { |
| + list, err := resolveAllowlist(Config{ |
| + Env: "production", |
| + AllowedAddresses: " Alice@Example.com, bob@example.com ,, carol@example.com ", |
| + }) |
| + if err != nil { |
| + t.Fatalf("resolveAllowlist: %v", err) |
| + } |
| + for _, addr := range []string{"alice@example.com", "ALICE@EXAMPLE.COM", "bob@example.com", "carol@example.com"} { |
| + if !list.allows(addr) { |
| + t.Errorf("list.allows(%q) = false, want true", addr) |
| + } |
| + } |
| + if list.allows("mallory@example.com") { |
| + t.Error("list.allows(\"mallory@example.com\") = true, want false — not on the configured list") |
| + } |
| +} |
| + |
| +// TestResolveAllowlistEmptyOutsideDevelopmentRefuses is fix |
| +// requirement 4's decision, pinned directly: an unset |
| +// $DOCS_ALLOWED_ADDRESSES must never mean "allow everyone" outside |
| +// development. resolveAllowlist's own doc comment has the full |
| +// reasoning for refusing at startup rather than the alternative |
| +// (silently admitting nobody) — this test only pins the outcome: |
| +// New must refuse to start, the same fail-closed shape |
| +// TestNewRefusesMissingInstanceKeyOutsideDevelopment and |
| +// TestNewRefusesMissingMailOutsideDevelopment (auth_test.go) already |
| +// pin for InstanceKey and Mailer. |
| +func TestResolveAllowlistEmptyOutsideDevelopmentRefuses(t *testing.T) { |
| + for _, env := range []string{"production", "staging", ""} { |
| + if _, err := resolveAllowlist(Config{Env: env}); err == nil { |
| + t.Errorf("resolveAllowlist with Env %q and no AllowedAddresses: err = nil, want a refusal", env) |
| + } |
| + } |
| +} |
| + |
| +// TestResolveAllowlistEmptyInDevelopmentAdmitsEveryone pins the other |
| +// half of the same decision: development's own convenience default |
| +// (TestSignIn, scripts/run-dev.sh) needs no allowlist configured at |
| +// all, exactly as it needed no InstanceKey or Mailer configured |
| +// before this task. |
| +func TestResolveAllowlistEmptyInDevelopmentAdmitsEveryone(t *testing.T) { |
| + list, err := resolveAllowlist(Config{Env: "development"}) |
| + if err != nil { |
| + t.Fatalf("resolveAllowlist: %v", err) |
| + } |
| + if !list.allows("anyone@example.com") { |
| + t.Error("an empty AllowedAddresses in development must admit everyone") |
| + } |
| +} |
| + |
| +// TestNewRefusesMissingAllowlistOutsideDevelopment is the New-level |
| +// version of TestResolveAllowlistEmptyOutsideDevelopmentRefuses, |
| +// exercised the way a real misconfigured deployment would actually |
| +// hit it — through New itself, alongside its own InstanceKey/Mailer |
| +// siblings in auth_test.go. |
| +func TestNewRefusesMissingAllowlistOutsideDevelopment(t *testing.T) { |
| + _, err := New(Config{ |
| + Env: "production", |
| + Origin: "https://docs.example.com", |
| + InstanceKey: "some-real-key", |
| + Mailer: loggingMailerStub{}, |
| + }) |
| + if err == nil { |
| + t.Fatal("New must refuse to start outside development with no allowlist configured") |
| + } |
| +} |
diff --git a/internal/docs/auth_test.go b/internal/docs/auth_test.go| new file mode 100644 |
| index 0000000..35bc797 |
| --- /dev/null |
| +++ b/internal/docs/auth_test.go |
| @@ -0,0 +1,282 @@ |
| +package docs_test |
| + |
| +import ( |
| + "net/http" |
| + "net/http/httptest" |
| + "strings" |
| + "testing" |
| + |
| + "github.com/carlosframework/docs/internal/docs" |
| +) |
| + |
| +// TestSignInRequiredForEverything is the brief's own literal test, |
| +// extended per fix round 1: a create endpoint reachable without a |
| +// session is the entire problem this task exists to solve, and a |
| +// "does the list require sign-in?" GET-only check reads straight |
| +// past it — a route moved out of the RequireSession group by a |
| +// future refactor would go undetected here otherwise. POST |
| +// /documents is sent with Sec-Fetch-Site: same-origin so csrf.Protect |
| +// (mounted above RequireSession in the middleware chain) lets it |
| +// through, isolating what actually refuses it: RequireSession itself, |
| +// not the CSRF check. A signed-out GET/HEAD redirects to sign-in; |
| +// anything else — POST included — 403s (rastrillo/auth's own |
| +// refuseSession, docs/site's own contract). |
| +func TestSignInRequiredForEverything(t *testing.T) { |
| + app, err := docs.NewForTest(t) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + for _, path := range []string{"/", "/d/anything"} { |
| + req := httptest.NewRequest(http.MethodGet, path, nil) |
| + rec := httptest.NewRecorder() |
| + app.Handler().ServeHTTP(rec, req) |
| + if rec.Code != http.StatusSeeOther && rec.Code != http.StatusFound { |
| + t.Errorf("GET %s signed out: status %d, want a redirect to sign-in", path, rec.Code) |
| + } |
| + } |
| + |
| + form := strings.NewReader("title=Should+not+be+created") |
| + req := httptest.NewRequest(http.MethodPost, "/documents", form) |
| + req.Header.Set("Content-Type", "application/x-www-form-urlencoded") |
| + req.Header.Set("Sec-Fetch-Site", "same-origin") |
| + rec := httptest.NewRecorder() |
| + app.Handler().ServeHTTP(rec, req) |
| + if rec.Code != http.StatusForbidden { |
| + t.Errorf("POST /documents signed out: status %d, want %d (RequireSession's own refusal, not a redirect)", rec.Code, http.StatusForbidden) |
| + } |
| +} |
| + |
| +// TestMagicLinkSignsSomebodyIn is the brief's own literal test: |
| +// TestSignIn mints a link without sending real mail, and following it |
| +// must establish a real session (a cookie), not merely 200. |
| +func TestMagicLinkSignsSomebodyIn(t *testing.T) { |
| + app, err := docs.NewForTest(t) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + link, err := app.TestSignIn(t, "reader@example.com") |
| + if err != nil { |
| + t.Fatalf("TestSignIn: %v", err) |
| + } |
| + // Following the link must establish a session. |
| + req := httptest.NewRequest(http.MethodGet, link, nil) |
| + rec := httptest.NewRecorder() |
| + app.Handler().ServeHTTP(rec, req) |
| + if len(rec.Result().Cookies()) == 0 { |
| + t.Fatal("following the magic link set no session cookie") |
| + } |
| +} |
| + |
| +// TestMagicLinkThenSignedInRouteWorks proves the session the previous |
| +// test only checked for the shape of is actually usable: the cookie |
| +// following the link sets must let a subsequent request reach a |
| +// RequireSession route — the whole point of Task 1, not just "a |
| +// cookie was set". |
| +func TestMagicLinkThenSignedInRouteWorks(t *testing.T) { |
| + app, err := docs.NewForTest(t) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + link, err := app.TestSignIn(t, "reader@example.com") |
| + if err != nil { |
| + t.Fatalf("TestSignIn: %v", err) |
| + } |
| + |
| + verifyReq := httptest.NewRequest(http.MethodGet, link, nil) |
| + verifyRec := httptest.NewRecorder() |
| + app.Handler().ServeHTTP(verifyRec, verifyReq) |
| + cookies := verifyRec.Result().Cookies() |
| + if len(cookies) == 0 { |
| + t.Fatal("following the magic link set no session cookie") |
| + } |
| + |
| + req := httptest.NewRequest(http.MethodGet, "/", nil) |
| + for _, c := range cookies { |
| + req.AddCookie(c) |
| + } |
| + rec := httptest.NewRecorder() |
| + app.Handler().ServeHTTP(rec, req) |
| + if rec.Code != http.StatusOK { |
| + t.Fatalf("GET / signed in: status %d, want 200", rec.Code) |
| + } |
| +} |
| + |
| +// TestSignInPageRendersSignedOut proves the sign-in form itself is |
| +// reachable signed out — it has to be, or nobody could ever sign in. |
| +func TestSignInPageRendersSignedOut(t *testing.T) { |
| + app, err := docs.NewForTest(t) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + req := httptest.NewRequest(http.MethodGet, "/signin", nil) |
| + rec := httptest.NewRecorder() |
| + app.Handler().ServeHTTP(rec, req) |
| + if rec.Code != http.StatusOK { |
| + t.Fatalf("GET /signin: status %d, want 200", rec.Code) |
| + } |
| +} |
| + |
| +// TestSignOutEndsTheSession proves POST /signout is real revocation, |
| +// not merely clearing a cookie the client could ignore: the session |
| +// must stop working for a RequireSession route even if a client |
| +// somehow kept presenting the old cookie value. |
| +func TestSignOutEndsTheSession(t *testing.T) { |
| + app, err := docs.NewForTest(t) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + link, err := app.TestSignIn(t, "reader@example.com") |
| + if err != nil { |
| + t.Fatalf("TestSignIn: %v", err) |
| + } |
| + verifyReq := httptest.NewRequest(http.MethodGet, link, nil) |
| + verifyRec := httptest.NewRecorder() |
| + app.Handler().ServeHTTP(verifyRec, verifyReq) |
| + cookies := verifyRec.Result().Cookies() |
| + if len(cookies) == 0 { |
| + t.Fatal("following the magic link set no session cookie") |
| + } |
| + |
| + signoutReq := httptest.NewRequest(http.MethodPost, "/signout", nil) |
| + signoutReq.Header.Set("Sec-Fetch-Site", "same-origin") |
| + for _, c := range cookies { |
| + signoutReq.AddCookie(c) |
| + } |
| + signoutRec := httptest.NewRecorder() |
| + app.Handler().ServeHTTP(signoutRec, signoutReq) |
| + if signoutRec.Code != http.StatusSeeOther { |
| + t.Fatalf("POST /signout: status %d, want %d", signoutRec.Code, http.StatusSeeOther) |
| + } |
| + |
| + req := httptest.NewRequest(http.MethodGet, "/", nil) |
| + for _, c := range cookies { |
| + req.AddCookie(c) |
| + } |
| + rec := httptest.NewRecorder() |
| + app.Handler().ServeHTTP(rec, req) |
| + if rec.Code != http.StatusSeeOther && rec.Code != http.StatusFound { |
| + t.Errorf("GET / with a signed-out session's old cookie: status %d, want a redirect to sign-in", rec.Code) |
| + } |
| +} |
| + |
| +// TestNewRefusesMissingInstanceKeyOutsideDevelopment is the same |
| +// fail-closed shape as Origin's own startup refusal (app_test.go): |
| +// InstanceKey seals auth's pending-link blob, and an empty one is |
| +// dangerous, not merely inconvenient (rauth.Config.InstanceKey's own |
| +// doc comment) — so it must refuse to start rather than default |
| +// outside development. |
| +func TestNewRefusesMissingInstanceKeyOutsideDevelopment(t *testing.T) { |
| + _, err := docs.New(docs.Config{ |
| + Env: "production", |
| + Origin: "https://docs.example.com", |
| + }) |
| + if err == nil { |
| + t.Fatal("New must refuse a missing InstanceKey outside development") |
| + } |
| +} |
| + |
| +// TestNewRefusesMissingMailOutsideDevelopment: a sign-in that cannot |
| +// send its link is not sign-in — the same fail-closed shape as the |
| +// origin check, per the brief. |
| +func TestNewRefusesMissingMailOutsideDevelopment(t *testing.T) { |
| + _, err := docs.New(docs.Config{ |
| + Env: "production", |
| + Origin: "https://docs.example.com", |
| + InstanceKey: "some-real-key", |
| + }) |
| + if err == nil { |
| + t.Fatal("New must refuse to start outside development with no mail configured") |
| + } |
| +} |
| + |
| +// TestSecondClickOfTheSameMagicLinkOffersRecovery is fix round 1 on |
| +// Task 3's own regression test: rauth.Auth's Verify handler |
| +// deliberately gives one error for "unknown, used and expired alike" |
| +// (its own doc comment), which means a magic link clicked a second |
| +// time — the ordinary case of someone opening an old sign-in email — |
| +// lands on the exact same ?err=expired outcome as a link that was |
| +// never valid at all. Confirmed directly against this app's own |
| +// router, not assumed: the first click consumes the link and sets a |
| +// session; the second click, with no cookie attached (a fresh tab, or |
| +// the same old email opened again after the first session already |
| +// expired), must redirect back to /signin with the specific "expired |
| +// or already used... enter your email for a new one" copy — never a |
| +// bare, uninformative dead end. |
| +func TestSecondClickOfTheSameMagicLinkOffersRecovery(t *testing.T) { |
| + app, err := docs.NewForTest(t) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + link, err := app.TestSignIn(t, "clicktwice@example.com") |
| + if err != nil { |
| + t.Fatalf("TestSignIn: %v", err) |
| + } |
| + |
| + firstReq := httptest.NewRequest(http.MethodGet, link, nil) |
| + firstRec := httptest.NewRecorder() |
| + app.Handler().ServeHTTP(firstRec, firstReq) |
| + if len(firstRec.Result().Cookies()) == 0 { |
| + t.Fatal("first click set no session cookie") |
| + } |
| + |
| + secondReq := httptest.NewRequest(http.MethodGet, link, nil) |
| + secondRec := httptest.NewRecorder() |
| + app.Handler().ServeHTTP(secondRec, secondReq) |
| + loc := secondRec.Header().Get("Location") |
| + if !strings.Contains(loc, "err=expired") { |
| + t.Fatalf("second click redirected to %q, want an err=expired outcome", loc) |
| + } |
| + |
| + signinReq := httptest.NewRequest(http.MethodGet, loc, nil) |
| + signinRec := httptest.NewRecorder() |
| + app.Handler().ServeHTTP(signinRec, signinReq) |
| + // NewForTest's app.Handler() never wires rastrillo.Options.BaseCatalog/ |
| + // Locales — only cmd/docs/main.go does, for real serving — so |
| + // rastrillo.T falls back to returning its key verbatim here |
| + // (render.go's own doc comment on Locales: "documented, harmless"). |
| + // That makes the raw catalog key the correct, precise thing to |
| + // assert on in this handler-level test: it pins exactly which |
| + // branch of signin's switch ran, without depending on the English |
| + // copy's exact wording. |
| + body := signinRec.Body.String() |
| + if !strings.Contains(body, "docs.signin.error.expired") { |
| + t.Errorf("signin page after a second click did not resolve docs.signin.error.expired; body: %s", body) |
| + } |
| + if strings.Contains(body, "docs.error.generic") { |
| + t.Errorf("signin page after a second click fell back to the app-wide docs.error.generic instead of the sign-in-specific expired copy") |
| + } |
| +} |
| + |
| +// TestSigninUnrecognizedErrorUsesSigninSpecificFallback is fix round |
| +// 1 on Task 3's own finding: GET /signin's switch on ?err= had a |
| +// default: branch falling back to docs.error.generic ("Something went |
| +// wrong.") — the app-wide string written for a render failure with |
| +// nowhere styled to land, not for this page's own ordinary outcomes. |
| +// ?err=keymail (a failed keymail callback, rastrillo/auth's own |
| +// Callback handler) and ?err=1 (a raw Begin failure, logged |
| +// server-side) both reach that default branch in real use — this |
| +// pins both directly against the handler, without depending on |
| +// rastrillo internals staying exactly as they are today, and checks |
| +// the fallback is sign-in-specific rather than the app-wide string. |
| +func TestSigninUnrecognizedErrorUsesSigninSpecificFallback(t *testing.T) { |
| + app, err := docs.NewForTest(t) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + // See TestSecondClickOfTheSameMagicLinkOffersRecovery's own comment |
| + // on why the raw catalog key, not the resolved English copy, is |
| + // what this handler-level test can observe and correctly should |
| + // assert on. |
| + for _, errParam := range []string{"keymail", "1", "some-future-value-nobody-has-written-a-case-for-yet"} { |
| + req := httptest.NewRequest(http.MethodGet, "/signin?err="+errParam, nil) |
| + rec := httptest.NewRecorder() |
| + app.Handler().ServeHTTP(rec, req) |
| + body := rec.Body.String() |
| + if !strings.Contains(body, "docs.signin.error.unknown") { |
| + t.Errorf("err=%q: signin page did not resolve docs.signin.error.unknown; body: %s", errParam, body) |
| + } |
| + if strings.Contains(body, "docs.error.generic") { |
| + t.Errorf("err=%q: signin page fell back to the app-wide docs.error.generic instead of the sign-in-specific fallback", errParam) |
| + } |
| + } |
| +} |
diff --git a/internal/docs/devauth.go b/internal/docs/devauth.go| deleted file mode 100644 |
| index 65a6694..0000000 |
| --- a/internal/docs/devauth.go |
| +++ /dev/null |
| @@ -1,81 +0,0 @@ |
| -// This file exists only because spec §8.1's home — the shared |
| -// credential origin that issues a signed identity assertion — does |
| -// not exist yet; it hands every request one fixed development |
| -// identity purely so the app is reachable in a browser, and it is |
| -// deleted the day this app verifies a real signed assertion instead. |
| -package docs |
| - |
| -import ( |
| - "context" |
| - "fmt" |
| - "net/http" |
| -) |
| - |
| -// devSubject is the one identity devauth ever grants. Fixed rather |
| -// than configurable: a real identity is exactly what this stands in |
| -// for, so giving it knobs would just be building a worse version of |
| -// the thing it replaces. |
| -const devSubject = "dev" |
| - |
| -// DevIdentity is what devAuthMiddleware attaches to a request's |
| -// context — the shape spec §8.1's verified assertion will eventually |
| -// fill in for real. |
| -type DevIdentity struct { |
| - Subject string |
| -} |
| - |
| -type devIdentityKey struct{} |
| - |
| -// CurrentDevIdentity returns the identity devAuthMiddleware attached |
| -// to r, if any. Nothing in this app requires a signed-in viewer yet, |
| -// so no handler calls this today — it exists so a later task can ask |
| -// "is anyone signed in" without reaching into this file's internals, |
| -// and it goes away with the rest of this file once §8.1 lands. |
| -func CurrentDevIdentity(r *http.Request) (DevIdentity, bool) { |
| - id, ok := r.Context().Value(devIdentityKey{}).(DevIdentity) |
| - return id, ok |
| -} |
| - |
| -// devAuthMiddleware stamps every request with the fixed development |
| -// identity. New wires this in only after guardDevAuth has approved |
| -// cfg — see New and NewWithDevAuth below. |
| -func devAuthMiddleware(next http.Handler) http.Handler { |
| - return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { |
| - ctx := context.WithValue(r.Context(), devIdentityKey{}, DevIdentity{Subject: devSubject}) |
| - next.ServeHTTP(w, r.WithContext(ctx)) |
| - }) |
| -} |
| - |
| -// guardDevAuth is the single choke point every path to the |
| -// development identity must pass through — New calls it directly, so |
| -// it applies whether an app is built via NewWithDevAuth, via New |
| -// itself with DevAuth set by hand, or via NewForTest. |
| -// |
| -// Fail closed: cfg.DevAuth false is always fine (there is nothing to |
| -// guard against), but cfg.DevAuth true demands cfg.Env be exactly |
| -// "development" — no allowlist of "development-like" strings to fall |
| -// out of, so an unrecognised value, a typo, or empty (New defaults "" |
| -// to "production" before this runs) all refuse identically to a |
| -// deliberate "production". There is exactly one string that is ever |
| -// permitted. |
| -func guardDevAuth(cfg Config) error { |
| - if !cfg.DevAuth { |
| - return nil |
| - } |
| - if cfg.Env != "development" { |
| - return fmt.Errorf("docs: DevAuth requires Env %q to start development-only, insecure identity; got Env %q — refusing to start", "development", cfg.Env) |
| - } |
| - return nil |
| -} |
| - |
| -// NewWithDevAuth is New, named for the one call site that means to |
| -// opt into the development-only identity: cmd/docs/main.go's explicit |
| -// -dev-auth flag. It is not a second code path — it forces |
| -// cfg.DevAuth true and calls New unchanged, so the guard that |
| -// actually enforces "development only" (guardDevAuth, run inside New) |
| -// lives in exactly one place no matter which name a caller reaches it |
| -// through. |
| -func NewWithDevAuth(cfg Config) (*App, error) { |
| - cfg.DevAuth = true |
| - return New(cfg) |
| -} |
diff --git a/internal/docs/devauth_test.go b/internal/docs/devauth_test.go| deleted file mode 100644 |
| index 0d30ca4..0000000 |
| --- a/internal/docs/devauth_test.go |
| +++ /dev/null |
| @@ -1,80 +0,0 @@ |
| -package docs_test |
| - |
| -import ( |
| - "net/http" |
| - "net/http/httptest" |
| - "strings" |
| - "testing" |
| - |
| - "github.com/carlosframework/docs/internal/docs" |
| -) |
| - |
| -// TestDevAuthRefusesOutsideDevelopment is the plan's own literal test: |
| -// the guard is the point of this task — a build without the dev flag |
| -// must refuse to start rather than silently authenticating everyone. |
| -func TestDevAuthRefusesOutsideDevelopment(t *testing.T) { |
| - _, err := docs.NewWithDevAuth(docs.Config{DevAuth: true, Env: "production"}) |
| - if err == nil { |
| - t.Fatal("dev auth must refuse to start outside development") |
| - } |
| - if !strings.Contains(err.Error(), "development") { |
| - t.Errorf("error should say why: %v", err) |
| - } |
| -} |
| - |
| -// TestDevAuthGivesAnIdentityInDevelopment is the plan's own literal |
| -// test: NewForTest fixes Env at "development" with DevAuth enabled, so |
| -// a request must not come back unauthorized. |
| -func TestDevAuthGivesAnIdentityInDevelopment(t *testing.T) { |
| - app, err := docs.NewForTest(t) // NewForTest sets Env: development |
| - if err != nil { |
| - t.Fatal(err) |
| - } |
| - req := httptest.NewRequest(http.MethodGet, "/", nil) |
| - rec := httptest.NewRecorder() |
| - app.Handler().ServeHTTP(rec, req) |
| - if rec.Code == http.StatusUnauthorized { |
| - t.Error("dev auth should provide an identity in development") |
| - } |
| -} |
| - |
| -// TestDevAuthRefusesEmptyEnv proves the fail-closed default: an empty |
| -// Env must not be treated as "development-like". New defaults "" to |
| -// "production" before the guard runs, so this exercises that the |
| -// default itself is refused, not just an explicit "production". |
| -func TestDevAuthRefusesEmptyEnv(t *testing.T) { |
| - _, err := docs.NewWithDevAuth(docs.Config{DevAuth: true, Env: ""}) |
| - if err == nil { |
| - t.Fatal("dev auth must refuse to start with an empty Env") |
| - } |
| - if !strings.Contains(err.Error(), "development") { |
| - t.Errorf("error should say why: %v", err) |
| - } |
| -} |
| - |
| -// TestDevAuthRefusesTypoedEnv proves there is no allowlist to fall out |
| -// of loosely — only the exact string "development" is ever permitted, |
| -// so a near-miss (a plausible env-var typo) refuses exactly like a |
| -// deliberate "production" rather than being treated as close enough. |
| -func TestDevAuthRefusesTypoedEnv(t *testing.T) { |
| - _, err := docs.NewWithDevAuth(docs.Config{DevAuth: true, Env: "developement"}) |
| - if err == nil { |
| - t.Fatal("dev auth must refuse to start with a typo'd Env") |
| - } |
| - if !strings.Contains(err.Error(), "development") { |
| - t.Errorf("error should say why: %v", err) |
| - } |
| -} |
| - |
| -// TestDevAuthRefusesViaNewDirectly proves the guard is a single choke |
| -// point: calling New itself with DevAuth set by hand, bypassing |
| -// NewWithDevAuth entirely, refuses exactly the same way. |
| -func TestDevAuthRefusesViaNewDirectly(t *testing.T) { |
| - _, err := docs.New(docs.Config{DevAuth: true, Env: "production"}) |
| - if err == nil { |
| - t.Fatal("New itself must refuse DevAuth outside development, not just NewWithDevAuth") |
| - } |
| - if !strings.Contains(err.Error(), "development") { |
| - t.Errorf("error should say why: %v", err) |
| - } |
| -} |
diff --git a/internal/docs/handlers.go b/internal/docs/handlers.go| index 839be23..c0b46c7 100644 |
| --- a/internal/docs/handlers.go |
| +++ b/internal/docs/handlers.go |
| @@ -10,6 +10,7 @@ import ( |
| "gorm.io/gorm" |
| "github.com/carlosframework/rastrillo" |
| + rauth "github.com/carlosframework/rastrillo/auth" |
| ) |
| // handlers holds what the app's HTTP handlers need — rastrillo |
| @@ -32,6 +33,15 @@ type handlers struct { |
| // reaches a person with no documents yet exactly as it reaches |
| // everyone else (fix round 1: the original version of this screen |
| // had no path to POST /documents at all outside a hand-typed curl). |
| +// |
| +// SignOutLabel backs the sign-out control list.html's header also |
| +// carries — added in the fix round that deleted the development-only |
| +// stand-in identity: POST /signout (auth.go) always existed and |
| +// worked, but nothing on any page linked to it, which matters once |
| +// this is reachable at a real URL rather than only ever behind a |
| +// laptop's own access. A session lasts 30 days |
| +// (rastrillo/auth.DefaultSessionTTL); a page with no way to end one |
| +// on purpose is a real gap on something public, not a cosmetic one. |
| type listPage struct { |
| Title string |
| Groups []docGroup |
| @@ -39,6 +49,7 @@ type listPage struct { |
| EmptyBody string |
| CreateLabel string |
| CreatePlaceholder string |
| + SignOutLabel string |
| } |
| // docGroup is one recency bucket ("Today", "This week", ...) and the |
| @@ -57,6 +68,23 @@ type docRow struct { |
| When string |
| } |
| +// owner resolves the signed-in person's Store.Person id for r — |
| +// deliberately auth.From(r).Address, never sessions.UserID: under the |
| +// magic-link plugin the session Subject is the verified email itself |
| +// (rastrillo SKILL.md §5), so UserID's ParseInt would fail and, worse, |
| +// a version of this code that dropped its ok return would silently |
| +// scope every query to person 0. Every handler below is mounted only |
| +// inside app.go's RequireSession group, so From always finds an |
| +// identity here in practice; the error return exists so a handler |
| +// answers 500 rather than panicking if that ever stops being true. |
| +func (h *handlers) owner(r *http.Request) (int64, error) { |
| + id, ok := rauth.From(r) |
| + if !ok { |
| + return 0, errors.New("docs: no signed-in identity on request") |
| + } |
| + return h.store.EnsurePerson(r.Context(), id.Address) |
| +} |
| + |
| // list serves GET /: the document list, spec §6.4 and Paul's four |
| // rulings against it — newest first, grouped by recency, one row per |
| // document. Cut, deliberately, not by oversight: no template |
| @@ -67,11 +95,20 @@ type docRow struct { |
| // engine renders there — reserved, not faked: today it is an empty, |
| // bordered box. |
| // |
| -// A row shows title and when it was last touched, not who: Document |
| -// carries no author column yet (Task 5 adds identity), and rendering |
| -// a "who" today would mean inventing one. |
| +// A row shows title and when it was last touched, not who: List is |
| +// already scoped to the signed-in viewer's own documents (Store.List's |
| +// owner parameter), so every row's "who" is the same person reading |
| +// the page — rendering it would be inventing display data, not |
| +// showing real ownership. |
| func (h *handlers) list(w http.ResponseWriter, r *http.Request) { |
| - docs, err := h.store.List(r.Context()) |
| + owner, err := h.owner(r) |
| + if err != nil { |
| + h.logger.Error("docs: resolve signed-in person", "err", err) |
| + http.Error(w, rastrillo.T(r, "docs.error.generic"), http.StatusInternalServerError) |
| + return |
| + } |
| + |
| + docs, err := h.store.List(r.Context(), owner) |
| if err != nil { |
| h.logger.Error("docs: list documents", "err", err) |
| http.Error(w, rastrillo.T(r, "docs.error.generic"), http.StatusInternalServerError) |
| @@ -85,6 +122,7 @@ func (h *handlers) list(w http.ResponseWriter, r *http.Request) { |
| EmptyBody: rastrillo.T(r, "docs.list.empty.body"), |
| CreateLabel: rastrillo.T(r, "docs.list.create.label"), |
| CreatePlaceholder: rastrillo.T(r, "docs.list.create.placeholder"), |
| + SignOutLabel: rastrillo.T(r, "docs.signout.label"), |
| } |
| if err := h.render.page(w, r, "list", data); err != nil { |
| h.logger.Error("docs: render list", "err", err) |
| @@ -102,12 +140,19 @@ func (h *handlers) list(w http.ResponseWriter, r *http.Request) { |
| // navigation, replays the GET the redirect points at, never the POST |
| // that created the document. |
| func (h *handlers) create(w http.ResponseWriter, r *http.Request) { |
| + owner, err := h.owner(r) |
| + if err != nil { |
| + h.logger.Error("docs: resolve signed-in person", "err", err) |
| + http.Error(w, rastrillo.T(r, "docs.error.generic"), http.StatusInternalServerError) |
| + return |
| + } |
| + |
| title := strings.TrimSpace(r.PostFormValue("title")) |
| if title == "" { |
| title = rastrillo.T(r, "docs.document.untitled") |
| } |
| - doc, err := h.store.Create(r.Context(), title) |
| + doc, err := h.store.Create(r.Context(), owner, title) |
| if err != nil { |
| h.logger.Error("docs: create document", "err", err) |
| http.Error(w, rastrillo.T(r, "docs.error.generic"), http.StatusInternalServerError) |
| @@ -124,22 +169,30 @@ func (h *handlers) create(w http.ResponseWriter, r *http.Request) { |
| // replays to "", which views/show.html must render as an ordinary |
| // empty canvas rather than erroring. |
| type showPage struct { |
| - Title string |
| - Text string |
| + Title string |
| + Text string |
| + SignOutLabel string // see listPage.SignOutLabel's own doc comment |
| } |
| // show serves GET /d/{sqid}: the document read-only, its title and |
| // its CRDT text inside a page-shaped canvas (views/show.html). An |
| -// unknown or malformed sqid 404s — spec §4.2's contract that a |
| -// document you cannot see is indistinguishable from one that does not |
| -// exist. There is no signed-in viewer yet (identity is Task 5), so |
| -// today "cannot see" only ever means "no such row"; the 404 is the |
| -// same either way, and nothing here will need to change once scoping |
| -// lands — it only gains another way to reach this same branch. |
| +// unknown sqid and a sqid someone else owns 404 identically — spec |
| +// §4.2's contract that a document you cannot see is indistinguishable |
| +// from one that does not exist — because Store.Get's owner-scoped |
| +// query never distinguishes "no such row" from "not yours" in the |
| +// first place; there is no separate check here that could 403 by |
| +// mistake. |
| func (h *handlers) show(w http.ResponseWriter, r *http.Request) { |
| sqid := r.PathValue("sqid") |
| - doc, err := h.store.Get(r.Context(), sqid) |
| + owner, err := h.owner(r) |
| + if err != nil { |
| + h.logger.Error("docs: resolve signed-in person", "err", err) |
| + http.Error(w, rastrillo.T(r, "docs.error.generic"), http.StatusInternalServerError) |
| + return |
| + } |
| + |
| + doc, err := h.store.Get(r.Context(), owner, sqid) |
| if err != nil { |
| if errors.Is(err, gorm.ErrRecordNotFound) { |
| http.Error(w, rastrillo.T(r, "docs.error.notfound"), http.StatusNotFound) |
| @@ -157,7 +210,7 @@ func (h *handlers) show(w http.ResponseWriter, r *http.Request) { |
| return |
| } |
| - data := showPage{Title: doc.Title, Text: text} |
| + data := showPage{Title: doc.Title, Text: text, SignOutLabel: rastrillo.T(r, "docs.signout.label")} |
| if err := h.render.page(w, r, "show", data); err != nil { |
| h.logger.Error("docs: render show", "err", err) |
| http.Error(w, rastrillo.T(r, "docs.error.generic"), http.StatusInternalServerError) |
| @@ -202,10 +255,11 @@ func groupByRecency(r *http.Request, now time.Time, docs []*Document) []docGroup |
| // recencyBucket returns a stable bucket key and the catalog key for |
| // its label, comparing calendar days in UTC — Document.CreatedAt and |
| // UpdatedAt are both stored in UTC (Store.Create), and there is no |
| -// viewer time zone to convert to yet, since there is no signed-in |
| -// viewer (Task 5). time.Truncate(24*time.Hour) lines up with the |
| -// calendar day specifically because both arguments are UTC: the day |
| -// never shifts under DST the way it can in a zone that observes it. |
| +// per-viewer time zone to convert to yet (a signed-in viewer exists |
| +// now, but nothing stores their zone). time.Truncate(24*time.Hour) |
| +// lines up with the calendar day specifically because both arguments |
| +// are UTC: the day never shifts under DST the way it can in a zone |
| +// that observes it. |
| func recencyBucket(now, t time.Time) (key, labelKey string) { |
| days := int(now.Truncate(24*time.Hour).Sub(t.Truncate(24*time.Hour)).Hours() / 24) |
| switch { |
diff --git a/internal/docs/list_test.go b/internal/docs/list_test.go| index 9236f21..a047183 100644 |
| --- a/internal/docs/list_test.go |
| +++ b/internal/docs/list_test.go |
| @@ -30,14 +30,42 @@ var shellClassVocab = map[string]bool{ |
| "page": true, |
| } |
| +// signInCookies drives a real sign-in through app.TestSignIn |
| +// (docs.App, testing.go) and the magic link it mints, returning the |
| +// cookies that established session sets — every document route now |
| +// sits behind rastrillo/auth's RequireSession (auth.go), so any test |
| +// below that exercises "/", "/documents" or "/d/{sqid}" needs a |
| +// signed-in request to reach the handler at all, not just a |
| +// same-origin one. |
| +func signInCookies(t *testing.T, app *docs.App, address string) []*http.Cookie { |
| + t.Helper() |
| + link, err := app.TestSignIn(t, address) |
| + if err != nil { |
| + t.Fatalf("TestSignIn: %v", err) |
| + } |
| + req := httptest.NewRequest(http.MethodGet, link, nil) |
| + rec := httptest.NewRecorder() |
| + app.Handler().ServeHTTP(rec, req) |
| + cookies := rec.Result().Cookies() |
| + if len(cookies) == 0 { |
| + t.Fatal("following the magic link set no session cookie") |
| + } |
| + return cookies |
| +} |
| + |
| func TestListShowsDocuments(t *testing.T) { |
| app, _ := docs.NewForTest(t) |
| ctx := context.Background() |
| - if _, err := app.Store().Create(ctx, "Board minutes"); err != nil { |
| + owner := app.TestPerson(t, "reader@example.com") |
| + if _, err := app.Store().Create(ctx, owner, "Board minutes"); err != nil { |
| t.Fatal(err) |
| } |
| + cookies := signInCookies(t, app, "reader@example.com") |
| req := httptest.NewRequest(http.MethodGet, "/", nil) |
| + for _, c := range cookies { |
| + req.AddCookie(c) |
| + } |
| rec := httptest.NewRecorder() |
| app.Handler().ServeHTTP(rec, req) |
| @@ -57,7 +85,11 @@ func TestListShowsDocuments(t *testing.T) { |
| func TestEmptyListSaysSo(t *testing.T) { |
| app, _ := docs.NewForTest(t) |
| + cookies := signInCookies(t, app, "reader@example.com") |
| req := httptest.NewRequest(http.MethodGet, "/", nil) |
| + for _, c := range cookies { |
| + req.AddCookie(c) |
| + } |
| rec := httptest.NewRecorder() |
| app.Handler().ServeHTTP(rec, req) |
| if rec.Code != http.StatusOK { |
| @@ -81,11 +113,15 @@ func TestEmptyListSaysSo(t *testing.T) { |
| // protection. |
| func TestCreateThenRead(t *testing.T) { |
| app, _ := docs.NewForTest(t) |
| + cookies := signInCookies(t, app, "reader@example.com") |
| form := strings.NewReader("title=Minutes") |
| req := httptest.NewRequest(http.MethodPost, "/documents", form) |
| req.Header.Set("Content-Type", "application/x-www-form-urlencoded") |
| req.Header.Set("Origin", docs.DefaultOrigin) |
| + for _, c := range cookies { |
| + req.AddCookie(c) |
| + } |
| rec := httptest.NewRecorder() |
| app.Handler().ServeHTTP(rec, req) |
| @@ -98,6 +134,9 @@ func TestCreateThenRead(t *testing.T) { |
| } |
| req2 := httptest.NewRequest(http.MethodGet, loc, nil) |
| + for _, c := range cookies { |
| + req2.AddCookie(c) |
| + } |
| rec2 := httptest.NewRecorder() |
| app.Handler().ServeHTTP(rec2, req2) |
| if rec2.Code != http.StatusOK { |
| @@ -129,7 +168,14 @@ func TestCreateWrongOriginRejected(t *testing.T) { |
| // And confirm nothing was actually created — the refusal is real, |
| // not just a wrong status code on an otherwise-successful write. |
| - rows, err := app.Store().List(context.Background()) |
| + // csrf.Protect refuses this request before RequireSession or |
| + // h.owner ever run (app.go mounts it first), so no document could |
| + // have been created under any owner; TestPerson here only mints an |
| + // id to list against, it does not stand in for a session that was |
| + // ever actually established. |
| + ctx := context.Background() |
| + owner := app.TestPerson(t, "reader@example.com") |
| + rows, err := app.Store().List(ctx, owner) |
| if err != nil { |
| t.Fatal(err) |
| } |
| @@ -140,7 +186,11 @@ func TestCreateWrongOriginRejected(t *testing.T) { |
| func TestUnknownDocument404s(t *testing.T) { |
| app, _ := docs.NewForTest(t) |
| + cookies := signInCookies(t, app, "reader@example.com") |
| req := httptest.NewRequest(http.MethodGet, "/d/nosuchthing", nil) |
| + for _, c := range cookies { |
| + req.AddCookie(c) |
| + } |
| rec := httptest.NewRecorder() |
| app.Handler().ServeHTTP(rec, req) |
| if rec.Code != http.StatusNotFound { |
diff --git a/internal/docs/migrations/0002_owners.sql b/internal/docs/migrations/0002_owners.sql| new file mode 100644 |
| index 0000000..b8c60af |
| --- /dev/null |
| +++ b/internal/docs/migrations/0002_owners.sql |
| @@ -0,0 +1,21 @@ |
| +CREATE TABLE IF NOT EXISTS people ( |
| + id INTEGER PRIMARY KEY, |
| + email TEXT NOT NULL UNIQUE, |
| + created_at DATETIME NOT NULL |
| +); |
| + |
| +-- DEFAULT 0 is a deliberate decision, not a placeholder: SQLite's |
| +-- INTEGER PRIMARY KEY on people.id starts at 1, so owner_id = 0 can |
| +-- never match a real person. Any document that existed before this |
| +-- migration ran is backfilled to owner_id = 0 and becomes permanently |
| +-- invisible through Store (store.go's owned() explicitly matches |
| +-- nothing for owner <= 0, so no future caller can accidentally expose |
| +-- these rows either) -- there is no live deployment yet (this plan is |
| +-- what takes Docs live for the first time), so today that set is |
| +-- empty in practice, but the behaviour is real and permanent for |
| +-- anyone who runs this migration against a database that already has |
| +-- rows: those documents are orphaned on purpose, not lost by oversight. |
| +-- Pinned by TestOrphanedDocumentsAreVisibleToNobody in scope_test.go. |
| +ALTER TABLE documents ADD COLUMN owner_id INTEGER NOT NULL DEFAULT 0; |
| + |
| +CREATE INDEX IF NOT EXISTS idx_documents_owner_id ON documents (owner_id); |
diff --git a/internal/docs/models.go b/internal/docs/models.go| index d1447c9..ada43ba 100644 |
| --- a/internal/docs/models.go |
| +++ b/internal/docs/models.go |
| @@ -36,11 +36,21 @@ var Schema = migrate.MustFromFS(migrationFS, "docs") |
| // corrupts the document. This is the Go side of the rule clippy.toml |
| // enforces in Rust (disallowed-methods on yrs::Doc::new / |
| // Doc::with_client_id). |
| +// |
| +// OwnerID is the document's owner — Person.ID, never taken from a |
| +// request body (Store.Create's owner parameter comes from the |
| +// signed-in person; see handlers.go's owner helper and rastrillo |
| +// SKILL.md §3/§4's mass-assignment rule). Every read and write is |
| +// scoped by it through store.go's own owned function, deliberately |
| +// not rastrillo's scope.Owned — see that function's doc comment and |
| +// spec §4.2. |
| type Document struct { |
| ID int64 `gorm:"primaryKey"` |
| Sqid string `gorm:"column:sqid;uniqueIndex;not null"` |
| Title string `gorm:"column:title;not null"` |
| + OwnerID int64 `gorm:"column:owner_id;not null;index"` |
| + |
| GUID string `gorm:"column:guid;not null"` |
| ClientID uint64 `gorm:"column:client_id;not null"` |
| @@ -49,6 +59,23 @@ type Document struct { |
| DeletedAt *time.Time `gorm:"column:deleted_at"` |
| } |
| +// Person is this app's own record of someone who has signed in — |
| +// deliberately separate from rastrillo/auth's session Subject, which |
| +// under the magic-link plugin is the verified email address itself, |
| +// not a numeric id (rastrillo SKILL.md §5: "Under auth, never |
| +// sessions.UserID: the Subject is the verified email... map the |
| +// address to your user row's id before scoping"). Store.EnsurePerson |
| +// is the one place that mapping happens, get-or-create, keyed on |
| +// Email. A document's OwnerID is a Person.ID, never an email — so an |
| +// email can change (a future feature) without rewriting every |
| +// document row it ever owned. |
| +type Person struct { |
| + ID int64 `gorm:"primaryKey"` |
| + Email string `gorm:"column:email;uniqueIndex;not null"` |
| + |
| + CreatedAt time.Time `gorm:"column:created_at;not null"` |
| +} |
| + |
| // DocUpdate is one Yjs update in a document's log, applied in Seq |
| // order to rebuild the document's CRDT state (Store.replay). |
| // |
diff --git a/internal/docs/render.go b/internal/docs/render.go| index c353638..2be369e 100644 |
| --- a/internal/docs/render.go |
| +++ b/internal/docs/render.go |
| @@ -68,6 +68,35 @@ var Catalog = rastrillo.Catalog{ |
| // this app writes, not the caller's own input, so it is a catalog |
| // key rather than a hardcoded default. |
| "docs.document.untitled": "Untitled document", |
| + |
| + // docs.signin.* — GET /signin (auth.go's signin handler): the |
| + // form itself, and every outcome rastrillo/auth's own handlers |
| + // redirect back here with (?sent=1, |
| + // ?err=rate|address|expired|keymail|1). docs.signin.error.unknown |
| + // is that switch's own default: branch (fix round 1 on Task 3): |
| + // every one of these outcomes means the same operational thing on |
| + // this one page — sign-in did not complete, ask for a new link — |
| + // so its fallback must not borrow docs.error.generic ("Something |
| + // went wrong."), an app-wide string written for a render failure |
| + // with nowhere else to land, not for this page's own normal, |
| + // recoverable outcomes. See auth.go's signin handler for why no |
| + // finer distinction between ?err=keymail and ?err=1 is drawn |
| + // either (rastrillo/auth's own handlers already refuse to be an |
| + // oracle between "wrong" and "unknown"). |
| + "docs.signin.title": "Sign in", |
| + "docs.signin.body": "Enter your email address and we'll send you a link to sign in.", |
| + "docs.signin.address.label": "Email address", |
| + "docs.signin.submit": "Send link", |
| + "docs.signin.sent": "Check your email for a link to sign in.", |
| + "docs.signin.error.rate": "Too many attempts. Wait a few minutes and try again.", |
| + "docs.signin.error.address": "That doesn't look like a valid email address.", |
| + "docs.signin.error.expired": "That link has expired or was already used. Enter your email below for a new one.", |
| + "docs.signin.error.unknown": "We couldn't sign you in. Enter your email below for a new link.", |
| + |
| + // docs.signout.label is the sign-out control's text (list.html's |
| + // and show.html's page headers) — both its button label and its |
| + // accessible name. |
| + "docs.signout.label": "Sign out", |
| } |
| // Locales and DefaultLocale declare this app's locale set for |
| @@ -92,8 +121,9 @@ var viewsFS embed.FS |
| // pageTemplates maps a page name to its file under views/. |
| var pageTemplates = map[string]string{ |
| - "list": "views/list.html", |
| - "show": "views/show.html", |
| + "list": "views/list.html", |
| + "show": "views/show.html", |
| + "signin": "views/signin.html", |
| } |
| // renderer owns the app's parsed templates and its fingerprinted |
diff --git a/internal/docs/scope_test.go b/internal/docs/scope_test.go| new file mode 100644 |
| index 0000000..cb5377a |
| --- /dev/null |
| +++ b/internal/docs/scope_test.go |
| @@ -0,0 +1,216 @@ |
| +package docs |
| + |
| +import ( |
| + "context" |
| + "testing" |
| + "time" |
| +) |
| + |
| +// TestOwnedScopesByOwner tests store.go's owned function directly — |
| +// the plan's own requirement (Task 2: "Write the scoping as one |
| +// function that every query goes through, and test that function |
| +// directly"), not only indirectly through Store.List/Store.Get, which |
| +// would prove the seam is wired in but not pin what the seam itself |
| +// actually does. Deliberately not a test of rastrillo's scope.Owned |
| +// (which this app does not use — see owned's own doc comment and |
| +// spec §4.2): this exercises Docs' own function, in this package, so |
| +// the day it grows a grants join the test above is what has to keep |
| +// passing. |
| +func TestOwnedScopesByOwner(t *testing.T) { |
| + app, err := NewForTest(t) |
| + if err != nil { |
| + t.Fatalf("NewForTest: %v", err) |
| + } |
| + ctx := context.Background() |
| + store := app.Store() |
| + |
| + alice, err := store.EnsurePerson(ctx, "alice@example.com") |
| + if err != nil { |
| + t.Fatalf("EnsurePerson(alice): %v", err) |
| + } |
| + bob, err := store.EnsurePerson(ctx, "bob@example.com") |
| + if err != nil { |
| + t.Fatalf("EnsurePerson(bob): %v", err) |
| + } |
| + |
| + doc, err := store.Create(ctx, alice, "Alice's document") |
| + if err != nil { |
| + t.Fatalf("Create: %v", err) |
| + } |
| + |
| + var aliceRows []*Document |
| + if err := owned(store.db.WithContext(ctx), alice).Find(&aliceRows).Error; err != nil { |
| + t.Fatalf("owned(alice): %v", err) |
| + } |
| + if len(aliceRows) != 1 || aliceRows[0].ID != doc.ID { |
| + t.Errorf("owned(alice) = %+v, want exactly [%d]", aliceRows, doc.ID) |
| + } |
| + |
| + var bobRows []*Document |
| + if err := owned(store.db.WithContext(ctx), bob).Find(&bobRows).Error; err != nil { |
| + t.Fatalf("owned(bob): %v", err) |
| + } |
| + if len(bobRows) != 0 { |
| + t.Errorf("owned(bob) returned %d row(s), want 0 — bob does not own alice's document", len(bobRows)) |
| + } |
| + |
| + // Owner 0 is scoped to nothing — not an unused id that merely |
| + // never happens to match a row (999999 would prove that, but is a |
| + // value that never existed), but the one real value |
| + // 0002_owners.sql's migration actually writes onto every |
| + // pre-ownership document, and the one owned() refuses |
| + // structurally regardless of what the table holds (fix round 1, |
| + // Finding 2). TestOwnedRejectsOwnerZero and |
| + // TestOrphanedDocumentsAreVisibleToNobody below cover the same |
| + // refusal against a row that genuinely has owner_id = 0; this |
| + // assertion holds even with no such row present at all. |
| + var noRows []*Document |
| + if err := owned(store.db.WithContext(ctx), 0).Find(&noRows).Error; err != nil { |
| + t.Fatalf("owned(0): %v", err) |
| + } |
| + if len(noRows) != 0 { |
| + t.Errorf("owned(0) returned %d row(s), want 0", len(noRows)) |
| + } |
| +} |
| + |
| +// TestOwnedRejectsOwnerZero is fix round 1's own regression test |
| +// (Finding 2): a reviewer found owned(g, 0), Store.List(ctx, 0) and |
| +// Store.Get(ctx, 0, ...) all happily returned a row whose owner_id is |
| +// 0 — exactly what 0002_owners.sql's DEFAULT 0 backfill produces for |
| +// a document that predates ownership (see that migration's own |
| +// comment). This inserts such a row directly, bypassing Store.Create |
| +// (which only ever receives a real Person id from its caller), to |
| +// reproduce that exact shape, then confirms owned(g, 0) still matches |
| +// nothing even though a matching row genuinely exists. Negative |
| +// owners are checked too — the same "not a real person" reasoning, |
| +// not a special case for the literal value 0. |
| +func TestOwnedRejectsOwnerZero(t *testing.T) { |
| + app, err := NewForTest(t) |
| + if err != nil { |
| + t.Fatalf("NewForTest: %v", err) |
| + } |
| + ctx := context.Background() |
| + store := app.Store() |
| + |
| + now := time.Now().UTC() |
| + orphan := &Document{ |
| + Sqid: "orphanzero1", |
| + Title: "Pre-ownership document", |
| + OwnerID: 0, |
| + GUID: "orphan-guid-zero", |
| + ClientID: 1, |
| + CreatedAt: now, |
| + UpdatedAt: now, |
| + } |
| + if err := store.db.WithContext(ctx).Create(orphan).Error; err != nil { |
| + t.Fatalf("insert orphaned document: %v", err) |
| + } |
| + |
| + var zeroRows []*Document |
| + if err := owned(store.db.WithContext(ctx), 0).Find(&zeroRows).Error; err != nil { |
| + t.Fatalf("owned(0): %v", err) |
| + } |
| + if len(zeroRows) != 0 { |
| + t.Errorf("owned(0) returned %d row(s), want 0 — owner 0 is never a real person, even when a row with owner_id = 0 exists", len(zeroRows)) |
| + } |
| + |
| + var negRows []*Document |
| + if err := owned(store.db.WithContext(ctx), -1).Find(&negRows).Error; err != nil { |
| + t.Fatalf("owned(-1): %v", err) |
| + } |
| + if len(negRows) != 0 { |
| + t.Errorf("owned(-1) returned %d row(s), want 0", len(negRows)) |
| + } |
| +} |
| + |
| +// TestOrphanedDocumentsAreVisibleToNobody is fix round 1's own |
| +// regression test (Finding 3): 0002_owners.sql's DEFAULT 0 backfill |
| +// is a deliberate decision — a document that existed before ownership |
| +// did becomes permanently invisible — but that property was |
| +// previously asserted nowhere. This pins it through the same Store |
| +// API a real request uses (List and Get), not just owned() directly: |
| +// an orphaned document must not appear in anyone's list, and must not |
| +// be fetchable by anyone via its sqid, exactly as if it did not |
| +// exist — spec §4.2's own contract, applied to the migration's own |
| +// backfill rather than only to a document someone else owns. |
| +func TestOrphanedDocumentsAreVisibleToNobody(t *testing.T) { |
| + app, err := NewForTest(t) |
| + if err != nil { |
| + t.Fatalf("NewForTest: %v", err) |
| + } |
| + ctx := context.Background() |
| + store := app.Store() |
| + |
| + now := time.Now().UTC() |
| + orphan := &Document{ |
| + Sqid: "orphanzero2", |
| + Title: "Another pre-ownership document", |
| + OwnerID: 0, |
| + GUID: "orphan-guid-visible", |
| + ClientID: 2, |
| + CreatedAt: now, |
| + UpdatedAt: now, |
| + } |
| + if err := store.db.WithContext(ctx).Create(orphan).Error; err != nil { |
| + t.Fatalf("insert orphaned document: %v", err) |
| + } |
| + |
| + alice, err := store.EnsurePerson(ctx, "alice-orphan-check@example.com") |
| + if err != nil { |
| + t.Fatalf("EnsurePerson(alice): %v", err) |
| + } |
| + bob, err := store.EnsurePerson(ctx, "bob-orphan-check@example.com") |
| + if err != nil { |
| + t.Fatalf("EnsurePerson(bob): %v", err) |
| + } |
| + |
| + for _, owner := range []int64{alice, bob} { |
| + list, err := store.List(ctx, owner) |
| + if err != nil { |
| + t.Fatalf("List(%d): %v", owner, err) |
| + } |
| + for _, d := range list { |
| + if d.Sqid == orphan.Sqid { |
| + t.Errorf("owner %d's list contains the orphaned document %q", owner, orphan.Sqid) |
| + } |
| + } |
| + |
| + if _, err := store.Get(ctx, owner, orphan.Sqid); err == nil { |
| + t.Errorf("owner %d fetched the orphaned document %q", owner, orphan.Sqid) |
| + } |
| + } |
| + |
| + // And owner 0 itself — the value the row actually carries — must |
| + // not see it either; owned() refuses it outright rather than |
| + // treating it as "the orphan's own owner." |
| + if _, err := store.Get(ctx, 0, orphan.Sqid); err == nil { |
| + t.Error("owner 0 fetched the orphaned document — owned() must refuse owner 0 unconditionally") |
| + } |
| +} |
| + |
| +// TestEnsurePersonIsStableAndCaseInsensitive pins EnsurePerson's own |
| +// contract, since it is what turns a session's verified address into |
| +// the owner id every scoped query above keys on: the same address |
| +// (however it is cased) must always resolve to the same Person id, or |
| +// two requests from the same person could silently own different |
| +// libraries. |
| +func TestEnsurePersonIsStableAndCaseInsensitive(t *testing.T) { |
| + app, err := NewForTest(t) |
| + if err != nil { |
| + t.Fatalf("NewForTest: %v", err) |
| + } |
| + ctx := context.Background() |
| + store := app.Store() |
| + |
| + first, err := store.EnsurePerson(ctx, "Alice@Example.com") |
| + if err != nil { |
| + t.Fatalf("EnsurePerson: %v", err) |
| + } |
| + second, err := store.EnsurePerson(ctx, " alice@example.com ") |
| + if err != nil { |
| + t.Fatalf("EnsurePerson (again): %v", err) |
| + } |
| + if first != second { |
| + t.Errorf("EnsurePerson(%q) = %d, EnsurePerson(%q) = %d, want the same id", "Alice@Example.com", first, " alice@example.com ", second) |
| + } |
| +} |
diff --git a/internal/docs/seed.go b/internal/docs/seed.go| new file mode 100644 |
| index 0000000..fd608af |
| --- /dev/null |
| +++ b/internal/docs/seed.go |
| @@ -0,0 +1,107 @@ |
| +package docs |
| + |
| +import ( |
| + "context" |
| + "embed" |
| + "fmt" |
| + "time" |
| +) |
| + |
| +// seedFS embeds this app's own sample-document source. Both halves of |
| +// each document live here: the plain-text .txt a person edits, and the |
| +// matching .update.bin a real Yjs update built from it — see |
| +// fixtures/generate-seed.mjs, the script that turns one into the |
| +// other. Only the .bin half is ever applied to a document (Seed, |
| +// below); the .txt half is embedded so a test can hold the two |
| +// honest against each other (seed_test.go's |
| +// TestSeedContentMatchesItsSource) rather than trusting that an |
| +// edited .txt and a stale, un-regenerated .bin still agree. |
| +// |
| +//go:embed seeddata/*.txt seeddata/*.update.bin |
| +var seedFS embed.FS |
| + |
| +// seedDoc describes one sample document: Base names both |
| +// seeddata/<Base>.txt and seeddata/<Base>.update.bin, Title is the |
| +// document's row title (Store.Create's title parameter — data, not |
| +// authored UI copy, the same reasoning handlers.go's docRow.Title |
| +// doc comment gives for why a document's own title is never a |
| +// catalog key), and Ago is how far before Seed runs its timestamps |
| +// should land. |
| +type seedDoc struct { |
| + Base string |
| + Title string |
| + Ago time.Duration |
| +} |
| + |
| +// seedDocs is CARLOS Docs' own sample set: a project proposal, a |
| +// short note, meeting notes with attendees and actions, a short piece |
| +// of documentation, a letter, and one deliberately long document, per |
| +// the plan's own suggested shapes. Ago values are spread across every |
| +// bucket handlers.go's recencyBucket recognises (today, yesterday, |
| +// this week, earlier) and, within "today", across two different |
| +// times — so List's grouping has something to show rather than |
| +// collapsing six documents seeded in the same second into one |
| +// indistinguishable "Today" pile. |
| +var seedDocs = []seedDoc{ |
| + {Base: "packing-list", Title: "Packing List — Thursday Site Visit", Ago: 20 * time.Minute}, |
| + {Base: "proposal", Title: "Proposal: Refitting the Elm Street Reading Room", Ago: 3 * time.Hour}, |
| + {Base: "meeting-notes", Title: "Notes — Tuesday Planning Sync", Ago: 26 * time.Hour}, |
| + {Base: "documentation", Title: "Quick Reference: Resetting the Site Printer", Ago: 3*24*time.Hour + 4*time.Hour}, |
| + {Base: "letter", Title: "Letter to the Harbourview Allotment Committee", Ago: 9 * 24 * time.Hour}, |
| + {Base: "history", Title: "A History of the Millbrook Waterworks", Ago: 52 * 24 * time.Hour}, |
| +} |
| + |
| +// Seed populates owner's document list with CARLOS Docs' sample |
| +// content: real English prose, several paragraphs each, applied as |
| +// genuine Yjs updates through Store.ApplyUpdate — the same route a |
| +// person's own typing takes, never a text column written directly. |
| +// There is no Go-side Yjs encoder (internal/core applies updates and |
| +// reads text; the ABI exposes no insert), so each update below is not |
| +// built here — it is fixtures/generate-seed.mjs's committed output, |
| +// exactly the route the wire-compatibility gate's own fixtures |
| +// already use (ReadFixture, testing.go). |
| +// |
| +// It is meant for a fresh, empty database — cmd/docs/main.go's -seed |
| +// flag, gated to Env == "development", is the only caller — and does |
| +// not check for or skip existing documents: calling it twice against |
| +// the same owner seeds the sample set twice over. That is a feature |
| +// for reasoning about, not a safety net around; the safety belongs at |
| +// the caller, not here (main.go's own doc comment on why -seed is |
| +// gated the way it is). |
| +func Seed(ctx context.Context, app *App, owner int64) error { |
| + store := app.Store() |
| + base := time.Now().UTC() |
| + |
| + for _, sd := range seedDocs { |
| + doc, err := store.Create(ctx, owner, sd.Title) |
| + if err != nil { |
| + return fmt.Errorf("docs: seed: create %q: %w", sd.Title, err) |
| + } |
| + |
| + update, err := seedFS.ReadFile("seeddata/" + sd.Base + ".update.bin") |
| + if err != nil { |
| + return fmt.Errorf("docs: seed: read update for %q: %w", sd.Title, err) |
| + } |
| + if err := store.ApplyUpdate(ctx, doc, update); err != nil { |
| + return fmt.Errorf("docs: seed: apply update for %q: %w", sd.Title, err) |
| + } |
| + |
| + // ApplyUpdate just bumped both timestamps to the real "now" — |
| + // correct for a real edit, wrong for a seeded document meant |
| + // to land in a specific recency bucket. Backdating both |
| + // columns directly, after the fact, through the same owned() |
| + // seam every other write in store.go goes through (store.go's |
| + // own doc comment on owned: "the one place that can make it |
| + // structurally impossible" for a write to drift off that |
| + // scope) — doc.OwnerID is already owner here, so this is |
| + // belt-and-braces, not a correction of anything wrong above. |
| + at := base.Add(-sd.Ago) |
| + if err := owned(store.db.WithContext(ctx), owner). |
| + Model(&Document{}). |
| + Where("id = ?", doc.ID). |
| + Updates(map[string]any{"created_at": at, "updated_at": at}).Error; err != nil { |
| + return fmt.Errorf("docs: seed: backdate %q: %w", sd.Title, err) |
| + } |
| + } |
| + return nil |
| +} |
diff --git a/internal/docs/seed_test.go b/internal/docs/seed_test.go| new file mode 100644 |
| index 0000000..5c84f04 |
| --- /dev/null |
| +++ b/internal/docs/seed_test.go |
| @@ -0,0 +1,131 @@ |
| +package docs_test |
| + |
| +import ( |
| + "context" |
| + "strings" |
| + "testing" |
| + |
| + "github.com/carlosframework/docs/internal/docs" |
| +) |
| + |
| +// TestSeedProducesReadableDocuments is the task brief's own test, |
| +// verbatim: Seed must leave at least four documents behind, and every |
| +// one of them must carry substantial text read back through the real |
| +// CRDT (Store.Text), not a database column. |
| +func TestSeedProducesReadableDocuments(t *testing.T) { |
| + app, _ := docs.NewForTest(t) |
| + ctx := context.Background() |
| + owner := app.TestPerson(t, "seed@example.com") |
| + |
| + if err := docs.Seed(ctx, app, owner); err != nil { |
| + t.Fatalf("Seed: %v", err) |
| + } |
| + |
| + list, err := app.Store().List(ctx, owner) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + if len(list) < 4 { |
| + t.Fatalf("seeded %d documents, want at least 4", len(list)) |
| + } |
| + |
| + // Every seeded document must have real content, through the CRDT. |
| + for _, d := range list { |
| + text, err := app.Store().Text(ctx, d) |
| + if err != nil { |
| + t.Fatalf("Text(%q): %v", d.Title, err) |
| + } |
| + if len(text) < 200 { |
| + t.Errorf("%q has %d characters — seed content should be substantial", d.Title, len(text)) |
| + } |
| + } |
| +} |
| + |
| +// TestSeedVariesTimestamps pins the plan's own requirement that |
| +// seeded documents do not all land in the same second: List's own |
| +// list.html groups by recency (handlers.go's recencyBucket), and every |
| +// document created in the same second shows nothing of that. This |
| +// does not assert exact values — Seed's own Ago durations are an |
| +// implementation detail free to change — only that CreatedAt is not |
| +// identical across every document, which a naive "loop and Create" |
| +// seeder (no backdating at all) would fail. |
| +func TestSeedVariesTimestamps(t *testing.T) { |
| + app, _ := docs.NewForTest(t) |
| + ctx := context.Background() |
| + owner := app.TestPerson(t, "seed@example.com") |
| + |
| + if err := docs.Seed(ctx, app, owner); err != nil { |
| + t.Fatalf("Seed: %v", err) |
| + } |
| + |
| + list, err := app.Store().List(ctx, owner) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + |
| + seen := map[string]bool{} |
| + for _, d := range list { |
| + seen[d.CreatedAt.Format("2006-01-02T15:04")] = true |
| + } |
| + if len(seen) < 2 { |
| + t.Fatalf("all %d seeded documents share the same CreatedAt minute — timestamps are not varied", len(list)) |
| + } |
| +} |
| + |
| +// TestSeedContentMatchesItsSource holds seed.go's committed |
| +// .update.bin fixtures honest against the .txt files they were built |
| +// from (fixtures/generate-seed.mjs): if a .txt is edited without |
| +// regenerating its .bin, the document Store.Text() actually replays |
| +// stops matching what a person editing seeddata/ would expect to see, |
| +// silently — this test is what makes that loud instead. It is the Go |
| +// side of the same contract the wire-compatibility gate's own |
| +// fixtures (fixtures/generate.mjs, scripts/wire-gate.sh) hold for |
| +// fixtures/data. |
| +func TestSeedContentMatchesItsSource(t *testing.T) { |
| + app, _ := docs.NewForTest(t) |
| + ctx := context.Background() |
| + owner := app.TestPerson(t, "source-check@example.com") |
| + |
| + if err := docs.Seed(ctx, app, owner); err != nil { |
| + t.Fatalf("Seed: %v", err) |
| + } |
| + |
| + list, err := app.Store().List(ctx, owner) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + |
| + titleSeen := map[string]bool{} |
| + for _, d := range list { |
| + titleSeen[d.Title] = true |
| + } |
| + |
| + for _, want := range []string{ |
| + "Proposal: Refitting the Elm Street Reading Room", |
| + "Notes — Tuesday Planning Sync", |
| + "Quick Reference: Resetting the Site Printer", |
| + "Letter to the Harbourview Allotment Committee", |
| + "A History of the Millbrook Waterworks", |
| + "Packing List — Thursday Site Visit", |
| + } { |
| + if !titleSeen[want] { |
| + t.Errorf("seeded set is missing %q", want) |
| + } |
| + } |
| + |
| + for _, d := range list { |
| + text, err := app.Store().Text(ctx, d) |
| + if err != nil { |
| + t.Fatalf("Text(%q): %v", d.Title, err) |
| + } |
| + if strings.TrimSpace(text) == "" { |
| + t.Errorf("%q replayed to empty text", d.Title) |
| + } |
| + // A loose but real cross-check: the replayed text should read |
| + // as prose (contains at least one space-separated sentence), |
| + // not e.g. a stray binary artefact from a stale .bin. |
| + if !strings.Contains(text, " ") { |
| + t.Errorf("%q replayed to %q, which does not look like prose", d.Title, text) |
| + } |
| + } |
| +} |
diff --git a/internal/docs/seeddata/documentation.txt b/internal/docs/seeddata/documentation.txt| new file mode 100644 |
| index 0000000..43a331c |
| --- /dev/null |
| +++ b/internal/docs/seeddata/documentation.txt |
| @@ -0,0 +1,12 @@ |
| +Quick Reference: Resetting the Site Printer |
| + |
| +This covers the shared printer on the second floor. If it is showing a flashing amber light and refusing new jobs, try this before calling facilities. |
| + |
| +1. Open the front panel and check the paper path for a jam, even if the display does not report one — the sensor on this model sometimes misses a torn corner. |
| +2. If there is no jam, hold the power button for five seconds until the display goes dark, then release. Do not unplug it; the soft reset clears the queue without losing its network settings, and unplugging does not. |
| +3. Wait about thirty seconds for it to reboot. The amber light should turn solid, then go out entirely once it rejoins the network. |
| +4. Send a one-page test print from any machine on the floor. If it prints, you are done. |
| + |
| +If the amber light comes back within a few minutes of the reset, the fault is usually the fuser unit rather than anything a reset can fix — stop here and log a ticket with facilities rather than repeating the reset, which will not help a second time. |
| + |
| +For anything involving a physical paper jam that will not clear, do not force the rollers. Log a ticket instead; the previous unit's rollers were damaged this way and the replacement cost more than the service call would have. |
diff --git a/internal/docs/seeddata/documentation.update.bin b/internal/docs/seeddata/documentation.update.bin| new file mode 100644 |
| index 0000000..147634d |
| Binary files /dev/null and b/internal/docs/seeddata/documentation.update.bin differ |
diff --git a/internal/docs/seeddata/history.txt b/internal/docs/seeddata/history.txt| new file mode 100644 |
| index 0000000..e82e370 |
| --- /dev/null |
| +++ b/internal/docs/seeddata/history.txt |
| @@ -0,0 +1,29 @@ |
| +A History of the Millbrook Waterworks |
| + |
| +Early years |
| + |
| +Before 1887, Millbrook drew its water the way most small towns did: from private wells, a handful of shared pumps on the main street, and, for those who could afford it, a cistern fed by roof runoff. This worked poorly. The town's own health records from the 1870s show repeated outbreaks of what was then called "summer fever" — almost certainly waterborne illness, though the germ theory behind it was still new enough that most residents blamed the weather instead. It took a particularly bad outbreak in the summer of 1883, which killed eleven people in a town of under two thousand, to force the question of a proper water supply onto the town council's agenda for good. |
| + |
| +The council spent nearly four years arguing about it. Not about whether a waterworks was needed — by 1884 almost nobody disputed that — but about where to draw from, who would pay for it, and whether the town should build and run it itself or contract the work out to a private company, as several neighbouring towns had done. The private-company faction lost, narrowly, after the council toured a waterworks in a nearby town that had been run privately for six years and found its rates had tripled with no corresponding improvement in service. Millbrook decided to build and own its own works. |
| + |
| +Construction and the first reservoir |
| + |
| +Construction began in the spring of 1886, drawing from Millbrook Creek roughly two miles upstream of the town centre, where the water was judged cleanest. The site chosen was a natural basin in the hills that needed only a modest earthen dam to become a workable reservoir — this became the original Millbrook Reservoir, still in use today though heavily rebuilt since. The engineer in charge, a Scottish immigrant named Alasdair Reid who had worked on waterworks in Glasgow before coming to North America, insisted on a slow sand filtration system rather than the cheaper alternative of no filtration at all, which was still common practice for a town this size at the time. This decision, more than any other single one, is usually credited with why Millbrook's water-related illness rates dropped so sharply once the works opened, well before most of its neighbours saw similar improvements. |
| + |
| +The works opened in October 1887, a year and a half after construction began, at a final cost of just under sixty thousand dollars — considerably over the original estimate, mostly due to unexpected rock excavation for the pipeline route. The opening was marked with a public ceremony at which the mayor reportedly drank a glass of water drawn directly from the new mains, a piece of theatre that was apparently repeated, with much less enthusiasm, at every subsequent mayoral inauguration for the next thirty years. |
| + |
| +Expansion through the early twentieth century |
| + |
| +The original works served the town well for about two decades, but Millbrook's population nearly doubled between 1890 and 1910 as a shoe-manufacturing plant opened on the eastern edge of town and drew workers from surrounding rural areas. By 1908 the reservoir was running close to capacity in dry summers, and the council authorized a second reservoir, built further upstream and connected by a new pipeline, completed in 1912. This is the reservoir now known locally as the "new" reservoir, despite being well over a century old itself — a naming quirk that has confused more than one newcomer looking at an old map. |
| + |
| +The shoe plant itself became the works' single largest customer for several decades, at one point accounting for nearly a fifth of total water usage in town. When the plant closed in 1968, following the broader decline of domestic shoe manufacturing across the region, the waterworks lost enough revenue that rates rose noticeably for several years afterward — one of the few times in the works' history that a single customer's departure showed up directly in ordinary residents' bills. |
| + |
| +Modernization |
| + |
| +The slow sand filters that Reid insisted on in 1887 remained in continuous use, with periodic resurfacing, until 1974, when the works were modernized with a mechanical filtration and chlorination system more typical of the era. Two of the original filter beds were preserved rather than demolished, at the request of the town's newly formed historical society, and can still be seen — no longer operational, but maintained as an exhibit — at what is now the Millbrook Waterworks Museum, itself opened in 1981 in the original pump house. |
| + |
| +Further upgrades followed roughly every twenty years: a new treatment plant in 1996 that finally retired the last of the 1912-era pipework, a supervisory control system installed in 2009 that allowed the works to be monitored remotely for the first time rather than requiring an operator on site around the clock, and most recently a lead service line replacement program begun in 2019 and still ongoing, targeting the roughly six hundred properties in the town's oldest neighbourhoods still connected by pipe materials that predate any of the modern safety standards. |
| + |
| +The works today |
| + |
| +The Millbrook Waterworks now serves just over nine thousand customers across the town and two adjoining unincorporated areas that have contracted for service over the years. It remains municipally owned, as the 1884 council decided it should be, and is one of relatively few waterworks of its size in the region never to have been sold to a private operator, a fact the utility's own annual report has mentioned, with evident pride, in some form for at least the last twenty consecutive years. |
diff --git a/internal/docs/seeddata/history.update.bin b/internal/docs/seeddata/history.update.bin| new file mode 100644 |
| index 0000000..4209405 |
| Binary files /dev/null and b/internal/docs/seeddata/history.update.bin differ |
diff --git a/internal/docs/seeddata/letter.txt b/internal/docs/seeddata/letter.txt| new file mode 100644 |
| index 0000000..84c68ec |
| --- /dev/null |
| +++ b/internal/docs/seeddata/letter.txt |
| @@ -0,0 +1,13 @@ |
| +Dear Members of the Harbourview Allotment Committee, |
| + |
| +I am writing about the water butt on plot 14, which has been leaking from its base since the middle of last month. I have tried tightening the tap fitting twice, which helped for a few days each time, but the leak has come back worse than before and I think the seal itself has gone rather than the tap. |
| + |
| +I know the committee ordered three replacement butts in the spring for exactly this kind of thing, and I would be grateful if plot 14 could be next in line for one. In the meantime I have moved what I can catch into a spare bucket, but it is not keeping up, and the bed nearest the fence is starting to show it. |
| + |
| +I am around most weekday evenings after six if someone from the committee wants to take a look before deciding. Otherwise happy to go with whatever the usual process is. |
| + |
| +Thank you for looking into this. |
| + |
| +Warm regards, |
| +Eleanor Vance |
| +Plot 14 |
diff --git a/internal/docs/seeddata/letter.update.bin b/internal/docs/seeddata/letter.update.bin| new file mode 100644 |
| index 0000000..37be790 |
| Binary files /dev/null and b/internal/docs/seeddata/letter.update.bin differ |
diff --git a/internal/docs/seeddata/meeting-notes.txt b/internal/docs/seeddata/meeting-notes.txt| new file mode 100644 |
| index 0000000..0b80537 |
| --- /dev/null |
| +++ b/internal/docs/seeddata/meeting-notes.txt |
| @@ -0,0 +1,30 @@ |
| +Notes — Tuesday Planning Sync |
| + |
| +Attendees: Marisol Ferrer, Tom Okafor, Dana Whitfield, Ben Sacher (dialed in) |
| +Absent: Priya Nair (on leave) |
| + |
| +Agenda |
| + |
| +1. Status of the intake form redesign |
| +2. Printer replacement for the second floor |
| +3. Feedback from last week's user sessions |
| +4. Any other business |
| + |
| +Discussion |
| + |
| +Marisol walked through the intake form redesign. The new version cuts the field count from twenty-three to fourteen, mostly by removing fields nobody was reading back anyway. Tom raised a concern that the shorter form drops the "referred by" field entirely, which the outreach team still uses for their own reporting — agreed to keep it, but move it to an optional section at the end rather than the top. |
| + |
| +Dana reported that the second-floor printer has now jammed four times this month, more than any other printer in the building. Facilities quoted three hundred and twenty pounds for a replacement of the same model, which nobody thought was worth it given the repeat failures. Ben suggested looking at the model currently used on the fourth floor instead, which has had no reported issues in over a year. |
| + |
| +Feedback from last week's user sessions was mostly positive. Two of the five participants got stuck on the same step — confirming their email address a second time — and thought it was a bug rather than a deliberate confirmation step. Dana will pass this to design as a candidate for the next round of copy changes. |
| + |
| +No other business was raised. |
| + |
| +Action items |
| + |
| +- Marisol: move the "referred by" field to the optional section, have a revised mock ready by Friday |
| +- Tom: confirm with outreach that the optional placement still works for their reporting |
| +- Dana: get a quote for the fourth-floor printer model before next week's sync |
| +- Ben: draft a short note to design about the email confirmation step |
| + |
| +Next sync: Tuesday, same time. |
diff --git a/internal/docs/seeddata/meeting-notes.update.bin b/internal/docs/seeddata/meeting-notes.update.bin| new file mode 100644 |
| index 0000000..492083c |
| Binary files /dev/null and b/internal/docs/seeddata/meeting-notes.update.bin differ |
diff --git a/internal/docs/seeddata/packing-list.txt b/internal/docs/seeddata/packing-list.txt| new file mode 100644 |
| index 0000000..9590e93 |
| --- /dev/null |
| +++ b/internal/docs/seeddata/packing-list.txt |
| @@ -0,0 +1,9 @@ |
| +Packing List — Thursday Site Visit |
| + |
| +Leaving from the depot at 7:30, back by mid-afternoon. Weather forecast says overcast with a chance of rain after noon, so pack for both. |
| + |
| +Bring: hard hats (two spare in the van already, check anyway), the folding tape measure not the laser one since the laser one's battery is still on charge, high-vis vests, the site access letter printed and signed, spare batteries for the camera, and a full water bottle each — there is no tap on site. |
| + |
| +Do not bring the old clipboard with the cracked hinge. Use the new one from the second drawer. |
| + |
| +Meet at the van, not at the site gate — the gate code changed last week and only Priya has the new one written down properly. |
diff --git a/internal/docs/seeddata/packing-list.update.bin b/internal/docs/seeddata/packing-list.update.bin| new file mode 100644 |
| index 0000000..b8fc2d9 |
| Binary files /dev/null and b/internal/docs/seeddata/packing-list.update.bin differ |
diff --git a/internal/docs/seeddata/proposal.txt b/internal/docs/seeddata/proposal.txt| new file mode 100644 |
| index 0000000..781ea43 |
| --- /dev/null |
| +++ b/internal/docs/seeddata/proposal.txt |
| @@ -0,0 +1,19 @@ |
| +Proposal: Refitting the Elm Street Reading Room |
| + |
| +The reading room at the Elm Street branch has not been touched since the 2009 renovation, and it shows. The carpet is worn through in front of the returns desk, half the reading lamps no longer hold a bulb steady, and the two armchairs by the window have been patched with tape often enough that patching them again is no longer worth doing. This proposal asks for a modest refit rather than a full renovation: new flooring, working lamps, replacement seating, and a better use of the wall that currently holds nothing but a community noticeboard nobody has updated since March. |
| + |
| +Why now |
| + |
| +Branch usage is up eleven percent over last year, mostly from people staying for an hour or more rather than just returning books. That is exactly the use a reading room should support, and exactly what this space currently discourages. Members have raised the state of the seating in three of the last four suggestion-box rounds, which is as close to a mandate as a public library ever gets. |
| + |
| +What this covers |
| + |
| +The scope is deliberately narrow. It does not touch the shelving layout, which works fine, or the circulation desk, which was redone two years ago and does not need attention. It covers: replacing the carpet with the same commercial-grade tile used in the children's section, which has held up well; six new reading lamps with a longer warranty than the last batch; four chairs and a small sofa to replace the taped-up furniture; and converting the noticeboard wall into two more low shelving units, since the noticeboard's actual function has mostly moved to the branch's front window. |
| + |
| +Budget and timeline |
| + |
| +The estimate, including installation, comes to just under four thousand pounds, most of it in the flooring. We have already spoken informally with the contractor who did the children's section floor, and they could fit this in during the first two weeks of October, which is a slow period for the branch and would let the room close for the shortest practical stretch — four working days rather than the six a full weekend-to-weekend closure would need. |
| + |
| +What we are asking for |
| + |
| +Approval to proceed with the budget above, and confirmation that October closure dates are acceptable to the branch's other scheduled activities. If approved this month, the work can be finished well before the winter reading push begins in November, when the room sees its heaviest use of the year. |
diff --git a/internal/docs/seeddata/proposal.update.bin b/internal/docs/seeddata/proposal.update.bin| new file mode 100644 |
| index 0000000..5278def |
| Binary files /dev/null and b/internal/docs/seeddata/proposal.update.bin differ |
diff --git a/internal/docs/store.go b/internal/docs/store.go| index 8ba25e3..fde79d1 100644 |
| --- a/internal/docs/store.go |
| +++ b/internal/docs/store.go |
| @@ -6,6 +6,7 @@ import ( |
| "errors" |
| "fmt" |
| mrand "math/rand/v2" |
| + "strings" |
| "time" |
| "github.com/google/uuid" |
| @@ -69,13 +70,112 @@ func newSqid() (string, error) { |
| // expected. |
| const maxSqidAttempts = 5 |
| -// Create makes a new document: a GUID and a client id assigned here |
| -// by the app (never drawn from yrs — see Document's doc comment) and |
| -// stored, an app-generated sqid, and the given title. It does not |
| -// touch the CRDT core at all: an empty document needs no update log |
| -// entry, and Text/ApplyUpdate are what open a core handle when there |
| -// is work for one to do. |
| -func (s *Store) Create(ctx context.Context, title string) (*Document, error) { |
| +// owned scopes g to documents owner may see — the one seam every |
| +// query in this file goes through, per the plan's own ruling. |
| +// Deliberately not rastrillo's scope.Owned (SKILL.md §3): that helper |
| +// is `g.Where("user_id = ?", owner)`, which is exactly what this is |
| +// today, but authored, versioned and tested outside this package. |
| +// Applied to a table that later grows sharing (spec §4.2's `grants` |
| +// join, out of scope for this plan), scope.Owned would keep silently |
| +// filtering to creator-only forever — a document library that looks |
| +// empty rather than broken, the worst available failure shape, on the |
| +// helper an author would naturally reach for. Writing `owned` here |
| +// instead means the day sharing lands, this is the one function that |
| +// changes (to add the join), not a search-and-replace across |
| +// scattered WHERE clauses — and TestOwnedScopesByOwner (scope_test.go) |
| +// pins its current, ownership-only contract so a future edit that |
| +// widens it does so on purpose. |
| +// |
| +// owner <= 0 matches nothing, deliberately, rather than trusting every |
| +// caller to reject it first. people.id starts at 1 (SQLite's INTEGER |
| +// PRIMARY KEY), so 0 is never a real person — it is exactly the value |
| +// 0002_owners.sql's migration backfilled onto every document that |
| +// existed before ownership did (see that file's own comment, and |
| +// TestOrphanedDocumentsAreVisibleToNobody in scope_test.go, which |
| +// pins this). Fix round 1 on this task: a reviewer found owned(g, 0), |
| +// Store.List(ctx, 0) and Store.Get(ctx, 0, ...) all happily returned |
| +// those orphaned rows — nothing upstream exploited that yet, but nothing |
| +// prevented a careless future caller from doing so either, and the |
| +// seam is the one place that can make it structurally impossible |
| +// rather than merely unexercised today. Matching nothing (a `1 = 0` |
| +// clause) rather than returning an error keeps every call site's |
| +// chaining (.Where/.Order/.Find) unchanged, and gives owner 0 the same |
| +// outcome as any other owner with no visible rows — no distinct error |
| +// shape for "invalid owner" to leak through a 500 or otherwise |
| +// distinguish itself from "you just have nothing here" (spec §4.2's |
| +// own reasoning against a distinguishable failure mode, applied here |
| +// to the seam itself rather than only to the 404-vs-403 handler |
| +// boundary). |
| +// |
| +// It does not filter deleted_at: that is not an ownership concern. |
| +// Folding it in here would make `owned` do two unrelated jobs under |
| +// one name, and would silently change behaviour for any future caller |
| +// that wants every one of an owner's rows regardless of deletion state |
| +// (an admin or export path, say) without ever needing to touch this |
| +// function. List and Get add `deleted_at IS NULL` themselves, |
| +// deliberately visible at the call site. |
| +func owned(g *gorm.DB, owner int64) *gorm.DB { |
| + if owner <= 0 { |
| + return g.Where("1 = 0") |
| + } |
| + return g.Where("owner_id = ?", owner) |
| +} |
| + |
| +// EnsurePerson maps an authenticated address to this app's own, |
| +// numeric Person id — get-or-create, keyed on a case-insensitive, |
| +// trimmed Email. This is the translation rastrillo SKILL.md §5 |
| +// requires under the magic-link plugin: the session Subject is the |
| +// verified email itself, not a numeric id, so sessions.UserID returns |
| +// (0, false) and must never be used (see Document.OwnerID's doc |
| +// comment). handlers.go's owner helper calls this on every |
| +// authenticated request; TestPerson (testing.go) calls it directly so |
| +// a test can mint an owner id without going through HTTP at all — the |
| +// two must and do agree, since TestPerson is this method and nothing |
| +// else. |
| +func (s *Store) EnsurePerson(ctx context.Context, email string) (int64, error) { |
| + email = strings.TrimSpace(strings.ToLower(email)) |
| + if email == "" { |
| + return 0, errors.New("docs: EnsurePerson: address is empty") |
| + } |
| + |
| + var p Person |
| + err := s.db.WithContext(ctx).Where("email = ?", email).First(&p).Error |
| + if err == nil { |
| + return p.ID, nil |
| + } |
| + if !errors.Is(err, gorm.ErrRecordNotFound) { |
| + return 0, fmt.Errorf("docs: look up person %q: %w", email, err) |
| + } |
| + |
| + p = Person{Email: email, CreatedAt: time.Now().UTC()} |
| + if err := s.db.WithContext(ctx).Create(&p).Error; err != nil { |
| + // A concurrent first sign-in from the same brand-new address |
| + // can race two inserts against the unique index on email; the |
| + // loser looks up what the winner just created rather than |
| + // failing the request over a benign collision. |
| + if errors.Is(err, gorm.ErrDuplicatedKey) { |
| + if err2 := s.db.WithContext(ctx).Where("email = ?", email).First(&p).Error; err2 == nil { |
| + return p.ID, nil |
| + } |
| + } |
| + return 0, fmt.Errorf("docs: create person %q: %w", email, err) |
| + } |
| + return p.ID, nil |
| +} |
| + |
| +// Create makes a new document owned by owner: a GUID and a client id |
| +// assigned here by the app (never drawn from yrs — see Document's doc |
| +// comment) and stored, an app-generated sqid, and the given title. It |
| +// does not touch the CRDT core at all: an empty document needs no |
| +// update log entry, and Text/ApplyUpdate are what open a core handle |
| +// when there is work for one to do. |
| +// |
| +// owner is always a Person id the caller already resolved from the |
| +// signed-in session (handlers.go's owner helper, or TestPerson in |
| +// tests) — never a value read off the request body. rastrillo |
| +// SKILL.md §3: "Creating takes the owner from the session, never the |
| +// form." |
| +func (s *Store) Create(ctx context.Context, owner int64, title string) (*Document, error) { |
| guid := uuid.NewString() |
| // math/rand/v2's Int64 returns a non-negative value in [0, 1<<63), |
| // so converting to uint64 never wraps — the brief's "a counter or |
| @@ -92,6 +192,7 @@ func (s *Store) Create(ctx context.Context, title string) (*Document, error) { |
| doc := &Document{ |
| Sqid: sqid, |
| Title: title, |
| + OwnerID: owner, |
| GUID: guid, |
| ClientID: clientID, |
| CreatedAt: now, |
| @@ -109,18 +210,22 @@ func (s *Store) Create(ctx context.Context, title string) (*Document, error) { |
| return nil, fmt.Errorf("docs: create document: %w", lastErr) |
| } |
| -// List returns every non-deleted document, newest first — "newest" |
| -// meaning most recently touched (UpdatedAt), the same clock |
| -// handlers.go's groupByRecency/formatWhen bucket and display, and the |
| -// one ApplyUpdate now bumps on every applied update. Ordering by |
| -// CreatedAt instead (fix round 2's own bug: they agreed only by |
| +// List returns every one of owner's non-deleted documents, newest |
| +// first — "newest" meaning most recently touched (UpdatedAt), the same |
| +// clock handlers.go's groupByRecency/formatWhen bucket and display, |
| +// and the one ApplyUpdate now bumps on every applied update. Ordering |
| +// by CreatedAt instead (fix round 2's own bug: they agreed only by |
| // accident, because nothing bumped UpdatedAt away from CreatedAt) let |
| // this list and its own recency buckets disagree the moment a |
| // document's UpdatedAt ever moved without a matching reorder here — |
| // see store_test.go's TestListOrdersByUpdatedAt. |
| -func (s *Store) List(ctx context.Context) ([]*Document, error) { |
| +// |
| +// A document owner does not own is not in this list — not filtered |
| +// out afterward, never selected in the first place, through owned |
| +// above. |
| +func (s *Store) List(ctx context.Context, owner int64) ([]*Document, error) { |
| var docs []*Document |
| - err := s.db.WithContext(ctx). |
| + err := owned(s.db.WithContext(ctx), owner). |
| Where("deleted_at IS NULL"). |
| Order("updated_at DESC, id DESC"). |
| Find(&docs).Error |
| @@ -130,10 +235,15 @@ func (s *Store) List(ctx context.Context) ([]*Document, error) { |
| return docs, nil |
| } |
| -// Get looks up a document by its sqid. |
| -func (s *Store) Get(ctx context.Context, sqid string) (*Document, error) { |
| +// Get looks up one of owner's documents by its sqid. A sqid that |
| +// exists but belongs to someone else is indistinguishable from a sqid |
| +// that does not exist at all — both return gorm.ErrRecordNotFound |
| +// through owned above, never a distinct "yes, but not yours" outcome — |
| +// which is what lets handlers.go answer 404 and never 403 for a |
| +// document (spec §4.2). |
| +func (s *Store) Get(ctx context.Context, owner int64, sqid string) (*Document, error) { |
| var doc Document |
| - err := s.db.WithContext(ctx). |
| + err := owned(s.db.WithContext(ctx), owner). |
| Where("sqid = ? AND deleted_at IS NULL", sqid). |
| First(&doc).Error |
| if err != nil { |
| @@ -216,7 +326,45 @@ func (s *Store) Text(ctx context.Context, doc *Document) (string, error) { |
| // persisted — see the errors.Is-friendly wrapping in replay's doc |
| // comment and Handle.Apply's for why an uncertain outcome must not be |
| // written down as a fact. |
| +// |
| +// Before any of that, doc.OwnerID is verified against the row itself. |
| +// Fix round 2 on this task (Finding 1 again, this time for real): the |
| +// previous fix scoped only the trailing updated_at bump through |
| +// owned(doc.OwnerID), on the theory that it was "the one write in this |
| +// file that skipped the seam." That was wrong — the doc_updates INSERT |
| +// above (and the MAX(seq) query that sizes it) is keyed on doc.ID |
| +// alone and always has been, and it runs long before the bump. Scoping |
| +// only the last write meant a *Document carrying a stranger's OwnerID |
| +// still replayed, applied and persisted the content; only the |
| +// timestamp was refused, and a gorm UPDATE matching zero rows returns |
| +// no error, so the whole call returned nil. Content wrote, silently, |
| +// on a mismatched owner. |
| +// |
| +// The honest fix is not to scope some subset of the writes below — it |
| +// is to refuse the entire operation before any of them run. doc is a |
| +// value a caller can construct or mutate freely (as the regression |
| +// test below does); it is not a database guarantee. Store.Get is |
| +// owner-scoped, so doc.OwnerID is trustworthy *when doc came from |
| +// Get* — but nothing at this call site enforces that it did, which is |
| +// the entire defect. So rather than trust the struct, this re-derives |
| +// ownership from the database itself: look up document doc.ID through |
| +// the same owned(doc.OwnerID) seam every other query in this file |
| +// uses, and require the row to exist. owned's owner <= 0 rule (see |
| +// its own doc comment) still applies unchanged here: an orphaned |
| +// document with owner_id 0 cannot be "owned" by any doc.OwnerID, |
| +// including a tampered 0, so it can never pass this check and this |
| +// route can never make an orphan writable. A mismatched or missing |
| +// owner makes the Where match zero rows, First returns |
| +// gorm.ErrRecordNotFound, and this method returns before replay is |
| +// even called — no core touched, no row written. |
| func (s *Store) ApplyUpdate(ctx context.Context, doc *Document, update []byte) error { |
| + var ownerCheck Document |
| + if err := owned(s.db.WithContext(ctx), doc.OwnerID). |
| + Where("id = ?", doc.ID). |
| + First(&ownerCheck).Error; err != nil { |
| + return fmt.Errorf("docs: apply update to document %d: verify owner %d: %w", doc.ID, doc.OwnerID, err) |
| + } |
| + |
| h, err := s.replay(ctx, doc) |
| if err != nil { |
| return err |
| @@ -254,7 +402,17 @@ func (s *Store) ApplyUpdate(ctx context.Context, doc *Document, update []byte) e |
| // to the same `now`), so List ordering by either column looked |
| // identical and the divergence stayed invisible until something |
| // — this method — actually moved UpdatedAt away from CreatedAt. |
| - if err := s.db.WithContext(ctx). |
| + // |
| + // Still routed through owned(doc.OwnerID), same as every other |
| + // write in this method now — ownership was already verified above |
| + // before replay ran, so by this point the row is guaranteed to |
| + // exist under doc.OwnerID and this can never match zero rows. It |
| + // stays scoped anyway rather than falling back to a bare |
| + // `Where("id = ?", doc.ID)`: SKILL.md §3, verbatim, "Scope the |
| + // write, not just the read that loaded the row," and a defense |
| + // that only holds because of a check earlier in the same function |
| + // is one accidental reordering away from not holding at all. |
| + if err := owned(s.db.WithContext(ctx), doc.OwnerID). |
| Model(&Document{}). |
| Where("id = ?", doc.ID). |
| Update("updated_at", time.Now().UTC()).Error; err != nil { |
diff --git a/internal/docs/store_test.go b/internal/docs/store_test.go| index 88071cb..b4bbf7a 100644 |
| --- a/internal/docs/store_test.go |
| +++ b/internal/docs/store_test.go |
| @@ -2,6 +2,8 @@ package docs_test |
| import ( |
| "context" |
| + "net/http" |
| + "net/http/httptest" |
| "testing" |
| "time" |
| @@ -14,8 +16,9 @@ func TestCreateListGetRoundTrip(t *testing.T) { |
| t.Fatalf("NewForTest: %v", err) |
| } |
| ctx := context.Background() |
| + owner := app.TestPerson(t, "owner@example.com") |
| - made, err := app.Store().Create(ctx, "First document") |
| + made, err := app.Store().Create(ctx, owner, "First document") |
| if err != nil { |
| t.Fatalf("Create: %v", err) |
| } |
| @@ -26,7 +29,7 @@ func TestCreateListGetRoundTrip(t *testing.T) { |
| t.Fatal("Create must assign a GUID and client id, never draw them from yrs") |
| } |
| - list, err := app.Store().List(ctx) |
| + list, err := app.Store().List(ctx, owner) |
| if err != nil { |
| t.Fatalf("List: %v", err) |
| } |
| @@ -34,7 +37,7 @@ func TestCreateListGetRoundTrip(t *testing.T) { |
| t.Fatalf("List = %+v, want one document titled First document", list) |
| } |
| - got, err := app.Store().Get(ctx, made.Sqid) |
| + got, err := app.Store().Get(ctx, owner, made.Sqid) |
| if err != nil { |
| t.Fatalf("Get: %v", err) |
| } |
| @@ -49,8 +52,9 @@ func TestTextComesFromTheCore(t *testing.T) { |
| t.Fatalf("NewForTest: %v", err) |
| } |
| ctx := context.Background() |
| + owner := app.TestPerson(t, "owner@example.com") |
| - doc, err := app.Store().Create(ctx, "With content") |
| + doc, err := app.Store().Create(ctx, owner, "With content") |
| if err != nil { |
| t.Fatalf("Create: %v", err) |
| } |
| @@ -90,8 +94,9 @@ func TestListOrdersByUpdatedAt(t *testing.T) { |
| t.Fatalf("NewForTest: %v", err) |
| } |
| ctx := context.Background() |
| + owner := app.TestPerson(t, "owner@example.com") |
| - older, err := app.Store().Create(ctx, "Older document") |
| + older, err := app.Store().Create(ctx, owner, "Older document") |
| if err != nil { |
| t.Fatalf("Create(older): %v", err) |
| } |
| @@ -99,12 +104,12 @@ func TestListOrdersByUpdatedAt(t *testing.T) { |
| // calls back to back can land in the same tick on a coarse clock, |
| // and this test's whole point is ordering, not simultaneity. |
| time.Sleep(5 * time.Millisecond) |
| - newer, err := app.Store().Create(ctx, "Newer document") |
| + newer, err := app.Store().Create(ctx, owner, "Newer document") |
| if err != nil { |
| t.Fatalf("Create(newer): %v", err) |
| } |
| - list, err := app.Store().List(ctx) |
| + list, err := app.Store().List(ctx, owner) |
| if err != nil { |
| t.Fatalf("List: %v", err) |
| } |
| @@ -118,7 +123,7 @@ func TestListOrdersByUpdatedAt(t *testing.T) { |
| t.Fatalf("ApplyUpdate(older): %v", err) |
| } |
| - list, err = app.Store().List(ctx) |
| + list, err = app.Store().List(ctx, owner) |
| if err != nil { |
| t.Fatalf("List after update: %v", err) |
| } |
| @@ -126,3 +131,149 @@ func TestListOrdersByUpdatedAt(t *testing.T) { |
| t.Fatalf("List after applying an update to the older document = %+v, want [older, newer] — applying an update must move a document to the top of the list", list) |
| } |
| } |
| + |
| +// TestADocumentYouDoNotOwnIsNotThere is the brief's own literal test |
| +// (Task 2, plan 3): Bob must not be able to fetch Alice's document by |
| +// sqid, and it must not appear in his own list either — both routed |
| +// through Store.Get/List's owner parameter and store.go's owned seam, |
| +// never a separate "is this yours" check that could leak a distinct |
| +// error shape. |
| +func TestADocumentYouDoNotOwnIsNotThere(t *testing.T) { |
| + app, _ := docs.NewForTest(t) |
| + ctx := context.Background() |
| + |
| + alice := app.TestPerson(t, "alice@example.com") |
| + bob := app.TestPerson(t, "bob@example.com") |
| + |
| + doc, err := app.Store().Create(ctx, alice, "Alice's private notes") |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + |
| + // Bob must not be able to fetch it, and must not be told it exists. |
| + if _, err := app.Store().Get(ctx, bob, doc.Sqid); err == nil { |
| + t.Fatal("Bob fetched Alice's document") |
| + } |
| + |
| + list, err := app.Store().List(ctx, bob) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + if len(list) != 0 { |
| + t.Errorf("Bob's list has %d documents, want 0", len(list)) |
| + } |
| +} |
| + |
| +// TestUnownedDocumentOverHTTPIs404Not403 is the brief's own literal |
| +// test: the same isolation TestADocumentYouDoNotOwnIsNotThere proves |
| +// at the store, proven again over the real router, because a store |
| +// that scopes correctly and a handler that still leaks a 403 (or a |
| +// 200 on someone else's document) would both pass the store-level |
| +// test alone. Spec §4.2: a document you cannot see returns 404, so a |
| +// stranger cannot learn a sqid exists by probing it. |
| +func TestUnownedDocumentOverHTTPIs404Not403(t *testing.T) { |
| + app, _ := docs.NewForTest(t) |
| + ctx := context.Background() |
| + alice := app.TestPerson(t, "alice@example.com") |
| + doc, _ := app.Store().Create(ctx, alice, "Alice's private notes") |
| + |
| + req := app.TestRequest(t, "bob@example.com", http.MethodGet, "/d/"+doc.Sqid, nil) |
| + rec := httptest.NewRecorder() |
| + app.Handler().ServeHTTP(rec, req) |
| + |
| + if rec.Code != http.StatusNotFound { |
| + t.Errorf("status %d, want 404 — a document you cannot see does not exist (spec §4.2)", rec.Code) |
| + } |
| +} |
| + |
| +// TestApplyUpdateRefusesAnotherOwnersDocument is fix round 2's own |
| +// regression test (Finding 1, corrected): fix round 1's version of |
| +// this test was named as if it proved the write was refused, but it |
| +// only ever asserted that UpdatedAt held still — a green test |
| +// certifying a property nobody wanted, while the actual defect (the |
| +// content write) went unchecked and unfixed underneath it. |
| +// |
| +// The real bug: ApplyUpdate's doc_updates INSERT (and the MAX(seq) |
| +// query that sizes it) has always been keyed on doc.ID alone, with no |
| +// owner predicate at all. Only the trailing updated_at bump was ever |
| +// scoped through owned(doc.OwnerID), and it runs last — long after |
| +// the content has already been replayed, applied and persisted. A |
| +// gorm UPDATE matching zero rows returns no error, so a *Document |
| +// carrying a stranger's OwnerID used to replay and persist the |
| +// stranger's content into the real owner's document, then return nil: |
| +// only the timestamp was refused, silently, while "hello world" |
| +// landed in the victim's log. |
| +// |
| +// This asserts the actual contract: a mismatched owner must refuse |
| +// the whole operation, up front, with a real error — not partially |
| +// apply and then fail silently on the last write. It checks all four |
| +// legs: the call returns an error, the victim's content never changes |
| +// (via Store.Text, reading the real CRDT replay — not a column that |
| +// could look right while the log underneath it is wrong), the |
| +// victim's UpdatedAt never moves (fix round 1's original property, |
| +// kept), and — so this test cannot pass by making ApplyUpdate refuse |
| +// everything — the legitimate owner's own ApplyUpdate still works end |
| +// to end. SKILL.md §3, verbatim: "Scope the write, not just the read |
| +// that loaded the row." |
| +func TestApplyUpdateRefusesAnotherOwnersDocument(t *testing.T) { |
| + app, err := docs.NewForTest(t) |
| + if err != nil { |
| + t.Fatalf("NewForTest: %v", err) |
| + } |
| + ctx := context.Background() |
| + owner := app.TestPerson(t, "owner@example.com") |
| + |
| + doc, err := app.Store().Create(ctx, owner, "Real owner's document") |
| + if err != nil { |
| + t.Fatalf("Create: %v", err) |
| + } |
| + before, err := app.Store().Get(ctx, owner, doc.Sqid) |
| + if err != nil { |
| + t.Fatalf("Get (before): %v", err) |
| + } |
| + textBefore, err := app.Store().Text(ctx, before) |
| + if err != nil { |
| + t.Fatalf("Text (before): %v", err) |
| + } |
| + |
| + // A real gap so a spurious bump would be visible against the |
| + // clock, not hidden by two timestamps landing in the same tick. |
| + time.Sleep(5 * time.Millisecond) |
| + |
| + tampered := *doc |
| + tampered.OwnerID = owner + 1_000_000 // guaranteed to belong to nobody |
| + |
| + update := docs.ReadFixture(t, "hello") |
| + if err := app.Store().ApplyUpdate(ctx, &tampered, update); err == nil { |
| + t.Fatal("ApplyUpdate returned nil for a mismatched owner — want a real error, before anything is written") |
| + } |
| + |
| + after, err := app.Store().Get(ctx, owner, doc.Sqid) |
| + if err != nil { |
| + t.Fatalf("Get (after): %v", err) |
| + } |
| + textAfter, err := app.Store().Text(ctx, after) |
| + if err != nil { |
| + t.Fatalf("Text (after): %v", err) |
| + } |
| + if textAfter != textBefore { |
| + t.Errorf("victim document's content changed from %q to %q — the stranger's update landed despite the mismatched owner", textBefore, textAfter) |
| + } |
| + if !after.UpdatedAt.Equal(before.UpdatedAt) { |
| + t.Errorf("UpdatedAt moved from %v to %v even though ApplyUpdate was called with a mismatched owner (%d instead of the document's real owner %d)", |
| + before.UpdatedAt, after.UpdatedAt, tampered.OwnerID, owner) |
| + } |
| + |
| + // Prove the fix does not break the working path: the legitimate |
| + // owner's own ApplyUpdate must still apply end to end. |
| + if err := app.Store().ApplyUpdate(ctx, doc, update); err != nil { |
| + t.Fatalf("ApplyUpdate (legitimate owner): %v", err) |
| + } |
| + legit, err := app.Store().Text(ctx, doc) |
| + if err != nil { |
| + t.Fatalf("Text (legitimate owner): %v", err) |
| + } |
| + if legit != "hello world" { |
| + t.Errorf("legitimate owner's ApplyUpdate did not persist: Text = %q, want %q", legit, "hello world") |
| + } |
| +} |
diff --git a/internal/docs/testing.go b/internal/docs/testing.go| index ad91d27..1a1136f 100644 |
| --- a/internal/docs/testing.go |
| +++ b/internal/docs/testing.go |
| @@ -1,8 +1,15 @@ |
| package docs |
| import ( |
| + "context" |
| + "fmt" |
| + "io" |
| + "net/http" |
| + "net/http/httptest" |
| + "net/url" |
| "os" |
| "path/filepath" |
| + "strings" |
| "testing" |
| ) |
| @@ -11,18 +18,19 @@ import ( |
| // _test.go file, so packages other than this one's own tests can reach |
| // it too. |
| // |
| -// Env is fixed at "development" with DevAuth enabled: a later task |
| -// (devauth.go) adds a development-only identity that refuses to start |
| -// outside development, and every test built on NewForTest — this |
| -// task's own and four later tasks' — assumes that identity is already |
| -// reachable rather than needing its own opt-in. |
| +// Env is fixed at "development": that is what lets InstanceKey and |
| +// Mailer (auth.go) default rather than refuse to start, and what |
| +// installs the *recordingMailer TestSignIn (below) reads a minted |
| +// link back from. It used to also enable a development-only stand-in |
| +// identity (devauth.go); that identity had zero readers once real |
| +// sign-in landed and was deleted in fix round 1 on Task 1 — every |
| +// test built on NewForTest signs in for real via TestSignIn now. |
| func NewForTest(t *testing.T) (*App, error) { |
| t.Helper() |
| app, err := New(Config{ |
| - DBPath: filepath.Join(t.TempDir(), "docs.db"), |
| - Env: "development", |
| - DevAuth: true, |
| + DBPath: filepath.Join(t.TempDir(), "docs.db"), |
| + Env: "development", |
| }) |
| if err != nil { |
| return nil, err |
| @@ -35,6 +43,129 @@ func NewForTest(t *testing.T) (*App, error) { |
| return app, nil |
| } |
| +// TestSignIn mints a magic link for address and returns its path |
| +// (e.g. "/auth/verify?token=..."), suitable for |
| +// httptest.NewRequest(http.MethodGet, link, nil) directly. It never |
| +// sends real mail: it POSTs /signin with force=1 — the escape hatch |
| +// auth.Begin's own doc comment describes ("skips classification and |
| +// goes straight to the magic link") — so no outbound classification |
| +// call ever happens, and it reads the link back out of the app's |
| +// *recordingMailer (auth.go), installed only in development |
| +// (NewForTest's Env), rather than through any real transport. It is |
| +// test scaffolding, not a shortcut available in production: an App |
| +// built with a non-development Env has no recordingMailer and this |
| +// returns an error. |
| +// |
| +// Every later task's tests that need a signed-in request build on |
| +// this — get its shape right (a path, ready for httptest.NewRequest) |
| +// and address (a plain email string) is all that changes. |
| +func (a *App) TestSignIn(t *testing.T, address string) (string, error) { |
| + t.Helper() |
| + |
| + if a.mailRecorder == nil { |
| + return "", fmt.Errorf("docs: TestSignIn requires Env %q; app was built with Env %q", "development", a.cfg.Env) |
| + } |
| + |
| + form := url.Values{"address": {address}, "force": {"1"}} |
| + req := httptest.NewRequest(http.MethodPost, "/signin", strings.NewReader(form.Encode())) |
| + req.Header.Set("Content-Type", "application/x-www-form-urlencoded") |
| + // auth.Begin CSRF-checks this itself (its own doc comment: an |
| + // unauthenticated endpoint that sends mail and makes outbound |
| + // calls decided by the submitted address must never accept a |
| + // cross-site form) and csrf.Protect guards it again app-wide; |
| + // Sec-Fetch-Site: same-origin is exactly what a real same-origin |
| + // browser POST sends and satisfies both the same way |
| + // list_test.go's TestCreateThenRead does for POST /documents. |
| + req.Header.Set("Sec-Fetch-Site", "same-origin") |
| + |
| + rec := httptest.NewRecorder() |
| + a.Handler().ServeHTTP(rec, req) |
| + if rec.Code != http.StatusSeeOther { |
| + return "", fmt.Errorf("docs: TestSignIn: POST /signin: status %d, want %d", rec.Code, http.StatusSeeOther) |
| + } |
| + if loc := rec.Header().Get("Location"); !strings.Contains(loc, "sent=1") { |
| + return "", fmt.Errorf("docs: TestSignIn: POST /signin redirected to %q, want a %q outcome", loc, "?sent=1") |
| + } |
| + |
| + a.mailRecorder.mu.Lock() |
| + body := a.mailRecorder.lastBody |
| + a.mailRecorder.mu.Unlock() |
| + |
| + link := magicLinkPattern.FindString(body) |
| + if link == "" { |
| + return "", fmt.Errorf("docs: TestSignIn: no magic link found in the mailed body for %q", address) |
| + } |
| + return linkPath(link) |
| +} |
| + |
| +// TestPerson creates (or finds) a person by address and returns this |
| +// app's own numeric id for them — Store.EnsurePerson, the same |
| +// address-to-id mapping a real request goes through via handlers.go's |
| +// owner helper (auth.From(r).Address, never sessions.UserID — see |
| +// Document.OwnerID's doc comment). It is what Store.Create/List/Get's |
| +// owner parameter expects, and what Task 2's own tests use directly |
| +// against the store without any HTTP round trip at all: |
| +// |
| +// alice := app.TestPerson(t, "alice@example.com") |
| +// doc, err := app.Store().Create(ctx, alice, "Alice's private notes") |
| +// |
| +// TestPerson does not sign anyone in — no cookie, no session. For a |
| +// request that carries one, use TestRequest, which calls TestPerson |
| +// itself so the two always agree on who an address maps to. |
| +func (a *App) TestPerson(t *testing.T, address string) int64 { |
| + t.Helper() |
| + id, err := a.store.EnsurePerson(context.Background(), address) |
| + if err != nil { |
| + t.Fatalf("docs: TestPerson(%q): %v", address, err) |
| + } |
| + return id |
| +} |
| + |
| +// TestRequest builds an httptest request for method and path, signed |
| +// in as address: a real magic-link round trip (TestSignIn) mints the |
| +// session cookies, and TestPerson ensures address's Person row exists |
| +// first — the same row Store.Create/List/Get's owner id needs to |
| +// already be there for handlers.go's owner helper to find on the |
| +// request this returns. |
| +// |
| +// For a state-changing method (anything but GET/HEAD) it also sets |
| +// Sec-Fetch-Site: same-origin, exactly what a real same-origin |
| +// browser sends and csrf.Protect (app.go) checks first (TestSignIn's |
| +// own doc comment) — so a later task's signed-in POST does not have |
| +// to re-derive this to get past CSRF. |
| +// |
| +// req := app.TestRequest(t, "bob@example.com", http.MethodGet, "/d/"+doc.Sqid, nil) |
| +func (a *App) TestRequest(t *testing.T, address, method, path string, body io.Reader) *http.Request { |
| + t.Helper() |
| + |
| + // Ensure address's Person row exists before any handler on this |
| + // request reads it — EnsurePerson is get-or-create, so this is a |
| + // no-op if TestSignIn's own callback already created it via the |
| + // exact same call. |
| + a.TestPerson(t, address) |
| + |
| + link, err := a.TestSignIn(t, address) |
| + if err != nil { |
| + t.Fatalf("docs: TestRequest: TestSignIn(%q): %v", address, err) |
| + } |
| + verifyReq := httptest.NewRequest(http.MethodGet, link, nil) |
| + verifyRec := httptest.NewRecorder() |
| + a.Handler().ServeHTTP(verifyRec, verifyReq) |
| + cookies := verifyRec.Result().Cookies() |
| + if len(cookies) == 0 { |
| + t.Fatalf("docs: TestRequest: following the magic link for %q set no session cookie", address) |
| + } |
| + |
| + req := httptest.NewRequest(method, path, body) |
| + for _, c := range cookies { |
| + req.AddCookie(c) |
| + } |
| + if method != http.MethodGet && method != http.MethodHead { |
| + req.Header.Set("Sec-Fetch-Site", "same-origin") |
| + } |
| + return req |
| +} |
| + |
| // ReadFixture loads fixtures/data/<name>.update.bin — a real Yjs |
| // update produced by browser yjs (fixtures/generate.mjs), never |
| // hand-edited. Not in a _test.go file for the same reason as |
diff --git a/internal/docs/views/list.html b/internal/docs/views/list.html| index fd1167f..d1565f5 100644 |
| --- a/internal/docs/views/list.html |
| +++ b/internal/docs/views/list.html |
| @@ -3,16 +3,25 @@ |
| through rastrillo.T, EmptyTitle/EmptyBody the same; a row's Title |
| and When are the document's own data and are printed as-is. |
| - The header's <form> is the app's one create control (fix round |
| - 1): a plain POST to /documents, no JavaScript, sitting beside the |
| - titles the same way page-header's own single-link ActionHref |
| - would — [rst-page-header]'s flex/space-between rule lays it out |
| - as the second flex child without any markup of its own. It is |
| - outside the {{if .Groups}} branch below on purpose: the header |
| + The header's create <form> is the app's one create control (fix |
| + round 1 on Task 1): a plain POST to /documents, no JavaScript. It |
| + is outside the {{if .Groups}} branch below on purpose: the header |
| renders unconditionally, so the control reaches a person with no |
| documents yet exactly the same way it reaches everyone else, |
| rather than only appearing once there is something to list. |
| + [docs-header-actions] wraps that create form and the sign-out |
| + form (POST /signout, auth.go) as one group — added in a later fix |
| + round once real sign-in existed and nothing on the page could end |
| + one: [rst-page-header]'s flex/space-between rule wants at most two |
| + top-level children (titles, one action area), so both controls |
| + live inside this one wrapper rather than becoming a third |
| + top-level child of the header itself. Sign-out is a second, |
| + separate <form>, not a second button in the create form, because |
| + they are two unrelated actions with two different targets |
| + (/documents vs /signout) and must never be merged into one |
| + submit. |
| + |
| Writes the bare rst-* attribute grammar throughout — the rst- |
| vocabulary always as a bare attribute, never as a class — which is |
| this app's whole-screen markup convention (Task 1's home page set |
| @@ -55,11 +64,19 @@ |
| color: var(--rst-text-muted); |
| font-size: var(--rst-fs-sm); |
| } |
| + /* [docs-header-actions]: the header's one flex child opposite the |
| + titles — [rst-page-header]'s space-between rule places it there |
| + with no layout rule of its own beyond this; everything it holds |
| + (the create form, the sign-out form) lays out as one inline |
| + group inside it. */ |
| + [docs-header-actions] { |
| + display: flex; |
| + flex-wrap: wrap; |
| + align-items: center; |
| + gap: var(--rst-sp-3); |
| + } |
| /* [docs-create-form]: the title field and its submit button as one |
| - inline row, matching page-header's own single-action slot — it is |
| - the header's second flex child, so [rst-page-header]'s |
| - space-between rule places it opposite the titles with no layout |
| - rule of its own beyond this. */ |
| + inline row. */ |
| [docs-create-form] { |
| display: flex; |
| flex-wrap: wrap; |
| @@ -74,10 +91,15 @@ |
| <div rst-page-header-titles> |
| <h1>{{.Title}}</h1> |
| </div> |
| - <form docs-create-form method="post" action="/documents"> |
| - <input rst-input type="text" name="title" placeholder="{{.CreatePlaceholder}}" aria-label="{{.CreateLabel}}" autocomplete="off"> |
| - <button rst-btn="primary" type="submit">{{.CreateLabel}}</button> |
| - </form> |
| + <div docs-header-actions> |
| + <form docs-create-form method="post" action="/documents"> |
| + <input rst-input type="text" name="title" placeholder="{{.CreatePlaceholder}}" aria-label="{{.CreateLabel}}" autocomplete="off"> |
| + <button rst-btn="primary" type="submit">{{.CreateLabel}}</button> |
| + </form> |
| + <form method="post" action="/signout"> |
| + <button rst-btn="ghost" type="submit">{{.SignOutLabel}}</button> |
| + </form> |
| + </div> |
| </header> |
| {{if .Groups}} |
| <div rst-list> |
diff --git a/internal/docs/views/show.html b/internal/docs/views/show.html| index d7584e5..443710e 100644 |
| --- a/internal/docs/views/show.html |
| +++ b/internal/docs/views/show.html |
| @@ -75,6 +75,15 @@ |
| <div rst-page-header-titles> |
| <h1>{{.Title}}</h1> |
| </div> |
| + {{/* Sign-out control (POST /signout, auth.go) — same reasoning as |
| + list.html's own [docs-header-actions] comment: a session lasts |
| + 30 days and this page needs a working way to end one on |
| + purpose, not just list.html. A single form here is its own |
| + header's one flex child, so no wrapper is needed the way |
| + list.html's create form forced one. */}} |
| + <form method="post" action="/signout"> |
| + <button rst-btn="ghost" type="submit">{{.SignOutLabel}}</button> |
| + </form> |
| </header> |
| <div docs-canvas> |
| <div docs-page> |
diff --git a/internal/docs/views/signin.html b/internal/docs/views/signin.html| new file mode 100644 |
| index 0000000..8517e0e |
| --- /dev/null |
| +++ b/internal/docs/views/signin.html |
| @@ -0,0 +1,51 @@ |
| +{{/* signin — GET /signin (auth.go's signin handler). This is the |
| + page rastrillo/auth's own handlers never render themselves |
| + (magic-links.md: "the sign-in page stays yours") — Begin, |
| + Callback and Verify report every outcome by redirecting back |
| + here with a query this page reads: ?sent=1 once a link has been |
| + emailed, ?err=rate|address|expired|1 when something went wrong. |
| + auth.go's signin handler resolves those into Sent/ErrorMessage |
| + already-translated strings; this template only decides whether |
| + to show them. |
| + |
| + The form itself POSTs straight to rastrillo/auth's own Begin |
| + (mounted at POST /signin, app.go) — there is no handler of this |
| + app's own in that path at all. force=1: this app never offers a |
| + passkey or keymail ceremony (spec §8.1's ruling — see auth.go's |
| + package comment), so there is nothing for a person to choose |
| + between; force always sends the plain magic link, skipping |
| + signin's classification network call outright rather than |
| + silently paying its latency for a decision this app never acts |
| + on. |
| + |
| + Writes the bare rst-* attribute grammar throughout, same |
| + convention as list.html/show.html and the same test |
| + (render_test.go's TestOurTemplatesUseTheAttributeGrammar) holds |
| + it to. The "callout" partial is rastrillo's own (ui/partials); |
| + invoking it here embeds none of its own markup into this file's |
| + bytes at all — only the {{template}} call does, which is exactly |
| + what that test walks. */}} |
| +{{define "title"}}{{.Title}}{{end}} |
| +{{define "content"}} |
| +<header rst-page-header> |
| + <div rst-page-header-titles> |
| + <h1>{{.Title}}</h1> |
| + </div> |
| +</header> |
| +{{if .Sent}} |
| +{{template "callout" dict "Body" .Sent "Tone" "positive"}} |
| +{{end}} |
| +{{if .ErrorMessage}} |
| +{{template "callout" dict "Body" .ErrorMessage "Tone" "negative" "Alert" true}} |
| +{{end}} |
| +<div rst-box> |
| + <div rst-box-head> |
| + <p>{{.Body}}</p> |
| + </div> |
| + <form method="post" action="/signin"> |
| + <input type="hidden" name="force" value="1"> |
| + <input rst-input type="email" name="address" placeholder="{{.AddressLabel}}" aria-label="{{.AddressLabel}}" autocomplete="email" required> |
| + <button rst-btn="primary" type="submit">{{.SubmitLabel}}</button> |
| + </form> |
| +</div> |
| +{{end}} |
diff --git a/internal/lint/no_sessions_userid_test.go b/internal/lint/no_sessions_userid_test.go| new file mode 100644 |
| index 0000000..09f8c3e |
| --- /dev/null |
| +++ b/internal/lint/no_sessions_userid_test.go |
| @@ -0,0 +1,185 @@ |
| +// Package lint holds build-time guards over this repo's own source — |
| +// properties that used to be enforced only by prose in a doc comment, |
| +// promoted to something `go test ./...` (and so scripts/wire-gate.sh's |
| +// "go: vet, test, gofmt" step) cannot pass without. |
| +package lint |
| + |
| +import ( |
| + "go/ast" |
| + "go/parser" |
| + "go/token" |
| + "os" |
| + "path/filepath" |
| + "strconv" |
| + "strings" |
| + "testing" |
| +) |
| + |
| +// bannedSessionsUserIDPackage and bannedSessionsUserIDFunc identify |
| +// the one call this repo's own code must never make. See |
| +// TestNoSessionsUserID's own doc comment for why. |
| +const ( |
| + bannedSessionsUserIDPackage = "github.com/carlosframework/rastrillo/sessions" |
| + bannedSessionsUserIDFunc = "UserID" |
| +) |
| + |
| +// repoRoot resolves the module root from this test's own directory — |
| +// internal/lint, two levels below it — the same "../.." relative-path |
| +// convention testing.go's ReadFixture already uses from internal/docs |
| +// (also two levels below the root), which works because `go test` |
| +// always runs with the package's own directory as its working |
| +// directory. |
| +func repoRoot(t *testing.T) string { |
| + t.Helper() |
| + root, err := filepath.Abs(filepath.Join("..", "..")) |
| + if err != nil { |
| + t.Fatalf("lint: resolve repo root: %v", err) |
| + } |
| + if _, err := os.Stat(filepath.Join(root, "go.mod")); err != nil { |
| + t.Fatalf("lint: %s does not look like the module root (no go.mod): %v", root, err) |
| + } |
| + return root |
| +} |
| + |
| +// skipDir reports whether WalkDir should not descend into a |
| +// directory: version control, and the two large generated/vendored |
| +// trees this repo's own build produces (fixtures/node_modules from |
| +// `npm ci`, core/target from `cargo build`) — neither holds Go source |
| +// this app wrote, and both are large enough that walking them would |
| +// only slow this test down for nothing. |
| +func skipDir(name string) bool { |
| + switch name { |
| + case ".git", "node_modules", "target": |
| + return true |
| + } |
| + return false |
| +} |
| + |
| +// TestNoSessionsUserID is a build-time guard, not a comment: this |
| +// repo's own code must never call sessions.UserID (rastrillo/auth's |
| +// underlying sessions package), because under the magic-link plugin |
| +// this app uses, the session Subject is the verified email address |
| +// itself — sessions.UserID's own contract can only ever return |
| +// (0, false) here, on every request, with no way for that to change |
| +// short of switching auth plugins. A version of this app's own code |
| +// that called it anyway would not error or panic; it would silently |
| +// scope a query to person/user id 0 and behave like an empty result — |
| +// the same failure class rastrillo's own scope.Owned has on a table |
| +// that grows sharing (store.go's own doc comment on `owned`): wrong |
| +// answers that look like "you just have nothing here," not a crash |
| +// that gets noticed. |
| +// |
| +// Four comments (models.go, store.go, handlers.go, testing.go) have |
| +// said "never sessions.UserID" since Task 1/2. A comment is a code |
| +// review that has to succeed every single time a future change is |
| +// made near this code; this test is what makes a violation impossible |
| +// to land instead of merely discouraged. It walks this repo's own Go |
| +// AST rather than grepping for the symbol, deliberately: a text scan |
| +// would have to carve out an exemption for every one of those four |
| +// comments (and grow that exemption list forever), and could never |
| +// tell a real call apart from a comment or a string that merely |
| +// mentions the name. Flagging only actual call expressions — Fun |
| +// resolving, through the file's own import spec, to |
| +// sessions.UserID — needs no exemption list at all and cannot be |
| +// fooled by a comment quoting the symbol, including this test's own. |
| +// |
| +// Credit: adapted from the Sheets session's TestNoSessionsUserID, |
| +// the same guard for the same reason under the same auth plugin. |
| +func TestNoSessionsUserID(t *testing.T) { |
| + root := repoRoot(t) |
| + fset := token.NewFileSet() |
| + |
| + err := filepath.WalkDir(root, func(path string, d os.DirEntry, err error) error { |
| + if err != nil { |
| + return err |
| + } |
| + if d.IsDir() { |
| + if path != root && skipDir(d.Name()) { |
| + return filepath.SkipDir |
| + } |
| + return nil |
| + } |
| + if !strings.HasSuffix(path, ".go") { |
| + return nil |
| + } |
| + |
| + f, err := parser.ParseFile(fset, path, nil, parser.SkipObjectResolution) |
| + if err != nil { |
| + return err |
| + } |
| + |
| + // aliases maps this file's own local identifier for the |
| + // banned package to true (an ordinary named or default |
| + // import) — resolved per file, because the same package can |
| + // legally be imported under different local names in |
| + // different files. dotImported is set instead when the file |
| + // dot-imports it, which puts UserID in scope unqualified |
| + // (a bare *ast.Ident call, no selector at all) rather than as |
| + // sessions.UserID — the AST shape a plain "sessions.UserID" |
| + // text search would never even think to look for, and |
| + // exactly the kind of gap a symbol-precise walk closes for |
| + // free. |
| + aliases := map[string]bool{} |
| + dotImported := false |
| + for _, imp := range f.Imports { |
| + importPath, err := strconv.Unquote(imp.Path.Value) |
| + if err != nil || importPath != bannedSessionsUserIDPackage { |
| + continue |
| + } |
| + switch { |
| + case imp.Name == nil: |
| + aliases["sessions"] = true // the package's own default name |
| + case imp.Name.Name == ".": |
| + dotImported = true |
| + case imp.Name.Name == "_": |
| + // blank-imported for side effects only; nothing in |
| + // this file can reference it by name at all. |
| + default: |
| + aliases[imp.Name.Name] = true |
| + } |
| + } |
| + if len(aliases) == 0 && !dotImported { |
| + // This file does not import the banned package under any |
| + // name — nothing it writes can possibly call it, however |
| + // often it mentions the name in a comment or a string. |
| + return nil |
| + } |
| + |
| + ast.Inspect(f, func(n ast.Node) bool { |
| + call, ok := n.(*ast.CallExpr) |
| + if !ok { |
| + return true |
| + } |
| + switch fn := call.Fun.(type) { |
| + case *ast.SelectorExpr: |
| + if fn.Sel.Name != bannedSessionsUserIDFunc { |
| + return true |
| + } |
| + id, ok := fn.X.(*ast.Ident) |
| + if !ok || !aliases[id.Name] { |
| + return true |
| + } |
| + case *ast.Ident: |
| + if !dotImported || fn.Name != bannedSessionsUserIDFunc { |
| + return true |
| + } |
| + default: |
| + return true |
| + } |
| + pos := fset.Position(call.Pos()) |
| + rel, relErr := filepath.Rel(root, pos.Filename) |
| + if relErr != nil { |
| + rel = pos.Filename |
| + } |
| + t.Errorf( |
| + "%s:%d: calls sessions.UserID — banned in this app: under the magic-link plugin the session Subject is the verified email address, so this can only ever return (0, false) here; use auth.From(r).Address and Store.EnsurePerson instead (see handlers.go's owner helper)", |
| + rel, pos.Line, |
| + ) |
| + return true |
| + }) |
| + return nil |
| + }) |
| + if err != nil { |
| + t.Fatalf("lint: walk %s: %v", root, err) |
| + } |
| +} |
diff --git a/scripts/run-dev.sh b/scripts/run-dev.sh| index b560598..5be306c 100755 |
| --- a/scripts/run-dev.sh |
| +++ b/scripts/run-dev.sh |
| @@ -1,20 +1,40 @@ |
| #!/usr/bin/env bash |
| -# One command to see the app in a browser: build it with the |
| -# development-only identity (internal/docs/devauth.go) turned on, |
| -# against a scratch database that starts empty every run, on a fixed |
| -# local port so the URL below is always the same one to open. |
| +# One command to see the app in a browser: build it with -env |
| +# development (cmd/docs/main.go's -env flag — see docs.Config.Env's |
| +# own doc comment for why this is a flag of its own rather than |
| +# riding on some other one), against a scratch database that starts |
| +# empty every run, on a fixed local port so the URL below is always |
| +# the same one to open. |
| # |
| -# Never for anything but a laptop. -dev-auth only exists in a normal |
| -# build — cmd/docs/devauth_flag_release.go compiles it out entirely |
| -# with `-tags release`, which is what an actual deploy must build |
| -# instead; see that file for what a build tag closes and what it does |
| -# not. |
| +# Never for anything but a laptop: -env development is what lets an |
| +# empty $DOCS_INSTANCE_KEY and unconfigured mail default instead of |
| +# refusing to start (internal/docs/auth.go), and what makes a magic |
| +# link land in this process's own log instead of a real mailbox — |
| +# fine for a throwaway local database, never for anything real. There |
| +# used to also be a development-only stand-in identity gated the same |
| +# way (internal/docs/devauth.go, turned on with -dev-auth); it had |
| +# zero readers once real sign-in landed and was deleted in fix round |
| +# 1 on Task 1 — this script now signs in for real, the same magic |
| +# link a real deployment would use, just logged instead of mailed. |
| set -euo pipefail |
| cd "$(dirname "$0")/.." |
| port="${DOCS_DEV_PORT:-8099}" |
| addr="localhost:${port}" |
| +# -seed (cmd/docs/main.go) populates the fresh database above with |
| +# CARLOS Docs' own sample documents (internal/docs/seed.go) — real |
| +# English prose applied through the CRDT, not lorem ipsum — so the |
| +# canvas, the typography and the list have something worth looking at |
| +# from the first run rather than two words in an empty document. |
| +# Gated to Env == development the same way this whole script is (see |
| +# the header comment above): -seed refuses outright against anything |
| +# else, so it can never reach a database that might hold real work. |
| +# $DOCS_SEED_ADDRESS overrides which address gets the sample set, |
| +# same DOCS_DEV_PORT-style override as above; unset, main.go's own |
| +# devSeedAddress ("dev@example.com") is used. |
| +seed_address="${DOCS_SEED_ADDRESS:-dev@example.com}" |
| + |
| # A scratch database per run, not docs.db in the repo root: this |
| # script exists so anyone can see the app from nothing, not so it |
| # accumulates state nobody wrote down. mktemp -d rather than a fixed |
| @@ -49,6 +69,7 @@ go build -o "${scratch}/docs" ./cmd/docs |
| # explicitly there (internal/docs/app.go's Config.Origin doc comment). |
| echo "docs: dev database at ${db}" |
| echo "docs: http://${addr}/" |
| +echo "docs: seeded sample documents for ${seed_address} — sign in with that address to see them" |
| # exec, not a plain invocation: this script's own PID becomes the |
| # server's PID, so a signal sent to it (Ctrl-C, or `kill` on the |
| @@ -56,4 +77,4 @@ echo "docs: http://${addr}/" |
| # rastrillo.Serve's own SIGTERM/SIGINT drain runs instead of orphaning |
| # a listener nothing can reach or clean up afterward. See the comment |
| # above the trap for what this costs. |
| -exec "${scratch}/docs" -dev-auth -addr "${addr}" -db "${db}" |
| +exec "${scratch}/docs" -env development -seed -addr "${addr}" -db "${db}" |