What changed
Everything that's been done to the codebase, newest first. Read the top entry after each round of work, then follow its links into the chapters that explain it.
The headings are Inter, and the shine plays every time
The landing page's two big headings moved from Instrument Serif to Inter, medium and tracked tight like react.email's. The headline's shine now plays on every load.
font-display was Instrument Serif at weight 400. The shine was hidden on a repeat visit in the same session.font-display is Inter, weight 500, −0.05em. The hero is 68px at most with 0.96 line height, and the closing heading 48px. The shine always plays, from 0.3s when the entrance is skipped.- The utility was repointed, so
hero.tsxandsections.tsxkept their class names. @fontsource/instrument-serifwas removed withbun remove, and its import is gone fromglobals.css.
A shine on the headline, and Verify opens into the field
The headline gets one sweep of light after it settles, copied from react.email's CSS. The hero now shows two buttons, and Verify turns into the input while the CLI command blurs away. Two phone overflows on the landing page are fixed too.
.headline-shineis an aria-hidden copy of the words with a 45° band,background-size: 225%, animatingbackground-position110% → 0% over 1.5s from 0.8s. It plays on every load, starting at 0.3s when the entrance is skipped, and is off for reduced motion.- The headline base is 82% white so the band shows.
DomainFormtakes the command asaside.MORPHis a 0.55s no-bounce spring for the shell and the button.OPEN_DOMAIN_FIELDletsFocusDomainButtonopen the field from the bottom of the page.- The phone overflow came from single-column grids growing to their widest line: the step cards and the failure explorer. Both now use
minmax(0, 1fr), and the tabs drop their icons belowsm.
A new mark, and a glossy hero icon
The logo is now an open ring with a tick running out through the gap, drawn from one filled path everywhere. The hero's tile became a glossy app icon after react.email's, in the globe's green.
logo.tsx, the icon route and the favicon. The hero tile was graphite like every other tile.MARK_PATH in logo.tsx is the one definition, and the icon route imports it. AppIcon draws the hero tile: rim, green body, glow, echoes of the mark, a soft top highlight, and the mark with a glow and a shadow. Its colours come from the globe's green, #46FEA5.app/icon.svgkeeps its own copy of the path, because it's a static file.- The mark's box is 25 by 23, so it sits centred in square slots like
size-5. .app-iconpads 2px around.app-icon-body, whose radius is 2px smaller, so the rim's curves stay parallel.- The claim and setup status tiles and the header use the new mark, still graphite.
- The lines inside the tile are echoes of the mark's centreline (
MARK_CENTERLINE,ECHOES), so they take its shape, the way react.email's echo its atom.
A globe you can turn, and edges that don't clip
The globe can be dragged to turn it, brightens under the pointer, and turns as it moves between pages. Method cards no longer lose their ring at the sides, and the Activity strip fades at its edges with a standard scrollbar.
yaw and pitch, turn the globe. Each scene has its own, eased with the position, and a mouse drag adds an offset with a fling. The step body's clip is 8px wider. The strip uses ScrollArea with scroll-fade-x.SCENE_TURNis 1.6 radians of yaw and −0.32 of pitch for claim-type pages.- Drag listens on
window, because the canvas ignores the pointer.NOT_THE_GLOBElists what a press can't start from: links, buttons, inputs and text. - Touch is ignored, so a phone still scrolls. With reduced motion, dragging works but there's no fling.
html[data-globe]sets the grab and grabbing cursors.ScrollAreain@workspace/uigainedviewportClassName,viewportRefandorientation.
Soft edges, and a surface under the title
Every page now has a progressive blur along the top and bottom of the window and an oval vignette over the globe, after react.email's hero. On claim-type pages the globe sits lower and the title sits on a dark surface at its lower edge.
EdgeFade adds a fixed vignette and six stacked backdrop blurs at each edge. On claim, setup and email pages the globe's lower edge is pinned 300px down (240px on a phone), content starts lower, and ClaimSurface darkens the sphere behind the title.BLUR_STEPSis 0.5, 1, 2, 4, 8 and 16px.bandMask(i, n)gives each layer a band that overlaps its neighbours.- The blur strips are
z-[5]: above the content, below the header'sz-10. The vignette is-z-10, likeStage, and wins by coming later in the DOM. CLAIM_HORIZON_PXlives instage-store.ts, soClaimSurfacecan read it without loading the shader code instart-globe.ts.poseForcomputes the claim pose's center from it:(horizon − radius · short side) / height.edge-fade.tsxmoved fromcomponents/landingtocomponents/stage, since five pages use it.- Claim, setup, confirm-email and unsubscribe pages start at
pt-32/sm:pt-40.
Real email through Resend, with a way out of every alert
Alerts now work like Have I Been Pwned's notifications: give an address, prove it's yours once, hear only when something happens, leave with one click. All three templates were sent through Resend and delivered.
/confirm-email and /unsubscribe, each a page with one button that POSTs. Every alert has an unsubscribe link and List-Unsubscribe headers, so mail apps show their own button.- The unsubscribe token is the one token stored unhashed, because it's read back for every send. It only turns one account's email off, and it's replaced when the address changes.
apps/web/scripts/send-test-email.tssends every template to Resend's test inbox, or to an address you pass.- The landing page's
email-notice.tsxis gone. The confirm page shows the result itself. - Migration
20261110015754_email_unsubscribeaddsaccounts.email_unsubscribe_token. - 6 new tests, 138 in
apps/weband 864 in all.
Rate limits are counted in Postgres
Every limit in the app now counts in a rate_limit_hits table, so all of Vercel's function instances share one tally.
consumeRateLimitdeletes a key's expired hits, counts what's left, and inserts if there's room, all in one transaction underpg_advisory_xact_lock(hashtext(key)).- The app's clock is passed in as
now, so tests move time the usual way. - The worker prunes hits older than an hour, for keys that never come back.
RateLimiteris async now, and the in-memory version is gone, so the API tests run against the real limiter.- The claim list stopped looking up a check cooldown per row. It never showed a Check now button.
- 7 new tests in
packages/db, 87 there now. One sends 20 requests at once against a limit of 5.
Confirming an email now says so, and a failed send leaves nothing behind
Writing these notes turned up loose ends in the alerts code. The ones that were real bugs are fixed, and four comments that had drifted from the code were corrected.
?email=confirmed in the URL and no message. If the confirmation email failed to send, the address stayed saved as if a link were on its way.components/landing/email-notice.tsx shows a toast for confirmed or expired and tidies the URL. setEmail clears the address when the send fails. The setup page no longer says the owner was told, which is only true if they turned alerts on.- The toast has a fixed id, so React running the effect twice in development still shows one.
- The comment on
moveinclaim-service.tsnow names the two paths that don't go through it: transfer and a new code. stage-store.tsandglobe.wgslno longer mention a wave on a keystroke.render-globe.mjssays it draws five frames, and its output folder is gitignored.- One new test: an address we couldn't send to isn't left waiting for a click. That makes 132 in
apps/weband 851 in all.
The notes moved with the app, and became the Prufy notes
These notes now live at apps/web/app/notes in the main checkout, are called Prufy notes, and have six new chapters. Every chapter the rename, sessions, alerts, setup links or the new UI touched was brought up to date.
apps/demo, described the Relay engine with its demo account switcher, and stopped at 775 tests.- Older entries on this page had
apps/demochanged toapps/webso their file lists still point at real files. Names likerelay-domainsin them are left as they were at the time. - Three playgrounds run app code as well as
packages/core: the page chapter usesshownPhasefromcomponents/claim/phase.tsand the presets fromlib/motion.ts, and the alerts chapter runsalertForTransitionover core's ownTRANSITIONStable. - The globe chapter's three pictures are real frames from the shader, drawn by
apps/web/scripts/render-globe.mjs. - The publish script was edited for the new layout: project
prufy-notes, no.envfiles orpublic/sw.jsin the copy, and a check that stops if the notes import the database, DNS, the UI package, server code or a.wgslfile. - The progress key in your browser is still
relay-notes-progress, so the chapters you'd marked as understood stay marked.
Prufy became installable
A manifest, generated icons and a service worker make the site a web app you can add to a Home Screen. The worker shows push notifications and opens the right claim when one is tapped.
app/manifest.ts makes it installable, app/icons/[size]/route.tsx draws the icon at five sizes, and public/sw.js handles pushes.- The worker caches nothing. Every page shows live status, and a stale verified would be worse than a spinner.
- It's registered on every page load by
components/service-worker.tsx, so it's already active when someone turns alerts on. pushSupport()inlib/push-client.tsreturns one of four answers, and the alerts card explains each in a sentence.- A notification click only ever opens a page on Prufy's own origin, whatever the payload said.
The landing page is the app, and the claim page is four steps
The plain test UI was replaced. The landing page's input starts a real claim, the domain field travels to the claim page, and the claim is laid out as four steps down a line with four ways to add the record. A helper gets a setup page of their own.
/ is the hero and three short sections. /claims/<id> is your domain, add one record, we check it, verified. /setup/<token> is the helper's view. All three read view.phase and lay themselves out to match.- The page never decides state, with one exception:
isWaitingForRecordincomponents/claim/phase.tsshows 'nothing at the name' as still adding. The worker's first check runs about 30 seconds in, and the page used to turn red under someone still reading. - The form puts the new claim in the query cache before navigating. A view transition only moves the field if the claim page renders it in the same commit.
- Motion follows one vocabulary in
lib/motion.ts: strong ease-out to enter, shorter exits, springs with no bounce for swaps, and height changes animated. - The pages are server-rendered now. The old layout loaded its providers on the client only, which left the HTML empty.
- Type is Inter and Commit Mono, the open-source neighbours of Resend's licensed typefaces. (Instrument Serif was used for the headings until Nov 9.)
The globe
The object behind every page is a sphere of dots with a wave crossing it, drawn by one WebGPU fragment shader. It moves between pages, sends a wave when a check runs and turns green when a domain is verified.
components/stage/globe.wgsl draws it, start-globe.ts runs it with vgpu, and pages steer it through a plain object in stage-store.ts.- There's no geometry and no texture. Every pixel works out whether it's on the sphere and how close it is to the nearest dot.
- Rings near the poles get fewer dots, so the spacing stays even. The wave is an angle from an origin.
- The state is a module-level object, not React state, because the render loop reads it every frame and none of it should re-render anything.
- With reduced motion on it draws one still frame. Without WebGPU a still gradient stands in, and the shader code is never downloaded.
apps/web/scripts/render-globe.mjsrenders it to PNG without a browser.
Alerts by email and push, and setup links
Six state changes now send an alert, by email through Resend and by Web Push. And an owner can make a setup link that lets someone else read one claim's record and ask for a check.
alertForTransition in core decides what's worth a message, move in claim-service.ts reports state changes to it, and share-service.ts makes links that are safe to forward.- The six kinds are verified, at risk, recovered, lapsed, expired and transferred. A check that changes nothing sends nothing, and neither does something the owner just did on the page.
- A transfer alerts both accounts, so ownership never moves silently.
- An email address gets exactly one message, the confirmation link, until it's clicked.
isPushEndpointonly accepts four known push services over HTTPS. Without it, a fake subscription could make the server call any address.- A setup link can't get a new code, stop the claim or see anything else. Like a session, only its hash is stored, and it expires in 30 days.
MailerandPushSenderare small interfaces on the container, so tests record what would have been sent.- New columns on
accounts, and two new tables:claim_sharesandpush_subscriptions.
Cookie sessions replaced the demo account switcher
There's no sign-up. A first claim creates an account and signs that browser in with a prufy_session cookie. The X-Demo-Account header, /api/accounts and the seeded accounts are gone.
sessions table, a random token in an HttpOnly, SameSite=Lax cookie, and only the token's SHA-256 in the database.- Only claim creation makes an account, and it validates the domain first, so a typo doesn't leave an empty account behind. Visiting the site writes nothing.
- New sessions are limited to 10 an hour per IP. It's the only thing stopping one client from becoming many accounts.
- Listing claims with no session returns an empty list, not a 401.
with-account.tsis still the only file that decides access.- Tests that need an owner and a rival create their own sessions.
Relay became Prufy
The made-up email product was dropped. The project is Prufy, a tool that only proves you own a domain. apps/demo became apps/web, and the DNS record is now _prufy-challenge.<domain> with the value prufy-domain-verification=<token>.
_relay-challenge with relay-domain-verification=.- Two CHECK constraints on
challengespin the record's name and value, so a migration drops them, rewrites the rows and adds them back. CHALLENGE_LABELandRECORD_VALUE_PREFIXinpackages/core/src/constants.tsare the source of the name.- The Domain Connect template is
domain-connect/prufy.dev.ownership.json. - ADRs 0001 to 0006, the earlier journal entries and the experiments still say Relay and
apps/demoon purpose. They record what was true when they were written. docs/brief.mdis now the reference for the problem, the people it's for and what's out of scope.- The same branch added agent skills for design, shaders and shadcn under
.claude/skillsand.agents/skills.
Vercel now deploys from GitHub
The relay-domains project is connected to the repo. Merging to main deploys production, other branches get previews, and a push that changes nothing the app uses is skipped.
- The skip rule is the project's Ignored Build Step, not a file in the repo. It runs
git diff --quietagainst the last deployed commit forapps/web,packages,bun.lock,package.jsonandturbo.json, leaving out Markdown. VERCEL_GIT_PREVIOUS_SHAis empty on a branch's first deployment, so the rule falls through and that first one always builds.- A free Neon plan can't bill you: at a limit the database pauses. Each database gets its own monthly allowance.
- Tested on a real preview built from GitHub: real DNS ran,
/noteswas a 404, and a claim made there never appeared in production. - The skip rule has run for real: Vercel cancelled the build for a docs-only commit, citing the Ignored Build Step.
The demo went live on Vercel
apps/web now runs at relay-domains.vercel.app, with Postgres on Neon. It's a separate Vercel project from these notes, and .vercelignore keeps the notes out of it.
setInterval loop as its worker and Postgres in Docker./api/cron/run-due-work every minute, and the database is Neon's free plan in iad1.withCronSecretguards the new route. It compares hashes in constant time, and refuses everything whenCRON_SECRETisn't set.- The app connects through Neon's pooled
DATABASE_URL. Migrations use the directDATABASE_URL_UNPOOLED, and never run on deploy. attachDatabasePoolfrom@vercel/functionsreleases idle connections before a function suspends. It does nothing off Vercel.- A real check of example.com from a Function reached Cloudflare's nameservers over UDP in about 9ms and read real TXT records. It was also the engine's first run on real Node.
The engine went up for review
The branch was renamed to feat/domain-claim-engine, pushed to GitHub, and opened as PR #2 against main: ten commits, each reviewable on its own.
claude/domain-claim-engine-63b051..github/workflows/ci.ymlstarts a Postgres service, then runs the migrations, the typecheck, Biome and the tests. It doesn't run the build.- Commit messages and the PR text carry no AI attribution, which is also the repo's rule in
CLAUDE.md. - CI passed on its first ever run, and the PR merged into
mainon Nov 9.
These notes became a living guide
The notes, and the rule that keeps them current, are now ignored by git. From here on, every meaningful change updates the chapters it touches and adds an entry to this page.
git add . away from a commit, and nothing required them to stay current.- Biome reads
.gitignore, sobun run checknow skips the notes. They're linted with--vcs-use-ignore-file=falseinstead. - Turbo doesn't hash ignored files, so the root
bun run typecheckcan replay an old pass. The notes are typechecked fromapps/webdirectly. tscandnext builddon't read.gitignore, so the notes still compile with the app. If a change topackages/corebreaks a playground, the typecheck inapps/webfails.- Every update ends by running
_deploy/deploy.sh, which publishes a static copy of only the notes to relay-engine-notes.vercel.app, so the phone copy stays current.
Moved to real Postgres
The engine stopped using PGlite, which runs Postgres inside the app's own process. Development, tests and CI now all use Postgres 18.6 in Docker.
- The race tests live in
packages/db/src/concurrency.test.ts. One proves a locked row gets skipped: withoutSKIP LOCKEDit hangs and times out. - If Postgres is unreachable, tests fail with instructions. They're never skipped, because a skipped test looks exactly like a passing one.
- The template is built under a temporary name and renamed at the end, so a crash halfway can't leave a broken one behind.
apps/web/lib/docs.test.tsfails if CI anddocker-compose.ymluse different Postgres images.
Closed two holes in Domain Connect
URLs in a provider's settings must now be HTTPS, and an error string sent back by a provider is cut to 200 characters before it's stored.
z.url() accepted any scheme, including javascript:, and one of these URLs gets opened in the person's browser. Provider errors were stored at any length.z.url({ protocol: /^https$/ }) refuses everything else, and MAX_PROVIDER_ERROR_LENGTH caps what's stored at 200 characters.urlSyncUX,urlAsyncUXandurlAPIall share the samehttpsUrlschema.
Docs, and tests that keep them honest
Added the decision log, the state machine doc and the verification doc, along with tests that fail when the docs stop matching the code.
apps/web/lib/docs.test.ts compares the state diagram and transition table in the docs with the ones the code produces.bun run docs:state-machineregenerates the doc from the real transition table.- The same test file checks that database enums come from core's tuples, and that the migration has both ownership indexes.
The API and the screens
apps/web gained the claims API, an account stand-in, rate limits, the background worker, the Domain Connect routes and a plain UI.
- Biome now errors on
!non-null assertions across the engine code, outside tests. withAccountholds every access decision, so real auth can replace the stand-in in one place.
The engine's three packages
packages/dns gathers evidence, packages/core judges it, and packages/db remembers the result. Each one can be tested on its own.
transitioninpackages/core/src/claim.tsis the only function that decides a status change.classifyinpackages/core/src/classify.tsturns raw observations into one named outcome.packages/db/src/schema.tsuses CHECK constraints and partial unique indexes, so illegal rows can't be stored.