Skip to content

feat!: rewrite aurora on react router, drizzle and a cookie-free tracker - #86

Merged
askides merged 42 commits into
mainfrom
feat/next
Aug 5, 2026
Merged

feat!: rewrite aurora on react router, drizzle and a cookie-free tracker#86
askides merged 42 commits into
mainfrom
feat/next

Conversation

@askides

@askides askides commented Aug 5, 2026

Copy link
Copy Markdown
Owner

Replaces the 2021 Next.js application entirely. The stack, the database schema,
the tracker and the dashboard are all new, and there is no migration path from a
1.x or 2.x installation.

What changed

  • React Router v8 in framework mode on a pnpm workspace, Tailwind 4 with
    shadcn/ui, Recharts, Vitest, oxlint + oxfmt in place of prettier.
  • Drizzle on Postgres replacing Prisma, with the schema defects that port
    surfaced fixed. Events are denormalised into one wide table.
  • A storage-free tracker, rewritten in TypeScript — 2.4 KB gzipped, writing
    nothing to cookies, localStorage, sessionStorage or IndexedDB. Visitor and
    session ids come from a rotating HMAC instead of a stored identifier.
  • Ingest rebuilt around sessionization and a duration token: referrers
    classified into acquisition channels at write time, country from edge headers
    only, client parsed from UA hints before the UA string, rate limiting on the
    unauthenticated collect endpoints, and CORS that echoes the caller's origin
    rather than allowing *.
  • Dashboard rebuilt around range and timezone pickers, a Recharts
    timeseries, tabbed breakdown panels and a sidebar shell.
  • Feature modules over a shared layer, with the one-way dependency direction
    enforced by oxlint rather than by convention.
  • tzdata pinned on both sides. Window boundaries resolve in JS and bucket
    grouping happens in SQL, so Node's zone database and Postgres's have to agree
    or the two halves of a chart disagree by a zone's offset, silently. Each side
    now asserts its own copy at build and at startup.

Release pipeline

The last three commits add what was missing entirely: conventional commits
enforced by a hook and by CI, release-please deriving versions and the changelog
from them, and a multi-arch image published to ghcr.io/askides/aurora on every
push to main and on every release. A README and the MIT LICENSE are restored.

Versions start at 4.0.0 — the 0.x–2.x tags belong to the project this
replaces, and 3.x is skipped so the rewrite gets a clean major.

Expect one red check

The Commitlint job lints every commit in the range, and the 39 rewrite commits
below predate the rule, so the range check fails here and only here. The PR
title check passes. Every PR opened after this one starts clean.

Merging

Prefer a merge commit over a squash. release-please-config.json pins
bootstrap-sha to 508d100, which stays reachable through a merge but not
through a squash. If you do squash, repoint bootstrap-sha at the resulting
commit before the first release runs.

After merging: tag v4.0.0, enable Allow GitHub Actions to create and approve
pull requests
, and make the GHCR package public once it first publishes.

askides and others added 30 commits August 3, 2026 23:30
Collapse the frontend/backend split into one React Router app and replace
lerna with a pnpm workspace.

Structure:
- apps/web    React Router 8 framework mode (dashboard + /collect + tracker)
- apps/docs   Nextra 1 -> 4 (Next 12 -> 16, app router + content dir)
- packages/tracker  tracker script, esbuild -> apps/web/public/tracker.js

Web app:
- CRA/react-scripts -> Vite; Chakra v1 -> Tailwind v4 + shadcn (Base UI)
- React 17 -> 19, Prisma 3 -> 7 (driver adapter + prisma.config.ts)
- SWR/axios hooks -> loaders; the 8 dashboard requests are now one loader,
  with range/tz in the URL so the view is shareable
- Vercel serverless handlers -> loaders/actions. Only /collect and
  /collect/:id stay HTTP (tracker calls them cross-origin); CORS is scoped
  to those two routes instead of every response
- JWT in localStorage -> signed httpOnly cookie sessions; the client-side
  route guard becomes a layout loader redirect
- Jest/node-mocks-http -> Vitest (38 tests); CI Node 12/14/16 -> 22

Fixes found while porting:
- timeseries built SQL via $queryRawUnsafe with wid/tz/dates interpolated;
  now parameter-bound, with unit/tz allow-listed (bad tz -> 400)
- POST /setup never checked whether a user already existed, so anyone could
  create an account on a live instance; both loader and action check now
- DELETE /websites/:id never verified ownership
- /collect/:id updated an event by id alone; now scoped to its website

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
- remove apps/docs and unwire it from the workspace, root scripts, CI and
  the Dockerfile
- remove the root README and apps/web/README.md (the latter was untouched
  React Router template boilerplate)
- prettier -> oxfmt, config carried over via `oxfmt --migrate=prettier`
  so the existing style (80 cols, es5 commas, prose wrap) is preserved
- add oxlint with typescript/react/import/jsx-a11y/unicorn/vitest plugins;
  CI gains a lint job running `lint` and `format:check`

Fixes surfaced by the new lint pass:
- tracker swallowed a caught error into an unused binding
- bcryptjs imported via default member access instead of named exports
- WebsiteForm used an object literal as a default prop value
- timezone validation was duplicated across analytics.server and
  queries.server; consolidated into app/lib/timezone.ts
- analytics test asserted inside a catch block

Also add a postinstall running `prisma generate`, so typecheck and tests
work on a fresh clone instead of failing on the missing generated client.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
app/lib/validation.ts held eight schemas that each had exactly one caller.
Each now lives next to the code that uses it:

- signInSchema  -> routes/signin.tsx
- setupSchema   -> routes/setup.tsx
- accountSchema -> routes/account.tsx
- collectSchema -> routes/collect.ts
- durationSchema -> routes/collect.$id.ts
- websiteSchema -> components/website-form.tsx (the create and edit routes
  both submit that form, so it stays with the form rather than duplicated)

Deleted as dead code: metricsFiltersSchema and timeseriesFiltersSchema (both
superseded by resolveFilters in analytics.server.ts) and the four unused
z.infer type aliases — none had a single reference.

validation.test.ts is split into routes/auth.test.ts and routes/collect.test.ts,
which stub the db/session modules the route files import at module scope.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Drops Prisma 7 for drizzle-orm 0.45 + drizzle-kit 0.31. Since this is a major
release with no data to preserve, the old migration history is replaced by a
single generated migration that has the fixes baked in rather than patched on.

Schema fixes (from the audit of the 2022 Prisma scaffold):
- events had no index beyond its primary key, while every dashboard panel
  filters website_id + a created_at range — ~17 statements per load, each
  scanning the whole table. Added (website_id, created_at).
- website_id and user_id were bare TEXT with no foreign keys, so "Delete
  Website" removed one row and orphaned the entire event history forever —
  a retention failure in a product sold on privacy. Both are now FKs with
  ON DELETE CASCADE, plus an index on websites.user_id for the cascade.
- metadata had no unique key, so the ingest path did a serial
  find-then-create per dimension: a sequential scan up to 6x per pageview,
  and a race that permanently duplicated rows. Now UNIQUE(type, value,
  version) with version NOT NULL DEFAULT '' (NULLs would not dedupe), and
  a single ON CONFLICT upsert.
- events.type defaulted to 'pageview' but the only query filtering on type
  looks for 'pageView', so any row created via the default was counted in
  the totals and invisible in the Pages breakdown. Default corrected and the
  payload tightened to a literal.
- duration was NOT NULL DEFAULT 0, making "never measured" indistinguishable
  from "lasted 0ms" and dragging the average down. Now nullable with a
  CHECK range, and bounded in the payload — /collect is unauthenticated and
  hands back the event id, so it was forgeable.
- timestamps were split between timestamptz(6) and naive timestamp(3). All
  timestamptz now: the timeseries does created_at AT TIME ZONE $tz, which
  is only correct over an instant.
- the implicit _EventToMetadata join table is now an explicit event_metadata
  table with a composite primary key.

Query layer: the breakdowns and statistics aggregate in Postgres instead of
hydrating every matching event row into Node to count it there; the five
statistics aggregates collapse into one pass with FILTER. Ingest runs in a
transaction. Drizzle has no @default(cuid()) or @updatedat, so ids come from
cuid2 and updated_at uses $onUpdate.

Deferred (product decisions, reported separately): denormalising dimensions
onto events, persisting the tracker's uid as a visitor id, normalising
referrer to its host, and the "Country" panel that actually shows language.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
An adversarial review of the port surfaced two genuine regressions, both
reproduced against a live Postgres:

- Timeseries buckets were decoded in the server's local timezone. Drizzle's
  node-postgres session returns TIMESTAMP columns as raw strings, and a plain
  sql`` template uses a no-op decoder, so `date_trunc(...) AT TIME ZONE tz`
  arrived as "2026-08-03 14:00:00" and `new Date()` parsed it as local time.
  The Prisma adapter pinned it to UTC. On any host not running UTC the chart
  matched nothing and flatlined at zero while the stat tiles showed real
  totals. Now decoded explicitly as UTC.
- The pg Pool had no 'error' listener. Node throws on an 'error' event with
  no handler, so a database restart, failover or idle-connection drop would
  take the whole server process down. The Prisma adapter installed one.

Also fixed while in there — pre-existing, not caused by the port: the padded
bucket series was built from whole hours on the server clock plus a zone
offset, which only lines up when the offset is a whole number of hours. For
India, Nepal, Iran, Newfoundland and central Australia every bucket landed on
:30 or :45, matched nothing, and the chart silently read zero. Buckets are now
generated with the same truncation the SQL uses. Verified for Asia/Kolkata,
Asia/Kathmandu, Asia/Tehran and Australia/Adelaide, and across four different
server timezones.

Smaller review findings: metadata.value is now part of a btree unique key and
btree caps an index tuple at ~2704 bytes, while Zod's .max() counts UTF-16
units — a multibyte referrer could pass validation and then abort the ingest
transaction, so free-text fields are bounded by bytes. avg() over double
precision returns a number rather than a string, so the annotation and the
truthiness guard were both wrong. updateUser/updateWebsite kept a Prisma-era
`= {}` default that drizzle rejects with "No values to set".

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
App runs locally; only backing service is Postgres. Host port 5434 to
avoid clashing with other local Postgres containers. Env example bumped
to match, and its stale Prisma comment corrected.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Every dimension is a column now; metadata and event_metadata are gone,
with them the per-ingest upsert and the two joins every panel paid to
group by browser. Adds visitor/session ids, channel + utm, screen class,
country, locale, props and revenue, plus check constraints for the four
closed sets and a partial unique index on view_token so the duration
beacon can only ever rewrite one row.

The migration backfills in a single pass and sets fillfactor 80: every
pageview is UPDATEd by the duration beacon, and no index touches
duration, is_a_bounce or updated_at, so those updates stay HOT.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
60 days of deterministic traffic — sessions, devices, places, sources,
custom events with revenue — so the dashboard has something with shape
to it and a metric regression is visible rather than plausible noise.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Salted HMAC over UTC date + site + IP + user agent, so an id is a daily
pseudonym rather than a device id — no cookie, no consent, and it is what
"unique visitor" means on the dashboard. Production refuses to boot
without its own AURORA_SALT; dev and test keep working unconfigured.

The client address comes from the header the deployment names, not from
whichever forwarding header the caller felt like setting, and the session
window is 30 minutes of inactivity rather than the old client-side 15.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Three headers a specific edge owns and strips on the way in, plus an
opt-in AURORA_COUNTRY_HEADER. No GeoIP database, and no generic names
like x-country-code that pass through from the client verbatim. A
deployment with no geo-aware proxy reports null, which is supported.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Host is reduced to a bare hostname, self-referrals dropped, and the visit
resolved once into direct/search/social/referral/campaign so no breakdown
query has to carry the host lists. Search hosts match whole — mail., docs.
and news.google.com are portals, and reading them as organic search
inflates the one number SEO work gets sized off.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Token bucket per client, 120/min with 240 burst so queued beacons
flushing after a bfcache restore are not punished. LRU-capped at 50k
keys: the idle sweep alone cannot bound the map, since nothing admitted
in the current window is eligible for eviction.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Client hints are read pair by pair with the GREASE brand dropped, and the
tracker's high-entropy answer wins over both — the ask can never land on
a third-party subresource origin. Browser and OS versions are kept to the
major and stored per column, so UA reduction hiding a version no longer
throws the name away with it. Bots are detected and dropped, and screen
class is bucketed from the reported width, which is a different question
from the form factor the UA claims.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Origin is checked against the website's own domain, echoed back rather
than wildcarded, and `null` is suppressed on both sides so no opaque
origin matches. Vary: Origin goes out unconditionally, or a shared cache
serves one site's allowance to another. The response also carries the
Accept-CH ask, since the beacon is often the only request this origin
ever sees.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Split into payload/transport/index with its own vitest suite. No
localStorage, sessionStorage, cookie or IndexedDB write anywhere —
ePrivacy 5(3) covers a storage key as much as a cookie, and identity and
sessionization are the server's job now. SPA navigations are tracked
through history patching with a settle window so a mount redirect reads
as a correction rather than a second view, duration ships as a beacon
keyed by an ephemeral per-view token, and the body stays text/plain so no
preflight rides along with an unload flush.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
/collect resolves identity, session, acquisition, client and place server
side, writes one wide row, and answers 204 with no body. Paths are
normalised — hash routes kept, anchors and OAuth fragments collapsed —
props and revenue are bounded to scalars, and every client string is
repaired of the NUL and lone surrogates Postgres refuses and bounded in
bytes rather than UTF-16 units.

collect/:id is replaced by a fixed collect/duration keyed on the
tracker's per-view token, so no event id ever reaches a third-party
origin, and a replayed token is a 204 rather than a 500.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Intl still lists 18 pre-2018 tzdata names and canonicalises none of them,
so picking Asia/Calcutta from the dashboard 500'd out of the query layer.
Each is mapped to its current name — checked to agree to the minute at
every month — instead of being filtered out, which would leave India,
Ukraine and Argentina with no zone to pick. Adds the zoned day helpers
the loader and the chart both need.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
One statement per panel over plain columns instead of two joins through
event_metadata. Uniques are count(DISTINCT visitor_id) rather than a
per-row flag that grew with the window; acquisition panels are scoped to
is_new_session, since referrer and channel are properties of an arrival
and counting them per pageview understated every referrer in proportion
to pages-per-visit — so a panel now carries its unit with its rows.

Range predicates are half-open, so the boundary instant stops belonging
to both the current and the comparison window, and 0 is a real epoch
millisecond again rather than a dropped filter that returned lifetime
totals to an anonymous reader. The timeseries arrives padded from the
same statement that counts it.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
subDays is calendar arithmetic in the host's zone, so "Last 24 hours"
measured 23 or 25 hours around a transition and the length of every
window was a property of the machine while every bucket was a property of
?tz. The presets are durations ending now, and they are the length their
label says: 7 and 30, not the 6 and 29 that were picked to make the chart
draw a round number of bars.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Numbers, durations, percentages, buckets, trends, referrers, channels and
countries in one place, every one pinned to an explicit locale and zone
so the server's render survives hydration.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Also pulls in Geist Mono for the numerics and links the tracker into web
as a dev dependency so the e2e ingest test drives the real script.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Three surfaces in a fixed elevation ladder, chart ramp taken from the
aurora emission spectrum so chart-1 is the brand hue and a single-series
chart is on-brand for free, and a text-eyebrow utility with its own
tailwind-merge class group — unknown text-* classes read as colours and
one of the pair would have been dropped. Adds the shadcn components the
new screens need: sidebar, sheet, popover, calendar, tabs, chart,
combobox, empty, item and the rest.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The website list loads once in the app layout, so the switcher and the
breadcrumb have it on every screen instead of each route refetching it.
Navbar and footer are gone, the collapsed state persists, and the mark is
redrawn as a stroked triangle on currentColor so it tracks the theme
rather than sitting at a light-mode hex on a dark surface.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
An area chart over the padded series the query returns, with the bucket
unit following the window, plus a sparkline for the website list.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Grouped panels with per-row bars, an expandable row cap and icons per
dimension. Each header reads its unit off the rows, so a panel counting
sessions can no longer be labelled views, and the metric hints say what
a daily visitor and a bar's basis actually are.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Presets plus a custom calendar window, both kept in the URL — a preset
stays a name so a shared "last 7 days" link means the last seven days to
whoever opens it. Stat tiles carry their trend against the previous
window of equal length, and the payload is typed as the shared Breakdowns
rather than a local literal that silently dropped six panels and the
whole goals list. The public dashboard serialises the site's name and URL
only, not the row behind them.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The list carries 24h visitors, a sparkline and a receiving/idle badge
from one overview query instead of being a list of names. Adding a site
happens in a sheet from anywhere the sidebar reaches, and the form grew
the snippet with a copy button and the share link, which appears the
moment the switch is flipped rather than after a save round-trip.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
A brand rail scoped dark in both themes — an aurora needs a night sky —
beside the form. Account, signin and setup move onto the field primitives
and report success through a toast.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Vite's scan walks route modules before server-only exports are stripped,
so discovery re-optimized mid-navigation and the reload landed while the
page was hydrating — rendered, never interactive. Base UI is listed
subpath by subpath for the same reason: two module graphs at once means a
component reads a React dispatcher that isn't its own.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
askides and others added 12 commits August 4, 2026 19:06
Node compiles its zone database in, Postgres reads its image's OS
packages, and the dashboard reads both — JS resolves a window's
boundaries, SQL groups the buckets inside it. postgres:16 was on 2026b
against Node's 2026a while this was written, which puts America/Vancouver
an hour out from November onward with nothing on screen to say so. Both
images are pinned to the patch and each checks its own copy at
build/health time, so drift fails instead of quietly disagreeing.

CI moves to the same Postgres pin and gains a Docker build job: the
tzdata assertion, the scoped installs and the runner stage's
hand-assembled node_modules are invisible to pnpm build.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
app/components and app/lib were two flat piles; a screen's components,
queries and domain logic now sit together under app/modules/<feature>,
and the pieces with no owner sit under app/shared. Route files stay put —
they are React Router's config surface and their generated ./+types
imports are per path — and app/shell holds the chrome that spans
features.

queries.server.ts is split along the seams it already had: users to
auth, websites and the overview to websites, breakdowns/statistics/
timeseries plus the window predicates to analytics. Its websites tests
move with it, which is why the pg-pool harness now exists twice.

Imports are relative inside a module and aliased across one, so a module
folder can be moved without touching its contents. components.json moves
with the tree or the next `shadcn add` writes to the old paths.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
shared/ may not import a feature module. Left to convention that holds
until the first hurried import, and then "shared" is a junk drawer again.
Tests are exempt: asserting a shared formatter's zone behaviour means
borrowing the module that defines zones.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
collect and collect/duration are the only routes that render nothing:
unauthenticated, CORS-answering, and posted to from third-party origins.
Grouping them says which surface is public API without touching a URL —
routes.ts names every path explicitly, so the file layout is free.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Its own module, because importing db.server constructs the connection
pool: a suite that stubs the pool would have to stub this too, which
means mocking away the logic it came to test. /signup needs the same
predicate.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The pair reads as signin/signout now, in the route, the session helper
and the URL the sidebar posts to.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
app-shell.tsx imports AppSidebar, so exporting both shells from it put the
authenticated chrome in the public dashboard's module graph — and rollup
emitted one 21KB chunk holding both, which the anonymous route pulled
behind its own 767 bytes. Verified in the build output: "Add website" and
"Account settings" were in the chunk a signed-out reader downloaded.

The public route now carries a 1.9KB chunk and a badge, and the sidebar
lives only where a session does.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The goals panel was importing seven symbols out of breakdown-panel.tsx —
the row cap, the expander wording, the truncation note, the column header,
the bar explanation — which made one sibling component the other's library
and put a 587-line module in the graph between the goals list and two
constants. They encode a rule that both lists have to make the identical
claim, which is easier to hold in one small file.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
collect.duration.ts imported `bounded` and `readPayload` from collect.ts,
the only route-to-route edge in the graph. Both schemas move with them,
which turned out to be the same problem: a route's non-route exports
survive into the client build — React Router only strips loader, action,
middleware and headers — so a schema exported from a route is a schema in
the browser bundle, and the build refuses a `.server` import until the
routes export nothing else. They now export loader and action only.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Commit messages become the input to versioning, so they are checked by a
commit-msg hook and again in CI — the hook is one `--no-verify` from being
skipped, and a squash merge takes the pull request title rather than any
commit the hook ever saw, so both are linted. Subjects are all lowercase.

release-please reads those commits and maintains a release pull request;
merging it bumps the version, writes the changelog, tags, and publishes the
GitHub release. `include-component-in-tag` is off so tags stay `vX.Y.Z`
rather than becoming `aurora-vX.Y.Z`, and refactor is shown rather than
hidden because nearly everything here is one.

Publishing keys off that job's output instead of living in a workflow
triggered `on: release`. A release created with GITHUB_TOKEN does not fire
release, create or tag push events — GitHub suppresses them so workflows
cannot trigger themselves — so the obvious wiring would never have run.

The Docker gate is now pull requests only, since publish builds the same
image on main. `prepare` is `husky || true` because the production install
in the Dockerfile has no devDependencies and would otherwise fail on it, and
CHANGELOG.md is exempt from oxfmt, which formats markdown and would fight
release-please over every release.

Versions start at 4.0.0: the 0.x-2.x tags belong to the Next.js project this
replaced, and 3.x is skipped so the rewrite gets a clean major.
Both were dropped along with the docs app, leaving the repository with no
entry point and an MIT claim in package.json that nothing backed. The readme
now carries what the Nextra site used to: configuration, the tracker snippet,
and how to run it.

AURORA_IP_HEADER gets a paragraph rather than a table row. Unset in
production it silently makes visitor, session and bounce figures forgeable,
which is the one setting a self-hoster can get wrong without noticing.

The license is the same MIT body, word for word, with the title line it was
missing and a copyright line naming a holder — the old one read
`Copyright (c) 2012-2021` with nothing after it. Extensionless so oxfmt,
which formats markdown at 80 columns, cannot reflow the text.
oxlint has flagged this as an error for as long as the rule has been on, so
`pnpm lint` exited non-zero on every run — which the new release workflow
turns from an annoyance into a block, since release and publish both sit
behind the lint job.

Removed rather than suppressed. The one other autoFocus in the app, on the
range picker's calendar, carries a disable comment because it fires when a
popover opens on a click; this one fires on page load, which is the case the
rule is actually about.
@askides
askides merged commit 1b262ac into main Aug 5, 2026
5 of 6 checks passed
@askides askides mentioned this pull request Aug 5, 2026
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