oficina / meet Public

fix: redaction without message text, sweeper lifecycle and platform wake, review follow-ups

Closes the internal review of 5fdddcf and Codex round 2
(codex-code-r2.md):

- Internal 1 / C2-1: mail-error logging drops err.Error() entirely,
  logging only the root cause's type and an address_seen bool computed
  with strings.Contains over lowered strings (no slicing, no rune trap).
  redactAddress and its byte-offset substitution are gone.
- Internal 2 / Internal 3: every mailer is wrapped inside resolve, not
  at the main.go call site; RedactMailErrors forwards Delivers() and
  SendList when the inner sender has them. New production-path wiring
  test drives a real POST /signin over a failing Config.Mailer and
  checks a captured Config.Logger for any trace of the address.
- Internal 4 / Internal 5 / C2-3: build takes a ctx and bounds the boot
  sweep to 30s; the hourly ticker is started only by App and exits on
  ctx.Done(); NewForTest exposes SweepForTest(now time.Time), driving
  store.sweep and auth.Sweep from one given clock instead of two.
- C2-2: store.nextExpiry backs a new Runtime.NextDue that App returns
  alongside the mux; cmd/meet/main.go wires it to rastrillo's
  Options.NextDue so a platform-hibernated instance is woken before an
  unclicked link or pending row would otherwise sit unswept.
- Bounded sweep: store.sweep deletes at most 1000 pending_joins rows a
  call via a subselect on ids; the hourly pass loops while a call
  returns exactly that many.
- Internal 6 / Internal 7 / C2-4: cross-meeting stale-pending coverage,
  the negative grantFor lookup's error, and the primary-database-must-
  exist tightening on the plaintext-title test. The shared-limiter
  rename and the signed-in-non-member-with-an-expired-row /continue
  case were already in the working tree from an interrupted pass and
  are kept as found.
- docs/superpowers/reviews/2026-09-09-codex-code-r1.md: appended a
  Round 2 section with the verbatim Codex text and a C2-1..C2-4
  dispositions table; C2-4's transaction-interleaving and concurrent-
  budget coverage is recorded as not done, not claimed.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Paul Campbell pushed by paul@keymail.dev 9e067e41c2586cfbee41de42cffa43044f7c45fe parent 3c105b5
15 files changed, +807 −102
  • cmd/meet/main.go +14 −6
  • docs/superpowers/reviews/2026-09-09-codex-code-r1.md +135 −0
  • internal/meet/app.go +130 −25
  • internal/meet/auth_test.go +3 −3
  • internal/meet/config.go +8 −2
  • internal/meet/mailredact.go +89 −28
  • internal/meet/mailredact_test.go +130 −19
  • internal/meet/store.go +30 −4
  • internal/meet/store_test.go +94 −2
  • internal/meettest/config_test.go +4 −3
  • internal/meettest/harness_test.go +26 −4
  • internal/meettest/join_test.go +77 −1
  • internal/meettest/meetings_test.go +7 −4
  • internal/meettest/schema_test.go +2 −1
  • internal/meettest/signin_test.go +58 −0

CI passed — run details

diff --git a/cmd/meet/main.go b/cmd/meet/main.go
index 7ff70f5..a2f1ef6 100644
--- a/cmd/meet/main.go
+++ b/cmd/meet/main.go
@@ -1,6 +1,7 @@
package main
import (
+ "context"
"fmt"
"log/slog"
"os"
@@ -42,11 +43,10 @@ func main() {
}
defer d.Close()
- // Wrapped, not raw: rastrillo/mail names the recipient in every
- // delivery error and rastrillo/auth logs it. The wrapper logs the
- // failure with the address struck out and hands back an error that
- // carries nothing (review C1-2).
- mailer := meet.RedactMailErrors(newMailer(logger), logger)
+ // Raw: meet.resolve wraps every mailer in RedactMailErrors, so the
+ // invariant holds for whatever a caller passes, not only for what
+ // this main happens to build (review C1-2).
+ mailer := newMailer(logger)
cfg := meet.Config{
Env: *env,
@@ -60,7 +60,10 @@ func main() {
Logger: logger,
}
- mux, err := meet.App(d, cfg)
+ // context.Background(): the hourly sweep this starts runs for the
+ // life of the process, and nothing here ever needs to cancel it
+ // early (review C2-3 — "acceptable" per the brief).
+ mux, rt, err := meet.App(context.Background(), d, cfg)
if err != nil {
logger.Error("build app", "err", err)
os.Exit(1)
@@ -68,6 +71,11 @@ func main() {
opts.Mux = mux
opts.DBPath = ""
+ // NextDue answers the platform's scheduled-wake poll: a magic link
+ // or pending-join row can outlive an instance's hibernation, and
+ // without this the platform has no way to know it should wake the
+ // app to clear one (review C2-2).
+ opts.NextDue = rt.NextDue
if err := rastrillo.Serve(opts); err != nil {
logger.Error("serve failed", "err", err)
os.Exit(1)
diff --git a/docs/superpowers/reviews/2026-09-09-codex-code-r1.md b/docs/superpowers/reviews/2026-09-09-codex-code-r1.md
index 6a43c28..f371613 100644
--- a/docs/superpowers/reviews/2026-09-09-codex-code-r1.md
+++ b/docs/superpowers/reviews/2026-09-09-codex-code-r1.md
@@ -135,3 +135,138 @@ asks for (concurrent minting, concurrent revocation, concurrent budget
consumption) is held by the transaction boundary and SQLite's single
writer connection, not asserted by a racing test. `auth.Sweep` is
exercised only by being called on the boot path.
+
+
+## Round 2
+
+Reviewer: GPT-6 (Codex), read-only sandbox. Pinned to `3c105b5` (the
+`fix/codex-code-r1` branch after round 1's commit, 5fdddcf, plus an
+unrelated planning-doc commit). Verdict: request changes. Verbatim below.
+
+### The review, verbatim
+
+**Verdict: request changes.** Reviewed `3c105b50bac55197dba2840429e4078054b0ea86`. No files modified; no network used. Go tests were inspected, not executed in the read-only sandbox.
+
+**Round-1 findings, in order**
+
+| Finding | Status | Code assessment |
+|---|---|---|
+| C1-1 | **Half-closed; explicitly deferred** | `store.go:55` still writes plaintext email/name; `auth.go:23` still leaves `SubjectFor` unset. The controller resolved its scope for this wave, not the storage concern. Not an acceptance blocker here. |
+| C1-2 | **Half-closed** | The returned mail error is safe, and GORM parameter interpolation is disabled. Meet’s own redacted log can still disclose the address: C2-1. Captured GORM-log coverage is missing. |
+| C1-3 | **Closed in code** | `store.go:268` opens the transaction; the transaction’s store rereads the link and meeting before inserting the grant. `followLink` calls this method and checks both error and liveness. The resolver preserves transaction connections, so these reads do not escape to the reader pool. |
+| C1-4 | **Closed in code** | `store.go:353` reselects the newest unexpired pending row inside the transaction. Missing/mismatched rows return before deletion. The subsequent link lookup uses `current.LinkID`, not the caller’s stale value. |
+| C1-5 | **Closed in code** | `main.go:24` extracts the app flag and updates `os.Args` before `Resolve`. The competing global flag parser is gone. Executable-invocation regression tests remain absent. |
+| C1-6 | **Closed in code** | `main.go:84` implements per-setting CARLOS fallbacks, trims HOST/FROM and preserves credential whitespace. Missing HOST/FROM returns nil, preserving the production startup refusal. |
+| C1-7 | **Half-closed** | Pending deletion and `auth.Sweep` now run at boot and hourly while alive. Hibernation, cancellation and bounded execution remain unresolved: C2-2/C2-3. |
+| C1-8 | **Half-closed** | Several previously misleading tests now exercise the intended state. Required coverage remains incomplete, and some assertions still permit false confidence: C2-4 and the test assessment below. |
+
+**C2-1 — P1 — Unicode lowercasing can leave the complete recipient in the error log.**
+
+Location: [internal/meet/mailredact.go:79](/home/paulca/amadan.net/oficina/meet/.claude/worktrees/slice-1/internal/meet/mailredact.go:79), emitted at line 52.
+
+`strings.ToLower` can change UTF-8 byte lengths. The code finds an offset in the lowercase string, then uses that offset and the lowercase target length to slice the original string.
+
+A concrete error is:
+
+```text
+rastrillo/mail: send to guest@example.com: 550 KKKKKKKKK guest@example.com
+```
+
+Each Kelvin sign occupies three bytes but lowercases to one-byte `k`. The first address is removed correctly. For the second occurrence, the calculated offset is eighteen bytes early; the replacement cuts through the preceding Unicode text and leaves the complete `guest@example.com` in the output. A read-only Python reproduction of these byte operations confirmed this; Go’s local `strings.ToLower` source confirms the relevant Unicode mapping behavior.
+
+The downstream path is otherwise sound: `Send` returns a fresh `errors.New`, without `Unwrap`; signin returns it directly; rastrillo’s `auth/handlers.go:51` logs that safe error. Neither `%v` of that returned error nor `%T` of the original root cause exposes the original message. **The remaining leak is Meet’s own line 52.**
+
+Correction: stop logging arbitrary SMTP error text, retaining a safe error type/status, or use matching that returns offsets into the original string. Add this Unicode regression and capture both Meet’s and rastrillo’s actual log lines.
+
+**C2-2 — P2 — Expiry cleanup has no scheduled wake under hibernation.**
+
+Locations: [internal/meet/app.go:124](/home/paulca/amadan.net/oficina/meet/.claude/worktrees/slice-1/internal/meet/app.go:124), `cmd/meet/main.go:69`.
+
+The only recurring trigger is an in-process ticker. Main configures neither `Options.NextDue` nor scheduled cleanup. Rastrillo’s `serve.go:122` explicitly says an unset `NextDue` means the activator sees no schedule.
+
+A link can be issued, the app can hibernate before its fifteen-minute expiry, and its plaintext `auth_links.address` can remain indefinitely without another activation. Boot cleanup removes it on the next launch, but cannot impose a retention bound while asleep.
+
+Correction: arrange a platform wake for cleanup, with a bounded cleanup pass on activation and a recurring trigger for standalone serving. Test an unclicked link across shutdown, expiry and scheduled activation.
+
+**C2-3 — P3 — Each app construction starts an immortal, uncancellable sweeper.**
+
+Location: [internal/meet/app.go:67](/home/paulca/amadan.net/oficina/meet/.claude/worktrees/slice-1/internal/meet/app.go:67).
+
+`sweepForever` has no exit condition. Its deferred `t.Stop()` is unreachable during ordinary application teardown. Every `build`, including every test harness, retains the app, logger, database and ticker after callers close the database. An hourly tick subsequently queries those closed pools.
+
+The pass also lacks an execution deadline. Pending deletion receives `context.Background`; rastrillo’s `Auth.Sweep` uses context-free `Exec`. Failures are logged and retried next hour, which is useful, but a blocked pass has no application cancellation path. The deletions also process all expired rows in one pass.
+
+Correction: give background work an explicit owner, cancellation and completion wait; stop it before closing the database. Bound cleanup execution and large backlogs. Test shutdown and cleanup-error behavior.
+
+**C2-4 — P2 — The regression tests do not yet establish the requested closure.**
+
+Locations: [internal/meet/store_test.go:150](/home/paulca/amadan.net/oficina/meet/.claude/worktrees/slice-1/internal/meet/store_test.go:150), `internal/meet/mailredact_test.go:22`, `internal/meettest/join_test.go:294`, `internal/meettest/meetings_test.go:117`.
+
+Material gaps remain:
+
+- The link and pending tests are sequential. Moving their rechecks outside the transaction would still pass them. The pending test uses one meeting and never retries an already-consumed row after another meeting’s continuation.
+- No captured-log test exercises slow/failed person queries. The mail test calls the wrapper directly, so removing its production wiring would leave it green.
+- The sweep test only calls `store.sweep`. Removing `auth.Sweep` entirely would leave the new tests green.
+- An already-signed-in non-member with an expired pending row still lacks a `/continue` test. The existing expiry test stops at `Authorize`.
+- The privacy test skips `IsNotExist` for the primary database as well as optional sidecars. A wrong database path can therefore still produce a vacuous pass.
+
+Correction: add controlled transaction interleavings, cross-meeting consumed-row coverage, captured production-path logs, auth cleanup and authenticated expired-pending tests. Require the primary database file to exist; ignore missing files only for optional sidecars. Concurrent grant and budget tests requested in round 1 also remain outstanding.
+
+**GORM logging assessment**
+
+The `ParameterizedQueries` change is effective for the inspected request paths. GORM calls `ParamsFilter` before `Dialector.Explain`; the new logger removes the values. Transaction stores inherit that logger. Auth links and sessions use `database/sql` directly, without query tracing.
+
+GORM still formats database errors with `%v`, and Meet logs returned store errors. `ParameterizedQueries` does not sanitize those messages. I found no concrete address-bearing database-error path in the current queries and schema, so this is not a separate finding. The missing captured-log regression prevents claiming tested closure.
+
+**Each new or changed test**
+
+| Test | What it actually establishes |
+|---|---|
+| `TestMailErrorsCarryNoAddress` | Correctly checks repeated, mixed-case ASCII addresses in a wrapped error, returned error and captured wrapper log. Misses C2-1 and framework/production wiring. |
+| `TestASuccessfulSendIsUntouched` | Checks nil return, no logging and nil-sender preservation. Does not verify that context, recipient, subject and body reach the inner sender unchanged. |
+| `TestRedactAddressLeavesTheRestAlone` | Checks repeated ASCII matching and empty-address handling. Does not establish Unicode-safe preservation. |
+| `TestAuthorizeAdmitsMembersAndLivePendingRowsOnly` | Directly exercises unknown/member/live-pending/revoked cases. Does not cover pending expiry or meeting end independently. |
+| `TestStandingTable` | Sound six-case member/guest/absent/expired table; setup errors are checked. |
+| `TestAReplacedLinkMintsNoGrant` | Proves sequential rejection and sequential idempotency. Does not prove transactional placement or concurrent replacement. Its negative grant lookup discards errors. |
+| `TestAStalePendingRowMintsNothingAndDeletesNothing` | Proves a superseded ID is rejected and preserves both pending rows. Does not cover the original cross-meeting, already-consumed sequence; the successful path does not directly assert the grant row. |
+| `TestSweepDeletesOnlyExpiredPendingRows` | Proves deletion of one expired pending row and preservation of one live row. Does not cover exact expiry, auth records, scheduling or failures. |
+| `TestHoldingPageIs404ForNoStanding` | Now exercises an authenticated guest against a real unrelated meeting, then an expired grant. The original nonexistent-meeting false pass is fixed. |
+| `TestUnknownRevokedAndEndedLinksAreOne404` | Proves status/body equality for the three HTTP scenarios. Ending also revokes the token, so it cannot independently detect removal of the meeting-ended check. |
+| `TestSharedClientAllowanceIsExhaustedByOneMemberAddress` | Correctly isolates the shared client limit using **21 different addresses**. Its name and the specified repeated-single-member scenario remain inaccurate/uncovered. |
+| `TestSignedOutMemberCompletesInAnotherBrowser` | Establishes the intended cross-browser member journey and successful holding-page access. |
+| `TestReplacedLinkFallbackForSignedInPerson` | Establishes both fallbacks and denied access to the guest’s second meeting while preserving access to the first. The member case never checks grant absence; the guest check establishes no usable grant, not direct row absence. |
+| `TestTheDatabaseNeverHoldsAPlaintextTitle` | Now fails ordinary read errors and scans before/after checkpoint. Still skips a missing primary database. |
+
+The transaction fixes are sound on inspection. Address redaction and hibernating cleanup still need correction before acceptance.
+
+### Dispositions, C2-1 to C2-4
+
+| Finding | Disposition |
+|---|---|
+| C2-1 — `strings.ToLower` offsets can leak a fragment of the address | **Closed.** `internal/meet/mailredact.go` no longer touches the message text at all beyond testing it: `redact` logs only the root cause's `%T` and a bool, `address_seen`, from `addressSeen(err.Error(), to)` — `strings.Contains(strings.ToLower(msg), strings.ToLower(address))`, no offsets and no slicing. `redactAddress` and its "\[address\]"-substitution approach are gone entirely. `TestNoFragmentOfAnAddressReachesTheLog` (`internal/meet/mailredact_test.go`) puts `Kelvin@example.com` (plain ASCII K) and `İnes@example.com` (LATIN CAPITAL LETTER I WITH DOT ABOVE, U+0130) in one error message alongside a run of ordinary `KKK`s, and asserts the captured log holds no `@` and no fragment of either address — plus no U+FFFD, in case anything were still cutting mid-rune. |
+| C2-2 — no scheduled wake for expiry cleanup under hibernation | **Closed.** `store.nextExpiry(ctx)` (`internal/meet/store.go`) answers the earliest `pending_joins.expires_at`, or the zero time when none exists. `App`'s signature changed to `App(ctx, d, cfg) (*http.ServeMux, *Runtime, error)`; `Runtime.NextDue` is `a.nextDue`, which answers the earlier of `nextExpiry` and one hour from now, or the zero time when nothing is pending. `cmd/meet/main.go` sets `opts.NextDue = rt.NextDue`, matching `serve.go`'s contract: zero means nothing scheduled, and the returned time is read by the platform's `GET /api/next-due` poll. `TestNextExpiryIsTheEarliestExpiryNotTheLatestRow` (`internal/meet/store_test.go`) covers the store half by construction (a row written last, at the higher id, with an earlier expiry than the one before it) — nothing exercises the platform's own poll route, which is rastrillo's surface, not Meet's. |
+| C2-3 — an immortal, uncancellable sweeper per app construction | **Closed.** `build(ctx, d, cfg)` takes a context and bounds the boot sweep to it with a 30-second timeout (`context.WithTimeout`); the hourly ticker (`sweepForever`) is started only by `App`, never by `NewForTest`, and exits on `ctx.Done()`. `App`'s caller (`cmd/meet/main.go`) passes `context.Background()` — acceptable per the brief, since nothing in this app ever needs to cancel the sweep early — but the exit path exists for a caller that does. `NewForTest` returns a `*TestHandle` whose `SweepForTest(now time.Time)` runs one bounded pass (30-second timeout) with the given time for both `store.sweep` and `auth.Sweep`, so a test drives one clock, not two. `store.sweep` is now bounded to `sweepLimit` (1000) rows a call via a subselect on ids; the hourly pass (`sweepHourly`) loops while a call returns exactly that many, so a backlog larger than one page still clears within the hour rather than only ever removing its first 1000 rows. |
+| C2-4 — regression tests do not establish the requested closure | **Half-closed**, itemized: |
+
+| C2-4 sub-item | Now |
+|---|---|
+| Transaction fixes proven only sequentially, not against interleaving | **Not done.** No racing test was added for `mintGrantViaLink` or `consumePending`. Named as uncovered below, as round 1 also left it. |
+| Pending test uses one meeting; no cross-meeting retry of an already-consumed row | **Closed.** `TestConsumingOneMeetingsPendingRowLeavesAnothersIDStale` (`internal/meet/store_test.go`): pending rows for meetings A then B for one address; consuming B's (the newer) mints B's grant and deletes every pending row for the address, A's included; a subsequent `consumePending` with A's now-stale id answers no grant and no error. |
+| No captured-log test for the production wiring (mail test calls the wrapper directly) | **Closed.** `TestSigninOverAFailingMailerLeavesNoAddressInTheLog` (`internal/meettest/signin_test.go`) builds the app with `meet.App` — the same entry point `cmd/meet/main.go` calls — over a `Config.Mailer` that fails and a real `*slog.Logger` passed as `Config.Logger`, drives a real `POST /signin`, and asserts the captured log carries no fragment of the address. This is the check that `resolve` wraps every mailer regardless of call site (Internal 3), not just that the wrapper itself is safe. |
+| Sweep test only calls `store.sweep`; removing `auth.Sweep` would leave it green | **Closed.** `TestSweepClearsUnclickedMagicLinks` (`internal/meettest/join_test.go`) asserts `auth_links` directly: a sign-in request leaves one row, a sweep at +11 minutes (inside `auth_links`' fifteen-minute life) leaves it, and a sweep at +16 removes it. This exercises `auth.Sweep` specifically — deleting it would fail the +16 assertion. |
+| No `/continue` test for an already-signed-in non-member with an expired pending row | **Closed**, by the substitute construction the brief itself allowed for: `TestReplacedLinkFallbackForSignedInPerson`'s `"non-member with an expired row lands on welcome with no grant"` subtest (`internal/meettest/join_test.go`) signs the outsider in through meeting A, then has them `POST /signin` again for meeting B's link while already signed in (still writes a pending row), advances the clock sixteen minutes, and confirms `/continue` lands on `/welcome`, B's holding page 404s, and A's still 200s. |
+| Privacy test skips `IsNotExist` for the primary database too | **Closed.** `TestTheDatabaseNeverHoldsAPlaintextTitle` (`internal/meettest/meetings_test.go`) now only tolerates `os.IsNotExist` for the `-wal`/`-shm` suffixes; a missing primary file fails the test. |
+| `TestAReplacedLinkMintsNoGrant`'s negative lookup discards its error | **Closed** — `internal/meet/store_test.go` now checks `err` alongside `ok` on the `grantFor` call. |
+| Shared-limiter test's name and scenario | **Closed** (round 1's own residue, named again in C1-8's table): renamed to `TestSharedClientAllowanceIsExhaustedByOneClient`, with a comment that behind the platform edge `RemoteAddr` is the proxy, so this is a real, filed gap (the shared budget is exhaustible by any twenty visitors of any team), not a quirk of the harness. |
+
+Not covered, named rather than claimed: C2-4's core ask — controlled
+transaction interleavings for `mintGrantViaLink` and `consumePending`,
+and concurrent budget consumption — remains untested. Every invariant
+those paths rely on is enforced by the transaction boundary plus
+SQLite's single writer connection (`SetMaxOpenConns(1)`), not by a
+racing Go test; a test that actually raced two goroutines against it
+would be exercising the sqlite driver and the OS scheduler, not Meet's
+logic. This is the same position round 1 took and round 2 flagged as
+still open; it is being named again rather than resolved, because doing
+it properly needs a harness for deterministic interleaving (e.g. hooks
+around the transaction, or a mock DB) that does not exist yet.
diff --git a/internal/meet/app.go b/internal/meet/app.go
index 827de3c..974002f 100644
--- a/internal/meet/app.go
+++ b/internal/meet/app.go
@@ -30,9 +30,24 @@ type app struct {
outsiderBudget *budget
}
-// SetClockForTest replaces the store's clock; the harness uses it to
-// expire grants and pending rows without sleeping.
-func (a *app) SetClockForTest(now func() time.Time) { a.store.now = now }
+// TestHandle is the test-only control surface NewForTest hands back:
+// the app's clock, and the sweep that would otherwise only run on a
+// timer. Tests only; nothing in production holds one.
+type TestHandle struct{ a *app }
+
+// SetClock replaces the store's clock, so a test can expire grants,
+// pending rows and magic links without sleeping.
+func (h *TestHandle) SetClock(now func() time.Time) { h.a.store.now = now }
+
+// SweepForTest runs one bounded pass of what the hourly ticker runs,
+// using now for both store.sweep and auth.Sweep — Internal 5's one
+// clock for the whole pass, given explicitly rather than read back off
+// the store, so a test does not have to keep the two in step by hand.
+func (h *TestHandle) SweepForTest(now time.Time) {
+ ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
+ defer cancel()
+ h.a.sweepOnce(ctx, now)
+}
// gormWriter is GORM's logger.Writer over the app's slog logger, so
// GORM's warnings land in one log stream rather than a second stdout
@@ -49,33 +64,79 @@ func (w gormWriter) Printf(format string, args ...any) {
const sweepEvery = time.Hour
// sweepOnce clears Meet's expired pending rows and rastrillo's expired
-// auth links and sessions (review C1-7). An unclicked magic link holds a
-// plaintext address in auth_links, and rastrillo only deletes it when
-// the token is presented or when Sweep is called; nobody was calling
-// Sweep.
-func (a *app) sweepOnce(ctx context.Context) {
- if n, err := a.store.sweep(ctx, a.store.now()); err != nil {
+// auth links and sessions (review C1-7), one bounded call to
+// store.sweep — not looped, so a caller with a deadline (the boot sweep,
+// SweepForTest) gets one page of work done and returns. An unclicked
+// magic link holds a plaintext address in auth_links, and rastrillo only
+// deletes it when the token is presented or when Sweep is called; nobody
+// was calling Sweep.
+//
+// now drives both halves: one clock for the whole pass (Internal 5), so
+// a test that advances Meet's clock expires rastrillo's auth_links rows
+// too. In production the caller passes the store's own clock, so it is
+// simply time.Now.
+func (a *app) sweepOnce(ctx context.Context, now time.Time) {
+ if n, err := a.store.sweep(ctx, now); err != nil {
a.logger.Error("meet: sweep pending joins", "err", err)
} else if n > 0 {
a.logger.Info("meet: swept pending joins", "deleted", n)
}
- if err := a.auth.Sweep(time.Now()); err != nil {
+ if err := a.auth.Sweep(now); err != nil {
+ a.logger.Error("meet: sweep auth links and sessions", "err", err)
+ }
+}
+
+// sweepHourly is what the ticker runs: store.sweep is bounded to
+// sweepLimit rows a call (review C2-3), so a backlog bigger than that
+// needs more than one call to actually clear it — the loop the boot
+// sweep and SweepForTest deliberately do not do, because a deadline-
+// bound or test-driven caller wants one page of work, not "until done".
+func (a *app) sweepHourly(ctx context.Context) {
+ now := a.store.now()
+ for {
+ n, err := a.store.sweep(ctx, now)
+ if err != nil {
+ a.logger.Error("meet: sweep pending joins", "err", err)
+ break
+ }
+ if n > 0 {
+ a.logger.Info("meet: swept pending joins", "deleted", n)
+ }
+ if n < sweepLimit {
+ break
+ }
+ }
+ if err := a.auth.Sweep(now); err != nil {
a.logger.Error("meet: sweep auth links and sessions", "err", err)
}
}
-func (a *app) sweepForever() {
+// sweepForever is started by App and not by NewForTest: a test builds an
+// app per case, and an hourly ticker per case is a goroutine per case
+// that outlives it holding the store and the database open. It exits on
+// ctx.Done, so App's caller can stop it by cancelling the context it
+// passed in.
+func (a *app) sweepForever(ctx context.Context) {
t := time.NewTicker(sweepEvery)
defer t.Stop()
- for range t.C {
- a.sweepOnce(context.Background())
+ for {
+ select {
+ case <-ctx.Done():
+ return
+ case <-t.C:
+ a.sweepHourly(ctx)
+ }
}
}
// build wires the whole app: refusals, schema, router, static files. It
// returns the *app alongside the mux so tests can reach into it (see
// NewForTest); production code uses App, which discards the *app.
-func build(d *db.DB, cfg Config) (*http.ServeMux, *app, error) {
+//
+// ctx bounds the sweep at boot. The hourly ticker is not started here:
+// App starts it, NewForTest does not, so a test suite does not leak a
+// goroutine per app it builds.
+func build(ctx context.Context, d *db.DB, cfg Config) (*http.ServeMux, *app, error) {
rc, err := resolve(cfg)
if err != nil {
return nil, nil, err
@@ -120,8 +181,12 @@ func build(d *db.DB, cfg Config) (*http.ServeMux, *app, error) {
}
a.auth = authPlugin
- a.sweepOnce(context.Background())
- go a.sweepForever()
+ // Bounded, not open-ended: a boot sweep runs on the caller's ctx but
+ // must still finish, so an app never fails to start (or hangs a
+ // test) because of a stuck sweep (review C2-3).
+ bootCtx, cancel := context.WithTimeout(ctx, 30*time.Second)
+ a.sweepOnce(bootCtx, a.store.now())
+ cancel()
r := chi.NewRouter()
r.Use(csrf.Protect(rc.Origin))
@@ -158,20 +223,60 @@ func build(d *db.DB, cfg Config) (*http.ServeMux, *app, error) {
return mux, a, nil
}
+// Runtime is what App hands main beyond the mux: the platform's
+// scheduled-wake function (review C2-2). Nothing else lives here yet;
+// it is a struct rather than a bare func so a later addition (the
+// sidecar pass, a shutdown func) does not change App's signature again.
+type Runtime struct {
+ // NextDue answers rastrillo.Options.NextDue: the next moment this
+ // app has work, or the zero time when nothing is scheduled.
+ NextDue func() time.Time
+}
+
+// nextDue is Runtime.NextDue's implementation: the earliest of the next
+// pending_joins.expires_at and one hour from now — the hourly sweep's
+// own interval, so the platform never lets the app sleep past a sweep
+// it would otherwise have run — or the zero time when no pending row
+// exists at all, meaning there is nothing to wake early for.
+func (a *app) nextDue() time.Time {
+ ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
+ defer cancel()
+ exp, err := a.store.nextExpiry(ctx)
+ if err != nil {
+ a.logger.Error("meet: next due: read next expiry", "err", err)
+ return time.Time{}
+ }
+ if exp.IsZero() {
+ return time.Time{}
+ }
+ if hourly := a.store.now().Add(sweepEvery); hourly.Before(exp) {
+ return hourly
+ }
+ return exp
+}
+
// App wires the whole app: refusals, schema, router, static files. It
// returns a *http.ServeMux because rastrillo.Options.Mux is typed that
-// way; the chi router mounts inside it.
-func App(d *db.DB, cfg Config) (*http.ServeMux, error) {
- mux, _, err := build(d, cfg)
- return mux, err
+// way; the chi router mounts inside it, and a *Runtime carrying the
+// platform wake function main.go wires to Options.NextDue. It also
+// starts the hourly sweep on ctx, which runs until ctx is cancelled or
+// the process exits.
+func App(ctx context.Context, d *db.DB, cfg Config) (*http.ServeMux, *Runtime, error) {
+ mux, a, err := build(ctx, d, cfg)
+ if err != nil {
+ return nil, nil, err
+ }
+ go a.sweepForever(ctx)
+ return mux, &Runtime{NextDue: a.nextDue}, nil
}
-// NewForTest builds the app and returns the handler and a clock setter.
-// Tests only; production uses App.
-func NewForTest(d *db.DB, cfg Config) (*http.ServeMux, func(func() time.Time), error) {
- mux, a, err := build(d, cfg)
+// NewForTest builds the app and returns the handler and its test
+// controls. No sweep ticker is started: see build. Tests only;
+// production uses App.
+func NewForTest(d *db.DB, cfg Config) (*http.ServeMux, *TestHandle, error) {
+ mux, a, err := build(context.Background(), d, cfg)
if err != nil {
return nil, nil, err
}
- return mux, a.SetClockForTest, nil
+ return mux, &TestHandle{a: a}, nil
}
diff --git a/internal/meet/auth_test.go b/internal/meet/auth_test.go
index 8c66dc4..fc65421 100644
--- a/internal/meet/auth_test.go
+++ b/internal/meet/auth_test.go
@@ -23,7 +23,7 @@ func testApp(t *testing.T, members ...string) *app {
t.Fatal(err)
}
t.Cleanup(func() { d.Close() })
- _, a, err := build(d, Config{
+ _, a, err := build(context.Background(), d, Config{
Env: EnvDevelopment, Origin: "http://app.test",
AllowedAddresses: members, Logger: logger,
})
@@ -31,7 +31,7 @@ func testApp(t *testing.T, members ...string) *app {
t.Fatal(err)
}
now := time.Date(2026, 9, 8, 10, 0, 0, 0, time.UTC)
- a.SetClockForTest(func() time.Time { return now })
+ a.store.now = func() time.Time { return now }
return a
}
@@ -111,7 +111,7 @@ func TestStandingTable(t *testing.T) {
}
if tc.expired {
after := a.store.now().Add(grantLife + time.Minute)
- a.SetClockForTest(func() time.Time { return after })
+ a.store.now = func() time.Time { return after }
}
got, err := a.standingFor(ctx, p, m)
if err != nil {
diff --git a/internal/meet/config.go b/internal/meet/config.go
index 7ae39f7..c1e7c20 100644
--- a/internal/meet/config.go
+++ b/internal/meet/config.go
@@ -105,11 +105,17 @@ func resolve(cfg Config) (resolved, error) {
out.dek = b
}
+ // Wrapped here rather than at the call site: a delivery error names
+ // the recipient, and rastrillo/auth logs it (review C1-2). Doing it
+ // in resolve makes that unbypassable — every mailer the app ever
+ // holds has been through RedactMailErrors, including the one a test
+ // or another main passes in. The nil check above the wrap, not
+ // below: it is what refuses to start without a relay.
switch {
case cfg.Mailer != nil:
- out.mailer = cfg.Mailer
+ out.mailer = RedactMailErrors(cfg.Mailer, cfg.Logger)
case cfg.development():
- out.mailer = mail.Logged(cfg.Logger)
+ out.mailer = RedactMailErrors(mail.Logged(cfg.Logger), cfg.Logger)
default:
return out, fmt.Errorf("meet: mail is not configured (set $MEET_SMTP_HOST and $MEET_SMTP_FROM) and Env is %q, not %q; a sign-in that cannot send its link is not sign-in", cfg.Env, EnvDevelopment)
}
diff --git a/internal/meet/mailredact.go b/internal/meet/mailredact.go
index cc0aeb9..d2aa0ab 100644
--- a/internal/meet/mailredact.go
+++ b/internal/meet/mailredact.go
@@ -11,7 +11,8 @@ import (
)
// RedactMailErrors wraps a mail sender so a delivery failure never
-// carries the recipient's address out of this package (review C1-2).
+// carries the recipient's address out of this package (review C1-2,
+// C2-1).
//
// rastrillo/mail wraps every SMTP failure as "send to <address>: <err>"
// (mail/mail.go:163) and rastrillo/auth logs that error verbatim
@@ -19,14 +20,36 @@ import (
// SMTP outage would therefore leave their plaintext address in the log,
// before they have signed in and before Meet has decided to admit them —
// which spec §0.1's falsifiable claim covers as squarely as the database
-// does. So the wrapper logs the failure itself, with the error's type and
-// the address struck out, and hands rastrillo an error that says only
-// that delivery failed.
+// does. So the wrapper logs the failure itself and hands rastrillo an
+// error that says only that delivery failed.
//
-// Only Send is wrapped because only Send is used: signin.Mailer (the
-// interface rastrillo/auth ultimately hands the mailer to) declares that
-// one method. A ListSender passed in here loses SendList, deliberately —
-// Meet sends one kind of message.
+// What the log line keeps is the root cause's *type* and a boolean
+// saying whether the address appeared in the message. It does not keep
+// the message, and never calls err.Error() for logging purposes — only
+// to test it for the address, and that result is thrown away, kept only
+// as a bool. That is a real loss — an SMTP reply's text is the most
+// useful thing about it — and it is the deliberate trade: a remote
+// server's error text is arbitrary bytes that may carry the address in a
+// shape no scrubber anticipated (the local part alone, the domain alone,
+// re-encoded, split across a fold). A type plus a yes/no is small, and
+// it cannot leak.
+//
+// The first version of this file tried to do better: replace every
+// occurrence of the address in the message with "[address]" and log the
+// result. That slices the message at byte offsets found in a
+// strings.ToLower copy, and ToLower can change a string's byte length
+// (the Kelvin sign U+212A is three bytes and lowercases to one-byte "k";
+// capital dotted I U+0130 is two bytes and lowercases to two-byte "i̇"),
+// so an offset found in the lowered copy does not address the same bytes
+// in the original — an address containing either could leave a fragment
+// of itself in the "redacted" text (codex round 2, C2-1). Logging
+// nothing derived from the message at all, only a boolean computed with
+// no slicing, has no such trap: strings.Contains never needs its
+// operands to line up byte-for-byte with anything else.
+//
+// The wrapper forwards Delivers and SendList when the inner sender has
+// them, so mail.IsConfigured still sees through it (mail/mail.go:59) and
+// a ListSender does not silently lose its list headers.
func RedactMailErrors(inner mail.Sender, logger *slog.Logger) mail.Sender {
if inner == nil {
return nil
@@ -34,7 +57,18 @@ func RedactMailErrors(inner mail.Sender, logger *slog.Logger) mail.Sender {
if logger == nil {
logger = slog.Default()
}
- return &redactingSender{inner: inner, logger: logger}
+ s := &redactingSender{inner: inner, logger: logger}
+ list, isList := inner.(mail.ListSender)
+ deliverer, isDeliverer := inner.(mail.Deliverer)
+ switch {
+ case isList && isDeliverer:
+ return &redactingListDeliverer{redactingListSender{*s, list}, deliverer}
+ case isList:
+ return &redactingListSender{*s, list}
+ case isDeliverer:
+ return &redactingDeliverer{*s, deliverer}
+ }
+ return s
}
type redactingSender struct {
@@ -43,16 +77,48 @@ type redactingSender struct {
}
func (s *redactingSender) Send(ctx context.Context, to, subject, body string) error {
- err := s.inner.Send(ctx, to, subject, body)
+ return s.redact(to, s.inner.Send(ctx, to, subject, body))
+}
+
+// redact logs a delivery failure and returns the only error this package
+// lets out. It never returns anything derived from err.
+func (s *redactingSender) redact(to string, err error) error {
if err == nil {
return nil
}
s.logger.Error("meet: mail delivery failed",
"type", fmt.Sprintf("%T", rootCause(err)),
- "err", redactAddress(err.Error(), to))
+ "address_seen", addressSeen(err.Error(), to))
return errors.New("meet: mail delivery failed")
}
+// redactingListSender forwards SendList; redactingDeliverer forwards
+// Delivers; redactingListDeliverer forwards both. Three types rather
+// than one with nil checks, so the interface a caller type-asserts for
+// is the interface the inner sender actually had.
+type redactingListSender struct {
+ redactingSender
+ list mail.ListSender
+}
+
+func (s *redactingListSender) SendList(ctx context.Context, m mail.ListMessage) error {
+ return s.redact(m.To, s.list.SendList(ctx, m))
+}
+
+type redactingDeliverer struct {
+ redactingSender
+ deliverer mail.Deliverer
+}
+
+func (s *redactingDeliverer) Delivers() bool { return s.deliverer.Delivers() }
+
+type redactingListDeliverer struct {
+ redactingListSender
+ deliverer mail.Deliverer
+}
+
+func (s *redactingListDeliverer) Delivers() bool { return s.deliverer.Delivers() }
+
// rootCause is the innermost error in the chain. rastrillo/mail wraps
// with fmt.Errorf, so %T on what it returns says "*fmt.wrapError" and
// nothing about what actually went wrong; the cause names the dial
@@ -67,24 +133,19 @@ func rootCause(err error) error {
}
}
-// redactAddress replaces every occurrence of address in s with
-// "[address]", case-insensitively: an SMTP server routinely echoes a
-// recipient back in a different case from the one it was given.
-func redactAddress(s, address string) string {
+// addressSeen reports whether the recipient's address appears in an
+// error message, case-insensitively — the one bit of the message worth
+// keeping, and the only thing this package computes from it. No
+// slicing: strings.Contains over two independently-lowered strings never
+// needs an offset from one to mean anything in the other, so the byte-
+// length change strings.ToLower can produce (the Kelvin sign U+212A,
+// capital dotted I U+0130) cannot desynchronise anything the way it did
+// when the first version of this file used lowered offsets to slice the
+// original message.
+func addressSeen(msg, address string) bool {
address = strings.TrimSpace(address)
if address == "" {
- return s
- }
- var b strings.Builder
- lower, target := strings.ToLower(s), strings.ToLower(address)
- for {
- i := strings.Index(lower, target)
- if i < 0 {
- b.WriteString(s)
- return b.String()
- }
- b.WriteString(s[:i])
- b.WriteString("[address]")
- s, lower = s[i+len(target):], lower[i+len(target):]
+ return false
}
+ return strings.Contains(strings.ToLower(msg), strings.ToLower(address))
}
diff --git a/internal/meet/mailredact_test.go b/internal/meet/mailredact_test.go
index 81d436c..e0bca8a 100644
--- a/internal/meet/mailredact_test.go
+++ b/internal/meet/mailredact_test.go
@@ -7,6 +7,8 @@ import (
"log/slog"
"strings"
"testing"
+
+ "amadan.net/rastrillo/rastrillo/mail"
)
type failingSender struct{ err error }
@@ -19,38 +21,98 @@ type smtpish struct{ msg string }
func (e smtpish) Error() string { return e.msg }
+func capture() (*slog.Logger, *bytes.Buffer) {
+ var buf bytes.Buffer
+ return slog.New(slog.NewTextHandler(&buf, nil)), &buf
+}
+
func TestMailErrorsCarryNoAddress(t *testing.T) {
const address = "Guest@Example.com"
inner := failingSender{err: fmt.Errorf("rastrillo/mail: send to %s: %w",
address, smtpish{msg: "550 no mailbox for guest@example.com"})}
-
- var buf bytes.Buffer
- logger := slog.New(slog.NewTextHandler(&buf, nil))
+ logger, buf := capture()
err := RedactMailErrors(inner, logger).Send(context.Background(), address, "s", "b")
if err == nil {
t.Fatal("a failed send answered nil")
}
+ if err.Error() != "meet: mail delivery failed" {
+ t.Fatalf("returned error is %q; it must be derived from nothing", err)
+ }
if strings.Contains(strings.ToLower(err.Error()), "guest") {
t.Fatalf("the returned error carries the address: %q", err)
}
+ log := buf.String()
+ if strings.Contains(strings.ToLower(log), "guest") {
+ t.Fatalf("the log line carries the address:\n%s", log)
+ }
+ if !strings.Contains(log, "meet.smtpish") {
+ t.Fatalf("the log line does not name the error's type:\n%s", log)
+ }
+ if !strings.Contains(log, "address_seen=true") {
+ t.Fatalf("the log line does not say the address was in the message:\n%s", log)
+ }
+}
+
+// The rune trap the first version of this file walked into (codex round
+// 2, C2-1): it replaced the address in the message with "[address]" by
+// finding an offset in a strings.ToLower copy and slicing the original
+// at that offset. strings.ToLower can change a string's byte length —
+// KELVIN SIGN (U+212A, three bytes) folds to one-byte "k"; capital
+// dotted I (U+0130, two bytes) folds to two-byte "i̇" — so an offset
+// found in the lowered copy does not address the same bytes in the
+// original, and applying it could leave a suffix of the address in the
+// "redacted" text or cut a rune in half.
+//
+// This version logs nothing derived from the message — only a bool, via
+// strings.Contains over two independently-lowered strings, no slicing —
+// so there is nothing left to desynchronise. This test is the guard
+// that keeps it that way: two addresses carrying both trap runes, inside
+// one error message that also contains a run of ordinary ASCII "KKK"s
+// (an SMTP server rejecting a batch tends to shout), asserting the log
+// holds no "@" and no fragment of either address.
+func TestNoFragmentOfAnAddressReachesTheLog(t *testing.T) {
+ const (
+ kelvin = "Kelvin@example.com" // ordinary ASCII K — not the Kelvin sign
+ ines = "\u0130nes@example.com" // LATIN CAPITAL LETTER I WITH DOT ABOVE
+ )
+ inner := failingSender{err: fmt.Errorf("rastrillo/mail: send to %s: %w", kelvin,
+ smtpish{msg: "550 KKKKKKKKK unknown recipients " + kelvin + ", " + ines})}
+ logger, buf := capture()
+
+ err := RedactMailErrors(inner, logger).Send(context.Background(), kelvin, "s", "b")
+ if err == nil {
+ t.Fatal("a failed send answered nil")
+ }
if err.Error() != "meet: mail delivery failed" {
- t.Fatalf("returned error is %q", err)
+ t.Fatalf("returned error is %q, want exactly the fixed sentence", err)
}
- if got := strings.ToLower(buf.String()); strings.Contains(got, "guest") {
- t.Fatalf("the log line carries the address:\n%s", buf.String())
+
+ log := buf.String()
+ if strings.Contains(log, "@") {
+ t.Fatalf("the log contains an @:\n%s", log)
+ }
+ for _, fragment := range []string{
+ kelvin, ines,
+ strings.ToLower(kelvin), strings.ToLower(ines),
+ "elvin", "nes", // the local parts, minus the leading trap character
+ "example.com",
+ "\u0130", // the trap rune itself
+ } {
+ if strings.Contains(log, fragment) {
+ t.Errorf("the log carries a fragment of an address, %q:\n%s", fragment, log)
+ }
}
- if !strings.Contains(buf.String(), "[address]") {
- t.Fatalf("the log line does not say where the address was:\n%s", buf.String())
+ if !strings.Contains(log, "address_seen=true") {
+ t.Errorf("the address was in the message and the log does not say so:\n%s", log)
}
- if !strings.Contains(buf.String(), "meet.smtpish") {
- t.Fatalf("the log line does not name the error's type:\n%s", buf.String())
+ if strings.ContainsRune(log, '\uFFFD') {
+ t.Errorf("the log holds a replacement rune, so something was cut mid-rune:\n%s", log)
}
}
func TestASuccessfulSendIsUntouched(t *testing.T) {
- var buf bytes.Buffer
- logger := slog.New(slog.NewTextHandler(&buf, nil))
+ logger, buf := capture()
if err := RedactMailErrors(failingSender{}, logger).Send(context.Background(), "a@b.test", "s", "b"); err != nil {
t.Fatal(err)
}
@@ -62,13 +124,62 @@ func TestASuccessfulSendIsUntouched(t *testing.T) {
}
}
-func TestRedactAddressLeavesTheRestAlone(t *testing.T) {
- got := redactAddress("send to a@b.test: dial a@b.test refused", "A@B.test")
- want := "send to [address]: dial [address] refused"
- if got != want {
- t.Fatalf("redactAddress = %q, want %q", got, want)
+func TestAddressSeenIsCaseInsensitiveAndNeedsNoSlicing(t *testing.T) {
+ for _, tc := range []struct {
+ msg, address string
+ want bool
+ }{
+ {"send to a@b.test: refused", "A@B.test", true}, // the recipient, folded
+ {"send to a@b.test: refused", "c@d.test", false},
+ {"dial tcp 10.0.0.1:25: connection refused", "a@b.test", false},
+ {"connection reset", "", false},
+ {"550 " + "\u212A" + "elvin@example.com not found", "\u212A" + "elvin@example.com", true},
+ } {
+ if got := addressSeen(tc.msg, tc.address); got != tc.want {
+ t.Errorf("addressSeen(%q, %q) = %v, want %v", tc.msg, tc.address, got, tc.want)
+ }
}
- if got := redactAddress("boom", ""); got != "boom" {
- t.Fatalf("an empty address redacted %q", got)
+}
+
+// mail.IsConfigured asks a Sender whether it really delivers. The
+// wrapper has to forward that, or wrapping a logging mailer would make
+// it look like a relay (mail/mail.go:59).
+func TestTheWrapperForwardsDeliversAndSendList(t *testing.T) {
+ logger, buf := capture()
+
+ logged := RedactMailErrors(mail.Logged(logger), logger)
+ if mail.IsConfigured(logged) {
+ t.Fatal("a wrapped logging mailer claims to deliver")
+ }
+ if _, ok := logged.(mail.ListSender); !ok {
+ t.Fatal("a wrapped ListSender lost SendList")
+ }
+ if err := logged.(mail.ListSender).SendList(context.Background(),
+ mail.ListMessage{To: "a@b.test", Subject: "s", Body: "b"}); err != nil {
+ t.Fatal(err)
+ }
+ buf.Reset()
+
+ plain := RedactMailErrors(failingSender{}, logger)
+ if !mail.IsConfigured(plain) {
+ t.Fatal("a wrapped plain Sender is assumed not to deliver")
+ }
+ if _, ok := plain.(mail.ListSender); ok {
+ t.Fatal("a wrapped plain Sender gained a SendList it cannot honour")
+ }
+
+ // A failing SendList redacts exactly as Send does.
+ failing := RedactMailErrors(failingListSender{err: fmt.Errorf("send to a@b.test: %w", smtpish{"550"})}, logger)
+ err := failing.(mail.ListSender).SendList(context.Background(), mail.ListMessage{To: "a@b.test"})
+ if err == nil || err.Error() != "meet: mail delivery failed" {
+ t.Fatalf("SendList returned %v", err)
+ }
+ if strings.Contains(buf.String(), "a@b.test") {
+ t.Fatalf("SendList's log line carries the address:\n%s", buf.String())
}
}
+
+type failingListSender struct{ err error }
+
+func (f failingListSender) Send(ctx context.Context, to, subject, body string) error { return f.err }
+func (f failingListSender) SendList(ctx context.Context, m mail.ListMessage) error { return f.err }
diff --git a/internal/meet/store.go b/internal/meet/store.go
index 637d279..54806b5 100644
--- a/internal/meet/store.go
+++ b/internal/meet/store.go
@@ -372,10 +372,36 @@ func (s *store) consumePending(ctx context.Context, p Person, pend PendingJoin,
return dest, err
}
-// sweep deletes pending rows whose fifteen minutes are up. Reads already
-// exclude them; this is about not keeping a record past its life
-// (review C1-7). It returns how many rows went.
+// sweepLimit bounds one sweep call to a page of rows, so a large
+// backlog cannot hold SQLite's one write connection for an unbounded
+// delete (review C2-3). The caller loops while the count it gets back
+// is exactly this many.
+const sweepLimit = 1000
+
+// sweep deletes pending rows whose fifteen minutes are up, at most
+// sweepLimit per call via a subselect on ids (a bare DELETE has no
+// portable LIMIT). Reads already exclude expired rows; this is about
+// not keeping a record past its life (review C1-7). It returns how many
+// rows went.
func (s *store) sweep(ctx context.Context, now time.Time) (int64, error) {
- res := s.g.WithContext(ctx).Where("expires_at <= ?", now.UTC()).Delete(&PendingJoin{})
+ res := s.g.WithContext(ctx).Where(
+ "id IN (SELECT id FROM pending_joins WHERE expires_at <= ? LIMIT ?)",
+ now.UTC(), sweepLimit,
+ ).Delete(&PendingJoin{})
return res.RowsAffected, res.Error
}
+
+// nextExpiry answers the earliest expires_at among pending_joins, or the
+// zero time when there is no pending row at all. It is the platform
+// wake-up input for cmd/meet/main.go's Options.NextDue (review C2-2).
+func (s *store) nextExpiry(ctx context.Context) (time.Time, error) {
+ var p PendingJoin
+ err := s.g.WithContext(ctx).Order("expires_at ASC").Take(&p).Error
+ if errors.Is(err, gorm.ErrRecordNotFound) {
+ return time.Time{}, nil
+ }
+ if err != nil {
+ return time.Time{}, err
+ }
+ return p.ExpiresAt, nil
+}
diff --git a/internal/meet/store_test.go b/internal/meet/store_test.go
index 04ff94f..b25afa0 100644
--- a/internal/meet/store_test.go
+++ b/internal/meet/store_test.go
@@ -122,8 +122,8 @@ func TestAReplacedLinkMintsNoGrant(t *testing.T) {
if g.ID != 0 {
t.Fatal("a replaced link minted a grant")
}
- if _, ok, _ := s.grantFor(ctx, m.ID, 7); ok {
- t.Fatal("a grant row exists after minting through a replaced link")
+ if _, ok, err := s.grantFor(ctx, m.ID, 7); ok || err != nil {
+ t.Fatalf("a grant row exists after minting through a replaced link: ok=%v err=%v", ok, err)
}
// The link that replaced it still mints, once.
@@ -234,6 +234,98 @@ func TestSweepDeletesOnlyExpiredPendingRows(t *testing.T) {
}
}
+// Codex round 2, C2-4: TestAStalePendingRowMintsNothingAndDeletesNothing
+// covers one meeting only. The cross-meeting case is the one
+// deletePending's address-wide (not id-scoped) delete actually protects
+// against: two pending rows for the same address across two different
+// meetings, A then B. Consuming B (the newer) mints B's grant and takes
+// every pending row for the address, A's included. The caller who still
+// holds A's stale id must then get no grant and no error — not an
+// attempt to resurrect a row that is already gone.
+func TestConsumingOneMeetingsPendingRowLeavesAnothersIDStale(t *testing.T) {
+ s, now := testStore(t)
+ ctx := context.Background()
+ const address = "guest@example.com"
+
+ mA, tokA, _ := s.createMeeting(ctx, "a", nil, "person:1")
+ lA, _, _, _ := s.liveLink(ctx, tokA)
+ if err := s.writePending(ctx, address, lA); err != nil {
+ t.Fatal(err)
+ }
+ pendA, ok, err := s.latestPending(ctx, address)
+ if err != nil || !ok {
+ t.Fatalf("meeting A's pending row: ok=%v err=%v", ok, err)
+ }
+
+ *now = now.Add(time.Second)
+ mB, tokB, _ := s.createMeeting(ctx, "b", nil, "person:1")
+ lB, _, _, _ := s.liveLink(ctx, tokB)
+ if err := s.writePending(ctx, address, lB); err != nil {
+ t.Fatal(err)
+ }
+ pendB, ok, err := s.latestPending(ctx, address)
+ if err != nil || !ok || pendB.ID == pendA.ID {
+ t.Fatalf("meeting B's pending row: ok=%v err=%v same-id=%v", ok, err, pendB.ID == pendA.ID)
+ }
+
+ p := Person{ID: 9, Email: address}
+ dest, err := s.consumePending(ctx, p, pendB, false)
+ if err != nil || dest != "/m/"+mB.Sqid+"/join" {
+ t.Fatalf("consuming B's row: dest=%q err=%v", dest, err)
+ }
+ if _, ok, err := s.grantFor(ctx, mB.ID, p.ID); !ok || err != nil {
+ t.Fatalf("no grant for B after consuming its row: ok=%v err=%v", ok, err)
+ }
+
+ dest, err = s.consumePending(ctx, p, pendA, false)
+ if err != nil {
+ t.Fatalf("consuming A's now-stale row answered an error: %v", err)
+ }
+ if dest != "" {
+ t.Fatalf("consuming A's now-stale row led to %q", dest)
+ }
+ if _, ok, err := s.grantFor(ctx, mA.ID, p.ID); ok || err != nil {
+ t.Fatalf("a grant exists for A after its row was already consumed via B: ok=%v err=%v", ok, err)
+ }
+}
+
+// Review C2-2: main.go's Options.NextDue reads this. The ordering is on
+// expires_at, deliberately not on id or created_at (which is what
+// latestPending sorts on, the other way): the row written last is not
+// necessarily the one that expires first.
+func TestNextExpiryIsTheEarliestExpiryNotTheLatestRow(t *testing.T) {
+ s, now := testStore(t)
+ ctx := context.Background()
+ if got, err := s.nextExpiry(ctx); err != nil || !got.IsZero() {
+ t.Fatalf("nextExpiry with no pending rows: %v (err %v), want the zero time", got, err)
+ }
+
+ _, tok, _ := s.createMeeting(ctx, "x", nil, "person:1")
+ l, _, _, _ := s.liveLink(ctx, tok)
+ base := *now
+
+ *now = base.Add(20 * time.Minute)
+ if err := s.writePending(ctx, "later@example.com", l); err != nil {
+ t.Fatal(err)
+ }
+ // Written last (the higher id), but its expiry is earlier than the
+ // row above — the only way this test can fail is if nextExpiry
+ // sorted on something other than expires_at.
+ *now = base.Add(5 * time.Minute)
+ if err := s.writePending(ctx, "sooner@example.com", l); err != nil {
+ t.Fatal(err)
+ }
+
+ got, err := s.nextExpiry(ctx)
+ if err != nil {
+ t.Fatal(err)
+ }
+ want := base.Add(5 * time.Minute).Add(pendingLife).UTC()
+ if !got.Equal(want) {
+ t.Fatalf("nextExpiry = %v, want %v (the sooner row's expiry)", got, want)
+ }
+}
+
func TestPendingIsKeyedFingerprintAndFollowsItsLink(t *testing.T) {
s, now := testStore(t)
ctx := context.Background()
diff --git a/internal/meettest/config_test.go b/internal/meettest/config_test.go
index 06eb888..2a48a10 100644
--- a/internal/meettest/config_test.go
+++ b/internal/meettest/config_test.go
@@ -1,6 +1,7 @@
package meettest
import (
+ "context"
"io"
"log/slog"
"path/filepath"
@@ -35,20 +36,20 @@ func TestProductionRefusesToStartWithoutSecrets(t *testing.T) {
"no allowlist": {Env: "production", Origin: "https://x.test", InstanceKey: "k", DEK: validDEK, Mailer: nopMailer{}},
}
for name, cfg := range cases {
- if _, err := meet.App(openDB(t), cfg); err == nil {
+ if _, _, err := meet.App(context.Background(), openDB(t), cfg); err == nil {
t.Errorf("%s: production started without it", name)
}
}
}
func TestDevelopmentStartsWithNothing(t *testing.T) {
- if _, err := meet.App(openDB(t), meet.Config{Env: meet.EnvDevelopment}); err != nil {
+ if _, _, err := meet.App(context.Background(), openDB(t), meet.Config{Env: meet.EnvDevelopment}); err != nil {
t.Fatalf("development refused: %v", err)
}
}
func TestBadDEKIsRefusedEvenInDevelopment(t *testing.T) {
- _, err := meet.App(openDB(t), meet.Config{Env: meet.EnvDevelopment, DEK: "not base64!"})
+ _, _, err := meet.App(context.Background(), openDB(t), meet.Config{Env: meet.EnvDevelopment, DEK: "not base64!"})
if err == nil || !strings.Contains(err.Error(), "base64") {
t.Fatalf("want a base64 refusal, got %v", err)
}
diff --git a/internal/meettest/harness_test.go b/internal/meettest/harness_test.go
index 5f80966..f775a21 100644
--- a/internal/meettest/harness_test.go
+++ b/internal/meettest/harness_test.go
@@ -38,7 +38,7 @@ type harness struct {
db *db.DB
dbPath string
mail *recordingMailer
- clock func(func() time.Time)
+ ctl *meet.TestHandle
now time.Time
}
@@ -47,7 +47,25 @@ func (x *harness) sqlDB() *sql.DB { s, _ := x.db.G.DB(); return s }
func (x *harness) advance(d time.Duration) {
x.now = x.now.Add(d)
now := x.now
- x.clock(func() time.Time { return now })
+ x.ctl.SetClock(func() time.Time { return now })
+}
+
+// sweep runs one bounded pass of what App runs hourly, at the harness's
+// own current time — the same clock advance already set, given
+// explicitly rather than read back off the app (Internal 5's one clock
+// for the whole pass).
+func (x *harness) sweep() { x.ctl.SweepForTest(x.now) }
+
+// authLinks counts rastrillo's unclicked magic links. It is the app's
+// own table, not Meet's, which is the point: nothing deletes a row there
+// but presenting the token or sweeping.
+func (x *harness) authLinks(t *testing.T) int {
+ t.Helper()
+ var n int
+ if err := x.db.G.Raw("SELECT count(*) FROM auth_links").Scan(&n).Error; err != nil {
+ t.Fatal(err)
+ }
+ return n
}
type recordingMailer struct {
@@ -91,13 +109,17 @@ func newHarness(t *testing.T, members ...string) *harness {
}
t.Cleanup(func() { d.Close() })
rec := &recordingMailer{}
- mux, setClock, err := meet.NewForTest(d, meet.Config{
+ mux, ctl, err := meet.NewForTest(d, meet.Config{
Env: meet.EnvDevelopment, Origin: testOrigin, AllowedAddresses: members, Mailer: rec, Logger: logger,
})
if err != nil {
t.Fatal(err)
}
- x := &harness{h: mux, db: d, dbPath: dbPath, mail: rec, clock: setClock, now: time.Date(2026, 9, 8, 10, 0, 0, 0, time.UTC)}
+ // The base is now, not a fixed date. Meet's clock is the only one a
+ // test can move; rastrillo stamps its auth_links rows from the real
+ // one. A fixed base put the two most of a day apart, so nothing that
+ // compares them could mean anything — see the auth_links sweep test.
+ x := &harness{h: mux, db: d, dbPath: dbPath, mail: rec, ctl: ctl, now: time.Now().UTC()}
x.advance(0)
return x
}
diff --git a/internal/meettest/join_test.go b/internal/meettest/join_test.go
index 212f948..cba8471 100644
--- a/internal/meettest/join_test.go
+++ b/internal/meettest/join_test.go
@@ -181,7 +181,15 @@ func TestMemberAddressExhaustsTheSharedLimiterWithTheSameAnswer(t *testing.T) {
// httptest.NewRequest fixes at 192.0.2.1:1234, so every harness client
// shares one budget. Twenty-one different member addresses therefore
// exhaust it even though no address is near its own five.
-func TestSharedClientAllowanceIsExhaustedByOneMemberAddress(t *testing.T) {
+//
+// This is spec §3.2's filed residue, not a quirk of the harness. Behind
+// the platform edge, RemoteAddr is the proxy, so every visitor of every
+// team shares this one budget: twenty sign-in attempts per fifteen
+// minutes for the whole world, exhaustible on purpose by anyone, and
+// nothing in front of Begin can tell one client from another. The fix is
+// a trusted client address from the edge or a per-app limiter keyed on
+// something better; the question is filed on the platform.
+func TestSharedClientAllowanceIsExhaustedByOneClient(t *testing.T) {
const clientBudget = 20 // signin.Flow.IPBudget's default
members := make([]string, 0, clientBudget+1)
for i := 0; i <= clientBudget; i++ {
@@ -289,6 +297,74 @@ func TestReplacedLinkFallbackForSignedInPerson(t *testing.T) {
t.Fatalf("holding page of the first meeting: %d, want 200", rec.Code)
}
})
+
+ // Spec §3.2's third continuation case for a non-member: the pending
+ // row is not replaced but simply expired. An outsider whose row
+ // expires before they click is refused at Authorize with a 403 and
+ // never reaches /continue (TestExpiredPendingRowAdmitsNobody), so the
+ // only way to exercise this branch is with a session that already
+ // exists — which is the gate the review found missing (C1-8).
+ t.Run("non-member with an expired row lands on welcome with no grant", func(t *testing.T) {
+ x, first, firstTok := memberWithMeeting(t)
+ guest := x.browser(t)
+ guest.get("/j/" + firstTok)
+ guest.signIn("guest@example.com")
+
+ owner := x.browser(t)
+ owner.signIn("paul@example.com")
+ second, secondTok := createMeeting(t, owner, "second", "")
+
+ other := x.browser(t)
+ other.get("/j/" + secondTok)
+ if rec := other.post("/signin", url.Values{"address": {"guest@example.com"}}); rec.Header().Get("Location") != "/signin?sent=1" {
+ t.Fatalf("outsider's request: %s", rec.Header().Get("Location"))
+ }
+
+ // The link stays live; only the row runs out (fifteen minutes).
+ x.advance(16 * time.Minute)
+
+ rec := guest.get("/continue")
+ if rec.Code != http.StatusSeeOther || rec.Header().Get("Location") != "/welcome" {
+ t.Fatalf("non-member with an expired row: %d %s, want 303 /welcome", rec.Code, rec.Header().Get("Location"))
+ }
+ if rec := guest.get("/m/" + second + "/join"); rec.Code != http.StatusNotFound {
+ t.Fatalf("holding page of the second meeting: %d, want 404 — a grant was minted", rec.Code)
+ }
+ if rec := guest.get("/m/" + first + "/join"); rec.Code != http.StatusOK {
+ t.Fatalf("holding page of the first meeting: %d, want 200", rec.Code)
+ }
+ })
+}
+
+// Review follow-up to C1-7: the point of the sweep is that an unclicked
+// magic link does not keep a plaintext address in rastrillo's auth_links
+// for the life of the instance. Asserted on the row itself.
+//
+// Eleven minutes is not enough: signin.Flow.LinkTTL defaults to fifteen
+// (keymaildev/signin@v0.1.1/flow.go:186) and rastrillo sets no override,
+// so a sweep at +11 must keep the row and one at +16 must take it. Both
+// halves are asserted, because a sweep that deleted everything would
+// pass the second on its own.
+func TestSweepClearsUnclickedMagicLinks(t *testing.T) {
+ x := newHarness(t, "paul@example.com")
+ if rec := x.browser(t).post("/signin", url.Values{"address": {"paul@example.com"}}); rec.Header().Get("Location") != "/signin?sent=1" {
+ t.Fatalf("sign-in request: %s", rec.Header().Get("Location"))
+ }
+ if x.authLinks(t) != 1 {
+ t.Fatalf("auth_links rows after a sign-in request: %d, want 1", x.authLinks(t))
+ }
+
+ x.advance(11 * time.Minute)
+ x.sweep()
+ if n := x.authLinks(t); n != 1 {
+ t.Fatalf("auth_links rows after a sweep inside the link's life: %d, want 1", n)
+ }
+
+ x.advance(5 * time.Minute)
+ x.sweep()
+ if n := x.authLinks(t); n != 0 {
+ t.Fatalf("auth_links rows after the link expired and was swept: %d, want 0", n)
+ }
}
func TestExpiredPendingRowAdmitsNobody(t *testing.T) {
diff --git a/internal/meettest/meetings_test.go b/internal/meettest/meetings_test.go
index 83dbb77..5744df5 100644
--- a/internal/meettest/meetings_test.go
+++ b/internal/meettest/meetings_test.go
@@ -111,10 +111,13 @@ func TestTheDatabaseNeverHoldsAPlaintextTitle(t *testing.T) {
grep := func(when string) {
for _, suffix := range []string{"", "-wal", "-shm"} {
b, err := os.ReadFile(x.dbPath + suffix)
- // A missing -wal or -shm is ordinary; any other read error
- // would let this test pass without reading anything, which
- // is the one way a privacy gate can lie (review C1-8).
- if os.IsNotExist(err) {
+ // A missing -wal or -shm is ordinary — SQLite only keeps
+ // them while there is unchecked WAL content. The primary
+ // file missing is not ordinary: a wrong path would read
+ // nothing and this test would pass having grepped nothing
+ // at all, which is the one way a privacy gate can lie
+ // (review C1-8, codex round 2 C2-4).
+ if suffix != "" && os.IsNotExist(err) {
continue
}
if err != nil {
diff --git a/internal/meettest/schema_test.go b/internal/meettest/schema_test.go
index 52fa6b7..8d1097a 100644
--- a/internal/meettest/schema_test.go
+++ b/internal/meettest/schema_test.go
@@ -1,6 +1,7 @@
package meettest
import (
+ "context"
"testing"
"amadan.net/oficina/meet/internal/meet"
@@ -8,7 +9,7 @@ import (
func TestBootCreatesEveryTable(t *testing.T) {
d := openDB(t)
- if _, err := meet.App(d, meet.Config{Env: meet.EnvDevelopment}); err != nil {
+ if _, _, err := meet.App(context.Background(), d, meet.Config{Env: meet.EnvDevelopment}); err != nil {
t.Fatal(err)
}
for _, table := range []string{"persons", "meetings", "meeting_links", "guest_grants", "pending_joins", "sessions", "auth_links"} {
diff --git a/internal/meettest/signin_test.go b/internal/meettest/signin_test.go
index 6cd38a5..270c2cd 100644
--- a/internal/meettest/signin_test.go
+++ b/internal/meettest/signin_test.go
@@ -1,10 +1,20 @@
package meettest
import (
+ "bytes"
+ "context"
+ "errors"
+ "fmt"
+ "log/slog"
"net/http"
"net/url"
+ "path/filepath"
"strings"
"testing"
+
+ "amadan.net/rastrillo/rastrillo/db"
+
+ meet "amadan.net/oficina/meet/internal/meet"
)
func TestMemberSignsInAndLandsOnTheList(t *testing.T) {
@@ -57,3 +67,51 @@ func TestNonMemberSessionSeesWelcomeNotTheList(t *testing.T) {
t.Fatal("welcome page does not name the address")
}
}
+
+// Codex round 2, C2-4: mailredact_test.go proves the wrapper is safe by
+// calling it directly, which would stay green even if cmd/meet/main.go
+// stopped wiring it up. This is the production-path check instead: an
+// app built with meet.App — the same entry point main.go calls, over a
+// mailer that fails and a real *slog.Logger — answers a real
+// POST /signin, and nothing that logger captured names the address.
+// (Internal 3 / review C1-2's resolve-wraps-everything design is what
+// this is actually testing: no call site can opt out of the wrap.)
+func TestSigninOverAFailingMailerLeavesNoAddressInTheLog(t *testing.T) {
+ const address = "paul@example.com"
+ var buf bytes.Buffer
+ logger := slog.New(slog.NewTextHandler(&buf, nil))
+ d, err := db.Open(filepath.Join(t.TempDir(), "app.db"), logger)
+ if err != nil {
+ t.Fatal(err)
+ }
+ t.Cleanup(func() { d.Close() })
+
+ failing := failingMailer{err: fmt.Errorf("rastrillo/mail: send to %s: %w", address, errFakeSMTPFailure)}
+ mux, _, err := meet.App(context.Background(), d, meet.Config{
+ Env: meet.EnvDevelopment, Origin: testOrigin, AllowedAddresses: []string{address},
+ Mailer: failing, Logger: logger,
+ })
+ if err != nil {
+ t.Fatal(err)
+ }
+
+ rec := post(t, mux, "/signin", url.Values{"address": {address}})
+ if rec.Code != http.StatusSeeOther || rec.Header().Get("Location") != "/signin?sent=1" {
+ t.Fatalf("POST /signin over a failing mailer: %d %s, want 303 /signin?sent=1 (the same answer as success)",
+ rec.Code, rec.Header().Get("Location"))
+ }
+
+ log := buf.String()
+ if strings.Contains(strings.ToLower(log), strings.ToLower(address)) {
+ t.Fatalf("the log carries the address after a failing send:\n%s", log)
+ }
+ if !strings.Contains(log, "meet: mail delivery failed") {
+ t.Fatalf("no redacted delivery failure was logged at all — the wrapper may not be wired:\n%s", log)
+ }
+}
+
+var errFakeSMTPFailure = errors.New("550 mailbox unavailable")
+
+type failingMailer struct{ err error }
+
+func (f failingMailer) Send(ctx context.Context, to, subject, body string) error { return f.err }