Prufy notes
As we build · 4 min

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.

Nov 9, 2026FeatureNot committed

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.

Before
font-display was Instrument Serif at weight 400. The shine was hidden on a repeat visit in the same session.
After
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.tsx and sections.tsx kept their class names.
  • @fontsource/instrument-serif was removed with bun remove, and its import is gone from globals.css.
Files
apps/web/app/globals.css
apps/web/components/landing/hero.tsx
apps/web/components/landing/sections.tsx
apps/web/package.json
Read more
Nov 9, 2026FeatureNot committed

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.

Before
The input was always open, with the cursor in it on a computer, and the command sat on its own line below. The page was 442px wide on a 375px phone.
After
Verify a domain and the command sit side by side. Pressing Verify animates the same button and shell into the field with one spring, focuses it, and Escape closes it. The page fits 375px.
  • .headline-shine is an aria-hidden copy of the words with a 45° band, background-size: 225%, animating background-position 110% → 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.
  • DomainForm takes the command as aside. MORPH is a 0.55s no-bounce spring for the shell and the button.
  • OPEN_DOMAIN_FIELD lets FocusDomainButton open 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 below sm.
Files
apps/web/components/landing/domain-form.tsx
apps/web/components/landing/hero.tsx
apps/web/components/landing/command-pill.tsx
apps/web/components/landing/focus-domain-button.tsx
apps/web/components/landing/sections.tsx
apps/web/components/landing/failure-explorer.tsx
apps/web/app/globals.css
Read more
Nov 9, 2026FeatureNot committed

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.

Before
The mark was two strokes, a ring and a tick, copied into logo.tsx, the icon route and the favicon. The hero tile was graphite like every other tile.
After
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.svg keeps 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-icon pads 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.
Files
apps/web/components/logo.tsx
apps/web/components/landing/hero.tsx
apps/web/app/globals.css
apps/web/app/icon.svg
apps/web/app/icons/[size]/route.tsx
Read more
Nov 9, 2026FeatureNot committed

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.

Before
The globe ignored the pointer, and a page change only moved and resized it, like a flat picture. A step's body clipped the selected card's ring. The Activity strip cut events off at a hard edge, with the platform's scrollbar.
After
Two new uniforms, 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_TURN is 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_GLOBE lists 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.
  • ScrollArea in @workspace/ui gained viewportClassName, viewportRef and orientation.
Files
apps/web/components/stage/globe.wgsl
apps/web/components/stage/start-globe.ts
apps/web/app/globals.css
apps/web/components/claim/timeline.tsx
apps/web/components/claim/events-strip.tsx
packages/ui/src/components/scroll-area.tsx
Read more
Nov 9, 2026FeatureNot committed

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.

Before
Content scrolled off at a hard line, and the globe was cut off by the window. On a claim page the globe was a thin strip along the top, at a height that changed with the window.
After
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_STEPS is 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's z-10. The vignette is -z-10, like Stage, and wins by coming later in the DOM.
  • CLAIM_HORIZON_PX lives in stage-store.ts, so ClaimSurface can read it without loading the shader code in start-globe.ts.
  • poseFor computes the claim pose's center from it: (horizon − radius · short side) / height.
  • edge-fade.tsx moved from components/landing to components/stage, since five pages use it.
  • Claim, setup, confirm-email and unsubscribe pages start at pt-32 / sm:pt-40.
Files
apps/web/components/stage/edge-fade.tsx
apps/web/components/stage/stage-store.ts
apps/web/components/stage/start-globe.ts
apps/web/app/globals.css
apps/web/app/page.tsx
apps/web/app/claims/[id]/page.tsx
apps/web/app/setup/[token]/page.tsx
apps/web/app/confirm-email/page.tsx
apps/web/app/unsubscribe/page.tsx
Read more
Nov 9, 2026Featureef7539e

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.

Before
The confirmation link was a GET that confirmed by being opened, and an alert had no way to turn itself off.
After
The links in emails open /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.ts sends every template to Resend's test inbox, or to an address you pass.
  • The landing page's email-notice.tsx is gone. The confirm page shows the result itself.
  • Migration 20261110015754_email_unsubscribe adds accounts.email_unsubscribe_token.
  • 6 new tests, 138 in apps/web and 864 in all.
Files
apps/web/lib/server/emails.tsx
apps/web/lib/server/alerts.ts
apps/web/lib/server/notification-service.ts
apps/web/components/email-action.tsx
apps/web/app/confirm-email/page.tsx
apps/web/app/unsubscribe/page.tsx
apps/web/scripts/send-test-email.ts
packages/db/src/queries/notifications.ts
Read more
Nov 9, 2026Safety23d5d61

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.

Before
Limits were counted in memory. Each function instance kept its own count, so a limit of 5 an hour was really 5 an hour per instance.
After
One row per counted action, as a sliding window. An advisory lock per key makes two requests for the same key take turns, so they can't both read 'under the limit'.
  • consumeRateLimit deletes a key's expired hits, counts what's left, and inserts if there's room, all in one transaction under pg_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.
  • RateLimiter is 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.
Files
packages/db/src/queries/rate-limit.ts
packages/db/src/rate-limit.test.ts
packages/db/src/schema.ts
apps/web/lib/server/rate-limit.ts
apps/web/lib/server/claim-service.ts
docs/decisions/0011-rate-limits-in-postgres.md
Read more
Nov 9, 2026FixNot committed

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.

Before
Clicking the confirmation link landed on the home page with ?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.
After
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 move in claim-service.ts now names the two paths that don't go through it: transfer and a new code.
  • stage-store.ts and globe.wgsl no longer mention a wave on a keystroke. render-globe.mjs says 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/web and 851 in all.
Files
apps/web/components/landing/email-notice.tsx
apps/web/app/page.tsx
apps/web/lib/server/notification-service.ts
apps/web/lib/server/claim-service.ts
apps/web/components/setup/setup-page.tsx
apps/web/lib/server/alerts-and-shares.test.ts
Read more
Nov 9, 2026ToolingNot committed

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.

Before
The notes only existed in an old worktree under apps/demo, described the Relay engine with its demo account switcher, and stopped at 775 tests.
After
They sit beside the app they describe. New chapters: Sessions without sign-up, Setup links, Alerts, The page and its motion, The globe, and Installable, with push.
  • Older entries on this page had apps/demo changed to apps/web so their file lists still point at real files. Names like relay-domains in them are left as they were at the time.
  • Three playgrounds run app code as well as packages/core: the page chapter uses shownPhase from components/claim/phase.ts and the presets from lib/motion.ts, and the alerts chapter runs alertForTransition over core's own TRANSITIONS table.
  • 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 .env files or public/sw.js in the copy, and a check that stops if the notes import the database, DNS, the UI package, server code or a .wgsl file.
  • The progress key in your browser is still relay-notes-progress, so the chapters you'd marked as understood stay marked.
Files
apps/web/app/notes/_lib/chapters.ts
apps/web/app/notes/_chapters/access.tsx
apps/web/app/notes/_chapters/alerts.tsx
apps/web/app/notes/_chapters/interface.tsx
apps/web/app/notes/_deploy/deploy.sh
.claude/rules/study-notes.md
Read more
Nov 9, 2026Feature298a5cc

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.

Before
A website only. Nothing could reach someone who'd closed the tab.
After
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() in lib/push-client.ts returns 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.
Files
apps/web/app/manifest.ts
apps/web/app/icons/[size]/route.tsx
apps/web/public/sw.js
apps/web/lib/push-client.ts
apps/web/components/service-worker.tsx
Read more
Nov 9, 2026Feature57b2eae a293e0c 938a8c2 4498da9 f982153 0b8d181

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.

Before
A form, a table and a stack of cards whose only job was to exercise the engine. An account switcher sat in the header.
After
/ 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: isWaitingForRecord in components/claim/phase.ts shows '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.)
Files
apps/web/app/page.tsx
apps/web/components/landing/domain-form.tsx
apps/web/components/claim/claim-page.tsx
apps/web/components/claim/phase.ts
apps/web/components/claim/methods.tsx
apps/web/components/setup/setup-page.tsx
apps/web/lib/motion.ts
docs/decisions/0010-the-landing-page-is-the-app.md
Read more
Nov 9, 2026Featuread296bb

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.

Before
A plain black page.
After
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.mjs renders it to PNG without a browser.
Files
apps/web/components/stage/globe.wgsl
apps/web/components/stage/start-globe.ts
apps/web/components/stage/stage-store.ts
apps/web/components/stage/stage.tsx
apps/web/scripts/render-globe.mjs
apps/web/next.config.ts
Read more
Nov 9, 2026Feature588156c fa90e5f

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.

Before
The only way to find out what happened was to keep the page open, and the only person who could act on a claim was whoever started it.
After
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.
  • isPushEndpoint only 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.
  • Mailer and PushSender are small interfaces on the container, so tests record what would have been sent.
  • New columns on accounts, and two new tables: claim_shares and push_subscriptions.
Files
packages/core/src/alerts.ts
apps/web/lib/server/alerts.ts
apps/web/lib/server/notification-service.ts
apps/web/lib/server/share-service.ts
apps/web/lib/server/emails.tsx
apps/web/lib/server/mailer.ts
apps/web/lib/server/push.ts
packages/db/drizzle/20261110000501_alerts_and_share_links/migration.sql
docs/decisions/0009-alerts-and-setup-links.md
Read more
Nov 9, 2026Safetyf27e685

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.

Before
A header naming an account, and a dropdown to switch between two seeded ones. It proved nothing.
After
A 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.ts is still the only file that decides access.
  • Tests that need an owner and a rival create their own sessions.
Files
apps/web/lib/server/session.ts
apps/web/lib/server/with-account.ts
packages/db/src/queries/sessions.ts
packages/db/drizzle/20261109235922_device_sessions/migration.sql
docs/decisions/0008-sessions-without-sign-up.md
Read more
Nov 9, 2026Structure033d939 826f494 4991892

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>.

Before
Domain verification was one step inside Relay, an email marketing product, and the record was _relay-challenge with relay-domain-verification=.
After
One product with one job. The repo, the app folder, the local database, the Domain Connect template and the record all carry the new name.
  • Two CHECK constraints on challenges pin the record's name and value, so a migration drops them, rewrites the rows and adds them back.
  • CHALLENGE_LABEL and RECORD_VALUE_PREFIX in packages/core/src/constants.ts are 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/demo on purpose. They record what was true when they were written.
  • docs/brief.md is 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/skills and .agents/skills.
Files
packages/core/src/constants.ts
packages/db/drizzle/20261109235104_rename_challenge_to_prufy/migration.sql
domain-connect/prufy.dev.ownership.json
docs/brief.md
docs/decisions/0007-prufy-one-product-one-job.md
Read more
Nov 9, 2026Tooling72026cb

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.

Before
The demo only deployed when someone ran the CLI, and previews didn't exist.
After
Pushing does the deploying. Previews sit behind Vercel login and use their own free Neon database, so test claims never reach production.
  • The skip rule is the project's Ignored Build Step, not a file in the repo. It runs git diff --quiet against the last deployed commit for apps/web, packages, bun.lock, package.json and turbo.json, leaving out Markdown.
  • VERCEL_GIT_PREVIOUS_SHA is 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, /notes was 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.
Files
apps/web/README.md
docs/decisions/0006-hosting-on-vercel.md
Read more
Nov 9, 2026Structure244d361 bd0289d c0b1c9c 828d9b5

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.

Before
The app only ran on your laptop, with a setInterval loop as its worker and Postgres in Docker.
After
It runs in Vercel Functions on real Node. Vercel Cron calls /api/cron/run-due-work every minute, and the database is Neon's free plan in iad1.
  • withCronSecret guards the new route. It compares hashes in constant time, and refuses everything when CRON_SECRET isn't set.
  • The app connects through Neon's pooled DATABASE_URL. Migrations use the direct DATABASE_URL_UNPOOLED, and never run on deploy.
  • attachDatabasePool from @vercel/functions releases 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.
Files
.vercelignore
apps/web/vercel.json
apps/web/app/api/cron/run-due-work/route.ts
apps/web/lib/server/with-account.ts
apps/web/lib/server/container.ts
packages/db/src/database.ts
docs/decisions/0006-hosting-on-vercel.md
Read more
Nov 9, 2026Tooling56c6287

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.

Before
The work lived only on this machine, on a branch called claude/domain-claim-engine-63b051.
After
It's on GitHub as PR #2, and the branch name no longer mentions Claude.
  • .github/workflows/ci.yml starts 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 main on Nov 9.
Files
.github/workflows/ci.yml
.gitignore
Read more
Nov 9, 2026Tooling56c6287

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.

Before
The notes were untracked files, one git add . away from a commit, and nothing required them to stay current.
After
Git ignores both. Each change updates the affected chapters and logs itself here, with why it happened and what isn't finished.
  • Biome reads .gitignore, so bun run check now skips the notes. They're linted with --vcs-use-ignore-file=false instead.
  • Turbo doesn't hash ignored files, so the root bun run typecheck can replay an old pass. The notes are typechecked from apps/web directly.
  • tsc and next build don't read .gitignore, so the notes still compile with the app. If a change to packages/core breaks a playground, the typecheck in apps/web fails.
  • 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.
Files
.gitignore
.claude/rules/study-notes.md
apps/web/app/notes/_lib/changes.ts
apps/web/app/notes/_chapters/what-changed.tsx
apps/web/app/notes/_deploy/deploy.sh
Read more
Nov 8, 2026Structurea91d0c9

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.

Before
PGlite has a single in-process connection, so two transactions could never overlap, and the concurrency tests couldn't really contend.
After
Real Postgres on port 55432. Tests use separate connections, so transactions truly overlap, and each test gets its own database cloned from a migrated template.
  • The race tests live in packages/db/src/concurrency.test.ts. One proves a locked row gets skipped: without SKIP LOCKED it 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.ts fails if CI and docker-compose.yml use different Postgres images.
Files
docker-compose.yml
packages/db/src/database.ts
packages/db/src/testing.ts
packages/db/src/concurrency.test.ts
.github/workflows/ci.yml
Read more
Nov 8, 2026Safetyec28dab

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.

Before
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.
After
z.url({ protocol: /^https$/ }) refuses everything else, and MAX_PROVIDER_ERROR_LENGTH caps what's stored at 200 characters.
  • urlSyncUX, urlAsyncUX and urlAPI all share the same httpsUrl schema.
Files
packages/dns/src/domain-connect.ts
apps/web/lib/server/claim-service.ts
Read more
Nov 8, 2026Docsd5655d2

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.

Before
Nothing connected the written docs to the code they described.
After
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-machine regenerates 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.
Files
docs/decisions.md
docs/state-machine.md
docs/verification.md
apps/web/lib/docs.test.ts
Read more
Nov 8, 2026Feature617cdfe f2e7b7f

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.

Before
The logic existed only as packages that nothing was calling.
After
You can claim a domain in a browser, and a worker keeps re-checking it without you.
  • Biome now errors on ! non-null assertions across the engine code, outside tests.
  • withAccount holds every access decision, so real auth can replace the stand-in in one place.
Files
apps/web/lib/contract.ts
apps/web/lib/server/api.ts
apps/web/lib/server/claim-service.ts
apps/web/lib/server/with-account.ts
apps/web/lib/server/worker.ts
Read more
Nov 8, 2026Feature18afb81 09de5b7 69be2c3 6c0d1e2

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.

Before
An empty workspace with strict TypeScript, Vitest and CI set up.
After
A complete claim lifecycle, from a typed domain to a stored and audited verdict.
  • transition in packages/core/src/claim.ts is the only function that decides a status change.
  • classify in packages/core/src/classify.ts turns raw observations into one named outcome.
  • packages/db/src/schema.ts uses CHECK constraints and partial unique indexes, so illegal rows can't be stored.
Files
packages/core/src/claim.ts
packages/core/src/classify.ts
packages/dns/src/runner.ts
packages/db/src/schema.ts
Read more