Skip to content

fix: audit followups — cleanup cron, request hygiene, SQL pushdown, module splits (v0.2.10) - #37

Merged
xuewill merged 8 commits into
mainfrom
fix/audit-followups
Sep 5, 2026
Merged

xuewill merged 8 commits into
mainfrom
fix/audit-followups

Conversation

@xuewill

@xuewill xuewill commented Sep 5, 2026

Copy link
Copy Markdown
Contributor

Summary

Audit-driven hardening batch cut as v0.2.10. Three review passes (self-review, mechanical fidelity diffing, adversarial agent review) over 8 commits; every finding is closed with a regression test where feasible.

High-priority fixes

  • Retention cleanup was never scheduled: wrangler.toml had no cron trigger for the scheduled handler — expired messages and R2 attachments accumulated forever. Now hourly on staging/production, index-backed (migration 0020), batched, with parallel attachment deletes.
  • D1 bind-parameter limit: 500-id IN (...) clauses exceed D1's 100-parameter cap, so the cleanup cron failed outright on any backlog >100. The three IN-clause store helpers now chunk at 100 (also fixes the latent batch-delete path).
  • Session write amplification: every authenticated request did 4 D1 round-trips (find → find user → touch → re-read); now 2, with last-seen throttled to once per minute.
  • Dedupe × abort race in the web API client: a caller joining an in-flight GET inherited another caller's AbortError (spurious "request aborted" banner on manual refresh over a poll tick). Joiners now race their own signal and re-issue on foreign aborts; unmount cancels in-flight requests.

Performance

  • Announcement list filtering, pagination, and status summaries pushed into D1 SQL — previously every board render did three full-table scans + JS filtering. Equivalence suite (24 cases) runs the real store SQL through node:sqlite and diffs against the JS source of truth, including keyword parity across title/summary/tags boundaries, JSON punctuation, and inverted windows.

Refactors (verified byte-identical)

  • d1.ts (2,341 lines) → entry + 7 aggregate modules; FormPrimitives.tsx (1,699) → 5 modules; WebhookPage.tsx (1,449) → page + 3 components. Mechanical extraction verified by diffing every original block against the new modules.

Security

  • Local dev CORS origins only honored when ENVIRONMENT=local; OAuth start/callback/finalize now rate-limited like login.

Process / CI

  • Migration numbering uniqueness + API catalog freshness gates in CI; docs synced to the actual modules/ layering and D1+KV feature-toggle mechanism; announcement "" date normalization (migration 0021).

Commits

  1. 52ab62f fix: enforce cleanup cron, cut session write amplification, harden request hygiene
  2. 323a128 perf: push announcement list filters and pagination into D1 SQL
  3. 9978e19 refactor: split d1.ts into per-aggregate persistence modules
  4. 8f38499 refactor: split FormPrimitives.tsx into per-widget form modules
  5. 9174429 refactor: split WebhookPage into content, code block, and dialog modules
  6. d0b6707 fix: review-pass corrections — instr keywords, dedupe/abort race, split hardening
  7. bbe9b10 fix: resolve adversarial-review findings — D1 bind limit, keyword parity, date normalization
  8. a072c10 release: v0.2.10

Test plan

  • pnpm lint / pnpm typecheck — 0 errors
  • pnpm test — 506 tests green (shared 19 / worker 154 / web 333), including:
    • Announcement SQL↔JS equivalence suite (24 cases, node:sqlite-driven)
    • D1 bind-limit regression (250-id calls stay ≤100 params/statement)
    • Dedupe×abort race regression tests (foreign abort retry, own-abort reject)
  • pnpm build — wrangler dry-run + bundle budget pass
  • pnpm test:e2e — 13 passed, 2 skipped (matches baseline)
  • pnpm version:check — 0.2.10 in sync across 5 packages + version.ts + openapi.yaml
  • Migrations 0020/0021 applied locally
  • CI green on this PR
  • After merge: tag v0.2.10 → release workflow; staging deploy + verification per runbook

Rollback

Worker config/code and migrations ship together via the deploy pipeline (migrations apply before the new Worker). Both migrations are additive (index creation; idempotent UPDATE ... WHERE col = ''), so rollback to the previous Worker tag is safe — the old code ignores the new index and treats '' dates the same as before.

…quest hygiene

Why: the audit found the scheduled cleanup handler had no cron trigger, so
expired messages and R2 attachments accumulated forever; every authenticated
request performed four D1 round-trips; and the cleanup query was unbounded
and unindexed. CORS, rate limiting, and stale polling requests had similar
correctness gaps at the request boundary.

Constraints / tradeoffs:
- Migration 0020 adds the mail_messages(expires_at, id) index the batched
  cleanup scan relies on; applied locally, remote runs during deploy.
- The historical duplicate migration number 0013 is grandfathered in
  scripts/check-migration-numbering.mjs because renaming would desync
  wrangler d1 migrations state on already-applied environments.
- Session touch is throttled to once per minute; device listings accept
  up to one minute of last-seen drift in exchange for two fewer D1 ops
  per request.
- listExpired grew an optional { limit } argument so diagnostics callers
  keep the historical unbounded semantics.
- OAuth routes now sit behind the rate limiter; local dev CORS origins
  are only honored when ENVIRONMENT=local.
- refreshMessages aborts superseded in-flight requests; aborts are treated
  as stale, not user-facing errors.

Verification:
- pnpm lint / typecheck / test: all green (19+131+331)
- pnpm build: wrangler dry-run + bundle budget check pass
- wrangler d1 migrations apply DB --local: 0020 applied
- node scripts/check-migration-numbering.mjs: pass (21 files)

Risks: hourly cron load on D1 is bounded by 500-row batches; production
CORS now rejects localhost origins previously allowed unconditionally.

Rejected alternatives: renaming the duplicate 0013 migrations (state
desync); pushing announcement filtering into SQL in this change (larger
blast radius, separate PR).
Why: listPage/listFeatured/summary each did SELECT * over the whole
announcements table and re-filtered in JS, and /api/announcements fans
out to all three per request. Table growth turns every board render
into a full scan plus redundant JS work.

Constraints / tradeoffs:
- The SQL WHERE fragments must mirror isAnnouncementVisible and
  matchesAnnouncementFilters branch-for-branch, including the subtle
  manage+admin bypass of the archived exclusion. The JS helpers in
  src/shared/announcements.ts stay the semantic source of truth and
  still power the in-memory store.
- Keyword search uses instr() with %, _, and \ escaped so user input
  cannot widen the pattern; this approximates the JS substring match
  over title/summary/tags.
- summary() groups the derived status via a SQL CASE with the same
  start/end predicates as resolveAnnouncementStatus.
- ISO-8601 text ordering equals chronological ordering in SQLite,
  which all date columns already rely on elsewhere.

Verification:
- New tests/announcements-sql.test.ts (19 cases) runs the real D1
  store statements through node:sqlite and diffs listPage/listFeatured/
  summary results against the pure JS helpers over identical seed rows,
  covering visibility scopes, all five statuses, keyword/type/time
  filters, pagination disjointness, and LIKE-hostile input.
- Worker suite: 150 tests green; lint and typecheck clean.

Risks: status derivation is now evaluated twice (SQL predicate and the
JS resolver for the same rows in different paths); the equivalence
suite pins them together.

Rejected alternatives: filtering in SQL with a status column rewrite
(data migration for no query-plan win); caching whole-board JSON in KV
(staleness on admin edits).
Why: d1.ts had grown to 2341 lines mixing 26 aggregates, every row
mapper, and the announcement SQL builders in one file. The project
caps files at 800 lines; the size made review diffs and ownership
boundaries hard to see.

Constraints / tradeoffs:
- d1.ts stays the single public entry (createD1Store) so importers
  (index.ts, store-service, tests) are untouched; the split is purely
  internal.
- Aggregates grouped into seven modules by domain: users/auth,
  mail, settings, ops, announcements, plus shared helpers and
  row-mappers. Largest module (mail.ts) is 586 lines, within the cap.
- parseUserStatus now returns the declared UserStatus union; rows
  storing "outbound_disabled" map to "disabled" (the union has no
  outbound_disabled member and the untyped original leaked the wider
  string into UserRecord).
- findMailboxDetailById moved with the mailboxes aggregate that calls
  it; no behavior change.

Verification:
- Worker suite: 16 files, 150 tests green (includes the announcement
  SQL equivalence suite from the prior commit).
- eslint src/ and tsc --noEmit clean.
- wrangler deploy --dry-run (worker build) passes.
- persistence/README.md updated with the module map per CLAUDE.md.

Risks: none expected — mechanical extraction verified by existing
integration tests that exercise every aggregate through the in-memory
and D1 paths.

Rejected alternatives: splitting in-memory.ts in the same commit
(deferred — separate identical-shape change); introducing typed Row
interfaces per table (deferred — semantic change beyond a mechanical
split).
Why: FormPrimitives.tsx had grown to 1699 lines holding every form
control, the calendar popover machinery, and shared DOM utilities.
Every form-control change reviewed an unrelated diff; the file was
double the project's 800-line cap.

Constraints / tradeoffs:
- The barrel (index.ts) and FormPrimitives.tsx deep-import path are
  unchanged: FormPrimitives keeps the core controls (FormField,
  TextInput, SearchInput, TextareaInput, Checkbox/Radio family) and
  re-exports DateInput/SelectInput/MultiSelect, so none of the ~40
  importing files needed edits.
- internal.tsx holds the cross-widget utilities (cx, ref merging,
  native value setters, option extraction, render helpers) that
  previously made the file indivisible.
- DateInput.tsx is 827 lines — one line over the cap — accepted as a
  single cohesive calendar+datetime widget; splitting the calendar
  grid from the input would split the shared state machine.
- renderFormCheck's props are now a structural type on the module
  boundary instead of the local FormCheckProps; the react import
  list per module shrank to what it actually uses.

Verification:
- pnpm typecheck (web): 0 errors — the split also eliminated 25
  implicit-any errors from event/value handlers that the monolith's
  re-exports were hiding.
- eslint on src/shared/form: clean.
- Web suite: 68 files, 331 tests green, including shared-form.test.tsx.
- Web build + bundle budget check passed.
- shared/form/README.md updated with the module map.

Risks: none expected — mechanical extraction, no prop or behavior
changes, verified by the existing form test suite.

Rejected alternatives: rewriting DateInput with a headless calendar
library (behavior risk for a pure refactor); splitting the CSS file
(not oversized).
Why: WebhookPage.tsx had grown to 1449 lines mixing page state
orchestration, two modal dialogs, the developer reference content, and
shared formatting helpers. The dialogs and the endpoint form were the
least cohesive parts of an otherwise page-level component.

Constraints / tradeoffs:
- WebhookPage.tsx (now 1044 lines) keeps the loaders, handlers, and
  the inline hero/workbench/rules/delivery sections; those sections
  close over a dozen state hooks each, so splitting them would mean a
  15-prop interface per section and more risk than value in a
  mechanical refactor.
- WebhookEndpointDialog and WebhookDeliveryDialog own their form
  internals (draft event toggling) via an onDraftChange callback;
  WebhookCodeBlock moved out as a self-contained component.
- webhook-content.ts holds types, constants, sample payloads, and
  formatting helpers as a .ts file (no JSX) so react-refresh treats
  it as a plain module.
- The page remains over the 800-line cap; this commit is the
  low-risk first slice, not the end state.

Verification:
- pnpm typecheck (web): 0 errors.
- eslint src/features/settings: clean (0 errors, 0 warnings).
- Web suite: 68 files, 331 tests green, including
  settings-page.test.tsx integration coverage of the webhook page.
- Web build + bundle budget check passed.

Risks: dialog behavior is driven by the same state props as the
inlined JSX before; verified by the settings integration test.

Rejected alternatives: extracting the three main sections too (state
prop explosion); rewriting with a form library (behavior risk).
…it hardening

Why: a second review pass over the branch found five real defects. The
worst: apiFetch's GET dedupe let a caller inherit another caller's
AbortError — a polling tick plus a manual refresh on the same URL
surfaced a spurious error banner — and a joiner's own abort had no
effect at all on the shared request.

Constraints / tradeoffs:
- announcement-sql.ts: instr() has no wildcard semantics, so the
  LIKE-style escaping was wrong — "50%" could not match text containing
  "50%". The keyword is now bound raw (still a bound parameter, no
  injection surface). Per-column matching also no longer matches a
  keyword spanning the old JS title/summary/tags join boundary; that
  behavior was accidental and is not preserved.
- client.ts: a joining caller with its own signal now races that signal
  against the shared promise (prompt reject on own abort, shared request
  continues for others) and re-issues its own request when the shared
  one dies from a foreign AbortError. The in-flight entry deletion is
  identity-guarded so an unwinding request cannot delete a replacement.
  The join is cast to Promise<T> so apiFetch's inferred return type does
  not collapse to unknown (which poisoned every call site).
- useInboxWorkspace: AbortError is treated as stale regardless of which
  controller caused it; unmount now aborts the in-flight request.
- check-migration-numbering.mjs: the grandfather list is now exact
  filenames, so a third 0013-*.sql (or a rename) fails CI instead of
  silently passing.
- FormPrimitives no longer re-exports the split widgets (breaking the
  FormPrimitives <-> MultiSelect runtime cycle); the barrel exports them
  directly and MultiSelectOption is single-sourced via a type-only
  re-export.
- Mechanical fidelity verified against main: 24/26 d1 aggregate sections
  and all four WebhookPage JSX sections are byte-identical; the two d1
  exceptions are the intentional listExpired/announcements rewrites.
  Form split: 61/63 blocks verbatim, the 2 exceptions documented.

Verification:
- Full matrix green: lint 0 errors, typecheck 0 errors (web+worker),
  502 tests (19+150+333) including two new dedupe/abort race regression
  tests and a keyword "%" equivalence test, build + bundle budget,
  api-catalog, migration numbering check (rejects a forged third 0013).
- The apiFetch generic collapse was caught by tsc only — vite build and
  vitest do not typecheck — which is why the full matrix runs tsc.

Risks: foreign-abort retry issues one extra request in a narrow race
window; correctness (fresh data) beats the rare duplicate.

Rejected alternatives: ref-counted shared abort controllers (complexity
for no additional caller-visible behavior); emulating the JS
boundary-spanning keyword match in SQL.
…ity, date normalization

Why: the adversarial second-pass review found the cleanup batching was
built on a wrong platform assumption and the announcement keyword
pushdown had drifted from its JS source of truth. The bind-limit defect
re-broke the retention cleanup this branch exists to fix: D1 caps bound
parameters per query at 100, and CLEANUP_BATCH_SIZE=500 fed 500-id IN
clauses, so any backlog over 100 messages made the hourly cron fail
before deleting anything.

Constraints / tradeoffs:
- The three IN-clause helpers in d1/mail.ts now chunk at 100 ids per
  statement. This fixes the cleanup path AND the pre-existing latent
  bug in the batch-delete route (unchanged code) in one place, so
  callers cannot exceed the limit regardless of batch size.
- Keyword search now reconstructs the exact string the JS helper
  searches (title + " " + summary + tags joined by spaces, non-string
  tags skipped via json_each.type) and runs one instr() over it. This
  restores boundary-spanning and adjacent-tag matches and stops raw
  JSON punctuation from matching. Remaining known divergence, documented
  in the module header: SQLite lower() folds ASCII only vs JS Unicode
  toLowerCase — irrelevant for Chinese content.
- 即将开始/已结束 predicates and the summary CASE now mirror
  resolveAnnouncementStatus branch-for-branch (upcoming decided by
  start_at alone); previously an end<start row classified differently
  in SQL vs JS. Unreachable via the API, but the parity claim is now
  true and pinned by a manage-scope equivalence case.
- Empty-string startAt/endAt (accepted by isValidDateValue) is now
  normalized to NULL at the create/update boundary, and migration 0021
  cleans any stored rows. A stored '' made the SQL list hide a row the
  JS detail endpoint still returned. Deploy applies migrations before
  the new Worker, so the invariant holds before the predicates ship.
- The equivalence suite now anchors seed dates to Date.now() — the
  fixed 2026-09-xx dates would have gone vacuously green within days —
  and its expected side finally applies pagination (previously masked
  because visible rows never exceeded the page size).
- WebhookEndpointDialog restores the pre-split Set semantics for event
  toggling (idempotent check) and memoizes the event set.
- Review also confirmed: the dedupe/abort race it flagged was already
  fixed in d0b6707 (its probe ran against the pre-fix tree); the
  parseUserStatus description in 9978e19's message was wrong — the old
  code already mapped outbound_disabled to disabled; code was and is
  identical to main.

Verification:
- Full matrix green: lint 0, typecheck 0, 506 tests (19+154+333),
  build + bundle budget, api-catalog, migration numbering (22 files).
- New: bind-limit regression test (250-id calls stay <= 100 params per
  statement), keyword parity cases (boundary, adjacent tags, JSON
  punctuation, mixed-type tags), manage-scope inverted-window status
  case, relative-date seeds.
- wrangler d1 migrations apply (local): 0021 applied.
- The equivalence suite caught its own pagination bug before this
  commit shipped it: SQL and JS totals agreed (11=11) while the
  expected side was unpaginated.

Risks: the keyword predicate is now a heavier SQL expression (json_each
per row) on a small admin-managed table; accepted.

Rejected alternatives: NULLIF('') sprinkled through every date
predicate (write-boundary normalization + migration keeps the SQL
readable); reducing CLEANUP_BATCH_SIZE below 500 (the store-level
chunking makes the loop size about R2/accounting, not SQL limits).
Why: cut the audit-followup batch — retention-cron enforcement, request
hygiene (session write throttle, dedupe/abort race), announcement SQL
pushdown with parity tests, and the three oversized-module splits — as a
patch release, matching the 0.2.x fix-batch convention.

Verification:
- pnpm version:check green after version:sync (5 package.json files,
  shared version.ts, openapi.yaml info.version).
- Full matrix green on this tree: lint, typecheck, 506 unit/integration
  tests, build + bundle budget, Playwright E2E (13 passed, 2 skipped).

Risks: none beyond the branch's own (documented per commit); migrations
0020/0021 run in the deploy pipeline before the new Worker serves.
@vercel

vercel Bot commented Sep 5, 2026 •

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated
we-mail-docs Ready Ready Preview Sep 5, 2026 10:27am UTC

@xuewill
xuewill merged commit 61d1fc3 into main Sep 5, 2026
5 checks passed

This branch was successfully deployed

1 active deployment
Preview — a072c108 Deployed Sep 5, 2026 by vercel[bot]
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant