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:

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:

  1. 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.ai is to visit a homework platform. Any future application either grows its own settings page — repeating the problem — or has none.
  2. Suspension is two unconnected switches. Logto has is_suspended; AnswerHub has isBanned. 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.
  3. 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.md user story 10 was written to prevent.
  4. 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

  1. 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.
  2. As an account holder, I want that page to live under the accounts.siao.ai name, so that it is obvious which site owns my account.
  3. As an account holder, I want that page in Traditional Chinese, so that it reads like the rest of the family.
  4. 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.
  5. 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.
  6. 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.
  7. 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.
  8. 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

  1. 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.
  2. 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.
  3. 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.
  4. 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.
  5. 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

  1. 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.
  2. 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.
  3. As an AnswerHub user, I want my member number to stay AnswerHub's own, so that it means what it has always meant here.
  4. 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.
  5. As an AnswerHub administrator, I want a ban to stop someone posting here without touching their siao.ai account, so that a moderation decision on one platform is not an identity decision for the family.

Suspension and sign-out

  1. 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.
  2. 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.
  3. 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.
  4. 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.
  5. 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

  1. 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.
  2. 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.
  3. 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.
  4. 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.
  5. 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.
  6. 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.
  7. 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.
  8. 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.
  9. 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

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

Avatar storage

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.

  1. 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.
  2. 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.
  3. 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."
  4. Daily reconciliation. A scheduled pass reconciles every local copy against the provider, catching anything the webhook dropped during a restart or deploy.

Applications become read-only

Suspension

Sign-out

Scope boundary: tailnet only

Ordering

The order is a decision, not a schedule, because one step is irreversible and three others are the evidence that justifies taking it.

  1. Back up the application database and record the owner's existing profile values.
  2. 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.
  3. Measure whether self-service edits emit the webhook event.
  4. Build the read paths and the sync seam. The application's write paths still exist, so this step is reversible.
  5. Remove the write paths and the columns. Irreversible; gated on everything above being verified.
  6. Sign-out and suspension propagation.
  7. 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:

Highest-value unit tests, chosen because a missed branch here is a stale identity or a live session that should be dead:

End-to-end tests:

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

Further Notes