logo
Published on

Stairno 3. Building Sign-in and Going Live

Authors
  • avatar
    Name
    seren-wib
    Twitter
Contents

Roadmap step 3. Sign-in was actually built to the documents from step 2 and put live. The big picture is in Stairno 0. The whole roadmap.

In one line

From the early hours to the late morning of 18 October, an empty repo got sign-up, sign-in, verification mail, device management and a sign-in gateway for other sites, and api.stairno.com and account.stairno.com went live. In the afternoon, the account menu shared with the hub was attached, the acceptance checklist was checked on the production addresses, and step 3 closed.

1. How the work was done

The rules came before the code.

  • One feature = one issue + one PR. A feature like "password reset mail" goes on a card, and a change containing only that feature is submitted
  • Commits come in test → feat pairs. Following TDD, the failing test is committed first, and the code that makes it pass is the next commit. The history alone shows "what was promised and how it was kept"
  • Requirement numbers in test names. Like FR-3 returns the same body for an unknown email and a wrong password
  • Merging into main means deploying. In this step, a PR that passed CI went into main, and main went to production automatically. This changes in step 4

The code was written by an AI coding agent following these rules, and I reviewed the PRs and made the decisions. More than 60 PRs went into main during step 3.

2. Setting up the skeleton

One repo holds three pieces.

FolderWhat
apps/apiThe shared API. Hono + Better Auth + DB
apps/accountThe account center screens. Astro
packages/contractThe agreements both share: input validation rules, the list of error codes

Why packages/contract exists: the sign-up form checks "the password must be at least so many characters", and the API must check exactly the same thing. Writing the rule in two places means they drift apart one day. So it is written once and both use it.

CI runs these on every PR.

  1. Code formatting check
  2. Type check (are the kinds of values right)
  3. All tests
  4. Whether the DB blueprint and migration files match each other
  5. Actually building both servers
  6. Sign-up → sign-in from start to finish on the real Workers engine (workerd)

3. The first-day check: does it really run on Workers?

The second line of the step 2 risk table, "the chosen tools do not run on Workers", was checked on day one. Better Auth, Drizzle and the Neon connection all say on paper that they support Workers, but you do not know until you run them.

The conclusion was "it works". But one bug that never shows on Node.js came out.

If the first request right after the server woke up (cold start) was the health check (/health), every sign-in request after it hung without an answer.

Here is why. The first request starts getting Better Auth ready, then finishes its own work and ends before the preparation is done. When a request ends, workerd throws away the work that request started. So the "preparing" state never finishes, and the following requests waiting on it hang with it. Node.js does not throw that work away, so the problem does not show there.

The fix: the request that starts the preparation now waits until it is done before ending. Writing "actually try it on Workers on day one" as a risk response was the right call.

4. One feature at a time

One by one, in order, test first every time.

The API side

FeatureWhat it does
Sign-upEmail, password, accepting the terms. Passwords that have leaked before are refused
Verification mailSigning up sends a "confirm your email" message, in Korean or English, in the user's language
Sign-inA single failure reason only (it does not say whether the email is unknown or the password is wrong)
Sign out, sign out everywhereDelete one of my sessions, or all of them
Password resetA new password from a link in the mail
ProfileChange name, language, email
Device listSee where you are signed in and sign out only the devices you choose
Account deletionDeleted after confirming the password once more
Session rulesKept 30 days, extended a day at a time with daily use. The cookie cannot be read by JavaScript and travels only over https
Sign-in gateway for sitesAn OIDC provider. Sites are registered only by an operator command
TokensAccess tokens 1 hour, refresh tokens 90 days
Connected sitesSee which sites you signed into with Stairno and disconnect them
Error formatEvery error in one shape, { error: { code, message } }. Codes only from a fixed list
SecurityOnly registered sites may call the API (CORS), security headers, rate limiting on sign-in, sign-up, reset and mail resend
Mail logSent mail is recorded, and a failing mail service does not break the sign-up itself

The screen side

Sign-in, sign-up, password reset, email confirmation, the consent screen, the account home, profile, signed-in devices, connected sites and account deletion. Visiting / sends you to /ko or /en based on the browser's language.

The screens also got a Content Security Policy (CSP): an allow-list saying "only scripts from these addresses run on this page". Even if someone sneaks a script into the page, the browser does not run it.

Terms and privacy policy

Account data is collected here, so they live in the account center. I turned the required items from the relevant articles of Korea's Personal Information Protection Act and the Personal Information Protection Commission's drafting guide into a checklist and filled them in one by one. The data collected is the minimum (email, name, payment records), and the processors (Cloudflare, Neon, Resend) and their countries are listed. Sign-up also confirms the user is at least 14.

At first the operator field had my name in it, but a personal name stamped on a public page bothered me, so it became "Stairno privacy desk" with a contact address. At step 9 (business registration), when real payments start, the representative's name goes back in as the law requires.

5. Going live

Deploy automation

When something is merged into main, GitHub Actions ships it to production in this order.

  1. Tests
  2. Apply migrations to the production DB
  3. Deploy the API
  4. Deploy the account center

If the step that changes the DB structure fails, nothing after it runs. This prevents new code from running on an old DB structure.

What a person had to do

Some things had to be done by a person once before the automation could take over.

  • Create the DB on Neon and run the first migration by hand
  • Store the two servers' secrets at Cloudflare and the deploy keys at GitHub
  • Connect the subdomains api and account to the servers

Around 11 a.m. on 18 October, api.stairno.com and account.stairno.com went live.

Keeping mail out of spam

If the verification mail lands in spam, sign-up is blocked. So the stairno.com domain was authenticated with Resend. That means adding three records to the DNS (SPF, DKIM, DMARC) that let the receiving side check "did stairno.com really send this mail?". Signing up for real and receiving it in Gmail, it arrived in the inbox with all three marked as passed.

Measured in production

CheckResult
Sign-up, sign-in, sign-outPassed
Cookie settingsNot readable by JavaScript, https only, shared only among the stairno.com rooms
Security headersPassed
Requests sneaked in from another originRefused

And two problems turned up.

1) It goes over the free plan CPU limit. Password hashing is deliberately heavy. Measured in production, one sign-up took 152 ms of CPU and a sign-in about 95 ms, far above the Workers free plan's 10 ms per request. The requests succeeded when measured, but running over the limit is not something to keep doing. For now the only users are me and test accounts, so the free plan stays, and the decision log says to switch to the paid plan (5 dollars a month) as soon as sign-ups or sign-ins start failing on CPU.

2) Requests from Korea go through the US. Requests sent from Korea entered Cloudflare's Los Angeles data center (LAX). The round trip alone is about 0.2 seconds. That is decided by the plan and the internet provider, and I cannot change it. Instead I did what I could. The DB (Neon) is in the AWS Singapore region, so the API server was set to run next to the DB.

Request (from Korea, median)BeforeAfter
A request that does not use the DB227ms384ms
Health check (one DB call)391ms371ms
Public key list (several DB calls)816ms418ms
Sign-in check (no cookie)211ms399ms

It is not a free win. Requests with several DB calls were halved, but requests that do not call the DB take one more trip to Singapore and got slower. It was still the right side to pick, because almost every real API request calls the DB at least once. Session checks look at the DB every time, and rate limiting records are stored in the DB too. The only requests that skip the DB are "who is this?" from someone not signed in, and addresses that do not exist.

Polishing the screens

Looking at the live screens myself showed things to fix.

  • The "done" message showed even before sign-up was complete. Fixed so that hidden parts stay hidden
  • The links on the sign-up screen stayed after sign-up was finished
  • The sign-in and sign-up screens were put into a single card over the hub's night band and dot pattern so they connect with the hub

6. The account menu shared with the hub

In the afternoon, the account menu from Stairno 1 was attached. The order mattered, so it went one PR at a time.

  1. Build the account menu part in @stairno/ui and bump its version
  2. Let the API accept "who is this?" and "sign out" requests coming from the hub (stairno.com)
  3. If you came from the hub to sign in, send you back to the hub afterwards
  4. The account center header uses the menu. Clicking the logo goes to the hub
  5. The hub uses the menu (in the hub's repo)

Each step was checked in production by actually signing in. The signed-in state now shows at the top right of the hub's front page.

7. The tests were running under different conditions from production

The most unsettling find of this step. Better Auth turns off the origin check automatically in a test environment. So the test "refuse sign-in requests from another origin" was actually passing with the check switched off.

Production had been closed from the start (sending from another origin was in fact refused). But tests that run under different conditions from production cannot be trusted. A setting now keeps the check always on, so the tests run under the same conditions as production.

8. Acceptance checklist

On the afternoon of 18 October, the acceptance checklist was checked on the production addresses.

ItemResult
Every part 1 requirementAutomated tests (150 for the API, 134 for the screens, 6 end-to-end) + checked by hand in production
Verification mail lands in the inboxPassed in Gmail
Cookies, security headersPassed
Sign-up, sign-in and sign-out with the keyboard only, 360px wide screenPassed
Lighthouse accessibility 100, performance 90+Accessibility 100. Performance 91 in English and 81–85 in Korean, short of the target. Handed to stairno-ui as a font issue
No secrets in the repos or logsPassed. Zero automatic secret detection alerts on all three repos
Rerun the 13-step experiment against the production OIDC providerDeferred. No site to connect yet, so in step 5 (Labs)
Token refresh fails on connected sites after account deletionDeferred. For the same reason, in step 5. The automated test passes

Remember those two deferred rows. That "no site to connect yet, so later" check exacts its price in part 4a.

Step 3 done. Next come payments.

Next: Stairno 4. Building payments