SPEC_ACCOUNTS_CONSOLIDATION.md
Account consolidation — spec
What this covers: making accounts.siao.ai the single place a person
edits who they are, and reducing every other application to a read-only
consumer of that record. It covers the account front end, the profile
field map, the synchronisation mechanism, suspension, and sign-out
scope.
For what is actually built, deployed or reachable, see
ClaudeDocs/HANDOFF.md — status deliberately does not live here.
This spec extends SPEC_ACCOUNTS.md. That document decided where
identity lives. This one decides where a person's profile lives, and
how everything else reads it. Nothing in SPEC_ACCOUNTS.md is
superseded; several of its user stories (5, 6, 9, 10, 13) are satisfied
for the first time by the work described here.
Problem Statement
Identity moved to accounts.siao.ai, but profile did not. The result
is a person who exists once and is described twice.
AnswerHub's User table holds three unrelated kinds of thing in one
row:
- Identity — the Logto subject, email, name, username, avatar. Every one of these is a copy of something the identity provider already knows, and two of them (username, avatar) are copies with no source at all: AnswerHub writes them itself.
- Profile — biography and social links. These describe the person, not their relationship to AnswerHub, and there is nowhere else in the family to put them.
- Authorisation — member number, role, suspension state, content
visibility, session validity. These are genuinely AnswerHub's, and
SPEC_ACCOUNTS.mdalready decided they stay.
SiaoHub, by contrast, holds nothing: it has no database, and its session carries only a subject and a username. It cannot show a person anything about their own account, and has no page on which to try.
Four specific consequences make this worth fixing now rather than later:
- A person edits themselves in the wrong place. The only account
settings page in the family is AnswerHub's, so the way to change your
name across
siao.aiis to visit a homework platform. Any future application either grows its own settings page — repeating the problem — or has none. - Suspension is two unconnected switches. Logto has
is_suspended; AnswerHub hasisBanned. Neither knows the other exists. Suspending an account at the identity provider leaves every application session valid for up to thirty days, because those sessions are self-contained JWTs that no application re-checks. - Sign-out is per-application, and says otherwise. Each application
clears its own cookie. A person who signs out of one is still signed
in to the others, which is precisely the failure
SPEC_ACCOUNTS.mduser story 10 was written to prevent. - The cost only goes up. Every field duplicated today is a field that must be reconciled later, and every application added before this lands is another settings page to unwind.
Solution
accounts.siao.ai becomes the only place a person edits themselves.
Every other application reads and never writes.
The account front end is Logto's own Account Center, served under
the accounts.siao.ai name, not a bespoke application. This was
measured rather than assumed: the deployed instance already serves a
working Account Center that inherits the family's sage accent and dark
mode from the sign-in experience branding, and its bundle carries
zh-TW alongside en. Building a Next.js application in its place
would mean re-implementing WebAuthn enrolment, MFA enrolment, email
re-verification and social unlinking — the exact category of work
SPEC_ACCOUNTS.md refused when it declined to write an OIDC provider —
in exchange for layout freedom that satisfies no user story.
A person's profile is one record for the whole family, including their avatar. Avatar files go to Cloudflare R2 as Logto's storage provider, which is what makes drag-and-drop upload possible at the provider and lets AnswerHub's own upload route be deleted rather than merely hidden.
Applications keep a local copy, which is a cache and not a second truth. A cache is unavoidable: AnswerHub renders other people's names, avatars and biographies — a submission list shows dozens of authors at once — and an ID token carries claims for exactly one person, the one signing in. The choice is therefore not whether to copy, but how honestly the copy is labelled and how quickly it converges.
It converges by four paths, in descending order of speed and ascending order of reliability: a webhook from the provider, a refresh at sign-in, an on-demand refresh when a person views their own profile, and a daily reconciliation. No application exposes any route that can write a profile field.
Suspension is one-directional. The identity provider's suspension
means "this person cannot use siao.ai" and propagates down into every
application's session validity. An application's own ban means "this
person cannot post here" and propagates nowhere. Suspending someone from
a homework platform must not remove their identity.
Sign-out is family-wide. Signing out anywhere ends every application session, not just the cookie in front of you. The provider's back-channel logout was the first design and was measured not to deliver, so the applications tell each other directly over the private network — see the sign-out decision below for what was eliminated first.
All of this is built and verified on the tailnet. Publishing
accounts.siao.ai to the public internet — DNS, tunnel ingress, issuer
rename, the Google redirect URI, the open-registration decision, and
whether the Logto console stays reachable — is a separate, owner-gated
decision and is deliberately not part of this work.
User Stories
Account holder — one place to be yourself
- As an account holder, I want one page that holds my name, avatar, biography and links, so that I describe myself once rather than once per application.
- As an account holder, I want that page to live under the
accounts.siao.ainame, so that it is obvious which site owns my account. - As an account holder, I want that page in Traditional Chinese, so that it reads like the rest of the family.
- As an account holder, I want that page to look like the sign-in page I just came from, so that I can tell I have not been redirected somewhere hostile.
- As an account holder, I want to drag a photo onto my avatar rather than find somewhere to host it and paste a URL, so that changing my picture is not a chore with prerequisites.
- As an account holder, I want to enrol and remove passkeys from that
page, so that
SPEC_ACCOUNTS.md's multi-device story is reachable without a console. - As an account holder, I want to see and unlink my Google connection from that page, so that I stay in control of how my account can be accessed.
- As an account holder, I want to review my active sessions, so that I can tell whether I left one open somewhere.
Account holder — what the applications show
- As an account holder, I want my new name and avatar to appear on AnswerHub without me doing anything else, so that the two feel like one product.
- As an account holder, I want to see my change reflected immediately when I look at my own profile, so that I do not think it failed.
- As an account holder, I want other people to see my change quickly too, without needing to know how quickly, so that "it updated" is the whole of my experience.
- As an account holder, I do not want to find a second, editable copy of my biography inside AnswerHub, so that I never have to wonder which one is real.
- As an account holder whose provider is briefly unavailable, I want applications to keep showing my last-known details rather than failing, so that an outage at the provider is not an outage everywhere.
AnswerHub user
- As an AnswerHub user, I want AnswerHub's settings page to keep the things that are actually AnswerHub's — my answers, my API keys, my member number — so that consolidation does not take away what only this platform can offer.
- As an AnswerHub user, I want the profile and avatar sections
replaced by a clearly-labelled link to
accounts.siao.ai, so that I know where to go rather than finding an empty section. - As an AnswerHub user, I want my member number to stay AnswerHub's own, so that it means what it has always meant here.
- As an AnswerHub user viewing someone else's profile, I want their biography and links to render as before, so that consolidation is invisible to a reader.
- As an AnswerHub administrator, I want a ban to stop someone posting
here without touching their
siao.aiaccount, so that a moderation decision on one platform is not an identity decision for the family.
Suspension and sign-out
- As the owner, I want suspending an account at the provider to lock that person out of every application within seconds, so that suspension is not a thirty-day suggestion.
- As the owner, I want to be sure a suspended person's existing session tokens stop working, not merely that they cannot obtain new ones, so that suspension is not defeated by a browser that is already open.
- As an account holder, I want signing out of one application to sign me out of all of them, so that leaving a shared computer is one action.
- As an account holder, I want sign-out to invalidate the session on the server and not only clear a cookie, so that a copied token does not outlive my sign-out.
- As an account holder with a clone credential, I want signing out of
the browser not to break a running
git fetch, so that a long-lived machine credential behaves like one.
Owner and operator
- As the owner, I want no application to hold a route capable of writing a profile field, so that "applications are read-only" is a property of the architecture rather than a convention.
- As the owner, I want the credential that reads the provider to be read-only, so that a leak of it cannot change anybody's identity.
- As the owner, I want that credential's existence recorded in the handoff the day it is created, so that no security claim in that file is false.
- As the owner, I want my own existing biography, links and avatar preserved through the migration, so that consolidation does not silently blank the only populated profile on the system.
- As the owner, I want the destructive schema change to happen last, after everything else has been verified, so that there is a point of no return and it is chosen rather than stumbled into.
- As the owner, I want every application's link to the account page to come from configuration, so that renaming the provider later is one environment variable and not a search-and-replace.
- As the owner, I want to know on day one whether the provider emits a webhook when a person edits their own profile, so that a mechanism is not built on an unverified assumption.
- As the owner, I want none of this work to require publishing
accounts.siao.ai, so that the publication decision stays mine to make separately. - As a future application author, I want to inherit the whole account experience by becoming an OIDC client and reading claims, so that application number four writes no settings page at all.
Implementation Decisions
The account front end
- Logto's built-in Account Center is the account front end. No bespoke application is written. Its configuration exposes username, email, profile, MFA and social; password remains disabled.
- The provider's root path redirects to the Account Center, so the provider's own name is the address of the account page.
- Interface language is Traditional Chinese. The Account Center
bundle already carries
zh-TW; this is configuration, not translation work. - Branding is inherited, not re-specified. The Account Center reads
the sign-in experience's colours, which already carry the family's
sage accent and dark mode. Any further styling uses the documented
logto_ac-class prefix — never guessed selectors, which is how the sign-in page was broken once already. - Applications link to the account page through configuration. Each application reads the account base URL from an environment variable. No application hard-codes the provider's hostname in markup.
Where each field lives
Identity and profile are the provider's. Authorisation is the application's. The full map:
| Field | Authority | AnswerHub |
|---|---|---|
| name | provider, standard claim | read-only copy |
| provider, standard claim | read-only copy | |
| avatar | provider, standard picture claim, file in R2 |
read-only copy in the existing image field |
| username | provider, standard claim | column removed |
| biography | provider, custom profile field | read-only copy |
| GitHub / X / website links | provider, custom profile fields, URL type | read-only copies |
| member number, role, ban state, content-visibility toggle, session validity | AnswerHub | unchanged |
- The four profile fields are custom profile fields, not standard
claims — including
website.websitedoes have a standard OIDC claim, but biography and the two social links do not, so they must be custom fields regardless. Routingwebsitedifferently would give four visually identical fields two read paths and two sync paths for no saving, since the other three require a userinfo call anyway. - AnswerHub keeps one avatar field, not two. It currently has both a provider-supplied image and a self-uploaded avatar and picks between them at render time. With one source there is one field; the self-upload column is removed.
- AnswerHub's username column is removed rather than synchronised. It is dead: no code outside tests reads or writes it. Synchronising a field nobody reads would manufacture a dependency.
Avatar storage
- Cloudflare R2 is configured as Logto's storage provider. Logto OSS supports S3-compatible, Azure and Google Cloud storage and has no local-filesystem option; without a provider the avatar field degrades to a URL text box, which is a product regression.
- R2 over an on-box MinIO because it adds no container, no backup target, and — decisively — no dependency on the tunnel for images to render.
- The self-hosting posture in
SPEC_ACCOUNTS.mdis about identity data; avatars are public images and are not covered by it. This is a deliberate, narrow exception and should be recorded as one. - AnswerHub's avatar upload route is deleted, together with its local upload directory usage. The magic-byte sniffing, generated filenames and rate limiting it contains were the fix for a real disclosure defect; deleting the route retires the defect class with it, and the equivalent protections become the provider's problem.
How applications read the consolidated record
Four paths, all converging on one new seam: a profile-sync function that takes provider subjects and updates local copies. Every path calls it; nothing else writes those fields.
- Webhook — primary. The provider's user-data-updated event posts to an AnswerHub endpoint. The endpoint is reachable only on the internal container network; it is not published. The payload's signature is verified.
- Sign-in refresh. The existing OIDC userinfo call at callback is extended to carry custom data, and the existing identity seam continues to write the copy — the same mechanism that already keeps email and name current.
- Self-view refresh. When a person views their own settings or profile page, their own record is refreshed on demand. This covers the one staleness anybody actually notices: "I changed it and it still shows the old value."
- Daily reconciliation. A scheduled pass reconciles every local copy against the provider, catching anything the webhook dropped during a restart or deploy.
- The provider is never called during page rendering for other people's data. A submission list renders dozens of authors; making that dozens of cross-service calls would turn the provider into a hard dependency of every page render, escalating "cannot sign in" into "site is blank".
- The read credential is a machine-to-machine application with
read-only user access. No write, no role management, no deletion.
HANDOFF.mdcurrently asserts that no standing admin credential exists; that sentence must be corrected the day this credential is created, because a false security claim is worse than an admitted one. - One assumption must be verified before the webhook is built on: whether a person editing their own profile through the Account Center emits the user-data-updated event. Vendor documentation states it for provider-side administrative updates and is silent on self-service. If it does not fire, the webhook covers administrative changes only and other people's data converges by daily reconciliation — a materially different design, and one that must be discovered by measurement rather than by a bug report.
Applications become read-only
- AnswerHub's profile-editing endpoint is removed, not guarded. A removed route cannot be re-enabled by a configuration mistake.
- AnswerHub's settings page keeps three sections — answers, API keys, account — and replaces profile and avatar with a read-only display plus a link to the account page.
- SiaoHub gains no settings page in this work. Its only application-owned concerns are clone approval and clone tokens, which are deferred. When that page is eventually built, the request-access flow it carries must share no code path with the granting mechanism, or the SSH-only property of approval degrades from an architectural fact into a convention.
Suspension
- Provider suspension propagates down. The suspension-status event causes AnswerHub to advance the affected account's session-validity timestamp, which invalidates every JWT that account holds. This uses the existing mechanism built for sign-out; no new concept is introduced.
- Application bans do not propagate up. Banning someone on AnswerHub
leaves their identity and their access to other applications
untouched, as
SPEC_ACCOUNTS.mduser story 17 requires. - SiaoHub cannot invalidate on suspension in this work, because its revocation store is introduced for sign-out and covers the same need; the residual exposure is bounded — a stale SiaoHub session grants nothing beyond what an anonymous visitor sees, because clone authorisation is re-evaluated from the approval file on every request.
- The two states are named differently in the interface — "帳號停用" at the provider, "禁止發表" in the application. Two switches with the same label is how the next person conflates them.
Sign-out
Applications tell each other directly. An application that signs somebody out posts to its siblings over the private container network, with a shared secret, and each records the same revocation it would have recorded from a logout token.
This replaces the original decision, which was that the provider would do it. That was tried first and measured to fail. Back-channel logout is enabled at the provider, each application registers a URI, the metadata survives into the OIDC client, the containers can reach each other, and the receiving endpoints work — all verified individually. With both applications confirmed in one provider session and that session destroyed by the sign-out, the second application still received nothing.
The reason is in the provider's
end_sessionhandler: every client except the one that started the sign-out is notified only when the logout form'slogout=yesfield arrives with the confirmation POST. Whether it does depends on a static HTML page the provider ships, its auto-submit, and which clients happen to share a provider session. A promise as specific as "signing out signs you out everywhere" should not rest on any of that.The cost is real and was avoided on purpose in the first design: the applications now know each other's addresses. That avoidance was justified by the provider being a reliable hub for sign-out, and measurement says it is not.
OIDC back-channel logout stays wired. When it does fire it does the same thing, and doing it twice is harmless because what is stored is a moment rather than an event.
AnswerHub's handler advances the session-validity timestamp, reusing the existing revocation mechanism.
SiaoHub gains a revocation store — a small writable file mapping subject to sign-out time, checked by the existing session accessor. Without it, back-channel logout has no observable effect on SiaoHub, because there is no browser present to clear a cookie and no server state to invalidate. A sign-out control that does not sign you out is worse than one that admits its limits.
This writable file does not weaken the "approval is SSH-only" property. That property protects the approval file; this is a different file, and its contents can only ever reduce a person's access.
Clone tokens do not expire on sign-out. They are machine credentials closer in kind to an SSH key; a browser sign-out must not break a running fetch. Revocation has its own mechanism.
Scope boundary: tailnet only
- The issuer remains the tailnet hostname throughout. DNS, tunnel ingress, the issuer rename, the Google redirect URI re-registration, the open-registration decision, and whether the provider's console stays publicly reachable are all excluded and remain owner-gated.
- Because links to the account page come from configuration, publication later is an environment-variable change per application.
- Back-channel logout and webhook URIs are internal-network addresses and do not change on publication.
Ordering
The order is a decision, not a schedule, because one step is irreversible and three others are the evidence that justifies taking it.
- Back up the application database and record the owner's existing profile values.
- Stand the provider side up — root redirect, language, custom profile fields, R2 — and populate the owner's profile there by hand, including re-uploading the avatar. Both sides now hold the data; nothing is read-only yet.
- Measure whether self-service edits emit the webhook event.
- Build the read paths and the sync seam. The application's write paths still exist, so this step is reversible.
- Remove the write paths and the columns. Irreversible; gated on everything above being verified.
- Sign-out and suspension propagation.
- Tests, deploy, verify from outside the container.
Testing Decisions
A good test here asserts what a person can observe — what a page renders, what an endpoint returns, whether a session still works — not the shape of an internal record or the order in which synchronisation paths ran. The existing conventions hold: Vitest for units, Playwright for end-to-end, and fixture servers standing in for external systems, as both repositories already do for the provider and for Forgejo.
Seams under test. Three existing, one new:
- The AnswerHub identity accessor (existing, unchanged signature) — still the only answer to "who is this visitor", now also the point at which a sign-in refreshes the profile copy.
- The SiaoHub session accessor (existing, unchanged signature) — gains a revocation check, exactly as the repository-visibility resolver gained an approval input.
- The OIDC userinfo boundary (existing) — extended to carry custom data; the fixture provider grows the same fields.
- The profile-sync function (new, and the only new seam). All four convergence paths call it. Testing it once covers the webhook, the sign-in refresh, the self-view refresh and the reconciliation, because they differ only in what triggers them.
Highest-value unit tests, chosen because a missed branch here is a stale identity or a live session that should be dead:
- Profile sync: a changed field, an unchanged field, a field cleared to empty at the provider, a subject the application has never seen, and a provider response missing custom data entirely. Clearing must clear; a missing payload must not overwrite with null.
- Session revocation in both applications: a token issued before the revocation moment, one issued after, one issued in the same second (must be refused — refusing wrongly is recoverable, admitting wrongly is not), and an absent revocation record.
- The revocation file: well-formed, empty, missing and malformed. A malformed file must not admit anybody it would otherwise refuse.
- Webhook handling: a valid signature, an invalid signature, a replayed payload, and an unknown event type.
- Suspension propagation: the event advances validity; an application ban does not touch the provider.
End-to-end tests:
- A profile edited at the provider appears on the AnswerHub profile page after synchronisation.
- The AnswerHub settings page offers no way to edit a profile field, and the former editing endpoint answers not-found.
- Signing out of one application makes a protected page in the other demand sign-in.
- A suspended account is refused by AnswerHub; a banned AnswerHub account still signs in and browses SiaoHub.
- Another person's profile still renders their biography and links unchanged — a regression test for behaviour that must not move.
Not worth testing: the provider's own correctness, R2's durability, or the Account Center's forms. What is worth testing is this project's configuration of them — that no application route can write a profile field, and that the read credential cannot write — because those are decisions, not vendor behaviour.
Pipeline gating is unchanged: unit tests on push, full suite on review, smoke tests against staging, a final gate before production.
Out of Scope
- Publishing
accounts.siao.ai: DNS, tunnel ingress, issuer rename, Google redirect URI re-registration. - The open-registration decision.
- Restricting or exposing the provider's administrative console.
- A SiaoHub settings page, clone-access page, or clone-request flow.
- Migrating anybody but the owner. One populated profile exists; it is copied by hand, not by script.
- Organisations, teams, or any profile field beyond the four named.
- Replacing the member number, role model, or moderation surface.
- Application bans propagating to the provider.
- Expiring clone tokens on sign-out.
- Front-channel logout, and any sign-out flow that requires a browser to visit each application.
Further Notes
- The measurement that decided the front end. The deployed provider
already answers at its account path with a working, branded Account
Center, and its bundle carries
zh-TW. Building a replacement was proposed before that was checked. The general lesson, already recorded once inNOTES_ACCOUNTS.mdchapter 20, repeats here: when the question is what something is like to use, look at the running system before comparing designs. - The copy is a cache, and the honest reason it exists is that claims describe one person and pages describe many. Any design that claims to eliminate the copy has either not looked at a submission list or intends to call the provider dozens of times per render.
- Single authority has a cost that does not exist today: after the write paths are removed, a provider outage means nobody can change their name anywhere. That is what single authority means, not a defect of this design — but it is new, and it should be recognised rather than discovered.
- The webhook's unverified assumption is the largest risk in this spec, and the ordering above exists to surface it before anything depends on it.
- Never
git add -Ain the infrastructure repository. Its working tree holds the provider's and Forgejo's live runtime data.