SPEC_ACCOUNTS.md
accounts.siao.ai — spec
What this covers: the design of accounts.siao.ai, the identity
provider for every siao.ai application — the account model, sign-in
methods, what each application keeps, and how cloning is authorised.
For what is actually built, deployed or reachable, see
ClaudeDocs/HANDOFF.md — status deliberately does not live here.
This spec supersedes parts of SPEC_SIAOHUB.md. Specifically: the
"Auth" implementation decision (Forgejo OAuth2 as SiaoHub's identity
provider), the clone-approval mechanism (Forgejo collaborator), and user
story 7 ("a logged-in user can clone any general-visibility repo"). See
Superseded decisions at the end of this file. Do not implement
SPEC_SIAOHUB.md's auth model — read this one instead.
Problem Statement
The siao.ai domain family is growing into several independent web
applications — the homepage, a git frontend at git.siao.ai, an
app index and homework/answer platform at apps.siao.ai, and more the
owner intends to add later. Today each application owns its own notion
of identity: SiaoHub authenticates against Forgejo's OAuth2 provider,
while AnswerHub keeps its own user table with passwords, email
verification, 2FA and magic links. The same person is two unrelated
accounts with two unrelated sessions, and every new application would
add a third, a fourth, and so on.
The owner wants the model every large service uses — one account that works across everything, the way a single Google account covers mail, photos, storage and developer tools. Three specific forces make this urgent rather than aspirational:
- Passkeys are bound to a domain. The owner wants passkeys as a primary sign-in method. A passkey registered at one subdomain does not work at another, so per-application authentication would force a user to enrol a separate passkey per application. Passkeys are effectively only workable behind a central identity provider.
- There are no real users yet. The AnswerHub database holds two rows, both the owner's own test accounts. Unifying identity now costs no migration, no account merging, and no user-facing disruption. After the platform opens and collects real submissions tied to real accounts, the same change becomes an order of magnitude more expensive.
- Application count is going up, not down. Each application that ships before unification is another one that has to be converted later.
Secondary problem: the current SiaoHub design uses Forgejo as its identity provider, which requires exposing Forgejo's own login page and OAuth2 endpoints to the public internet. That directly contradicts the project's central design commitment — Forgejo is a hidden backend and no part of its own UI is ever shown to a visitor.
Solution
Stand up accounts.siao.ai, a self-hosted Logto instance that
becomes the single identity provider for the entire siao.ai family.
Every application — AnswerHub, SiaoHub, and anything added later —
becomes an OIDC client of it and stops owning identity entirely.
Identity is deliberately narrow. accounts.siao.ai answers exactly one
question: who is this person? It does not answer what may they do —
roles, bans, member numbers and per-repo clone approvals stay in the
applications that mean something by them.
The account model:
- Google sign-in and passkeys are the primary methods.
- Passwords do not exist. No password field, no hash table, no reset flow, no brute-force surface, and no need for the application-level 2FA that only exists to compensate for passwords.
- Email OTP is a recovery path only — rate-limited, and not offered as a routine way to sign in.
Forgejo does not participate. It is never exposed publicly, never an identity provider, and never an OAuth2 client. SiaoHub continues to talk to it over the internal network with a backend token.
Because Forgejo stays hidden, git clone can no longer be served by
Forgejo directly. SiaoHub gains a read-only git smart-HTTP proxy, so
an approved user clones from git.siao.ai while Forgejo remains
unreachable from outside the host.
Registration is open, but registration is not clone permission.
Anyone may create a siao.ai account and use the applications; cloning
any repository additionally requires the owner's explicit approval,
granted out-of-band over SSH, exactly as before.
User Stories
Account holder — signing up and signing in
- As a visitor, I want to create one
siao.aiaccount, so that I can use every application under the domain without registering again. - As a visitor, I want to sign up with my existing Google account, so that I don't have to manage another credential.
- As a visitor, I want to sign up with a passkey using my device's biometrics, so that I never have to invent or remember a password.
- As a visitor, I want to be certain that no password of mine is stored by this site, so that a breach here cannot expose a credential I reuse elsewhere.
- As an account holder, I want to enrol more than one passkey (laptop and phone), so that losing one device does not lock me out.
- As an account holder who has lost access to every enrolled method, I want to recover my account through a one-time code sent to my email, so that I can get back in without contacting the owner.
- As an account holder, I want that recovery path to be rate-limited, so that it cannot be used as a back door into my account.
- As an account holder, I do not want the recovery path presented as a normal sign-in option, so that the primary methods stay primary and the recovery path stays rare.
- As an account holder already signed in at one
siao.aiapplication, I want to arrive at another one already signed in, so that it behaves like one product rather than several. - As an account holder, I want signing out to be meaningful across the family, so that leaving a shared computer does not leave a session open somewhere I forgot about.
- As an account holder, I want the sign-in page to look like the rest
of
siao.ai, so that I can tell I am not on a phishing page. - As an account holder, I want my session to survive for weeks, so that routine use does not mean re-authenticating constantly.
- As an account holder, I want to review and remove my enrolled passkeys and linked Google account, so that I stay in control of how my account can be accessed.
AnswerHub user
- As an AnswerHub user, I want to sign in with my
siao.aiaccount, so that AnswerHub does not need its own credentials. - As an AnswerHub user, I want my submissions, comments and likes to stay attached to me across the identity change, so that nothing I posted is orphaned.
- As an AnswerHub user, I want my member number (
serialNumber) to remain an AnswerHub concept, so that it means what it has always meant on that platform. - As an AnswerHub administrator, I want role and ban decisions to
remain AnswerHub's own, so that suspending someone from the homework
platform does not touch their
siao.aiidentity or their access to unrelated applications. - As an AnswerHub user, I want a first sign-in to create my AnswerHub profile automatically, so that there is no second registration step.
SiaoHub visitor and account holder
- As an anonymous visitor, I want to browse general-visibility repos without any account, so that the portfolio is readable by a recruiter or a passer-by who will never sign up.
- As an anonymous visitor, I want
visibility:previewrepos to still show only their first lines, so that the existing preview tier keeps working exactly as before. - As a signed-in account holder, I want to see clearly whether I am approved to clone a given repo, so that I am not guessing.
- As a signed-in account holder who is not approved, I want a clear statement that cloning requires the owner's approval, so that I know the gate is deliberate and not a bug.
- As an approved account holder, I want to run a normal
git clone https://git.siao.ai/<owner>/<repo>.git, so that I get a real repository with history rather than a snapshot. - As an approved account holder, I want
git fetchandgit pullto keep working afterwards, so that my clone stays useful over time. - As an account holder, I want cloning to require nothing but my
siao.aiidentity, so that I never need a Forgejo account. - As a visitor, I want
git.siao.aito never show me a Forgejo page, login form or OAuth consent screen, so that the design commitment that Forgejo is invisible actually holds.
Owner
- As the owner, I want to sign in to every one of my applications with one account, so that I stop maintaining separate credentials for my own infrastructure.
- As the owner, I want to approve a specific person for a specific repository, so that clone access is granted deliberately rather than implied by registration.
- As the owner, I want that approval to be possible only over SSH on the host, so that no web request — including one from my own authenticated session — can ever grant clone access.
- As the owner, I want the approval list to be version-controlled, so that who was granted what, when and why is answerable from git history without building an audit system.
- As the owner, I want granting approval to use the push-to-deploy chain I already have, so that I am not learning a second operational workflow.
- As the owner, I want to revoke an approval the same way I granted it, so that revocation is as routine as granting.
- As the owner, I want Forgejo to remain unreachable from the public internet after this change, so that the smallest possible surface is exposed.
- As the owner, I want to add a future application as an OIDC client with configuration rather than code, so that the marginal cost of application number four is near zero.
- As the owner, I want to hold no user passwords anywhere in my infrastructure, so that a password-disclosure incident is not possible.
- As the owner, I want the sign-in experience styled with the existing SiaoHub design tokens, so that the family looks like one product without a second design exercise.
- As the owner, I want existing sessions to survive a brief identity- provider outage, so that a home-server reboot does not sign everyone out of everything.
- As the owner, I want the identity provider to ride the same host, tunnel and deploy chain as everything else, so that I do not maintain a second deployment mechanism.
Implementation Decisions
Identity provider
- Product: Logto, self-hosted. Chosen over Zitadel, Authentik and
Keycloak. Deciding factors, in order: passkeys are supported as a
primary sign-in method and not only as a second factor; email OTP
and a Google connector are built in; the sign-in page is customisable
with the project's own CSS as a product feature rather than a
workaround; and its resource envelope fits a machine that is already
running four services. Zitadel was the runner-up and was ruled out on
deployment fit — its published guidance asks for materially more RAM,
two CPU cores minimum, and an HTTP/2 upstream from the reverse
proxy, which would mean changing the working
cloudflaredingress configuration. Authentik was ruled out on resource cost (four containers). Keycloak was considered as the conservative, longest-lived option and rejected on JVM footprint plus configuration and theming complexity. - Writing an OIDC provider was explicitly rejected. The customary argument for it — customisability — does not hold: the parts the owner wants to control (sign-in page appearance, user attributes) are configuration in an off-the-shelf provider, while the parts that would have to be written (PKCE, JWKS and key rotation, refresh-token revocation and replay detection, single logout, consent, client registration) are pure liability.
- Deployment: a new
logtoservice plus apostgresservice in the existinggit-siao-aidocker-compose stack, on the same self-hosted host, behind the same Cloudflare Tunnel, deployed by the existing systemd path-watcher chain (NOTES_GIT_SERVER.md§7–9). A new tunnel ingress rule routesaccounts.siao.aito it. No second deployment mechanism, consistent withSPEC_APPS.md's explicit decision not to maintain one. - Sign-in methods: Google connector and passkey/WebAuthn. Password authentication is disabled at the provider, not merely hidden.
- Recovery: email OTP, rate-limited, reachable from a recovery link rather than presented as a primary sign-in option. Requires a transactional email sender (Resend / Postmark / SES — not yet chosen); because it is a recovery path and not a sign-in path, its availability requirements are correspondingly lower.
- Branding: the SiaoHub design tokens (paper
#FAF9F6, ink#1A1A1A, muted#6E6B65, sage accent#5f8567, plus the existing dark-mode set) applied through Logto's sign-in-experience customisation. No new visual identity is designed for this project. - Registration is open. Anyone may create an account. This is deliberate and its consequences are bounded by the fact that registration grants no repository access whatsoever.
What lives where
- Identity lives in Logto: the person, their Google link, their passkeys, their email.
- Authorisation lives in the applications: AnswerHub owns
role,isBanned,showAllContentandserialNumber; SiaoHub owns per-repo clone approval. - Storing application authorisation in Logto user attributes was considered and rejected — with several applications sharing one provider, the user record becomes a dumping ground of unrelated application permissions.
AnswerHub
- NextAuth is removed entirely, not reconfigured as a single-OIDC-
client shim. Keeping it was evaluated on the grounds that it would
preserve the existing
auth()call sites; the codebase has only ten such call sites across eleven files, so the saving does not justify retaining a whole authentication dependency. - One new seam: a
getCurrentUser()function in AnswerHub's lib layer, which every route and page uses instead of importing an auth library directly. It resolves the Logto session and returns the local application profile. Changing identity providers again later touches this one function. - Schema changes — remove:
User.password,TwoFactorToken,TwoFactorConfirmation,User.isTwoFactorEnabled,VerificationToken,Account,Session. Add:User.logtoSub, unique, the link to the Logto identity. Keep:serialNumber,username,role,isBanned,showAllContent, profile fields, and every content relation (submissions,blogPosts,comments,likes,apiKeys). User.emailis retained as a denormalised copy for display and querying, sourced from Logto at sign-in; Logto remains the authority.- Just-in-time provisioning: a first sign-in with an unknown
logtoSubcreates the localUserrow, allocating aserialNumberthrough the existing rule. There is no separate registration step in AnswerHub. - No dual-running period. The old and new mechanisms do not coexist behind a flag. Two authentication systems running side by side is the standard failure mode of this kind of migration, and with two test rows in the database there is nothing to protect by hedging.
- The AnswerHub-specific routes that exist only to serve the old model (2FA toggle, email verification, magic-link login, registration, NextAuth handler) are deleted rather than left unreferenced.
SiaoHub
- Identity source changes from Forgejo OAuth2 to Logto OIDC. The
existing
getCurrentSession()accessor keeps its signature and its meaning, so no page or route that asks "who is this visitor" changes. The signed session cookie stays; only what fills it changes. - Forgejo OAuth2 is removed from SiaoHub, and Forgejo's OAuth2 endpoints are never exposed publicly. SiaoHub calls the Forgejo API with a backend token over the internal network. This is acceptable precisely because ordinary users have no Forgejo identity and no Forgejo-level permissions to preserve — every visitor-facing authorisation decision is SiaoHub's own.
- Clone authorisation changes.
resolveRepoVisibilitycurrently returnscanClone: session !== nullfor general-tier repos. It now additionally requires an explicit approval for that user and that repository. Registration alone grants browsing, never cloning. The function remains the single authorisation seam in the system — the new git proxy consults it rather than implementing a parallel check. - Approval store: a file, not a database. A version-controlled file
in the
git-siao-aiinfra repo, mounted read-only into the SiaoHub container, mapping an account identifier to the repositories it may clone. SiaoHub gains no database dependency. This choice does more than avoid a dependency: with no writable store reachable from the web process, "approval is SSH-only" becomes a property of the architecture rather than a convention that has to be re-asserted. Granting access is edit, commit, push — which the existing path-watcher chain deploys automatically. Git history is the audit log. The trade-off is that approval takes effect after a deploy cycle rather than instantly, and that the format would be unsuitable at a scale this project will not reach. - The existing
approve-clone-access.mjsscript is replaced. Its current mechanism — adding a Forgejo collaborator by Forgejo username — cannot work for users who have no Forgejo account. The replacement edits the approval file. It stays SSH/localhost-only, with no route, button or API endpoint anywhere in the application able to trigger it, and it gains a revoke operation alongside grant. - Read-only git smart-HTTP proxy. SiaoHub proxies exactly two
endpoints per repository — the ref advertisement
(
GET …/info/refs?service=git-upload-pack) and the fetch negotiation (POST …/git-upload-pack) — to the internal Forgejo, after consultingresolveRepoVisibility. Fetch/clone only: nogit-receive-pack, no push, ever. Requests must stream rather than buffer, since responses can be large. Authentication uses HTTP Basic with a per-user token issued by SiaoHub, because git clients cannot carry a browser session cookie. - Clone credentials: an approved user obtains a personal clone token from the SiaoHub UI, used as the HTTP Basic password. Tokens are revocable and bound to the account; revoking approval invalidates access regardless of token state, because the proxy re-checks authorisation on every request.
- The pre-existing ZIP archive route stays, gated by the same authorisation decision.
Sessions and availability
- The identity provider shares one home server and one tunnel with everything else, so an outage takes identity down with it. The mitigation is deliberately chosen over adding a second host: application sessions last approximately 30 days, so an outage affects only people who need to authenticate during it, not people already signed in.
- Running the provider on a separate VPS was considered and rejected as
contradicting
SPEC_APPS.md's explicit decision not to maintain a second deployment mechanism, for an availability problem no real user is currently experiencing. Logto's state is one PostgreSQL database and can be relocated later if that changes. - Logto Cloud was considered and rejected: identity is the most sensitive data in the family and the project's posture is self-hosted.
Ordering
- Standing up the provider and converting the applications must precede AnswerHub opening to real users. The migration is free while the user table holds two test rows and expensive afterwards.
- Function first, visual styling applied alongside — Logto's sign-in customisation is configuration and CSS, so doing it later wastes nothing, and a working sign-in page is easier to style than an imagined one.
- Before any of it: measure the host. Every resource figure in this
spec comes from vendor documentation, not from this machine. The host
goes from four services to six, and
free -h,docker statsanddf -hon the actual box are a precondition, not a formality. - Publishing
accounts.siao.aiis a separate, owner-gated decision and is not implied by this spec. Whether the tunnel is up, and which hostnames it serves, is state — it lives inHANDOFF.mdand is deliberately not asserted here. (An earlier version of this line said cloudflared was stopped; it stayed there long after that stopped being true, which is exactly what the header of this file warns against.)
Testing Decisions
A good test here asserts what a visitor, an account holder or the owner
can actually observe — a page's content, whether a clone succeeds, what
an HTTP endpoint returns — not the internal shape of a session object or
the intermediate steps of an authorisation calculation. Existing
conventions apply: Vitest for units, Playwright for end-to-end, and a
fixture server standing in for external systems, as SiaoHub already does
with e2e/fixtures/fake-forgejo-server.mjs.
Seams under test. Three, of which two already exist:
getCurrentSession()(SiaoHub, existing, signature unchanged) — the only place that answers "who is this visitor". The provider swap happens entirely behind it.resolveRepoVisibility()(SiaoHub, existing, gains approval input) — the only place that answers "what may they see or take", now including the git proxy.getCurrentUser()(AnswerHub, new) — replaces ten direct authentication-library call sites with one.
Highest-value unit tests, because a missed branch is a disclosure bug rather than a cosmetic one:
resolveRepoVisibilityacross the full matrix: general and preview tiers × anonymous, signed-in-unapproved, signed-in-approved-for-this- repo, and signed-in-approved-for-a-different-repo. That last case is the one most likely to be got wrong and must be asserted explicitly.- Approval-file parsing: a well-formed file, an empty file, a missing file, and a malformed file — each of which must deny rather than throw open. Absence of data is never permission.
- Session verification: valid, expired, tampered and absent tokens.
End-to-end tests:
- Anonymous browsing of a general repo still works with no account.
- A
visibility:previewrepo still truncates for anonymous visitors — an unchanged-behaviour regression test. - A signed-in but unapproved user sees the clone affordance in a clearly gated state and cannot obtain code through any route.
- An approved user can clone; the same user, after revocation, cannot.
- No Forgejo-authored page, login form or consent screen is reachable
from
git.siao.aiat any point in any of the above. - AnswerHub: first sign-in provisions a profile; a returning user keeps
their submissions, comments and likes; a banned user is refused by
AnswerHub while their
siao.aiaccount continues to work elsewhere.
Git proxy tests exercise the real client where possible — an actual
git clone against the running service in end-to-end runs, since a
mocked HTTP exchange would not catch protocol-level mistakes. Assert
specifically that git push is refused.
Not worth testing: Logto's own correctness. It is a dependency, not code under development. What is worth testing is the project's own configuration of it — that password authentication is genuinely unavailable, and that the OTP path is rate-limited — because those are decisions, not vendor behaviour.
Pipeline gating follows SPEC_SIAOHUB.md: unit tests on push, full
suite on review, smoke tests against staging, and a final gate before
production, with branch protection enforced for the owner too.
Out of Scope
- Any web-based approval interface, in any application, for any user including the owner. Approval is SSH-only by design and this remains a permanent property, not a temporary gap.
- Migrating existing users. There are two test rows; they are recreated, not migrated. No migration script is written.
- Forgejo accounts for anyone but the owner. Forgejo is not an identity provider and its user list is not synchronised with anything.
- Exposing Forgejo publicly in any form, including its git endpoints.
- Push access over HTTP. The proxy is fetch-only; the owner pushes over SSH as they do today.
- Passwords, and therefore password reset, password strength policy, and application-level 2FA.
- Organisations, teams or multi-tenancy in the identity provider.
- Listing
accounts.siao.aiin the homepage'sPLACESarray — it is infrastructure, not a destination. - Bringing the
git.siao.aitunnel back online, which is a separate owner-gated decision. - Choosing and configuring the transactional email sender, which is required before the recovery path works but is a distinct piece of work.
- Rate-limiting or abuse mitigation for open registration beyond what Logto provides.
Further Notes
- Registration deliberately grants nothing. A sign-in wall does not stop a determined person, since creating an account is free; what it stops is casual automated bulk collection. The security boundary that actually matters is the approval gate, and it is unaffected by how easy registration is.
- Default-deny remains the operating assumption for repositories.
Anything pushed to
git.siao.aishould be treated as readable by any visitor unless it is taggedvisibility:previewor kept off the box. This spec does not change that risk; it tightens the clone half of it while leaving the browse half exactly as it was. - The host has since been measured (ticket 01,
NOTES_ACCOUNTS.mdchapter 3), and it is roomier than this spec assumed: 8 cores, 6.3Gi RAM available of 7.5Gi with an unused 8Gi swap, 193G free disk, and only two containers running rather than the four stated above — AnswerHub is not deployed on this box yet. Logto plus PostgreSQL fits comfortably. - Consequence for the product choice, stated honestly: two of the three reasons Zitadel was rejected above (RAM headroom, CPU cores) do not survive the measurement. Only the HTTP/2-upstream requirement against the working Cloudflare Tunnel config remains. Logto is still the choice on its remaining merits — flatter concept model, sign-in-page theming, passwordless-by-default posture — but the resource argument should not be quoted as support for it.
- Forgejo, SiaoHub and git-SSH bind to a Tailscale address, not
0.0.0.0. Forgejo's isolation is therefore enforced by interface binding as well as tunnel configuration — do not undo this when adding services.
Superseded decisions
These entries in SPEC_SIAOHUB.md no longer apply:
| Superseded | Replaced by |
|---|---|
| Auth: Forgejo OAuth2 is SiaoHub's identity provider | Logto OIDC at accounts.siao.ai; Forgejo is never an identity provider and is never exposed |
| Auth: Google is added later as an OAuth2 source inside Forgejo | Google is a connector in Logto |
| User story 7: a logged-in user can clone any general-visibility repo | Cloning always requires explicit per-user, per-repo approval; registration grants browsing only |
| Clone approval by adding a Forgejo collaborator | Approval recorded in a version-controlled file, read-only to the application |
resolveRepoVisibility(repo, session) |
Same seam, additionally taking approval state |
| Clone served by Forgejo | Clone served by SiaoHub's read-only git smart-HTTP proxy |
Still in force from SPEC_SIAOHUB.md: the visibility-tier model based
on Forgejo repo topics, the preview tier's first-ten-lines rule,
resolveRepoVisibility as the single authorisation seam, approval being
SSH/localhost-only with no web equivalent anywhere, and the deployment
and pipeline-gating decisions.