NOTES_ACCOUNTS.md

accounts.siao.ai — journal

The journal for the siao.ai family's identity provider — how it was designed, built, deployed and repeatedly corrected. Spec in SPEC_ACCOUNTS.md; the console steps that need a human in git-siao-ai/logto/SETUP.md.

How to read this file

25 chapters, newest first. It is long — past the point where a journal would normally be split by subject — but it is deliberately kept whole: these chapters are one continuous narrative about building accounts.siao.ai, not two subsystems, and the threads below cross-reference each other heavily. Splitting would scatter them and break the references. If a genuinely separate subject starts here, split that out rather than cutting this one up.

Threads, so you can read one instead of all of it:

Thread Chapters
Why identity is shaped this way — the design decisions and two reversed recommendations 1
Content kept leaking — six bugs of one family, and the structural tests built to catch them 5, 6, 11, 12, 23
Browser and server disagreeing about an address — three separate symptoms, one cause 14, 16, 21
Getting the applications onto the identity provider 2, 5, 7, 8, 9, 10
Infrastructure: host, TLS, mail 3, 4, 13, 16, 19
Configuring the provider itself 15, 17, 18, 20, 22, 25

Chapter 9's account of the sign-in experience is superseded by chapter 25 — it is passwordless email + verification code, not username and password. Read 25 before planning any sign-in work.

Recurring lesson, if you read nothing else: a test that proves a decision is correct never proves the decision is reached. Chapters 6, 12, 22 and 23 are four separate instances of that, each found the hard way after the previous one was supposedly learned.

For what is currently built, deployed or reachable, see ClaudeDocs/HANDOFF.md — status does not live in this file.


25. Chapter 9 stopped being true and nobody noticed — the sign-in experience is passwordless

Planning AnswerHub's first real sign-in (NOTES_APPS.md ch.8) started from this file's own description of the provider, and that description was wrong.

Chapter 9 says the sign-in experience is Logto's default username and password, and that switching to email-identified passwordless was refused with enabled_connector_not_found because no email connector existed — so "no passwords" cannot be configured yet, "not a thing to forget."

What the provider actually answers today, from /api/.well-known/sign-in-exp:

Field Value
signIn.methods one method: identifier email, password: false, verificationCode: true
signUp identifier email, password: false, verify: true
signInMode SignInAndRegister — registration is open
color.primaryColor #587b60 / #83b58c — the app family's sage, already applied
hideLogtoBranding false — attribution kept, per the standing decision
mfa.factors WebAuthn, Totp
socialSignIn {} — Google is not enabled in the experience

It is passwordless, and has been for long enough that nobody remembers doing it. An SMTP connector exists (simple-mail-transfer-protocol, pointed at the mail container, sender [email protected], with Traditional Chinese templates for sign-in, register, recovery and generic codes), and Logto successfully handed a message to postfix on 2026-08-09 07:40 which did not stay in the queue.

A Google connector also exists and is fully configured with real credentials — but socialSignIn is empty, so it is configured and switched off. Enabling it needs its redirect URI registered at Google, and the only reachable URI names the tailnet host, so it stays off until the public name is settled and the URI can be registered once.

Why this matters more than the correction itself

Chapter 9's claim was accurate the day it was written. It described a blocker, the blocker was cleared later, and the chapter had no way to learn that. That is the ordinary failure mode of recording a blocked state in an append-only journal: the record of the obstacle outlives the obstacle, and reads as current because nothing contradicts it in place.

The rule this project already has — status lives only in the handoff — covers "what is built". It does not obviously cover "what is impossible right now", which is what chapter 9 recorded. It should. A blocker is a status claim. When one is cleared, the clearing needs a chapter, or the original stands.

Chapter 9 is left exactly as written; this chapter supersedes its conclusion. Do not plan sign-in work from chapter 9.

Status: corrected, verified against the running provider this session. The remaining true part of chapter 9 is that the machine-to- machine credential claudesetupm2m0000000 ("Temporary setup access") still exists and is still standing admin-level access that should be deleted once configuration is finished — that has not changed.


24. Rotating the keys I leaked, and purging them from history

Both remaining items from the full check, done in one pass.

The logout tests: two faults, one of them a lying fixture

The e2e suites failed because RP-initiated logout leaves the site, and the fixture advertised an end-session endpoint on the public host, which does not resolve in tests. Fixed by having the fixture answer that one endpoint itself and redirect back — so the logout flow is now followed rather than asserted. The other endpoints stay on the public host, because the redirect assertions are only meaningful if they point where production points.

AnswerHub still failed after that, and the cause was better than the fix. The sign-in helper set the session cookie by letting Playwright infer its path from a URL, which gave /. The OIDC callback sets it at /answerhub. A cookie at / is a different cookie, so logout cleared the one the app sets and left the one the test made — logout looked like a no-op and the header stayed signed in.

Found by dumping the browser's cookies before and after rather than reasoning about it: curl showed the correct Set-Cookie all along, which is exactly why reasoning was going nowhere. A fixture that does not mirror what the app does is testing a state the app never produces.

Both suites: 43 each, stable over two consecutive runs.

Rotation

Blast radius first, because it decided how careful to be: 2 repos, 1 user, 2 access tokens, 0 two-factor enrolments. Rotating SECRET_KEY invalidates encrypted columns, and with no 2FA there was nothing to lose. The four OAuth2 applications are the obsolete Forgejo-as-IdP ones.

Order chosen deliberately — rotate first, purge second. Purging history while the leaked values still work protects nothing; rotating makes the leaked copies worthless even if a copy of the old history exists somewhere I cannot reach.

  1. Both access tokens deleted (they were in the committed database).
  2. Forgejo stopped; SSH host keys and jwt/private.pem removed so it regenerates them.
  3. SECRET_KEY, INTERNAL_TOKEN, JWT_SECRET regenerated with Forgejo's own generate secret, not hand-rolled.
  4. Restarted; 2 repos and 1 user intact, API 200, SiaoHub still reading Forgejo, no errors in the log.

Host key fingerprint went from the leaked SHA256:fYPPskNX/… to SHA256:MaQTZxr7jgk…. My own known_hosts entry for the old key was removed and the new one re-verified out of band — compared against the file inside the container over the already-trusted admin SSH connection — before trusting it. Same procedure as when it was first added; a fingerprint accepted because it was the only one on offer is not verified.

Purge

git-filter-repo is not installed and this is 13 commits on one branch, so filter-branch --index-filter was enough. Two things that would have left the job half-done:

Verified: 0 objects under data/, no commit contains INTERNAL_TOKEN, .git down to 140K, and the legitimate history intact (10 commits still touch docker-compose.yml). The mirror went from 147 leaked objects to 0 after a force-push and its own gc.

Cleanup that was itself a risk

The rotation left two backups holding the old secrets — a tarball and app.ini.pre-rotation. Keeping a rollback is the reflex, but "old secrets lying around" is precisely the problem just fixed, and Forgejo was verified healthy. Both deleted.

A .gitignore for data/ now exists, with the reason written in it, so the next person meets the explanation before the mistake.

Status: complete. All services verified after rotation. What cannot be undone: anyone who copied the repo between cf8ef22 and now still holds the old values — which is why they were rotated rather than only purged.


23. Full check — and two of the worst findings are mine

Ran a full check across the family: tests, security review, code review, plus an adversarial pass over every Critical/High to weed out false positives. Nothing was fixed — read-only by design.

I committed live secrets to the infra repo

git ls-files in git-siao-ai returns, tracked and in history:

data/ssh/ssh_host_{rsa,ecdsa,ed25519}_key   git.siao.ai's SSH host private keys
data/gitea/jwt/private.pem
data/gitea/gitea.db                         admin row + API token table
data/gitea/conf/app.ini                     SECRET_KEY, INTERNAL_TOKEN, JWT_SECRET

Introduced by cf8ef22, authored "Claude", this session — the clone-approval commit, where I ran git add -A in a repo whose working tree also contains Forgejo's live runtime volume. 141 files. Then I pushed it to the backup mirror.

The .gitignore is one line: .DS_Store.

Worse than the mistake: I had looked straight at this and concluded the opposite. Chapter-era note said "data/ is untracked local leftover" — I inferred that from the directory looking like a stale copy instead of running git ls-files. One command would have settled it, and I skipped it in exactly the way I have been writing rules against all session.

Rotation is not enough — the values are in history, so history has to be rewritten and the mirror replaced. SSH host key disclosure additionally means anyone who has trusted git.siao.ai can be MITM'd.

Rule now hard for this project: never git add -A in git-siao-ai. Its working tree holds runtime state that is not source.

Server-side code execution in AnswerHub, reachable by any account

app/answerhub/blog/[slug]/page.tsx renders user-submitted content through next-mdx-remote/rsc, which evaluates MDX expressions in the server process. api/blog/route.ts requires only a session — no admin check — and publishes immediately.

Registration is open by design, so any account can read SESSION_SECRET, the OIDC client secret and the database. The verification pass built a concrete payload; this is not theoretical.

Not mine — it predates this session — but it is the highest-severity live vulnerability in the family.

A sixth content-disclosure bug, and why my structural test missed it

visibility:preview repos leak their entire README on the tree page. previewLineLimit is applied in the blob page and nowhere else.

The seam-coverage test passed, correctly: that page does call resolveRepoVisibility. It just ignores one field of the answer.

So the structural test I built after chapters 5–6 answers "does this route ask?" but not "does it use what it was told?". That is a real limit of the technique, now known: calling the seam is necessary, not sufficient. Checking field-level use would need something closer to taint analysis than a grep.

I broke three tests and shipped anyway

The logout fix (chapter 22) sends the browser to the provider's end-session endpoint. In the test environment that host does not resolve, so three e2e tests fail — one in siaohub, two in AnswerHub.

I ran vitest after that change and not the Playwright suites, then committed, pushed and deployed. The unit tests could not have caught it; the e2e tests did, and I did not run them.

Two reported Highs were false positives, and the check was worth it

The adversarial pass killed both: the avatar "path traversal" cannot escape (split(".").pop() can never contain ..), and the README XSS is not exploitable today (Forgejo registration disabled, push is SSH-only, so only the owner can author a README). The second is a real latent trap — it becomes Critical the moment a mirror or a second account exists — but reporting it as live would have been wrong.

Worth keeping: running the refutation pass changed two of five severities. Without it I would have handed over a list with two inflated entries, which costs trust on the ones that are real.

超越(2026-08-23):三項都已修並已部署,本節的「nothing fixed」已過期。 保留原文不改——更正本身才是內容。當天實測:

  • AnswerHub 的伺服器端 RCE:next-mdx-remote 已從程式碼與 package.json 移除,渲染改走 lib/answerhub/markdown.ts (allowDangerousHtml: false + rehype-sanitize),並有結構測試 釘住「不准 import next-mdx-remote」。部署中的容器內 node_modules/next-mdx-remote 不存在(image 建於 2026-08-17)。
  • visibility:preview 洩漏整份 README:RepoView.tsx 已截斷, raw/ 路由對預覽 repo 回 404,五個介面都套上 previewLineLimit
  • 每位使用者的 email 公開外洩:見 NOTES_APPS.md §18,已修並實測。

一個留下來的教訓,跟修補本身無關:日誌裡的狀態句會活得比它的真值久。 這一行在文件裡躺了六天,期間三項全部修完,而任何讀到它的人都會以為 系統還開著洞。狀態只該寫在 HANDOFF.md——這正是那條規則的理由。

Status: findings recorded, nothing fixed. Remediation order and the key-rotation decision are the owner's — rotating breaks existing known_hosts and rewriting history replaces the mirror.


22. "Log out" did not log out

Reported immediately after the redirect fix: log out, click log in, and you are straight back in with no prompt.

Working as written, and wrong. The logout route cleared this app's cookie and stopped. The identity provider's session was untouched, so the next /login reached authorize, found a live session, and issued a code without asking anything. On a shared machine that is the opposite of what the person clicking "log out" is asking for.

The helper existed the whole time

buildLogoutUrl() was written days ago, has unit tests, and passed every run — while nothing in the application called it. Grep found exactly one non-test reference: its own definition.

That is a specific and repeatable failure mode: a unit test proves a function is correct, never that it is reached. It is the same shape as the seam-coverage problem in chapters 6 and 12 — "is the decision right?" versus "does anyone ask?" — and I hit it again after building two structural tests specifically about it.

So the check added here is structural too: the logout route must reference buildLogoutUrl. A better unit test of the helper would have changed nothing.

AnswerHub had no logout helper at all, so it gained one.

Verified: both logout routes now redirect to /oidc/session/end with the right client_id and a registered post_logout_redirect_uri.

Worth noticing about this stretch

Three defects in a row — container-hostname redirect, silent re-login, and before them the unfiltered listing — were all found by the owner using the thing, not by any test. Each one was invisible to the suite for a structural reason: the test environment has no proxy, unit tests do not check call sites, and behavioural tests only assert decisions they already know to make.


21. Sign-in sent people to a container hostname

Reported after the first real use: signing in landed on https://f44dbda08a74:3000/ — a docker container id and its internal port, an address that resolves nowhere outside the compose network.

Cause, in code I touched:

NextResponse.redirect(new URL("/", request.url))

Behind the TLS terminator, request.url is the container's address, not the browser's. This worked right up until Caddy went in front, because until then the app was reached directly on its published port and the two happened to agree.

Same family as chapter 16 — browser and server disagreeing about where a service lives — and I fixed that one for OIDC discovery without noticing the app's own redirects had the identical assumption.

Fixed by deriving the origin from OIDC_REDIRECT_URI, which the identity provider validates exactly, so it cannot drift the way a separate variable would. Applied to both apps, both callback and logout.

Why no test caught it: every behavioural test runs against a dev server reached directly, where request.url is correct. The bug only exists behind a proxy. So the new test is structural — no route may build a redirect from request.url — rather than another assertion about behaviour that the test environment cannot reproduce.

The owner's instinct was also right and worth recording: a failing OIDC flow should hand the error back to the app's redirect_uri, not dead-end. It dead-ended here because the redirect target itself was unreachable, so there was nowhere for the browser to land.

Caddy access logging: on for one investigation, then off

Switched on to read the redirect chain, switched off in the same session. Logged URIs carry OIDC code and state — credential material that should not accumulate in docker logs. Noted in the Caddyfile so the next person turns it off again too.


20. It works, and the watermark question answered itself

A real person signed in. [redacted: email] exists in the default tenant with a live session — the first complete OIDC round trip in this project, and the last unproven core path.

The magic-link design is configured exactly as the owner described it: email identifier with a verification code, no password anywhere, Google as a social option, passkey sign-in enabled, and WebAuthn + TOTP enrollable from the Account Center.

The DKIM record closed the mail question

Published, resent, measured: status=sent (250 2.0.0 OK) with DKIM-Signature field added (s=mail, d=siao.ai). Self-hosted mail reaches Gmail.

It landed in spam on first contact, which is a different problem — authentication is now correct, reputation is not yet earned. The owner marked it as not-spam, which trains their own account. A DMARC record would help further at zero cost; Workspace relay is the planned answer.

The watermark: decided by using it, not by comparing screenshots

The owner had ruled out removing it (correctly — it is the author's condition for giving the software away), which left switching or staying. After actually using the finished flow they chose to stay, and said the attribution could even be featured rather than tolerated.

That is the outcome I should have steered toward several rounds earlier. I spent a lot of effort on a Zitadel evaluation that never reached a login page, and on licence analysis, when the cheapest path to a decision was to finish the flow so there was something real to judge. The lesson is narrow and useful: when a decision is about how something feels to use, the fastest route is to make it usable, not to research alternatives.

Recorded in ClaudeDesign/APP_FAMILY.md as a decision rather than a limitation, so nobody "fixes" it later.

Closed out


19. Self-hosted mail: measured why it fails, and the fix

Owner's decision: no budget, self-host mail now, Google Workspace later for deliverability. Built it and measured rather than predicting.

Postfix (boky/postfix) on the compose network, no published port, SMTP auth even internally — an unauthenticated relay is one misplaced port publish away from being an open relay.

The bounce, and why it is good news

550-5.7.26 Your email has been blocked because the sender is unauthenticated.
           Gmail requires all senders to authenticate with either SPF or DKIM.
           DKIM = did not pass
           SPF [siao-server...] with ip: [[redacted: address]] = did not pass

I expected "residential IP reputation", which would have been nearly unfixable. It is not — Gmail rejected on authentication, which is entirely fixable with DNS the owner already controls.

DKIM, not SPF, is the right fix here: SPF authorises an IP address, and [redacted: address] is a residential address that will change. DKIM signs with a key and does not care about the IP. Gmail's own message says "SPF or DKIM", so DKIM alone satisfies it and keeps working through an IP change.

DKIM_AUTOGENERATE=1 on the mail container generates the key (the image does not generate one by default — with an empty key directory OpenDKIM simply does not start, silently). Key persisted in a volume; the public half needs publishing as a TXT record at mail._domainkey.siao.ai.

A documented fact that was simply false

HANDOFF.md said DNS records (MX/DKIM/SPF) already existed for [email protected] and only the routing rule was unconfirmed. Checked:

dig TXT siao.ai              → nothing
dig MX  siao.ai              → nothing
dig TXT mail._domainkey…     → nothing

There are no mail DNS records at all. So [email protected] cannot receive mail either — not "routing unconfirmed", simply absent. Good news for this work (no existing SPF to conflict with), and corrected in the handoff.

Not yet done

The DKIM record is not published, so mail still bounces. One TXT record by the owner closes it, then the send should be re-measured rather than assumed.


18. Where the missing username / passkey / recovery fields actually live

The owner tried signing in and reported the sign-in page offered only "continue with Google" — nowhere to pick a username, save a passkey, or set a recovery email. Correct observation, and the reason is a consequence of my own configuration.

Setting signUp.identifiers: [] was what finally let passwords be turned off, but it also means sign-up collects nothing — the account is created purely from the Google identity. Those three things are not sign-in-page concerns in Logto's model at all.

They live in the Account Center (/api/account-center), which ships enabled: false. Enabled it, with username, email, mfa, profile and social editable, password off, and webauthnRelatedOrigins covering all three HTTPS origins. Added WebAuthn to mfa.factors so a passkey can actually be enrolled.

Passkey enrolment is a post-sign-in action, not a registration one. passkeySignIn lets a returning user authenticate with a passkey they already have; there is no "sign up with a passkey" — something has to create the account first. So the intended order is: sign in with Google once → Account Center → add passkey → subsequent sign-ins can use it. Worth stating because "passkey primary" in SPEC_ACCOUNTS.md reads like it should appear at registration, and it never will.

The Google error is expected, and needs one thing only the owner has

redirect_uri_mismatch. Logto sends https://[redacted: internal host]/callback/r0q19trg8l5g, and the Google Cloud project that owns Lwopan's credentials does not list it. Google's own error page has an "error details" expander showing the URI it actually received — that is ground truth and worth comparing against rather than trusting either of us to have derived it correctly.

Two things I got wrong

My custom CSS broke the page. The screenshot showed a black bar behind the heading: [class*="container"] matched an element inside the card and painted the page background onto it. Guessed selectors against someone else's markup cannot be verified, which is precisely the "eyeballing" failure the design skill warns about — I had written the rule and then confirmed nothing. Replaced with a near-empty file; the accent comes from the branding API's primaryColor, which is the supported path and needs no selector guessing.

"Powered by Logto" cannot be removed here. hideLogtoBranding returns "not supported in this environment" — it is a paid feature. It stays. Hiding it with custom CSS would be circumventing the licence terms of software being used for free, so it was not done.


17. Google, no passwords, and an accessibility defect in the design system

The owner asked why the login page looks like Logto rather than a custom registration page. It should be Logto's page — that is the design: one account for the family means no per-app registration page. What was wrong was that it looked like Logto's product, which is ticket 06, unfinished until now.

They also offered Lwopan's existing Google credentials as a stopgap, and that unblocked the chain.

Configured

Verification found a real defect, in the documented design system

The design skill requires computing contrast rather than eyeballing it. Doing that on the accent, which I had already flagged as the pairing most likely to fail:

white on sage #5f8567     4.17:1   needs 4.5   ✗
sage on paper #FAF9F6     3.96:1   needs 4.5   ✗
dark-mode sage            7.68:1               ✓

The light-mode accent in ClaudeDesign/APP_FAMILY.md fails WCAG AA — not just on the sign-in page, but everywhere in SiaoHub and AnswerHub that uses a primary button or an accent-colored link. It has presumably been failing since the palette was chosen.

Corrected to #587b60: lightness down ~4%, hue (133°) and saturation untouched, giving 4.75:1 and 4.52:1. Dark mode unchanged.

Applied to all three surfaces in one go — the two repos' token files and the sign-in page — because leaving them on different greens would have been worse than either value alone. Recorded in APP_FAMILY.md with the measurements, since it overturns a documented decision.

Worth noting how it was found: not by looking at the page, which looked fine. The failing and passing versions are visually indistinguishable. Only the arithmetic separates them, which is exactly why the skill mandates it.

A gap that is now documented rather than papered over

The app family has no logo asset at all — SiaoHub's mark is an inline SVG in a page component. The sign-in page therefore has no logo: Logto's was removed, and the homepage's favicon.svg was deliberately not borrowed, because ClaudeDesign forbids the two identities blending. Recorded in APP_FAMILY.md as a known gap; no logo is defensible for this family's restraint, and inventing one is a design decision that needs the owner.

Still outstanding

Google sign-in will not work until the redirect URI is registered in the Google Cloud project that owns Lwopan's credentials:

https://[redacted: internal host]/callback/r0q19trg8l5g

Only the owner can add that. Until then the sign-in page offers Google and passkey, and neither can complete — passkey because no account exists to attach one to, Google because of the missing redirect URI.


16. One hostname, and the OIDC round trip finally connects

The wall from chapter 15 is down. Both applications now redirect to a real sign-in page, on a URL that means the same thing to a browser and to a container.

Tailscale issues real certificates, which removed the hard part

tailscale status reported CertDomains: ['[redacted: internal host]'], meaning HTTPS certificates are enabled on this tailnet. tailscale cert then issues a genuine Let's Encrypt certificate for that name — publicly trusted, nothing exposed, no Cloudflare token, no self-signed certificate for anyone to install. curl confirms ssl_verify_result: 0, so browsers accept it silently.

That collapsed what looked like the expensive part of the problem.

Two detours worth recording

sudo tailscale set --operator=$USER was run on the wrong machine. The owner's shell was local, so the operator got set on their Mac — where the user siao does not exist — while the server stayed unchanged. Diagnosed by comparing prefs on both: the Mac had OperatorUser: siao, the server had no such field at all. Also worth undoing, since it had quietly removed their own account as operator on their own machine. Lesson: when an instruction involves sudo on a remote host, give the ssh -t host 'sudo …' form, not the bare command. $USER in an instruction is a second trap on top of that.

Host-level TLS could not work, for a reason that took measuring. tailscale serve set up four HTTPS ports in one command and worked perfectly from the Mac — and was useless, because the containers cannot reach the host's Tailscale address at all, on any port. Most likely Tailscale's own filtering (packets to the tailnet address arriving from another interface are dropped); unverifiable without root, so recorded as the symptom rather than the cause.

The shape that works

Caddy runs inside the compose network, with a network alias equal to the tailnet hostname:

browser    → MagicDNS → host:443 → published port → Caddy
container  → docker DNS → Caddy directly

Same URL, different resolution, one certificate that matches both. The container never crosses the boundary that was dropping its packets.

Result, measured from inside a container:

issuer     https://[redacted: internal host]/oidc
authorize  https://[redacted: internal host]/oidc/auth

— identical to what the browser gets. /login on both apps went from 500 to a 307 into /oidc/auth, which answers 303 into the real /sign-in page.

Generalisable: when browser and server disagree about a service's address, the fix is usually not two configuration values but one name that both can resolve — and the cheapest place to put the listener is wherever the more constrained party can reach.

Still not done


15. Management API: what it configured, and the wall it hit

The owner authorised using the Management API. Two surprises, one of them mine.

The built-in M2M applications are unusable here

m-default and m-admin — "Management API access for …" — look exactly like what is needed. They are not: all four seeded applications live in the admin tenant, and this self-hosted single instance serves one OIDC provider, the default tenant's. Discovery on port 3003 reports issuer: http://localhost:3002/oidc, the default tenant's. So the provider does not know those clients and answers invalid_client whichever endpoint, resource or auth method you try. They are Cloud-era artifacts.

The intended path is visible in the database: the default tenant has a role Logto Management API access and the resource https://default.logto.app/api. Created a machine-to-machine application in the default tenant, assigned that role, and the token worked immediately.

That credential still existsclaudesetupm2m0000000, named "Temporary setup access". It is a standing admin-level credential and should be deleted once configuration is finished.

Configured

Blocked, for a reason worth knowing

Switching to email-identified passwordless was refused:

sign_in_experiences.enabled_connector_not_found — Enabled Email connector not found.

Logto will not let you make email the identifier before an email connector exists, and that needs the transactional-email credential only the owner can supply. So "no passwords" cannot be configured yet — the sign-in experience is still Logto's default (username + password), which is the opposite of the spec. Not a thing to forget.

I verified something false, and it cost the next hour

Earlier I wrote that the app containers could reach Logto over the host's Tailscale address, based on this:

docker exec siaohub sh -c "wget -q -O- http://[redacted: address]:3002/... | head -c 60" && echo " <- 可以"

wget is not installed in that image. The pipeline's exit status came from head, which succeeded, so the && fired and printed "可以". A command that never ran was reported as a pass, and I built on it.

The real behaviour: UND_ERR_CONNECT_TIMEOUT. The container cannot reach that address at all — the port is published only on the Tailscale interface, and the docker bridge has no route to it.

This is precisely what my own notes skill now says to do — verify, do not recall — applied to a check I wrote myself. Worth keeping: cmd | head && echo OK reports the pipeline's last stage, not the command you care about. Use explicit exit checks, or a tool you have confirmed exists.

The wall: split horizon, exactly as predicted

Fetched over the compose network, discovery advertises:

authorization_endpoint  http://logto:3001/oidc/auth      ← only containers resolve this
issuer                  http://localhost:3002/oidc       ← and it disagrees with itself

So the browser gets redirected to a hostname that does not exist on the owner's Mac. Chapter 14 called this out in the abstract; here it is concretely, and it blocks the one core path still unproven — a full OIDC round trip.

There is no configuration of this shape that fixes it: the browser and the containers need the same URL to mean the same service, and today they cannot.

The fix is the same one chapter 14 identified for passkeys: one name, resolvable from both sides, with TLS. https://accounts.siao.ai resolving internally (Tailscale MagicDNS or hosts entries) to the host, TLS terminated there. That collapses the split horizon and makes WebAuthn possible, in one move — worth doing once rather than hacking around each symptom separately.

Status: partially configured, blocked on a decision. Applications registered and passkey enabled; passwords still on (needs an email connector); OIDC round trip unproven (needs one resolvable name).


14. The console refused to load — and the reason invalidates part of the design

The owner opened http://[redacted: address]:3003 and got "Insecure contexts (non-HTTPS) are not supported."

Not a Logto bug and not a misconfiguration: http://<ip> is not a secure context, and the console uses browser APIs restricted to one. http://localhost is treated as secure, so the fix is an SSH forward — verified by actually loading the page, which now renders the welcome screen.

Both ports have to be forwarded, not just the console's: the console authenticates against the core endpoint. LOGTO_ENDPOINT and LOGTO_ADMIN_ENDPOINT were repointed at those localhost URLs to match.

The part that matters more than the fix

WebAuthn requires a secure context too. Passkeys are the primary sign-in method (chapter 1), and passwords are disabled on purpose. So as things stand:

That is a real collision between two decisions that were each correct on their own: "passkeys first" (chapter 1) and "internal only, not on the internet" (this session). Nothing is broken today because the owner is the only user, but it has to be resolved before a second person exists.

Options recorded in git-siao-ai/logto/SETUP.md §8. The one that keeps both decisions is TLS on the private network — a certificate via DNS-01 needs no exposure, and an internally-resolvable name (Tailscale MagicDNS or a hosts entry) completes it.

Neither of us saw this coming, and it was not findable from the code: it needed a browser pointed at the real thing.

A silent breakage the fix nearly caused

Both apps took their OIDC issuer from ${LOGTO_ENDPOINT}. Repointing that at localhost therefore pointed the containers at themselves — localhost inside a container is the container. The apps would have failed discovery at the first sign-in attempt, and the cause would have looked nothing like the console change that caused it.

Decoupled: the browser-facing endpoint and the address the apps use to reach the provider are now separate variables, with the reason written where someone editing either will see it. Both apps verified still serving afterwards.

Generalisable: one variable meaning "where is this service" is a trap as soon as the answer differs by who is asking. Browser and server almost always disagree once anything is proxied, forwarded or containerised.


13. Deploying AnswerHub to the private network

Deployed at [redacted: address]:3004, no tunnel ingress rule, and apps.siao.ai still has no DNS. Every page answers 200; the SQLite database was created in its volume by the image's own prisma db push on first boot. /answerhub/login returns 500 until an application is registered at the provider — same as SiaoHub, same reason, contained to that route.

Volumes for both the database and uploads, so a rebuild discards neither. Memory after adding it: 5.7Gi available of 7.5Gi.

The Dockerfile had never worked from a clean checkout

The build failed immediately: COPY --from=build /app/public ./publicnot found. public/ is not tracked in the repo and does not exist in a fresh clone. It only ever existed on a machine that had run the app first, because the avatar upload route mkdirs it at runtime.

So the image had only ever been built on a developer machine where an untracked directory happened to be lying around. A textbook "works on my machine", and the kind of thing only a clean deploy finds. Fixed by tracking public/ with the uploads directory ignored inside it.

I committed to the wrong repository

Worth recording because the recovery matters more than the slip.

I ran the fix while the shell's working directory was still git-siao-ai from an earlier step, so public/ and its commit landed in the infra repo, where they mean nothing — and got pushed to that repo's backup mirror.

Caught it from the push output naming the wrong remote. Recovered with git reset --mixed HEAD~1 (not --hard: the working tree held an uncommitted docker-compose.yml edit that --hard would have thrown away — the same trap NOTES_HOMEPAGE.md records being hit twice with git checkout --), removed the stray directory, force-pushed the mirror back, then redid the work in the right repo.

The habit that would have prevented it: cd inside the same command as the work, rather than relying on where a previous command left the shell.


12. Applying the lesson to AnswerHub — and finding the same bug there

Chapters 5, 6 and 11 all found the same shape in SiaoHub: a route that serves content without asking who may see it. I had made the same kind of change to AnswerHub (swapping its identity source) and had not run the same audit. So I ran it.

The audit was one question: where is content read, and does each of those places make a visibility decision?

app/answerhub/page.tsx              where: { status: PUBLISHED, isHidden: false }   ✓
app/answerhub/api/search/route.ts   where: { status: PUBLISHED, isHidden: false }   ✓
app/answerhub/admin/review/page.tsx requireAdminUser() first                        ✓
app/answerhub/submissions/[slug]    findUnique({ where: { slug } })                 ✗

The detail page had no condition and no check. So an admin hiding a submission removed it from the homepage and from search, and left it fully readable — title, description and every uploaded file — to anyone holding the link. The moderation feature was cosmetic against anyone who already had the URL, which is the population that matters.

Not a bug I introduced; it had been there since the page was written. The listing queries were correct the whole time, which is exactly why it survived: every test asked is the filter right, none asked does this page apply one.

One thing I got wrong on the way

I assumed the blog detail page had the same hole, because it had the same shape (findUnique({ where: { slug } })). It didn't — it checked !post.published on the next line. Pattern-matching found the candidate; only reading the file settled it. Routed it through the seam anyway so the two content types cannot drift apart, but the report should say it was already gated, and now does.

The rule chosen for hidden content

isHidden is set only by the admin moderation API, so it means "taken down", not "the author chose to hide this" — the schema comment saying otherwise is stale. Taken-down content is therefore invisible to its author too. Admins keep access, because they have to be able to review what they took down.

Content that is merely unpublished (PENDING_REVIEW, ARCHIVED) stays visible to its author. Uploads are created PUBLISHED, so that path is for drafts and archives rather than the normal flow.

A test that was banning the wrong person

Adding e2e coverage made an existing admin test fail — but only when run alongside others, and only sometimes. The cause was its locator:

page.locator("div").filter({ hasText: title }).getByRole("button", …).first()

filter() matches every ancestor containing that text, including the container holding the whole list. So .first() could resolve to a different submission's button — and the test would hide one row and ban a different author. It passed for as long as the list only ever held one item.

Fixed by giving each row its own test id and scoping to it. Worth remembering: locator("div").filter({hasText}) is almost never what you want, and .first() on top of it hides the ambiguity instead of resolving it.

Verified

43 unit (+19) and 43 e2e (+2), stable across three consecutive runs, and the e2e pair proves the thing that actually matters: after hiding, the URL returns 404 for the author and for a signed-out visitor, and 200 for an admin.

Status: complete. AnswerHub now has the same two properties SiaoHub does — one seam that decides, and a structural test that fails when a new page forgets to ask it.


11. Deploying early, and the two bugs that only deploying revealed

The owner overruled my "wait for OIDC before rebuilding" and was right: rebuilding immediately is strictly safer, because the three disclosure fixes need no OIDC at all. Only /login degrades (500, config error, contained to that route), and login was unusable anyway with no provider configured and the tunnel down.

One gotcha caught before it happened: the compose file bind-mounts clone-approvals.json, which lives in the Forgejo deploy checkout — and that file did not exist, because the infra repo has never been pushed to Forgejo. Docker would have created a directory with that name. Fail-closed (nobody can clone), but a confusing artefact to debug later. Created the file first.

Bug 4 — I removed a safeguard without noticing it existed

Verifying the deploy, the home page listed four repos. That prompted the question: where does the repo list get filtered?

Nowhere. src/app/page.tsx called listRepos() and rendered the result.

It had always been safe by accident: SiaoHub called Forgejo unauthenticated, so Forgejo itself never returned private repos. My backend-token change (chapter 5) removed that accident and replaced it with nothing. The list was still correct only because FORGEJO_BACKEND_TOKEN is not set yet — and SETUP.md step 7 tells the owner to set it. Following the runbook would have published every private repo to anonymous visitors.

The general lesson: when you make a component more capable, audit what was relying on it being less capable. Nothing in the diff looked like a permissions change.

Bug 5 — canBrowse was computed everywhere and checked nowhere

Fixing bug 4 by adding canList raised the obvious follow-up: does anything honour canBrowse? A grep says no. Every page computed the decision and used only previewLineLimit and canClone.

So filtering the listing would have been theatre: a private repo would still have been fully readable by typing its URL. The listing is not a security boundary.

Fixed at the boundary that actually owns the question — the repo layout, which every nested page renders inside. A repo refused there never reaches them.

The structural test missed both, and that is the interesting part

Chapter 6's seam-coverage test exists precisely to catch "a route that serves repo content without asking the seam". It passed throughout, because its filter was rel.includes("[repo]") — and the root listing page is not under that segment.

A structural test is only as good as its definition of the surface. Mine encoded "repo pages live under [repo]", which was true of every example I had when I wrote it. Widened to include the root listing, and the exemption list now has to justify anything that renders repo data regardless of where it sits.

What made these findable

Deploying and then looking at the running thing — not reading the diff. "Four repos are listed; who decided that?" is not a question the test suite asks. Worth doing after every deploy, not just this one.

Verified

Status: complete. SiaoHub is deployed and current for the first time this session. cloudflared still inactive; nothing public.


10. Pushing on the boundary a second time — and finding a real bug behind it

Chapter 9 said the remaining work needed the admin console. True of the tickets. Not true of everything worth doing.

One verification I had written off was not blocked at all. Both OIDC clients hardcoded /oidc/auth, /oidc/token, /oidc/me. The real provider is running and its discovery document needs no credentials. So I read it. Every path matched.

But the document exposed something the passing tests never could:

issuer = https://accounts.siao.ai/oidc     ← not the base URL

The configuration variable is named OIDC_ISSUER and my code treats it as the base URL. Fill it in from Logto's own console — the obvious thing for whoever does step 3 of the runbook — and every endpoint becomes /oidc/oidc/auth. Sign-in fails at deploy time, in a variable that looks correct, with no test able to catch it. Every unit test would have stayed green, because they all shared the same wrong assumption as the code.

Fixed properly rather than by renaming the variable: endpoints now come from the discovery document. It accepts both readings of the value, caches per process, and fails loudly naming what it tried.

And a live test suitediscovery.live.test.ts in both repos — that runs this project's own code against the real instance, skipped unless LIVE_OIDC_ISSUER is set so the default suite still needs no network. Run against the running Logto: 4 tests, both repos, passing.

The lesson worth keeping: self-consistent tests cannot catch disagreement with the real provider. Ninety unit tests passed while the client and the provider disagreed about what "issuer" meant.

Discovery has a real cost, and a test caught it immediately: sign-in now genuinely requires reaching the provider, so both e2e suites broke until their fixtures served a discovery document. Their endpoints are the real public ones, so the redirect assertions keep their meaning without accounts.siao.ai needing to resolve.

Status: complete. SiaoHub 90 unit + 41 e2e, AnswerHub 24 unit + 41 e2e, 8 live tests against the real provider, both typecheck clean.


9. Where it stopped, and why that line is where it is

9 of 12 tickets done. The three that remain — 04, 05, 06 — all need Logto's admin console, and reaching it starts with creating an account and choosing a password. That is a hard boundary for an agent session, not a difficulty.

Having been wrong once about what "blocked" meant (chapter 8), I pushed on the boundary rather than accepting it:

What genuinely cannot be done here: creating the admin account, supplying a Google client secret, supplying an email provider's API key, minting the Forgejo backend token, and the external "is Forgejo reachable" check (which needs the tunnel, off on purpose).

Final state. Logto 1.42.0 running, 267MB, persisting across rebuilds. SiaoHub: 76 unit + 41 e2e. AnswerHub: 11 unit + 41 e2e, stable over three consecutive runs. Both typecheck clean. Three disclosure bugs fixed and a structural test in place so the fourth fails by default. cloudflared still inactive, untouched.


8. Tickets 07, 08, 12 — the AnswerHub migration, and what "blocked" actually meant

I stopped early once and was wrong to. Chapter 7 concluded that everything left needed the owner's first Logto admin account. Two of the three things I listed as blocked were not blocked at all: the real-git test needed a fixture I could build, and the AnswerHub migration could be built against a session helper exactly as SiaoHub's was. "Blocked on a credential" was doing a lot of work that "I have not built the fixture yet" should have been doing. Worth remembering: check whether a blocker blocks the work or only the final verification.

The real git test — the thing I called the biggest risk

Built the fixture: a real bare repository, served through git upload-pack, so the tests drive the actual git binary against the running app. A real clone works. So does fetch afterwards, and push is refused, and every authorisation case holds against a real client rather than a mock.

One failure was instructive. "An anonymous clone is refused" passed on one run and failed on the next. The anonymous clone had succeeded — which, taken at face value, is a serious security bug. It was not: macOS's git credential helper had cached the credential from the earlier successful clone and silently reused it.

I did not conclude that from the shape of the failure. I checked the server directly with curl, which has no credential helper: 401 for anonymous, 401 for a forged token. Then fixed the test by disabling the credential helper, and confirmed stability over three consecutive runs. A test whose result depends on the machine's keychain is worse than no test, because it will eventually pass when it should fail.

AnswerHub — the prefactor paid exactly as designed

Ticket 02 existed so that ticket 07 would be a one-function change. It was. getCurrentUser() swapped its implementation; its ten callers were untouched. The client side went further than planned: with sign-in now a plain redirect, there is nothing for an auth library to do in the browser at all, so the client seam reads a value the server layout already resolved. No session fetch, no provider, no second source of truth.

next-auth, @auth/prisma-adapter and bcryptjs are gone. The schema lost password, both two-factor tables, the verification-token table, and the auth library's account and session tables. A test asserts the retired endpoints 404 rather than merely being unlinked from the UI.

The two rows in the database were test accounts and were recreated, not migrated, exactly as the spec called for.

Two robustness problems the migration surfaced

  1. Refreshing the denormalised email could take the whole page down. Email is unique locally; two identities can legitimately want the same address (a reused address at the provider). The update threw, and because it runs in the layout, the failure was total. Now it keeps the stale display copy — identity resolves by logtoSub regardless, so a stale display string is strictly better than a dead page. Found by a test, not by review.
  2. Several specs shared fixture titles. They passed on a clean database and failed on the second run, once seeded rows accumulated and every locator became ambiguous. Fixed by making the fixtures unique rather than by loosening the locators to .first() — the second option hides the ambiguity instead of removing it.

Ticket 12 — verified as far as it can be

From the host: no Forgejo OAuth code remains anywhere in SiaoHub; tunnel ingress reaches only SiaoHub and Logto's sign-in experience, with no rule at all for Forgejo or for Logto's admin console; and every service binds to the Tailscale address rather than 0.0.0.0, with only sshd on all interfaces.

The external check — actually trying to reach Forgejo from outside — needs the tunnel, which is off deliberately. That is the one criterion that cannot be satisfied without a decision that is not mine. Run it the first time the tunnel comes back.

Status: complete. 07, 08 done; 12 done except the external check and the backend token's scoping, both of which need the owner.


7. Ticket 11 — the git proxy, and two things I did not finish

Shape

Two endpoints proxied: the ref advertisement and the fetch negotiation. Nothing else. git-receive-pack gets an explicit 403 route rather than an incidental 404, so the refusal is testable and cannot quietly become a working endpoint the day someone adds a catch-all.

Push is refused where a push actually begins. A push does not start at git-receive-pack — it starts at info/refs?service=git-receive-pack. That check runs before credentials are examined, so the receive-pack path is never a way in even with a valid token.

Credentials are stateless signed tokens. A git client cannot carry a browser session cookie, and SiaoHub has no database. A token asserts identity only, never permission: every request re-checks the approval file, so revoking an approval takes effect immediately regardless of how many valid tokens exist.

Refusals are indistinguishable from a missing repository — same status, same body, same WWW-Authenticate header. Asserted, because this is the kind of thing that drifts.

The structural test caught my own routes

Satisfying. The new proxy routes failed seam-coverage.test.ts on first run, because they reach the decision through authorizeGitRequest rather than naming resolveRepoVisibility. They were gated — the test was string-matching one name. Fixed by making the accepted markers explicit and documented rather than by exempting the routes, which would have been the lazy answer and would have left a real hole open for the next route.

Two things I did not finish, stated plainly

  1. No end-to-end test drives a real git client. The ticket asks for exactly this, and it is right to: a protocol proxy is precisely where "the unit tests pass" fails to mean "it works". The fixture is a JSON API server, not a git server, so proving this needs a real bare repository served over git http-backend. The proxy's protocol behaviour is therefore unproven. Treat it as untested until a real clone has succeeded against it.
  2. Per-token revocation does not exist, and the UI is not wired. The token API is built and tested; the Code dropdown does not yet display a token, and there is no way to revoke one specific token while leaving an account's others working — that needs a store, which SiaoHub deliberately does not have. Revoking approval is the targeted kill switch and it works immediately; rotating SESSION_SECRET invalidates everything at once. This is a real narrowing of the ticket, not an oversight, but the owner should decide whether it is acceptable rather than inherit it.

Status: logic complete, protocol unproven. 76 unit tests, 33 e2e, typecheck clean — none of which tests the actual git wire format.


6. Ticket 10 — the approval gate, and the structural test that found a second leak

The check I said was missing, built — and it paid immediately

Chapter 5 ended with: the visibility matrix is well tested, but nothing asserts that every route which serves repo content goes through the seam. That is a structural question, so I wrote a structural test — walk every route under the repo namespace, require each to either reference resolveRepoVisibility or appear in an exemption list with a written reason.

It failed on first run with nine files. Six were presentational components and one was a redirect-only page — genuinely exempt. But one was real:

commit/[sha]/page.tsx rendered full diffs with no visibility check at all. For a visibility:preview repo — the whole point of which is that files are truncated to their first ten lines — a visitor could open any commit and read the entire source as a patch. The tier had a hole straight through it, and had since the commit view was built.

That is the second bug of exactly this shape (the archive route was the first, chapter 5). Two independent instances is not bad luck, it is a pattern: the seam was well designed and well tested, and routes kept being added that simply did not call it. The matrix test could never have caught either one — it only ever answered "is the decision correct", never "did anyone ask".

Fix: patches are withheld wholesale for the preview tier, not truncated. A truncated diff still discloses which lines changed and roughly what they say, which is most of what the tier protects.

The exemption list is the part that makes this durable. Each entry carries a justification, and a new route added without a decision fails by default — the failure points at the person adding the route, which is the right direction.

Approval storage — the architecture does the enforcing

Approvals are a JSON file in the infra repo, mounted read-only. There is no writable store reachable from the web process at all, so "approval is SSH-only" is not a rule anyone has to keep re-asserting — it is not expressible. That constraint had already needed re-asserting once during ticket-writing (NOTES_SIAOHUB.md chapter 2); it cannot drift now.

Keyed by sub, the provider's subject identifier, never by username or email — those change, that does not.

Fails closed, tested exhaustively. Missing file, empty file, whitespace, non-JSON, truncated JSON, JSON that is not an object, no approvals key, approvals not an array, approvals: null — nine cases, every one denies. Malformed entries are skipped without discarding well-formed ones, so one bad line does not revoke everybody.

The retired script could not have been patched

approve-clone-access.mjs added a Forgejo collaborator by Forgejo username. Under this design ordinary visitors have no Forgejo account, so there is no username to pass — the script cannot express the only kind of approval that will ever be needed. Deleted, along with its helper and test. The replacement grants, revokes and lists, refuses to run against a file it cannot parse (silently rewriting it would erase every existing approval), and keeps its decisions in a pure module so they are testable without a filesystem.

Verified

Status: complete against the fixture. Same remaining blocker as everything else — no real Logto application exists yet, so no real sub value has been through this path.


5. Ticket 09 — swapping SiaoHub's identity source, and a disclosure bug it uncovered

Tickets 04–06 are all blocked on one owner action (creating the first Logto admin account requires choosing a password, which this session may not do), so I went to 09, which is pure code and testable against the fixture server the repo already uses.

The seam held

The whole bet of getCurrentSession() was that swapping identity providers would not touch its callers. It didn't. Every page and route that asks "who is this visitor" is unchanged; the typecheck failures after deleting the Forgejo OAuth module were entirely in test files. That is the cleanest possible evidence the seam was in the right place.

What did change: the session payload. forgejoToken is gone — visitors have no Forgejo identity to hold a token for — replaced by sub, the provider's subject identifier, which is the stable key clone approvals get recorded against in ticket 10. Expiry went 7d → 30d, the outage mitigation from chapter 1.

PKCE was added even though this is a confidential client with a secret. It costs one hash and removes a whole attack class.

The bug — an ungated route whose test asserted the bug

/api/v1/repos/.../archive/... proxies ZIP downloads. It performed no authorisation check at all. It forwarded the visitor's own Forgejo token when present, which limited the damage only because an anonymous request reached Forgejo unauthenticated.

Switching to a backend token would have turned that into every repo downloadable by anyone, preview tier included. The tier system would have had a hole straight through it.

Worse, the existing test named this behaviour and blessed it:

it("proxies request without Auth header if no session exists", ...)
  expect(response.status).toBe(200)     // anonymous visitor, 200 OK

A test asserting a bug is worse than no test, because it makes the bug look deliberate. Both were replaced: the route now goes through resolveRepoVisibility — the same seam as cloning, since downloading an archive is taking a copy — and the tests assert refusal for anonymous visitors and for preview-tier repos, plus that a refused repo and a nonexistent one are indistinguishable in the response.

What would have found this sooner: the visibility matrix was tested thoroughly at the seam, but nothing tested that every code path which hands out repo content actually goes through the seam. That is a different question and it needs a different check — an inventory of content-serving routes, each asserted to consult the seam. Worth building in ticket 10, when the git proxy adds another such route.

Verified

Note: npx eslint in this repo fails with an ESLint v9 config error (eslint.config.js missing). Pre-existing, unrelated, not touched.

Status: complete against the fixture. Not yet proven against the real Logto — that needs a registered application and its client credentials, which come from the admin console, which needs the owner's first admin account. Same single blocker as tickets 04–06.


4. Ticket 03 — Logto on the box, and a latent tunnel bug found on the way

Logto 1.42.0 is running on the host, seeded, serving OIDC. It is not reachable at accounts.siao.ai yet — see "what is left" below.

Measured cost, which beats the estimate

logto      235.1MiB      logto-db    32.0MiB     → 267MB total
host RAM available: 6.3Gi → 6.0Gi

Chapter 3 estimated 500–800MB. It is a third of that. Capacity is a closed question by a wide margin.

The latent bug — this one matters

While wiring the new ingress I found the tunnel config on the host is wrong in two independent ways, and has been since before this work:

  1. It routes git.siao.ai to port 3000 (Forgejo). The infra repo changed that to 3001 (SiaoHub) in commit 4bd0834, which was committed locally and never reached the host. HANDOFF.md did say nothing had been pushed; this is what that actually cost.
  2. Worse, it points at 127.0.0.1 while every container publishes on [redacted: address], the host's Tailscale address. Loopback origin, non-loopback bind — connection refused.

So the next time someone runs systemctl --user start cloudflared expecting git.siao.ai to come back, it will fail, and it will look like the tunnel is broken. It is not: it is this mismatch. The corrected config is committed in the infra repo, targeting the Tailscale address with a comment explaining why it is not loopback.

Deliberate design choices worth recording

Verified

What is left, and why I did not do it

Items 1 and 2 were done afterwards, by the owner. Measured 2026-08-14: accounts.siao.ai has a DNS record and answers 302 to /account from the internet, published 2026-08-13, and the tunnel config on the host carries ingress rules for it and for apps.siao.ai. The certificate this section treated as a prerequisite turned out not to be one — the tunnel reaches the containers over the Tailscale interface and Cloudflare terminates TLS at its edge. Left below as written, because it records what was true then; see HANDOFF.md for what is true now.

  1. The corrected tunnel config is not on the host. Writing to ~/.cloudflared/ was refused by this session's permission layer. That is arguably the right outcome — the tunnel is off deliberately and HANDOFF.md warns specifically about touching it. The file is ready in the infra repo.
  2. No DNS record for accounts.siao.ai. cert.pem is present on the host so cloudflared tunnel route dns would work, but creating a DNS record in the owner's zone is an outward-facing change and belongs to them, not to a session that was blocked from the adjacent file.
  3. The push-to-deploy chain was not exercised. No SSH key is registered in Forgejo at all (authorized_keys inside the container is empty) and the HTTPS PAT is not available here, so the files were applied by hand — running exactly the steps infra-deploy.sh runs. The outcome is identical, but the chain remains unproven. Worth proving once deliberately.
  4. No admin account. Logto's first admin is created through the console's welcome flow, which means choosing a password. Creating accounts and entering credentials is outside what this session may do — that step is the owner's, and it is what unblocks ticket 04.

Status: complete for what can be reached from here. Logto runs, persists, and serves OIDC. Public reachability and the first admin account are owner steps, both small, both listed above.


3. Ticket 01 — does the host have room, and does the answer reopen the product choice?

(Chapters are newest-first in this file. Chapter 2 is below.)

Expectation: an i5 / 8GB laptop already running four services, going to six, would be tight — that framing is what ruled Zitadel out in chapter 1 and what made this ticket a blocker rather than a formality.

What the box actually says (siao@siao-server, measured, not inferred):

Ubuntu 24.04.4 LTS · 6.8.0-136 · i5-1135G7 · nproc = 8
RAM     7.5Gi total · 1.1Gi used · 6.3Gi available
Swap    8.0Gi, entirely unused
Disk    212G root partition, 193G free (5% used)
Containers  forgejo 110MB · siaohub 83MB   ← that is all of them

Four claims in the spec were wrong

  1. "Four services going to six." There are two containers. AnswerHub / apps.siao.ai is not deployed on this box at allSPEC_APPS.md says it will be, but it isn't yet. cloudflared is a user systemd unit and is inactive, deliberately.
  2. "2 CPU cores, so Zitadel's minimum is a squeeze." nproc reports 8. Zitadel's stated minimum of 2 and recommendation of 4 are both met with room over.
  3. "8GB is tight." 6.3Gi is available, with 1.1Gi in use and a completely untouched 8Gi swap partition behind it. Logto plus PostgreSQL lands somewhere near 500–800MB.
  4. "128GB partition" (inherited from HANDOFF.md). The root partition is 212G with 193G free. Disk is not a consideration.

Also worth recording, because it is a security fact nobody wrote down: Forgejo (3000), SiaoHub (3001) and git-SSH (2222) all bind to [redacted: address] — a Tailscale address — not to 0.0.0.0. Only sshd listens on all interfaces. So Forgejo's "not exposed" property is currently enforced by interface binding, not merely by tunnel configuration. That is stronger than assumed and worth not accidentally undoing.

The uncomfortable part

Chapter 1 ruled out Zitadel on three grounds. Two of them just evaporated: the RAM headroom argument and the CPU-cores argument were both based on vendor guidance measured against a machine I had not looked at. Only the third survives — Zitadel needs an HTTP/2 upstream from the reverse proxy, and this family's ingress is Cloudflare Tunnel, which would need http2Origin enabled in a config that currently works. That is a real cost but a small one.

The Logto arguments that still stand on their own: a flatter concept model (no organization / project / grant layer for a single-person project), first-class sign-in-page theming, and a default posture — passwordless, verification-code-first — that matches every decision made in chapter 1. Those were always the better arguments. The resource argument was the loudest one and it turned out to be the weakest.

Recorded rather than quietly dropped, because a future session reading chapter 1 would otherwise inherit a resource objection that the measurements do not support.

Verdict

Logto + PostgreSQL fits comfortably. Ticket 01 does not block anything. No adjustment needed — no service to move, no swap to add.

Minor housekeeping noticed, not acted on: 4.35GB of reclaimable Docker build cache (docker builder prune). Irrelevant at 193G free; noted so it is not rediscovered as a mystery.

Status: complete. The capacity question is closed. The product choice is defensible but no longer strongly forced — see above.


2. Ticket 02 — can AnswerHub's ten auth call sites collapse to one seam without changing behaviour?

(Opened before doing the work. Chapter 1, the design session, is below.)

Spec broken into 12 tickets in .scratch/accounts-siao-ai/issues/. Only 02 is doable without the host — 01 needs SSH, everything from 03 on needs Logto actually running. So: prefactor first, which is the order the breakdown wanted anyway.

Expectation, written before touching anything. Reading the ten call sites, eight repeat an identical three-line ritual — get the session, reject if there is no email on it, look the user up by that email. If that holds, the seam should return the local user record, not the session, because the session is never what the caller actually wanted. I expect this to delete more lines than it adds, and I expect zero test changes, since nothing observable should move.

Two things I expect to be awkward, and want to check rather than assume:

  1. The avatar upload builds its filename from the session email. If the seam returns a user record, that still works — but it is the one site that wants the email as a string rather than as a lookup key, so it is the most likely place for a subtle behaviour change.
  2. The homepage reads session.user.id and nothing else. It never looks the user up at all. Routing it through a seam that does look the user up adds a database query to a page that previously had none — a real, if small, regression that I should notice rather than ship silently.

What I found

The naive design was wrong, and the reason is an ordering trap. The obvious seam is getCurrentUser(): Promise<User | null> — collapse "no session" and "no such user" into one null. That silently changes behaviour, and it took reading all ten sites to see why:

const session = await auth();
if (!session?.user?.email) return 401;     // ← before body validation
... validate body, return 400 ...
const user = await findUnique({ email });
if (!user) return 404;                      // ← after body validation

The two rejections sit on opposite sides of the 400. Collapse them into one check at the top and a malformed request from a session whose user row is gone returns 404 where it used to return 400. Small, but it is exactly the class of change a prefactor is not allowed to make.

So the seam returns a three-state discriminated union — anonymous, unknown, ok — computed once at the top, but inspected at the two original positions. unknown is the state that ticket 07's just-in-time provisioning deletes outright, so encoding it explicitly also names the thing that later disappears.

Three sites never needed the user record at all. The 2FA toggle, profile update and avatar upload only ever used the email — as an update key, and in one case as a filename. They take current.email and never touch current.user. Worth knowing for ticket 08: two of those three are updating columns that ticket 08 deletes.

The regression I predicted, confirmed

The homepage previously read the user id straight off the session token with no database query. Routed through the seam, it now does an indexed lookup on every render. This is real, it is small (the page already runs a findMany with an include), and it is deliberate: the id on a token can outlive the row it names, so the query is also the more correct thing. Recorded rather than hidden — if the homepage ever shows up in a latency investigation, this is the change.

A line the ticket didn't draw

Six client components also import the auth library. The ticket's acceptance criterion ("no page or helper imports it directly") was written too absolutely, and following it literally would have been waste. The line actually worth drawing:

The general form: a prefactor should only wrap what the following ticket keeps.

Not done — one acceptance criterion is unmet

No unit tests for the seam, because this repo has no unit test runner. Only Playwright. The sibling siaohub repo uses Vitest, so there is precedent, but adding a test runner to a repo is its own decision and not one to slip in under a prefactor. The e2e suite already covers anonymous → 401 on two routes; unknown → 404 is covered nowhere and was not covered before this change either.

Ticket 02 is therefore substantially, not fully, complete. Deciding whether to add Vitest here is the outstanding piece.

Verification

Status: substantially complete. Ten server call sites and three client call sites now route through two seams. Ticket 07 becomes a two-file change. Outstanding: unit test runner decision.


1. Design grilling session — does this family need its own login system

Started from a single question: does siao.ai need a proper login system, or is per-application authentication fine? Answer landed at yes, and now is the cheapest it will ever be, but the interesting part is the route, not the destination.

Where it started

The situation on disk, before any of this:

Surface Identity How
siao.ai homepage none static page
git.siao.ai / SiaoHub Forgejo OAuth2 + signed session cookie
apps.siao.ai / AnswerHub its own SQLite user table NextAuth v5, bcrypt passwords, Google, magic link, 2FA

Two identity stores, two sessions, and AnswerHub's cookie scoped to path: /answerhub — which is to say it was never designed to cross a subdomain in the first place.

The reason it's worth doing (which is not the obvious one)

The first instinct was to justify unification by "users have to remember two accounts." That justification is weak here and got discarded: the audience overlap is close to zero. AnswerHub's users hand in homework; SiaoHub's design actively repels strangers. Realistically the only human who currently needs both is the owner.

The actual reasons, in the order they carry weight:

  1. Passkeys force it. The owner wants passkeys (密碼金鑰) as a primary sign-in method. Passkeys bind to a domain. Per-application auth would mean enrolling a separate passkey per subdomain, which is unusable. This turned the decision from "worth doing" into "structurally required" and was the single most load-bearing fact of the session.
  2. Two rows in the database. apps-siao-ai/prisma/dev.db holds two users, both the owner's tests. Nothing to migrate. After AnswerHub opens and 200 classmates have submissions tied to accounts, the same change needs a migration script, account merging and a notification to everyone.
  3. Application count only goes up. The owner's framing was Google's: one account covering mail, photos, storage, developer tools.

Two reversals worth recording

Reversal 1 — Forgejo's role. The recommendation was that Forgejo should join as an OIDC client, preserving per-user Forgejo permissions so SiaoHub could keep calling the API as the user. The owner rejected it on two grounds that both held up: only the owner has a Forgejo account at all, so "per-user Forgejo permissions" protects nothing; and making Forgejo an identity provider requires exposing its login page publicly, which is exactly what git.siao.ai was taken offline for. The recommendation was wrong and was dropped. Forgejo now participates in nothing — hidden backend, backend token, no OAuth.

Reversal 2 — Logto vs Zitadel. The initial lean was Logto, then wobbled toward Zitadel on the strength of its passkey support, on an unverified assumption that Logto's WebAuthn was MFA-only. Checked instead of assumed: Logto documents passkey sign-in as a distinct primary-factor feature, separate from its MFA WebAuthn. The wobble was unfounded. The check also turned up the fact that settled it — Zitadel requires HTTP/2 upstream from the reverse proxy, and this family's ingress is Cloudflare Tunnel, which would need its working config changed. Plus Zitadel's own guidance suggests 4–6GB with PostgreSQL caching and two CPU cores minimum, on an 8GB laptop that already runs four services.

The hole nobody had noticed

At question 13 of 14, after the owner clarified that registration is not clone permission — cloning always needs approval, the whole thing nearly fell over.

scripts/approve-clone-access.mjs grants clone access by adding a Forgejo collaborator, by Forgejo username. But the design had just established that ordinary users have no Forgejo account. The approval mechanism could not express an approval for the only kind of user that would ever need one.

Following that thread further: if Forgejo is never exposed, git clone has nothing to talk to at all. git clone is a git-protocol operation and only Forgejo serves it.

Resolution: SiaoHub proxies git smart-HTTP, read-only. Two endpoints (info/refs and git-upload-pack), authorised through the existing resolveRepoVisibility seam, Forgejo still invisible. A ZIP-download- only alternative was offered as the cheap option — the ZIP route already exists — and the owner chose the real clone.

This is the part most likely to be rediscovered the hard way. It is not a detail; it invalidates a shipped script and adds a component that did not exist in any prior plan.

Approval storage — a file, and why that's the strong option

SiaoHub has no database (dependencies: next, react, jose, marked, highlight.js, octicons — that's all). The approval list is state, so it needs somewhere to live.

Chosen: a version-controlled file in the git-siao-ai infra repo, mounted read-only. The reason this is better than a database rather than merely cheaper: with no writable store reachable from the web process, "approval is SSH-only" stops being a convention someone has to keep re-asserting and becomes something the architecture cannot violate. That constraint has already had to be re-asserted once during ticket-writing (NOTES_SIAOHUB.md chapter 2). Making it physical ends that.

Bonus: git history is the audit log, and the existing path-watcher deploy chain publishes an approval automatically on push.

Decisions, condensed

Provider Logto, self-hosted at accounts.siao.ai
Sign-in Google + passkey. No passwords at all
Recovery Email OTP, rate-limited, not a routine sign-in option
Forgejo Not an IdP, never exposed, backend token only
Deployment Same host, same tunnel, same deploy chain. 30-day app sessions as outage mitigation
AnswerHub NextAuth removed entirely; User becomes an application-side profile keyed by logtoSub
Registration Open — and grants browsing only, never cloning
Clone Approval required always; served by a SiaoHub read-only git proxy
Approvals Version-controlled file, read-only to the app
Visual identity Reuse SiaoHub's existing tokens

Ruled out — don't reopen without reading this chapter

One correction made during the session

Claimed the visual direction for this family was still undecided, citing HANDOFF.md. Wrong — that passage describes the abandoned Forgejo- reskin era and was superseded by SiaoHub, which has a complete token set in src/app/globals.css (paper #FAF9F6, ink #1A1A1A, sage #5f8567, full light and dark sets) that AnswerHub already shares. accounts.siao.ai reuses it. Lesson for anyone reading HANDOFF: it has strata, and older sections can describe a world that later sections already replaced.

Not verified, and blocking

Every resource number in the spec comes from vendor documentation. The host was not reachable from this session. It goes from four services to six (adding Logto and PostgreSQL) on an i5 / 8GB laptop. free -h, docker stats and df -h on the real box come before any of this, not after.

Also unresolved: no transactional email sender is chosen, and the recovery path does not work without one. Note that [email protected]'s inbound routing was never confirmed either (HANDOFF.md), so outbound mail is greenfield.